功能:VIP 赞助会员系统
- 新建 vip_levels 表(名称、图标、颜色、经验/金币倍率、专属进入/离开模板) - 默认4个等级种子:白银🥈(×1.5)、黄金🥇(×2.0)、钻石💎(×3.0)、至尊👑(×5.0) - 后台 VIP 等级 CRUD 管理(新增/编辑/删除,配置模板和倍率) - 后台用户编辑弹窗支持设置 VIP 等级和到期时间 - ChatController 心跳经验按 VIP 倍率加成 - FishingController 正向奖励按 VIP 倍率加成(负面惩罚不变) - 在线名单显示 VIP 图标和管理员🛡️标识 - VIP 用户进入/离开使用专属颜色和标题 - 后台侧栏新增「👑 VIP 会员等级」入口
This commit is contained in:
218
app/Http/Controllers/Admin/AiProviderController.php
Normal file
218
app/Http/Controllers/Admin/AiProviderController.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI 厂商配置管理控制器(管理后台)
|
||||
*
|
||||
* 提供完整的 AI 厂商 CRUD 管理功能,包括:
|
||||
* - 列表/新增/编辑/删除 AI 厂商配置
|
||||
* - 切换启用/禁用状态
|
||||
* - 设置默认厂商(互斥,同时只有一个默认)
|
||||
* - 全局开关控制机器人是否启用
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiProviderConfig;
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AiProviderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 显示 AI 厂商配置列表页面
|
||||
*
|
||||
* 列出所有已配置的 AI 厂商,并显示全局开关状态。
|
||||
*
|
||||
* @return View AI 厂商管理页面
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$providers = AiProviderConfig::orderBy('sort_order')->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}!");
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
123
app/Http/Controllers/Admin/VipController.php
Normal file
123
app/Http/Controllers/Admin/VipController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台 VIP 会员等级管理控制器
|
||||
* 提供会员等级的 CRUD(增删改查)功能
|
||||
* 后台可自由创建、修改、删除会员等级
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VipLevel;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VipController extends Controller
|
||||
{
|
||||
/**
|
||||
* 会员等级管理列表页
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$levels = VipLevel::orderBy('sort_order')->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);
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/ChatBotController.php
Normal file
96
app/Http/Controllers/ChatBotController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:聊天机器人控制器
|
||||
*
|
||||
* 处理用户与 AI 机器人的对话请求。
|
||||
* 先检查全局开关(sysparam: chatbot_enabled),再调用 AiChatService 获取回复。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Sysparam;
|
||||
use App\Services\AiChatService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ChatBotController extends Controller
|
||||
{
|
||||
/**
|
||||
* 构造函数:注入 AI 聊天服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly AiChatService $aiChat,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 与 AI 机器人对话
|
||||
*
|
||||
* 接收用户消息,检查全局开关后调用 AI 服务获取回复。
|
||||
* 支持自动故障转移:默认厂商失败时自动尝试备用厂商。
|
||||
*
|
||||
* @param Request $request 请求对象,需包含 message 和 room_id
|
||||
* @return JsonResponse 机器人回复或错误信息
|
||||
*/
|
||||
public function chat(Request $request): JsonResponse
|
||||
{
|
||||
// 验证请求参数
|
||||
$request->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' => '对话上下文已清除,可以开始新的对话了。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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. 如果突破境界,向全房系统喊话广播!
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
126
app/Models/AiProviderConfig.php
Normal file
126
app/Models/AiProviderConfig.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI 厂商配置模型
|
||||
*
|
||||
* 对应 ai_provider_configs 表,管理多个 AI 厂商的 API 配置。
|
||||
* 支持多厂商切换、默认配置、自动故障转移等功能。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class AiProviderConfig extends Model
|
||||
{
|
||||
/**
|
||||
* 关联的数据库表名
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'ai_provider_configs';
|
||||
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'provider',
|
||||
'name',
|
||||
'api_key',
|
||||
'api_endpoint',
|
||||
'model',
|
||||
'temperature',
|
||||
'max_tokens',
|
||||
'is_enabled',
|
||||
'is_default',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
71
app/Models/AiUsageLog.php
Normal file
71
app/Models/AiUsageLog.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI 使用日志模型
|
||||
*
|
||||
* 对应 ai_usage_logs 表,记录每次 AI 接口调用的详细信息,
|
||||
* 包括 token 消耗、响应时间、成功/失败状态等。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AiUsageLog extends Model
|
||||
{
|
||||
/**
|
||||
* 关联的数据库表名
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'ai_usage_logs';
|
||||
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'user_id',
|
||||
'provider',
|
||||
'model',
|
||||
'action',
|
||||
'prompt_tokens',
|
||||
'completion_tokens',
|
||||
'total_tokens',
|
||||
'cost',
|
||||
'response_time_ms',
|
||||
'success',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
98
app/Models/VipLevel.php
Normal file
98
app/Models/VipLevel.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:VIP 会员等级模型
|
||||
* 存储会员名称、图标、颜色、倍率、专属进入/离开模板
|
||||
* 后台可完整 CRUD 管理
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class VipLevel extends Model
|
||||
{
|
||||
/** @var string 表名 */
|
||||
protected $table = 'vip_levels';
|
||||
|
||||
/** @var array 可批量赋值字段 */
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'icon',
|
||||
'color',
|
||||
'exp_multiplier',
|
||||
'jjb_multiplier',
|
||||
'join_templates',
|
||||
'leave_templates',
|
||||
'sort_order',
|
||||
'price',
|
||||
'duration_days',
|
||||
];
|
||||
|
||||
/** @var array 类型转换 */
|
||||
protected $casts = [
|
||||
'exp_multiplier' => '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);
|
||||
}
|
||||
}
|
||||
273
app/Services/AiChatService.php
Normal file
273
app/Services/AiChatService.php
Normal file
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:AI 聊天服务
|
||||
*
|
||||
* 统一对接多个 AI 厂商 API(OpenAI 兼容协议),实现:
|
||||
* - 多厂商自动故障转移(默认 → 备用按 sort_order 依次)
|
||||
* - Redis 维护每用户对话上下文(最近 10 轮)
|
||||
* - AI 调用日志记录(token 消耗、响应时间)
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AiProviderConfig;
|
||||
use App\Models\AiUsageLog;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class AiChatService
|
||||
{
|
||||
/**
|
||||
* 每个用户保留的最大对话轮数
|
||||
*/
|
||||
private const MAX_CONTEXT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* AI 请求超时时间(秒)
|
||||
*/
|
||||
private const REQUEST_TIMEOUT = 30;
|
||||
|
||||
/**
|
||||
* Redis 上下文 key 前缀
|
||||
*/
|
||||
private const CONTEXT_PREFIX = 'ai_chat:context:';
|
||||
|
||||
/**
|
||||
* Redis 上下文过期时间(秒),1 小时无对话自动清除
|
||||
*/
|
||||
private const CONTEXT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* 系统提示词(机器人人设)
|
||||
*/
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
你是一个聊天室小助手,名叫"AI小助手"。你的工作是陪用户聊天,回答他们的问题。
|
||||
要求:
|
||||
1. 始终使用中文回复
|
||||
2. 语气活泼友好,像朋友一样交流
|
||||
3. 回复简洁明了,一般不超过 200 字
|
||||
4. 可以适当使用颜文字或表情增加趣味性
|
||||
5. 如果用户问你是谁,告诉他们你是聊天室的 AI 小助手
|
||||
6. 不要输出任何 Markdown 格式(如 **加粗** 或 # 标题),使用纯文本回复
|
||||
PROMPT;
|
||||
|
||||
/**
|
||||
* 与 AI 机器人对话
|
||||
*
|
||||
* 优先使用默认配置,若调用失败则按 sort_order 依次尝试备用厂商。
|
||||
*
|
||||
* @param int $userId 用户 ID
|
||||
* @param string $message 用户发送的消息内容
|
||||
* @param int $roomId 房间 ID(用于日志记录)
|
||||
* @return array{reply: string, provider: string, model: string}
|
||||
*
|
||||
* @throws \Exception 所有 AI 厂商均不可用时抛出异常
|
||||
*/
|
||||
public function chat(int $userId, string $message, int $roomId): array
|
||||
{
|
||||
// 获取所有已启用的 AI 配置(默认的排最前面)
|
||||
$providers = AiProviderConfig::getEnabledProviders();
|
||||
|
||||
if ($providers->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);
|
||||
}
|
||||
}
|
||||
108
app/Services/VipService.php
Normal file
108
app/Services/VipService.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:VIP 会员服务层
|
||||
* 提供倍率计算、会员授予/撤销、模板渲染等核心逻辑
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\VipLevel;
|
||||
|
||||
class VipService
|
||||
{
|
||||
/**
|
||||
* 获取用户的经验倍率(非 VIP 返回 1.0)
|
||||
*/
|
||||
public function getExpMultiplier(User $user): float
|
||||
{
|
||||
if (! $user->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建 vip_levels 会员等级配置表
|
||||
* 存储会员等级名称、图标、颜色、倍率、专属模板等配置
|
||||
* 后台可完整 CRUD 管理
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建 vip_levels 表并填充默认数据
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('vip_levels', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:在 users 表添加 vip_level_id 外键
|
||||
* 关联 vip_levels 表,标识用户的会员等级
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 添加 vip_level_id 字段(如果不存在)并建立外键
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// 列可能已在之前失败的迁移中创建,仅在不存在时添加
|
||||
if (! Schema::hasColumn('users', 'vip_level_id')) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
70
database/migrations/2026_02_26_132600_create_ai_tables.php
Normal file
70
database/migrations/2026_02_26_132600_create_ai_tables.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:创建 AI 厂商配置表和 AI 使用日志表
|
||||
*
|
||||
* ai_provider_configs — 存储多个 AI 厂商的 API 配置(密钥、端点、模型等)
|
||||
* ai_usage_logs — 记录每次 AI 接口调用的 token 消耗和响应信息
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 执行迁移:创建 AI 相关的两张数据表
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// AI 厂商配置表
|
||||
Schema::create('ai_provider_configs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
357
resources/views/admin/ai-providers/index.blade.php
Normal file
357
resources/views/admin/ai-providers/index.blade.php
Normal file
@@ -0,0 +1,357 @@
|
||||
{{--
|
||||
文件功能:AI 厂商配置管理页面
|
||||
|
||||
提供 AI 厂商的完整 CRUD 管理:
|
||||
- 列表展示所有配置(名称、模型、状态等)
|
||||
- 新增/编辑厂商配置弹窗
|
||||
- 启用/禁用切换、设为默认、删除
|
||||
- 全局开关控制聊天机器人是否启用
|
||||
|
||||
@author ChatRoom Laravel
|
||||
@version 1.0.0
|
||||
--}}
|
||||
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', 'AI 厂商配置')
|
||||
|
||||
@section('content')
|
||||
<div x-data="{
|
||||
showForm: false,
|
||||
editId: null,
|
||||
form: {
|
||||
provider: '',
|
||||
name: '',
|
||||
api_key: '',
|
||||
api_endpoint: '',
|
||||
model: '',
|
||||
temperature: 0.3,
|
||||
max_tokens: 2048,
|
||||
sort_order: 0,
|
||||
},
|
||||
|
||||
/**
|
||||
* 打开新增弹窗,重置表单
|
||||
*/
|
||||
openNew() {
|
||||
this.editId = null;
|
||||
this.form = {
|
||||
provider: '',
|
||||
name: '',
|
||||
api_key: '',
|
||||
api_endpoint: '',
|
||||
model: '',
|
||||
temperature: 0.3,
|
||||
max_tokens: 2048,
|
||||
sort_order: 0,
|
||||
};
|
||||
this.showForm = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 打开编辑弹窗,填充现有数据
|
||||
*/
|
||||
openEdit(provider) {
|
||||
this.editId = provider.id;
|
||||
this.form = {
|
||||
provider: provider.provider,
|
||||
name: provider.name,
|
||||
api_key: '', // 编辑时不回填 API Key(已加密)
|
||||
api_endpoint: provider.api_endpoint,
|
||||
model: provider.model,
|
||||
temperature: provider.temperature,
|
||||
max_tokens: provider.max_tokens,
|
||||
sort_order: provider.sort_order,
|
||||
};
|
||||
this.showForm = true;
|
||||
},
|
||||
}">
|
||||
|
||||
{{-- 全局开关 + 操作栏 --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-6">
|
||||
<div class="p-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-lg font-bold text-gray-800">🤖 AI 聊天机器人</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500">全局开关:</span>
|
||||
<button id="chatbot-toggle-btn" onclick="toggleChatBot()"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {{ $chatbotEnabled ? 'bg-emerald-500' : 'bg-gray-300' }}">
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {{ $chatbotEnabled ? 'translate-x-6' : 'translate-x-1' }}"></span>
|
||||
</button>
|
||||
<span id="chatbot-status-text"
|
||||
class="text-sm font-bold {{ $chatbotEnabled ? 'text-emerald-600' : 'text-gray-400' }}">
|
||||
{{ $chatbotEnabled ? '已开启' : '已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button x-on:click="openNew()"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition font-bold text-sm">
|
||||
+ 添加 AI 厂商
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 厂商列表 --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left font-bold">厂商</th>
|
||||
<th class="px-6 py-3 text-left font-bold">模型</th>
|
||||
<th class="px-6 py-3 text-left font-bold">API 端点</th>
|
||||
<th class="px-6 py-3 text-center font-bold">参数</th>
|
||||
<th class="px-6 py-3 text-center font-bold">排序</th>
|
||||
<th class="px-6 py-3 text-center font-bold">状态</th>
|
||||
<th class="px-6 py-3 text-center font-bold">默认</th>
|
||||
<th class="px-6 py-3 text-center font-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse ($providers as $provider)
|
||||
<tr class="hover:bg-gray-50 transition {{ !$provider->is_enabled ? 'opacity-50' : '' }}">
|
||||
<td class="px-6 py-4">
|
||||
<div class="font-bold text-gray-800">{{ $provider->name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $provider->provider }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-xs">{{ $provider->model }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-xs text-gray-500 truncate block max-w-[200px]"
|
||||
title="{{ $provider->api_endpoint }}">
|
||||
{{ $provider->api_endpoint }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center text-xs text-gray-500">
|
||||
T={{ $provider->temperature }} / {{ $provider->max_tokens }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center text-gray-500">
|
||||
{{ $provider->sort_order }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<button onclick="toggleProvider({{ $provider->id }}, this)"
|
||||
class="px-3 py-1 rounded-full text-xs font-bold {{ $provider->is_enabled ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500' }}">
|
||||
{{ $provider->is_enabled ? '已启用' : '已禁用' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
@if ($provider->is_default)
|
||||
<span class="px-3 py-1 rounded-full text-xs font-bold bg-amber-100 text-amber-700">★
|
||||
默认</span>
|
||||
@else
|
||||
<button onclick="setDefault({{ $provider->id }})"
|
||||
class="px-3 py-1 rounded-full text-xs text-gray-400 hover:text-amber-600 hover:bg-amber-50 transition">
|
||||
设为默认
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button x-on:click="openEdit({{ $provider->toJson() }})"
|
||||
class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">编辑</button>
|
||||
<form action="{{ route('admin.ai-providers.destroy', $provider->id) }}" method="POST"
|
||||
onsubmit="return confirm('确定要删除 {{ $provider->name }} 吗?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="text-red-500 hover:text-red-700 text-xs font-bold">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-12 text-center text-gray-400">
|
||||
暂无 AI 厂商配置,请点击上方"添加 AI 厂商"按钮。
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- 新增/编辑弹窗 --}}
|
||||
<div x-show="showForm" x-cloak style="display: none;"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" x-on:click.self="showForm = false">
|
||||
<div class="bg-white rounded-xl shadow-xl w-[560px] max-h-[90vh] overflow-y-auto" x-transition>
|
||||
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 class="text-lg font-bold" x-text="editId ? '编辑 AI 厂商' : '添加 AI 厂商'"></h3>
|
||||
<button x-on:click="showForm = false" class="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
:action="editId ? '{{ url('admin/ai-providers') }}/' + editId : '{{ route('admin.ai-providers.store') }}'"
|
||||
method="POST" class="p-6 space-y-4">
|
||||
@csrf
|
||||
<template x-if="editId">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{{-- 厂商标识 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">厂商标识 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="text" name="provider" x-model="form.provider" required
|
||||
placeholder="如:deepseek, qwen, openai"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
{{-- 显示名称 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">显示名称 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="text" name="name" x-model="form.name" required placeholder="如:DeepSeek、通义千问"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- API Key --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">
|
||||
API Key
|
||||
<span x-show="!editId" class="text-red-500">*</span>
|
||||
<span x-show="editId" class="text-gray-400 font-normal">(留空表示不修改)</span>
|
||||
</label>
|
||||
<input type="password" name="api_key" x-model="form.api_key" :required="!editId"
|
||||
placeholder="sk-xxxxxxxxxxxx"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500 font-mono">
|
||||
</div>
|
||||
|
||||
{{-- API 端点 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">API 端点 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="url" name="api_endpoint" x-model="form.api_endpoint" required
|
||||
placeholder="https://api.deepseek.com"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<p class="text-xs text-gray-400 mt-1">系统会自动拼接 /v1/chat/completions</p>
|
||||
</div>
|
||||
|
||||
{{-- 模型 --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">模型名称 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="text" name="model" x-model="form.model" required
|
||||
placeholder="如:deepseek-chat, qwen-turbo"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
{{-- Temperature --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">Temperature</label>
|
||||
<input type="number" name="temperature" x-model="form.temperature" step="0.1"
|
||||
min="0" max="2"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
{{-- Max Tokens --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">最大 Tokens</label>
|
||||
<input type="number" name="max_tokens" x-model="form.max_tokens" min="100"
|
||||
max="32000"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
{{-- Sort Order --}}
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-1">排序</label>
|
||||
<input type="number" name="sort_order" x-model="form.sort_order" min="0"
|
||||
class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<p class="text-xs text-gray-400 mt-1">故障转移时按此排序</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t flex justify-end gap-3">
|
||||
<button type="button" x-on:click="showForm = false"
|
||||
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md text-sm transition">取消</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-indigo-600 text-white rounded-md font-bold hover:bg-indigo-700 text-sm transition">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 切换全局聊天机器人开关
|
||||
*/
|
||||
async function toggleChatBot() {
|
||||
try {
|
||||
const res = await fetch('{{ route('admin.ai-providers.toggle-chatbot') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
// 更新按钮样式
|
||||
const btn = document.getElementById('chatbot-toggle-btn');
|
||||
const text = document.getElementById('chatbot-status-text');
|
||||
if (data.enabled) {
|
||||
btn.className = btn.className.replace('bg-gray-300', 'bg-emerald-500');
|
||||
btn.firstElementChild.className = btn.firstElementChild.className.replace('translate-x-1',
|
||||
'translate-x-6');
|
||||
text.textContent = '已开启';
|
||||
text.className = text.className.replace('text-gray-400', 'text-emerald-600');
|
||||
} else {
|
||||
btn.className = btn.className.replace('bg-emerald-500', 'bg-gray-300');
|
||||
btn.firstElementChild.className = btn.firstElementChild.className.replace('translate-x-6',
|
||||
'translate-x-1');
|
||||
text.textContent = '已关闭';
|
||||
text.className = text.className.replace('text-emerald-600', 'text-gray-400');
|
||||
}
|
||||
alert(data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('操作失败:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换厂商启用/禁用状态
|
||||
*/
|
||||
async function toggleProvider(id, btn) {
|
||||
try {
|
||||
const res = await fetch('/admin/ai-providers/' + id + '/toggle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('操作失败:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设为默认 AI 厂商
|
||||
*/
|
||||
async function setDefault(id) {
|
||||
try {
|
||||
const res = await fetch('/admin/ai-providers/' + id + '/default', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('操作失败:' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@@ -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' }}">
|
||||
🎲 随机事件
|
||||
</a>
|
||||
<a href="{{ route('admin.vip.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.vip.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
👑 VIP 会员等级
|
||||
</a>
|
||||
<a href="{{ route('admin.ai-providers.index') }}"
|
||||
class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.ai-providers.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}">
|
||||
🤖 AI 厂商配置
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<a href="{{ route('rooms.index') }}"
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
<img src="/images/headface/{{ $user->headface ?? '01.gif' }}"
|
||||
class="w-8 h-8 rounded border object-cover">
|
||||
<span class="font-bold text-gray-800">{{ $user->username }}</span>
|
||||
@if ($user->isVip())
|
||||
<span title="{{ $user->vipName() }}"
|
||||
style="color: {{ $user->vipLevel?->color ?? '#f59e0b' }}">{{ $user->vipIcon() }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4 text-sm">{{ [0 => '保密', 1 => '男', 2 => '女'][$user->sex] ?? '保密' }}</td>
|
||||
@@ -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">
|
||||
</div>
|
||||
|
||||
{{-- VIP 会员设置 --}}
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">VIP 会员等级</label>
|
||||
<select name="vip_level_id" x-model="editingUser.vip_level_id"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
|
||||
<option value="">无(普通用户)</option>
|
||||
@foreach ($vipLevels as $vl)
|
||||
<option value="{{ $vl->id }}">{{ $vl->icon }}
|
||||
{{ $vl->name }}(×{{ $vl->exp_multiplier }}经验
|
||||
×{{ $vl->jjb_multiplier }}金币)</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">会员到期时间
|
||||
<span class="font-normal text-gray-400">(留空=永久)</span></label>
|
||||
<input type="date" name="hy_time" x-model="editingUser.hy_time"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 密码 --}}
|
||||
<div class="mt-4">
|
||||
<label
|
||||
|
||||
244
resources/views/admin/vip/index.blade.php
Normal file
244
resources/views/admin/vip/index.blade.php
Normal file
@@ -0,0 +1,244 @@
|
||||
{{--
|
||||
文件功能:后台 VIP 会员等级管理页面
|
||||
提供会员等级的新增、编辑、删除功能
|
||||
后台可自由管理所有会员等级配置
|
||||
|
||||
@author ChatRoom Laravel
|
||||
@version 1.0.0
|
||||
--}}
|
||||
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', 'VIP 会员等级管理')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div x-data="{
|
||||
showForm: false,
|
||||
editing: null,
|
||||
form: {
|
||||
name: '',
|
||||
icon: '⭐',
|
||||
color: '#f59e0b',
|
||||
exp_multiplier: 1.5,
|
||||
jjb_multiplier: 1.2,
|
||||
sort_order: 0,
|
||||
price: 10,
|
||||
duration_days: 30,
|
||||
join_templates: '',
|
||||
leave_templates: '',
|
||||
},
|
||||
|
||||
openCreate() {
|
||||
this.editing = null;
|
||||
this.form = {
|
||||
name: '',
|
||||
icon: '⭐',
|
||||
color: '#f59e0b',
|
||||
exp_multiplier: 1.5,
|
||||
jjb_multiplier: 1.2,
|
||||
sort_order: 0,
|
||||
price: 10,
|
||||
duration_days: 30,
|
||||
join_templates: '',
|
||||
leave_templates: '',
|
||||
};
|
||||
this.showForm = true;
|
||||
},
|
||||
|
||||
openEdit(level) {
|
||||
this.editing = level;
|
||||
this.form = {
|
||||
name: level.name,
|
||||
icon: level.icon,
|
||||
color: level.color,
|
||||
exp_multiplier: level.exp_multiplier,
|
||||
jjb_multiplier: level.jjb_multiplier,
|
||||
sort_order: level.sort_order,
|
||||
price: level.price,
|
||||
duration_days: level.duration_days,
|
||||
join_templates: level.join_templates_text,
|
||||
leave_templates: level.leave_templates_text,
|
||||
};
|
||||
this.showForm = true;
|
||||
}
|
||||
}">
|
||||
|
||||
{{-- 头部操作栏 --}}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800">会员等级列表</h2>
|
||||
<p class="text-sm text-gray-500">管理赞助会员等级,配置名称、图标、倍率和专属提示语</p>
|
||||
</div>
|
||||
<button @click="openCreate()"
|
||||
class="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-bold hover:bg-indigo-700 transition shadow-sm">
|
||||
+ 新增等级
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 等级卡片列表 --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
@foreach ($levels as $level)
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden hover:shadow-md transition">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-2xl">{{ $level->icon }}</span>
|
||||
<span class="font-bold text-lg"
|
||||
style="color: {{ $level->color }}">{{ $level->name }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-500">排序:{{ $level->sort_order }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">经验倍率</span>
|
||||
<span class="font-bold text-green-600">×{{ $level->exp_multiplier }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">金币倍率</span>
|
||||
<span class="font-bold text-yellow-600">×{{ $level->jjb_multiplier }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">赞助金额</span>
|
||||
<span class="font-bold text-gray-700">¥{{ $level->price }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">有效天数</span>
|
||||
<span class="font-bold">{{ $level->duration_days ?: '永久' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">当前会员</span>
|
||||
<span class="font-bold text-indigo-600">{{ $level->users()->count() }} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t bg-gray-50 px-5 py-3 flex justify-end space-x-2">
|
||||
<button
|
||||
@click="openEdit({
|
||||
id: {{ $level->id }},
|
||||
name: '{{ addslashes($level->name) }}',
|
||||
icon: '{{ $level->icon }}',
|
||||
color: '{{ $level->color }}',
|
||||
exp_multiplier: {{ $level->exp_multiplier }},
|
||||
jjb_multiplier: {{ $level->jjb_multiplier }},
|
||||
sort_order: {{ $level->sort_order }},
|
||||
price: {{ $level->price }},
|
||||
duration_days: {{ $level->duration_days }},
|
||||
join_templates_text: `{{ str_replace('`', '', implode("\n", $level->join_templates_array)) }}`,
|
||||
leave_templates_text: `{{ str_replace('`', '', implode("\n", $level->leave_templates_array)) }}`,
|
||||
requestUrl: '{{ route('admin.vip.update', $level->id) }}'
|
||||
})"
|
||||
class="text-xs bg-indigo-50 text-indigo-600 font-bold px-3 py-1.5 rounded hover:bg-indigo-600 hover:text-white transition">
|
||||
编辑
|
||||
</button>
|
||||
<form action="{{ route('admin.vip.destroy', $level->id) }}" method="POST"
|
||||
onsubmit="return confirm('确定删除等级 [{{ $level->name }}] 吗?关联用户会变为普通用户。')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit"
|
||||
class="text-xs bg-red-50 text-red-600 font-bold px-3 py-1.5 rounded hover:bg-red-600 hover:text-white transition">
|
||||
删除
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($levels->isEmpty())
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<p class="text-lg">暂无会员等级,点击右上角"新增等级"创建</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 新增/编辑弹窗 --}}
|
||||
<div x-show="showForm" style="display: none;"
|
||||
class="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
||||
<div @click.away="showForm = false"
|
||||
class="bg-white rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto" x-transition>
|
||||
<div class="bg-indigo-900 px-6 py-4 flex justify-between items-center rounded-t-xl text-white">
|
||||
<h3 class="font-bold text-lg" x-text="editing ? '编辑等级:' + editing.name : '新增会员等级'"></h3>
|
||||
<button @click="showForm = false" class="text-gray-400 hover:text-white text-xl">×</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<form :action="editing ? editing.requestUrl : '{{ route('admin.vip.store') }}'" method="POST">
|
||||
@csrf
|
||||
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">等级名称</label>
|
||||
<input type="text" name="name" x-model="form.name" required maxlength="50"
|
||||
class="w-full border rounded-md p-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">图标 (Emoji)</label>
|
||||
<input type="text" name="icon" x-model="form.icon" required maxlength="20"
|
||||
class="w-full border rounded-md p-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">颜色</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="color" name="color" x-model="form.color"
|
||||
class="w-10 h-8 border rounded cursor-pointer">
|
||||
<input type="text" x-model="form.color" maxlength="10"
|
||||
class="flex-1 border rounded-md p-2 text-sm font-mono">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">排序 (越大越高级)</label>
|
||||
<input type="number" name="sort_order" x-model="form.sort_order" required min="0"
|
||||
class="w-full border rounded-md p-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">经验倍率</label>
|
||||
<input type="number" name="exp_multiplier" x-model="form.exp_multiplier" required
|
||||
min="1" step="0.1" class="w-full border rounded-md p-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">金币倍率</label>
|
||||
<input type="number" name="jjb_multiplier" x-model="form.jjb_multiplier" required
|
||||
min="1" step="0.1" class="w-full border rounded-md p-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">赞助金额 (元)</label>
|
||||
<input type="number" name="price" x-model="form.price" required min="0"
|
||||
class="w-full border rounded-md p-2 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">有效天数 (0=永久)</label>
|
||||
<input type="number" name="duration_days" x-model="form.duration_days" required
|
||||
min="0" class="w-full border rounded-md p-2 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 专属模板 --}}
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">进入欢迎语模板
|
||||
<span class="font-normal text-gray-400">(每行一条,用 {username} 代替用户名)</span></label>
|
||||
<textarea name="join_templates" x-model="form.join_templates" rows="3" placeholder="例:贵宾{username}驾到!全场起立!"
|
||||
class="w-full border rounded-md p-2 text-sm"></textarea>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">离开提示语模板
|
||||
<span class="font-normal text-gray-400">(每行一条)</span></label>
|
||||
<textarea name="leave_templates" x-model="form.leave_templates" rows="3" placeholder="例:贵宾{username}潇洒离去..."
|
||||
class="w-full border rounded-md p-2 text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4 mt-4 border-t">
|
||||
<button type="button" @click="showForm = false"
|
||||
class="px-4 py-2 border rounded font-medium text-gray-600 hover:bg-gray-50">取消</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded font-bold hover:bg-indigo-700 shadow-sm"
|
||||
x-text="editing ? '保存修改' : '创建等级'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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' }}
|
||||
};
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js'])
|
||||
|
||||
@@ -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 = '<span style="font-size:14px; margin-right:3px;">🤖</span>' +
|
||||
'<span class="user-name" style="color: #16a34a; font-weight: bold;">AI小助手</span>';
|
||||
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 +=
|
||||
`<span style="font-size:12px; margin-left:2px; color:${vipColor};" title="${user.vip_name || 'VIP'}">${user.vip_icon}</span>`;
|
||||
}
|
||||
if (user.is_admin) {
|
||||
badges += `<span style="font-size:11px; margin-left:2px;" title="管理员">🛡️</span>`;
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<img class="user-head" src="/images/headface/${headface}" onerror="this.src='/images/headface/1.GIF'">
|
||||
<span class="user-name">${username}</span>
|
||||
<span class="user-name">${username}</span>${badges}
|
||||
`;
|
||||
|
||||
item.onclick = () => {
|
||||
@@ -330,8 +361,16 @@
|
||||
|
||||
const sysDiv = document.createElement('div');
|
||||
sysDiv.className = 'msg-line';
|
||||
sysDiv.innerHTML =
|
||||
`<span style="color: green">【欢迎】${msg}</span><span class="msg-time">(${timeStr})</span>`;
|
||||
|
||||
// VIP 用户使用专属颜色和图标
|
||||
if (user.vip_icon && user.vip_name) {
|
||||
const vipColor = user.vip_color || '#f59e0b';
|
||||
sysDiv.innerHTML =
|
||||
`<span style="color: ${vipColor}; font-weight: bold;">【${user.vip_icon} ${user.vip_name}】${msg}</span><span class="msg-time">(${timeStr})</span>`;
|
||||
} else {
|
||||
sysDiv.innerHTML =
|
||||
`<span style="color: green">【欢迎】${msg}</span><span class="msg-time">(${timeStr})</span>`;
|
||||
}
|
||||
container.appendChild(sysDiv);
|
||||
scrollToBottom();
|
||||
});
|
||||
@@ -343,7 +382,14 @@
|
||||
|
||||
const sysDiv = document.createElement('div');
|
||||
sysDiv.className = 'msg-line sys-msg';
|
||||
sysDiv.innerHTML = `<span style="color: gray">☆ ${user.username} 离开了聊天室 ☆</span>`;
|
||||
// VIP 用户离开也带专属颜色
|
||||
if (user.vip_icon && user.vip_name) {
|
||||
const vipColor = user.vip_color || '#f59e0b';
|
||||
sysDiv.innerHTML =
|
||||
`<span style="color: ${vipColor}">☆ ${user.vip_icon}${user.vip_name} ${user.username} 潇洒离去 ☆</span>`;
|
||||
} else {
|
||||
sysDiv.innerHTML = `<span style="color: gray">☆ ${user.username} 离开了聊天室 ☆</span>`;
|
||||
}
|
||||
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 = `<span class="msg-user" style="color: #000099;">${window.chatContext.username}</span>` +
|
||||
`对<span style="color: #16a34a; font-weight: bold;">🤖AI小助手</span>说:` +
|
||||
`<span class="msg-content">${escapeHtml(content)}</span>` +
|
||||
` <span class="msg-time">(${timeStr})</span>`;
|
||||
container2.appendChild(userDiv);
|
||||
|
||||
// 显示"思考中"提示
|
||||
const thinkDiv = document.createElement('div');
|
||||
thinkDiv.className = 'msg-line';
|
||||
thinkDiv.innerHTML = '<span style="color: #16a34a;">🤖 <b>AI小助手</b> 正在思考中...</span>';
|
||||
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 = `<span style="color: #16a34a; font-weight: bold;">🤖 AI小助手</span>` +
|
||||
`对<span class="msg-user" style="color: #000099;">${window.chatContext.username}</span>说:` +
|
||||
`<span class="msg-content" style="color: #333;">${escapeHtml(data.reply)}</span>` +
|
||||
` <span class="msg-time">(${replyTimeStr})</span>` +
|
||||
` <span style="font-size:10px; color:#aaa;">[${data.provider}]</span>`;
|
||||
container2.appendChild(botDiv);
|
||||
} else {
|
||||
// 显示错误信息
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'msg-line';
|
||||
errDiv.innerHTML = `<span style="color: #dc2626;">🤖【AI小助手】${data.message || '回复失败,请稍后重试'}</span>` +
|
||||
` <span class="msg-time">(${replyTimeStr})</span>`;
|
||||
container2.appendChild(errDiv);
|
||||
}
|
||||
} catch (e) {
|
||||
thinkDiv.remove();
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'msg-line';
|
||||
errDiv.innerHTML = '<span style="color: #dc2626;">🤖【AI小助手】网络连接错误,请稍后重试</span>';
|
||||
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 = '<span style="color: #16a34a;">🤖【系统】' + (data.message || '对话已重置') + '</span>';
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\ChatBotController;
|
||||
use App\Http\Controllers\ChatController;
|
||||
use App\Http\Controllers\FishingController;
|
||||
use App\Http\Controllers\RoomController;
|
||||
@@ -79,6 +80,10 @@ Route::middleware(['chat.auth'])->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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user