优化商店个性装扮体验
This commit is contained in:
@@ -143,6 +143,12 @@ enum CurrencySource: string
|
||||
/** 查看别人隐藏信息扣费 */
|
||||
case USER_INFO_REVEAL = 'user_info_reveal';
|
||||
|
||||
/** 购买消息装扮消耗(气泡/昵称颜色,扣除金币) */
|
||||
case MSG_DECORATION_BUY = 'msg_decoration_buy';
|
||||
|
||||
/** 购买头像框消耗(扣除金币) */
|
||||
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -190,6 +196,8 @@ enum CurrencySource: string
|
||||
self::GOMOKU_REFUND => '五子棋入场费返还',
|
||||
self::VIDEO_REWARD => '看视频奖励',
|
||||
self::USER_INFO_REVEAL => '信息查看付费',
|
||||
self::MSG_DECORATION_BUY => '消息装扮购买',
|
||||
self::AVATAR_FRAME_BUY => '头像框购买',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +413,10 @@ class ChatController extends Controller
|
||||
$messageData = array_merge($messageData, $imagePayload);
|
||||
}
|
||||
|
||||
// 6.5 将用户当前激活的消息装扮注入广播 payload(气泡样式 + 昵称颜色),前端据此渲染消息外观
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForMessage($user);
|
||||
$messageData = array_merge($messageData, $decorations);
|
||||
|
||||
// 3. 压入 Redis 缓存列表 (防炸内存,只保留最近 N 条)
|
||||
$this->chatState->pushMessage($id, $messageData);
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\EffectBroadcast;
|
||||
use App\Events\MessageSent;
|
||||
use App\Events\UserStatusUpdated;
|
||||
use App\Models\Room;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\UserPurchase;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\DecorationService;
|
||||
use App\Services\ShopService;
|
||||
use App\Support\ChatContentSanitizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -73,6 +75,8 @@ class ShopController extends Controller
|
||||
'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user),
|
||||
'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user),
|
||||
'sign_repair_card_item' => $signRepairCard,
|
||||
// 返回用户当前激活的装扮状态,前端用于渲染装扮卡片上的"已激活/剩余X天"标签
|
||||
'active_decorations' => app(DecorationService::class)->getActiveDecorations($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -121,6 +125,22 @@ class ShopController extends Controller
|
||||
'total_price' => $result['total_price'] ?? $item->price,
|
||||
];
|
||||
|
||||
// ── 装扮购买:向前端透传槽位与样式信息,用于即时更新装扮状态 ──
|
||||
if (! empty($result['slot'])) {
|
||||
$response['slot'] = $result['slot'];
|
||||
$response['style'] = $result['style'];
|
||||
$response['expires_at'] = $result['expires_at'];
|
||||
|
||||
// 购买后立即同步所有房间的在线名单,让昵称颜色/头像框立即生效
|
||||
$freshUser = $user->fresh();
|
||||
$presenceData = app(\App\Services\ChatUserPresenceService::class)->build($freshUser);
|
||||
foreach ($this->chatState->getUserRooms($user->username) as $rid) {
|
||||
$this->chatState->userJoin((int) $rid, $user->username, $presenceData);
|
||||
// 广播 UserStatusUpdated 到 Presence Channel,触发前端 chat:user-status-updated 刷新用户列表
|
||||
broadcast(new UserStatusUpdated((int) $rid, $user->username, $presenceData));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单次特效卡:广播给指定用户或全员 ────────────────────────
|
||||
if (isset($result['play_effect'])) {
|
||||
$recipient = trim($request->input('recipient', '')); // 空字符串 = 全员
|
||||
@@ -205,6 +225,7 @@ class ShopController extends Controller
|
||||
'ring' => "💍 【{$safeBuyer}】在商店购买了一枚「{$safeItemName}」,不知道打算送给谁呢?",
|
||||
'auto_fishing' => "🎣 【{$safeBuyer}】购买了「{$safeItemName}」,开启了 {$fishDuration} 的自动钓鱼模式!",
|
||||
ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$safeBuyer}】购买了 {$quantity} 张「{$safeItemName}」,准备把漏掉的签到补回来!",
|
||||
'msg_bubble', 'msg_name_color', 'avatar_frame' => "✨ 【{$safeBuyer}】购买了个人装扮「{$safeItemName}」,颜值 +1!",
|
||||
default => "🛒 【{$safeBuyer}】购买了「{$safeItemName}」。",
|
||||
};
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ class User extends Authenticatable
|
||||
'q3_time' => 'datetime',
|
||||
'has_received_new_gift' => 'boolean',
|
||||
'chat_preferences' => 'array',
|
||||
'active_decorations' => 'array',
|
||||
'daily_status_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@ class ChatUserPresenceService
|
||||
$payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0);
|
||||
}
|
||||
|
||||
// 将用户当前激活的头像框和昵称颜色注入在线用户载荷,前端据此渲染用户列表中的装饰效果
|
||||
$decorations = app(\App\Services\DecorationService::class)->getDecorationsForPresence($user);
|
||||
if (! empty($decorations['avatar_frame'])) {
|
||||
$payload['avatar_frame'] = $decorations['avatar_frame'];
|
||||
}
|
||||
if (! empty($decorations['name_color'])) {
|
||||
$payload['name_color'] = $decorations['name_color'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户个人装扮统一管理服务
|
||||
* 负责装扮的购买、激活、过期清理、以及向前端广播载荷注入装饰信息。
|
||||
* 所有装扮变更经由此服务,确保 users.active_decorations JSON 字段一致性。
|
||||
*
|
||||
* 当前支持的装扮槽位(存储在 active_decorations 的 key):
|
||||
* - bubble : 消息气泡边框样式
|
||||
* - name_color : 昵称颜色效果
|
||||
* - avatar_frame: 头像装饰边框
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\ShopItem;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPurchase;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 类功能:统一管理用户个人装扮的购买、激活、过期清理及广播数据注入。
|
||||
*/
|
||||
class DecorationService
|
||||
{
|
||||
/**
|
||||
* 商店商品 type → 装扮槽位映射。
|
||||
* 以后新增装扮类型,在此加一行即可。
|
||||
*/
|
||||
private const TYPE_TO_SLOT = [
|
||||
'msg_bubble' => 'bubble',
|
||||
'msg_name_color' => 'name_color',
|
||||
'avatar_frame' => 'avatar_frame',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param UserCurrencyService $currencyService 统一积分变更服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||||
*
|
||||
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||||
*
|
||||
* @param User $user 购买用户
|
||||
* @param ShopItem $item 装扮商品
|
||||
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
|
||||
*/
|
||||
public function purchase(User $user, ShopItem $item): array
|
||||
{
|
||||
// 根据商品类型映射到对应槽位
|
||||
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||||
if (! $slot) {
|
||||
return ['ok' => false, 'message' => '未知装扮类型'];
|
||||
}
|
||||
|
||||
// 校验金币余额
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
// 计算过期时间(至少 1 天)
|
||||
$days = max(1, (int) ($item->duration_days ?? 1));
|
||||
$expiresAt = Carbon::now()->addDays($days);
|
||||
|
||||
// 头像框与其他装扮使用不同的流水来源标识
|
||||
$source = $item->type === 'avatar_frame'
|
||||
? CurrencySource::AVATAR_FRAME_BUY
|
||||
: CurrencySource::MSG_DECORATION_BUY;
|
||||
|
||||
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||||
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
|
||||
// ① 通过统一积分服务扣除金币(含流水记录)
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$item->price, $source,
|
||||
"购买装扮:{$item->name}({$days}天)"
|
||||
);
|
||||
|
||||
// ② 写入购买记录(用于后台统计与用户回溯)
|
||||
UserPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并)
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$decorations[$slot] = [
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
];
|
||||
$user->active_decorations = $decorations;
|
||||
$user->save();
|
||||
});
|
||||
|
||||
// 重新读取最新余额,避免缓存脏数据
|
||||
$balanceAfter = (int) $user->fresh()->jjb;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
|
||||
'balance_after' => $balanceAfter,
|
||||
'slot' => $slot,
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前所有激活的装扮,同时自动清理已过期项。
|
||||
*
|
||||
* 采用懒过期策略,在每次读取时检查 expires_at,不依赖定时任务。
|
||||
* 如果清理了过期数据会自动写回 users.active_decorations。
|
||||
*
|
||||
* @param User $user 目标用户
|
||||
* @return array<string, array{style:string, expires_at:string}> 返回干净的装扮列表
|
||||
*/
|
||||
public function getActiveDecorations(User $user): array
|
||||
{
|
||||
$decorations = $user->active_decorations ?? [];
|
||||
|
||||
// 非数组(null 或格式异常)直接返回空
|
||||
if (! is_array($decorations)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$changed = false;
|
||||
|
||||
foreach ($decorations as $slot => $data) {
|
||||
// 格式校验:必须包含 expires_at 字段
|
||||
if (! is_array($data) || empty($data['expires_at'])) {
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析过期时间并与当前时间比较
|
||||
try {
|
||||
$expiresAt = Carbon::parse($data['expires_at']);
|
||||
if ($expiresAt->isPast()) {
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 时间格式异常也视为无效,清理之
|
||||
unset($decorations[$slot]);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 有过期项被清理时写回数据库
|
||||
if ($changed) {
|
||||
$user->active_decorations = $decorations;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return $decorations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息广播 payload 需要携带的装扮字段。
|
||||
*
|
||||
* 消息广播只需要气泡样式(msg_bubble)和昵称颜色(msg_name_color),
|
||||
* 头像框不需要在消息中展示。
|
||||
*
|
||||
* @param User $user 消息发送者
|
||||
* @return array{msg_bubble?:string, msg_name_color?:string}
|
||||
*/
|
||||
public function getDecorationsForMessage(User $user): array
|
||||
{
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$result = [];
|
||||
|
||||
if (! empty($decorations['bubble']['style'])) {
|
||||
$result['msg_bubble'] = $decorations['bubble']['style'];
|
||||
}
|
||||
if (! empty($decorations['name_color']['style'])) {
|
||||
$result['msg_name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户 Presence 载荷需要携带的装扮字段。
|
||||
*
|
||||
* 在线列表中主要展示头像框(avatar_frame)和昵称颜色(name_color),
|
||||
* 气泡样式仅作用于消息,不需要在用户列表中展示。
|
||||
*
|
||||
* @param User $user 目标用户
|
||||
* @return array{avatar_frame?:string, name_color?:string}
|
||||
*/
|
||||
public function getDecorationsForPresence(User $user): array
|
||||
{
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$result = [];
|
||||
|
||||
if (! empty($decorations['avatar_frame']['style'])) {
|
||||
$result['avatar_frame'] = $decorations['avatar_frame']['style'];
|
||||
}
|
||||
if (! empty($decorations['name_color']['style'])) {
|
||||
$result['name_color'] = $decorations['name_color']['style'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShopService
|
||||
{
|
||||
/**
|
||||
* @param DecorationService $decorationService 装扮服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DecorationService $decorationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 购买商品入口:扣金币、按类型分发处理
|
||||
*
|
||||
@@ -41,6 +48,10 @@ class ShopService
|
||||
'ring' => $this->buyRing($user, $item),
|
||||
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
|
||||
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
|
||||
// ── 个人装扮购买(委托给 DecorationService)───────────────
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item),
|
||||
default => ['ok' => false, 'message' => '未知商品类型'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:用户表增加 active_decorations JSON 列
|
||||
* 用于存储用户当前激活的个人装扮信息(气泡/昵称颜色/头像框)及过期时间。
|
||||
*
|
||||
* JSON 结构示例:
|
||||
* {
|
||||
* "bubble": {"style": "msg_bubble_golden", "expires_at": "2026-05-04T12:00:00+08:00"},
|
||||
* "name_color": {"style": "msg_name_rainbow", "expires_at": "2026-05-01T12:00:00+08:00"},
|
||||
* "avatar_frame":{"style": "avatar_frame_dragon", "expires_at": "2026-05-27T12:00:00+08:00"}
|
||||
* }
|
||||
*
|
||||
* @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
|
||||
{
|
||||
/**
|
||||
* 新增 active_decorations 列(可空 JSON)。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('active_decorations')->nullable()
|
||||
->comment('用户当前激活的装扮:bubble 气泡 / name_color 昵称颜色 / avatar_frame 头像框,含过期时间');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:删除 active_decorations 列。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('active_decorations');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为店铺商品类型加入个人装扮(消息气泡、昵称颜色、头像框)。
|
||||
*
|
||||
* 支持用户在商店购买有期限的个人装扮道具,购买后自动激活并在聊天和用户列表中展示。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 扩展 shop_items.type ENUM,加入三种装扮类型。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair','msg_bubble','msg_name_color','avatar_frame') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚:移除装扮类型值,将已有装扮商品标记为 one_time。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
// 将已有的装扮商品类型回退为 one_time,避免 ENUM 收缩报错
|
||||
DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` IN ('msg_bubble','msg_name_color','avatar_frame')");
|
||||
DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing','sign_repair') NOT NULL COMMENT '道具类型'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,9 @@ namespace Database\Seeders;
|
||||
use App\Models\ShopItem;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* 类功能:初始化聊天室商店的特效道具、功能卡与个人装扮商品。
|
||||
*/
|
||||
class ShopItemSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
@@ -86,10 +89,56 @@ class ShopItemSeeder extends Seeder
|
||||
['name' => '改名卡', 'slug' => 'rename_card', 'icon' => '🎭',
|
||||
'description' => '使用后可修改一次昵称(旧名保留30天黑名单,不可被他人注册)。注意:历史聊天记录中的旧名不会更改。',
|
||||
'price' => 5000, 'type' => 'one_time', 'duration_days' => null, 'sort_order' => 30],
|
||||
|
||||
// ── 消息气泡装扮 ──────────────────────────
|
||||
['name' => '鎏金流光气泡', 'slug' => 'msg_bubble_golden', 'icon' => '🟡',
|
||||
'description' => '浅金底纹、左侧金线和流光扫过,发言更醒目但不刺眼。',
|
||||
'price' => 300, 'type' => 'msg_bubble', 'duration_days' => 1, 'sort_order' => 50],
|
||||
['name' => '樱语花笺气泡', 'slug' => 'msg_bubble_sakura', 'icon' => '🌸',
|
||||
'description' => '粉白信笺底色配花点纹理,适合温柔、浪漫的发言氛围。',
|
||||
'price' => 500, 'type' => 'msg_bubble', 'duration_days' => 3, 'sort_order' => 51],
|
||||
['name' => '星河微光气泡', 'slug' => 'msg_bubble_star', 'icon' => '🌌',
|
||||
'description' => '淡蓝星河底纹和微光星点,保留清爽阅读感。',
|
||||
'price' => 800, 'type' => 'msg_bubble', 'duration_days' => 7, 'sort_order' => 52],
|
||||
['name' => '霓虹彩带气泡', 'slug' => 'msg_bubble_rainbow', 'icon' => '🌈',
|
||||
'description' => '顶部流动彩带和浅色底框,发言有动态高光。',
|
||||
'price' => 1500, 'type' => 'msg_bubble', 'duration_days' => 7, 'sort_order' => 53],
|
||||
['name' => '皇冠礼赞气泡', 'slug' => 'msg_bubble_crown', 'icon' => '👑',
|
||||
'description' => '金色礼赞底纹、皇冠角标和侧边金线,突出尊贵发言。',
|
||||
'price' => 3000, 'type' => 'msg_bubble', 'duration_days' => 30, 'sort_order' => 54],
|
||||
|
||||
// ── 昵称颜色装扮 ──────────────────────────
|
||||
['name' => '金色昵称', 'slug' => 'msg_name_golden', 'icon' => '🥇',
|
||||
'description' => '让你的昵称闪耀金光。',
|
||||
'price' => 200, 'type' => 'msg_name_color', 'duration_days' => 1, 'sort_order' => 60],
|
||||
['name' => '渐变昵称', 'slug' => 'msg_name_rainbow', 'icon' => '🎨',
|
||||
'description' => '彩虹渐变色昵称,五彩斑斓。',
|
||||
'price' => 500, 'type' => 'msg_name_color', 'duration_days' => 3, 'sort_order' => 61],
|
||||
['name' => '发光昵称', 'slug' => 'msg_name_glow', 'icon' => '✨',
|
||||
'description' => '昵称带柔和发光效果,暗夜中最亮的星。',
|
||||
'price' => 800, 'type' => 'msg_name_color', 'duration_days' => 7, 'sort_order' => 62],
|
||||
['name' => '火焰昵称', 'slug' => 'msg_name_flame', 'icon' => '🔥',
|
||||
'description' => '火焰色脉动昵称,热情似火。',
|
||||
'price' => 1500, 'type' => 'msg_name_color', 'duration_days' => 7, 'sort_order' => 63],
|
||||
|
||||
// ── 头像框装扮 ────────────────────────────
|
||||
['name' => '月银守护头像框', 'slug' => 'avatar_frame_silver', 'icon' => '🥈',
|
||||
'description' => '银白金属光泽外框,低调但比普通头像更精致。',
|
||||
'price' => 500, 'type' => 'avatar_frame', 'duration_days' => 7, 'sort_order' => 70],
|
||||
['name' => '金辉勋章头像框', 'slug' => 'avatar_frame_gold', 'icon' => '🥇',
|
||||
'description' => '金色勋章质感外框,带柔和光晕。',
|
||||
'price' => 1000, 'type' => 'avatar_frame', 'duration_days' => 7, 'sort_order' => 71],
|
||||
['name' => '星轨环绕头像框', 'slug' => 'avatar_frame_star', 'icon' => '⭐',
|
||||
'description' => '星轨渐变环绕头像旋转,适合高调展示。',
|
||||
'price' => 2000, 'type' => 'avatar_frame', 'duration_days' => 14, 'sort_order' => 72],
|
||||
['name' => '龙焰御守头像框', 'slug' => 'avatar_frame_dragon', 'icon' => '🐉',
|
||||
'description' => '红金御守质感外框,带虚线纹理和强烈光晕。',
|
||||
'price' => 5000, 'type' => 'avatar_frame', 'duration_days' => 30, 'sort_order' => 73],
|
||||
];
|
||||
|
||||
foreach ($items as $item) {
|
||||
ShopItem::firstOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]);
|
||||
// 商品名和描述可能随视觉样式迭代,Seeder 重跑时需要同步更新展示文案。
|
||||
ShopItem::updateOrCreate(['slug' => $item['slug']], $item + ['is_active' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,34 @@ const SHOP_GROUPS = [
|
||||
},
|
||||
];
|
||||
|
||||
const DECORATION_GROUPS = [
|
||||
{
|
||||
label: "💬 消息气泡",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "msg_bubble",
|
||||
},
|
||||
{
|
||||
label: "🎨 昵称颜色",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "msg_name_color",
|
||||
},
|
||||
{
|
||||
label: "🖼️ 头像框",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "avatar_frame",
|
||||
},
|
||||
];
|
||||
|
||||
const DECORATION_TYPE_TO_SLOT = {
|
||||
msg_bubble: "bubble",
|
||||
msg_name_color: "name_color",
|
||||
avatar_frame: "avatar_frame",
|
||||
};
|
||||
|
||||
let shopControlEventsBound = false;
|
||||
let shopLoaded = false;
|
||||
let giftItem = null;
|
||||
let activeDecorations = {};
|
||||
|
||||
/**
|
||||
* 读取商店根节点上由 Blade 注入的接口地址。
|
||||
@@ -106,6 +131,7 @@ export function openShopModal() {
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
bindShopTabs();
|
||||
if (!shopLoaded) {
|
||||
shopLoaded = true;
|
||||
fetchShopData();
|
||||
@@ -124,6 +150,45 @@ export function closeShopModal() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定商店 Tab 切换逻辑。
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function bindShopTabs() {
|
||||
const tabsContainer = document.getElementById("shop-tabs");
|
||||
if (!tabsContainer || tabsContainer.dataset.shopTabsBound) {
|
||||
return;
|
||||
}
|
||||
tabsContainer.dataset.shopTabsBound = "1";
|
||||
|
||||
tabsContainer.addEventListener("click", (event) => {
|
||||
const tab = event.target.closest("[data-shop-tab]");
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabName = tab.dataset.shopTab;
|
||||
const itemsList = document.getElementById("shop-items-list");
|
||||
const decorationsList = document.getElementById("shop-decorations-list");
|
||||
|
||||
// 切换当前 Tab 的选中状态。
|
||||
tabsContainer.querySelectorAll(".shop-tab").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shopTab === tabName);
|
||||
});
|
||||
|
||||
// 切换对应商品列表,装扮列表需要显式恢复 grid 布局。
|
||||
if (tabName === "items") {
|
||||
if (itemsList) itemsList.style.display = "";
|
||||
if (decorationsList) decorationsList.style.display = "none";
|
||||
} else {
|
||||
if (itemsList) itemsList.style.display = "none";
|
||||
// 装扮列表在 CSS 中为 display:none,需显式设置为 grid 才能覆盖
|
||||
if (decorationsList) decorationsList.style.display = "grid";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取商品数据并渲染列表。
|
||||
*
|
||||
@@ -136,6 +201,7 @@ export async function fetchShopData() {
|
||||
});
|
||||
const data = await response.json();
|
||||
renderShop(data);
|
||||
renderDecorations(data);
|
||||
} catch (error) {
|
||||
showShopToast("⚠ 加载失败,请重试", false);
|
||||
}
|
||||
@@ -245,6 +311,90 @@ export function renderShop(data) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染个人装扮商品列表。
|
||||
*
|
||||
* @param {Record<string, any>} data 商店接口数据
|
||||
* @returns {void}
|
||||
*/
|
||||
export function renderDecorations(data) {
|
||||
const list = document.getElementById("shop-decorations-list");
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录当前激活装扮,渲染时只给当前款式显示"已激活"。
|
||||
activeDecorations = data.active_decorations || {};
|
||||
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
list.innerHTML = "";
|
||||
|
||||
// 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框。
|
||||
const note = document.createElement("div");
|
||||
note.className = "decoration-note";
|
||||
note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。";
|
||||
list.appendChild(note);
|
||||
|
||||
DECORATION_GROUPS.forEach((group) => {
|
||||
const groupItems = items.filter((item) => item.type === group.type);
|
||||
if (!groupItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 分组标题(独占一整行),描述文字内嵌到标题中避免单独占一格
|
||||
const header = document.createElement("div");
|
||||
header.className = "shop-group-header";
|
||||
header.innerHTML = `${escapeHtml(group.label)}${group.desc ? ` <span>${escapeHtml(group.desc)}</span>` : ''}`;
|
||||
list.appendChild(header);
|
||||
|
||||
groupItems.forEach((item) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "shop-card";
|
||||
|
||||
// active_decorations 由后端按槽位名索引,先把商品 type 映射到对应槽位。
|
||||
const slot = DECORATION_TYPE_TO_SLOT[item.type] || null;
|
||||
const activeDeco = slot ? (activeDecorations[slot] || null) : null;
|
||||
const isActiveStyle = activeDeco && activeDeco.style === item.slug;
|
||||
const expiresAt = activeDeco ? activeDeco.expires_at : null;
|
||||
let daysLeft = "";
|
||||
if (isActiveStyle && expiresAt) {
|
||||
const remaining = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
daysLeft = remaining > 0 ? `剩余 ${remaining} 天` : "即将过期";
|
||||
}
|
||||
|
||||
const button = document.createElement("button");
|
||||
if (isActiveStyle) {
|
||||
button.className = "shop-btn";
|
||||
button.textContent = "续费 💰 " + Number(item.price || 0).toLocaleString();
|
||||
} else {
|
||||
button.className = "shop-btn";
|
||||
button.textContent = "购买 💰 " + Number(item.price || 0).toLocaleString();
|
||||
}
|
||||
button.addEventListener("click", () => confirmAndBuyItem(item));
|
||||
|
||||
// 仅当前已激活的款式显示状态标签,同槽位其他款式保持普通购买状态。
|
||||
const statusHtml = isActiveStyle
|
||||
? `<span class="decoration-status active">已激活${daysLeft ? ' · ' + daysLeft : ''}</span>`
|
||||
: "";
|
||||
|
||||
const validityHtml = buildValidityHtml(item);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="shop-card-top">
|
||||
<span class="shop-card-icon">${escapeHtml(item.icon)}</span>
|
||||
<span class="shop-card-name">${escapeHtml(item.name)}</span>
|
||||
</div>
|
||||
${statusHtml ? `<div class="decoration-status-line">${statusHtml}</div>` : ""}
|
||||
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
|
||||
${validityHtml}
|
||||
${isActiveStyle && expiresAt ? `<div class="decoration-expiry">⏳ 到期:${escapeHtml(expiresAt.replace('T', ' ').slice(0, 16))}</div>` : ""}
|
||||
`;
|
||||
card.appendChild(button);
|
||||
list.appendChild(card);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个商品卡片,并挂载购买或使用按钮事件。
|
||||
*
|
||||
@@ -302,9 +452,7 @@ function buildShopCardHtml(item, options) {
|
||||
<span style="position:absolute;top:-4px;right:-6px;background:#f43f5e;color:#fff;font-size:9px;font-weight:800;min-width:15px;height:15px;border-radius:8px;text-align:center;line-height:15px;padding:0 2px;">${options.ownedQty}</span>
|
||||
</span>`
|
||||
: `<span class="shop-card-icon">${escapeHtml(item.icon)}</span>`;
|
||||
const durationLabel = options.isAutoFishing && Number(item.duration_minutes || 0) > 0
|
||||
? `<div style="font-size:9px;margin-top:3px;color:#7c3aed;">⏱ 有效期 ${escapeHtml(formatMinutes(item.duration_minutes))}</div>`
|
||||
: "";
|
||||
const validityHtml = buildValidityHtml(item);
|
||||
const ringBonus = options.isRing && (Number(item.intimacy_bonus || 0) > 0 || Number(item.charm_bonus || 0) > 0)
|
||||
? `<div style="font-size:9px;margin-top:3px;display:flex;gap:8px;">
|
||||
${Number(item.intimacy_bonus || 0) > 0 ? `<span style="color:#f43f5e;">💞 亲密 +${Number(item.intimacy_bonus || 0)}</span>` : ""}
|
||||
@@ -319,10 +467,52 @@ function buildShopCardHtml(item, options) {
|
||||
</div>
|
||||
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
|
||||
${ringBonus}
|
||||
${durationLabel}
|
||||
${validityHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成购买前展示的有效期或生效方式文案。
|
||||
*
|
||||
* @param {Record<string, any>} item 商品数据
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildValidityText(item) {
|
||||
if (Number(item.duration_days || 0) > 0) {
|
||||
return `有效期:${Number(item.duration_days)} 天`;
|
||||
}
|
||||
|
||||
if (Number(item.duration_minutes || 0) > 0) {
|
||||
return `有效期:${formatMinutes(item.duration_minutes)}`;
|
||||
}
|
||||
|
||||
if (item.type === "instant") {
|
||||
return "购买后立即播放 1 次";
|
||||
}
|
||||
|
||||
if (item.type === "ring") {
|
||||
return "购买后存入背包,求婚时消耗";
|
||||
}
|
||||
|
||||
if (["one_time", "sign_repair"].includes(item.type)) {
|
||||
return "购买后存入背包,使用时消耗";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成商品卡片里的有效期标签 HTML。
|
||||
*
|
||||
* @param {Record<string, any>} item 商品数据
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildValidityHtml(item) {
|
||||
const text = buildValidityText(item);
|
||||
|
||||
return text ? `<div class="shop-validity">⏱ ${escapeHtml(text)}</div>` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分钟数,供自动钓鱼卡有效期展示。
|
||||
*
|
||||
@@ -351,7 +541,11 @@ async function confirmAndBuyItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""} 吗?`;
|
||||
const validityText = buildValidityText(item);
|
||||
const replacementText = DECORATION_TYPE_TO_SLOT[item.type]
|
||||
? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。"
|
||||
: "";
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`;
|
||||
const confirmed = await confirmShopPurchase(confirmMessage);
|
||||
|
||||
if (confirmed) {
|
||||
@@ -401,7 +595,8 @@ export function openGiftDialog(item) {
|
||||
}
|
||||
|
||||
if (itemName) {
|
||||
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()})`;
|
||||
const validityText = buildValidityText(item);
|
||||
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}${validityText ? ` · ${validityText}` : ""})`;
|
||||
}
|
||||
|
||||
if (dialog) {
|
||||
@@ -499,6 +694,14 @@ function handleBuySuccess(data, itemName) {
|
||||
|
||||
showShopToast(`✅ ${itemName} 购买成功!`, true);
|
||||
|
||||
// 装扮购买成功后先更新本地缓存,随后再拉接口刷新完整状态。
|
||||
if (data.slot && data.style) {
|
||||
activeDecorations[data.slot] = {
|
||||
style: data.style,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
// 购买者本地也要立即看到特效,广播只负责其他在线用户。
|
||||
if (data.play_effect && window.EffectManager) {
|
||||
window.EffectManager.play(data.play_effect);
|
||||
@@ -625,6 +828,7 @@ function exposeShopGlobals() {
|
||||
window.loadShop = loadShop;
|
||||
window.fetchShopData = fetchShopData;
|
||||
window.renderShop = renderShop;
|
||||
window.renderDecorations = renderDecorations;
|
||||
window.openGiftDialog = openGiftDialog;
|
||||
window.closeGiftDialog = closeGiftDialog;
|
||||
window.confirmGift = confirmGift;
|
||||
|
||||
@@ -341,6 +341,40 @@
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
/* 装扮列表区 — 与商品列表同风格 */
|
||||
#shop-decorations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
display: none;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
background: #f6faff;
|
||||
}
|
||||
|
||||
/* Tab 导航 */
|
||||
#shop-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #cde;
|
||||
flex-shrink: 0;
|
||||
background: #eef4fb;
|
||||
}
|
||||
.shop-tab {
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: bold;
|
||||
transition: all .2s;
|
||||
}
|
||||
.shop-tab.active { color: #336699 !important; border-bottom-color: #336699 !important; }
|
||||
.shop-tab:hover { color: #5a8fc0; }
|
||||
|
||||
/* 分组标题 — 独占一整行 */
|
||||
.shop-group-header {
|
||||
grid-column: 1 / -1;
|
||||
@@ -404,6 +438,37 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 装扮Tab购买说明 */
|
||||
.decoration-note {
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 10px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
/* 装扮卡片状态行(独立一行,显示在商品名下方) */
|
||||
.decoration-status-line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* 装扮卡片状态标签 */
|
||||
.decoration-status {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.decoration-status.active { background: #065f46; color: #6ee7b7; }
|
||||
.decoration-status.expired { background: #7f1d1d; color: #fca5a5; }
|
||||
/* 装扮有效期提示 */
|
||||
.decoration-duration {
|
||||
font-size: 10px;
|
||||
color: #6366f1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.decoration-expiry { font-size: 10px; color: #9ca3af; margin-top: 2px; }
|
||||
|
||||
.shop-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -598,11 +663,22 @@
|
||||
{{-- Toast --}}
|
||||
<div id="shop-toast"></div>
|
||||
|
||||
{{-- Tab 导航 --}}
|
||||
<div id="shop-tabs">
|
||||
<button class="shop-tab active" data-shop-tab="items">特效道具</button>
|
||||
<button class="shop-tab" data-shop-tab="decorations">个人装扮</button>
|
||||
</div>
|
||||
|
||||
{{-- 商品网格 --}}
|
||||
<div id="shop-items-list">
|
||||
<div style="grid-column:1/-1; text-align:center; color:#6366f1; padding:30px 0; font-size:13px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 装扮网格 --}}
|
||||
<div id="shop-decorations-list">
|
||||
<div style="grid-column:1/-1; text-align:center; color:#6366f1; padding:30px 0; font-size:13px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 改名内嵌遮罩 --}}
|
||||
<div id="shop-rename-overlay">
|
||||
<div id="shop-rename-box">
|
||||
|
||||
@@ -24,6 +24,238 @@
|
||||
@version 2.0.0
|
||||
--}}
|
||||
|
||||
{{-- 个人装扮样式(消息气泡 / 昵称颜色 / 头像框) --}}
|
||||
<style>
|
||||
/* ========== 消息气泡装扮:在原版逐行消息基础上增加纹理、角标和轻量动效 ========== */
|
||||
.msg-line[class*="msg-bubble--"] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
min-height: 24px;
|
||||
margin: 4px 0;
|
||||
padding: 5px 12px 5px 14px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(51, 102, 153, .16);
|
||||
background: rgba(255, 255, 255, .72);
|
||||
box-shadow: 0 1px 4px rgba(51, 102, 153, .12);
|
||||
}
|
||||
|
||||
.msg-line[class*="msg-bubble--"]::before,
|
||||
.msg-line[class*="msg-bubble--"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.msg-line[class*="msg-bubble--"] > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.msg-bubble--golden {
|
||||
border-color: rgba(217, 119, 6, .32) !important;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(245, 158, 11, .32) 0 4px, transparent 4px),
|
||||
radial-gradient(circle at 28px 8px, rgba(255, 255, 255, .85), transparent 10px),
|
||||
linear-gradient(135deg, #fff8df 0%, #fffdf5 56%, #fff1c2 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(217, 119, 6, .18);
|
||||
}
|
||||
|
||||
.msg-bubble--golden::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -36px;
|
||||
width: 36px;
|
||||
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .72), transparent);
|
||||
animation: msg-bubble-shine 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.msg-bubble--sakura {
|
||||
border-color: rgba(244, 114, 182, .32) !important;
|
||||
background:
|
||||
radial-gradient(circle at 18px 10px, rgba(244, 114, 182, .42) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 44px 20px, rgba(251, 207, 232, .86) 0 3px, transparent 4px),
|
||||
linear-gradient(135deg, #fff7fb 0%, #fff 48%, #ffe4f1 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .78), 0 2px 8px rgba(244, 114, 182, .14);
|
||||
}
|
||||
|
||||
.msg-bubble--star {
|
||||
border-color: rgba(79, 70, 229, .32) !important;
|
||||
background:
|
||||
radial-gradient(circle at 20px 9px, rgba(255, 255, 255, .9) 0 1px, transparent 2px),
|
||||
radial-gradient(circle at 76px 20px, rgba(99, 102, 241, .36) 0 2px, transparent 3px),
|
||||
linear-gradient(135deg, #eef2ff 0%, #f8fbff 54%, #dbeafe 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .8), 0 2px 10px rgba(79, 70, 229, .16);
|
||||
}
|
||||
|
||||
.msg-bubble--star::before {
|
||||
right: 10px;
|
||||
top: 5px;
|
||||
width: 42px;
|
||||
height: 16px;
|
||||
background: radial-gradient(circle, rgba(67, 56, 202, .42) 0 1px, transparent 2px);
|
||||
background-size: 11px 8px;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.msg-bubble--rainbow {
|
||||
border-color: rgba(59, 130, 246, .22) !important;
|
||||
background:
|
||||
linear-gradient(#ffffffd9, #ffffffd9) padding-box,
|
||||
linear-gradient(120deg, rgba(239, 68, 68, .16), rgba(245, 158, 11, .16), rgba(34, 197, 94, .16), rgba(59, 130, 246, .16), rgba(168, 85, 247, .16)) border-box !important;
|
||||
box-shadow: 0 2px 10px rgba(59, 130, 246, .14);
|
||||
}
|
||||
|
||||
.msg-bubble--rainbow::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7, #ef4444);
|
||||
background-size: 180% 100%;
|
||||
animation: msg-bubble-rainbow 4.2s linear infinite;
|
||||
}
|
||||
|
||||
.msg-bubble--crown {
|
||||
border-color: rgba(180, 83, 9, .34) !important;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(180, 83, 9, .24) 0 4px, transparent 4px),
|
||||
radial-gradient(circle at right 12px top 8px, rgba(251, 191, 36, .36), transparent 18px),
|
||||
linear-gradient(135deg, #fff7d6 0%, #fffdfa 46%, #fde68a 100%) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .82), 0 3px 12px rgba(180, 83, 9, .22);
|
||||
}
|
||||
|
||||
.msg-bubble--crown::after {
|
||||
content: "♛";
|
||||
top: 2px;
|
||||
right: 8px;
|
||||
z-index: 0;
|
||||
color: rgba(180, 83, 9, .26);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@keyframes msg-bubble-shine {
|
||||
0%, 62% { transform: translateX(0); opacity: 0; }
|
||||
72% { opacity: .82; }
|
||||
100% { transform: translateX(280px); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes msg-bubble-rainbow {
|
||||
from { background-position: 0% 50%; }
|
||||
to { background-position: 180% 50%; }
|
||||
}
|
||||
|
||||
/* ========== 昵称颜色 ========== */
|
||||
.msg-name--golden { color: #fbbf24 !important; font-weight: 700; }
|
||||
.msg-name--rainbow {
|
||||
background: linear-gradient(90deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg-name--glow {
|
||||
color: #e2e8f0 !important;
|
||||
text-shadow: 0 0 6px #818cf8, 0 0 14px #6366f1;
|
||||
}
|
||||
.msg-name--flame {
|
||||
color: #f97316 !important;
|
||||
font-weight: 700;
|
||||
animation: name-flame 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes name-flame {
|
||||
0%, 100% { text-shadow: 0 0 4px #ef4444; }
|
||||
50% { text-shadow: 0 0 10px #fbbf24, 0 0 16px #ef4444; }
|
||||
}
|
||||
|
||||
/* ========== 头像框 ========== */
|
||||
.avatar-frame-wrapper {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.avatar-frame-wrapper .user-head {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.avatar-frame::before,
|
||||
.avatar-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatar-frame--silver {
|
||||
background: conic-gradient(from 25deg, #ffffff, #94a3b8, #e2e8f0, #64748b, #ffffff);
|
||||
box-shadow: 0 0 0 1px rgba(148, 163, 184, .38), 0 2px 8px rgba(100, 116, 139, .24);
|
||||
}
|
||||
|
||||
.avatar-frame--silver::before,
|
||||
.avatar-frame--gold::before,
|
||||
.avatar-frame--star::before,
|
||||
.avatar-frame--dragon::before {
|
||||
inset: 4px;
|
||||
border-radius: 50%;
|
||||
background: #eaf3ff;
|
||||
}
|
||||
|
||||
.avatar-frame--gold {
|
||||
background: conic-gradient(from -20deg, #fff7ad, #f59e0b, #fff1a6, #b45309, #fff7ad);
|
||||
box-shadow: 0 0 0 1px rgba(217, 119, 6, .34), 0 0 12px rgba(245, 158, 11, .38);
|
||||
}
|
||||
|
||||
.avatar-frame--star {
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, #ffffff 0 2px, transparent 3px),
|
||||
conic-gradient(from 0deg, #fef08a, #818cf8, #ffffff, #fbbf24, #818cf8, #fef08a);
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, .48);
|
||||
animation: frame-rotate 4s linear infinite;
|
||||
}
|
||||
|
||||
.avatar-frame--star::after {
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .9) 0 2px, transparent 3px);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.avatar-frame--dragon {
|
||||
background:
|
||||
conic-gradient(from 45deg, #7f1d1d, #f59e0b, #ef4444, #991b1b, #f59e0b, #7f1d1d);
|
||||
box-shadow: 0 0 14px rgba(239, 68, 68, .42), 0 0 0 1px rgba(127, 29, 29, .38);
|
||||
}
|
||||
|
||||
.avatar-frame--dragon::after {
|
||||
inset: 5px;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(254, 202, 202, .82);
|
||||
}
|
||||
|
||||
@keyframes frame-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 聊天室前端交互逻辑
|
||||
@@ -1821,9 +2053,25 @@
|
||||
|
||||
// 女生名字使用玫粉色
|
||||
const nameColor = (user.sex == 2) ? 'color:#e91e8c;' : '';
|
||||
// ── 昵称颜色装扮 ──
|
||||
var userNameExtraClass = '';
|
||||
if (user.name_color) {
|
||||
userNameExtraClass = ' msg-name--' + user.name_color.replace(/^msg_name_/, '');
|
||||
}
|
||||
// ── 头像框装扮 ──
|
||||
var avatarHtml = '';
|
||||
if (user.avatar_frame) {
|
||||
var frameClass = 'avatar-frame--' + user.avatar_frame.replace(/^avatar_frame_/, '');
|
||||
avatarHtml = '<span class="avatar-frame-wrapper">' +
|
||||
'<span class="avatar-frame ' + frameClass + '"></span>' +
|
||||
'<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">' +
|
||||
'</span>';
|
||||
} else {
|
||||
avatarHtml = '<img class="user-head" src="' + headImgSrc + '" onerror="this.src=\'/images/headface/1.gif\'">';
|
||||
}
|
||||
item.innerHTML = `
|
||||
<img class="user-head" src="${headImgSrc}" onerror="this.src='/images/headface/1.gif'">
|
||||
<span class="user-name" style="${nameColor}">${username}</span>
|
||||
${avatarHtml}
|
||||
<span class="user-name${userNameExtraClass}" style="${nameColor}">${username}</span>
|
||||
<span class="user-badge-slot">${badges}</span>
|
||||
`;
|
||||
|
||||
@@ -1834,6 +2082,7 @@
|
||||
targetContainer.replaceChildren(fragment);
|
||||
refreshRenderedUserBadges(targetContainer);
|
||||
}
|
||||
window._renderUserListToContainer = _renderUserListToContainer;
|
||||
|
||||
function renderUserList() {
|
||||
if (userListRenderTimer) {
|
||||
@@ -2122,9 +2371,20 @@
|
||||
div.dataset.blockKey = blockRuleKey;
|
||||
}
|
||||
|
||||
// ── 消息气泡装扮 ──
|
||||
if (msg.msg_bubble) {
|
||||
var bubbleStyle = msg.msg_bubble.replace(/^msg_bubble_/, '');
|
||||
div.classList.add('msg-bubble--' + bubbleStyle);
|
||||
}
|
||||
|
||||
const timeStr = msg.sent_at || '';
|
||||
let timeStrOverride = false;
|
||||
|
||||
var nameClass = '';
|
||||
if (msg.msg_name_color) {
|
||||
nameClass = ' msg-name--' + msg.msg_name_color.replace(/^msg_name_/, '');
|
||||
}
|
||||
|
||||
// 系统用户名列表(不可被选为聊天对象)
|
||||
const systemUsers = ['钓鱼播报', '星海小博士', '系统传音', '系统公告', '送花播报', '系统', '欢迎', '系统播报', '神秘箱子'];
|
||||
|
||||
@@ -2201,15 +2461,15 @@
|
||||
};
|
||||
|
||||
// 用户名(单击切换发言对象,双击查看资料;事件委托已迁至 Vite right-panel.js)
|
||||
const clickableUser = (uName, color) => {
|
||||
const clickableUser = (uName, color, extraClass = '') => {
|
||||
const safeName = escapeHtml(uName);
|
||||
if (uName === 'AI小班长') {
|
||||
return `<span class="msg-user" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
|
||||
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
|
||||
}
|
||||
if (systemUsers.includes(uName) || isGameLabel(uName)) {
|
||||
return `<span class="msg-user" style="color: ${color};">${safeName}</span>`;
|
||||
return `<span class="msg-user${extraClass}" style="color: ${color};">${safeName}</span>`;
|
||||
}
|
||||
return `<span class="msg-user" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
|
||||
return `<span class="msg-user${extraClass}" data-chat-message-user data-u="${safeName}" style="color: ${color}; cursor: pointer;">${safeName}</span>`;
|
||||
};
|
||||
|
||||
// 普通用户(包括 AI小班长)用数据库头像,播报类用特殊喇叭图标
|
||||
@@ -2330,7 +2590,7 @@
|
||||
});
|
||||
|
||||
html =
|
||||
`${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor)}:</span><span class="msg-content" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
|
||||
`${headImg}<span style="font-weight: bold;">${clickableUser(msg.from_user, fontColor, nameClass)}:</span><span class="msg-content" style="color: ${fontColor}">${parsedContent}</span>${giftHtml}`;
|
||||
}
|
||||
} else if (msg.is_secret) {
|
||||
if (msg.from_user === '系统') {
|
||||
@@ -2341,7 +2601,7 @@
|
||||
`<span style="color:#16a34a;font-weight:bold;">📢 系统:</span><span style="color:#15803d;">${msg.content}</span>`;
|
||||
} else {
|
||||
// 普通悄悄话样式(原版:紫色斜体,使用自然语序动作)
|
||||
const fromHtml = clickableUser(msg.from_user, '#cc00cc');
|
||||
const fromHtml = clickableUser(msg.from_user, '#cc00cc', nameClass);
|
||||
const toHtml = clickableUser(msg.to_user, '#cc00cc');
|
||||
const verbStr = msg.action ?
|
||||
buildActionStr(msg.action, fromHtml, toHtml, '悄悄说') :
|
||||
@@ -2351,7 +2611,7 @@
|
||||
}
|
||||
} else if (msg.to_user && msg.to_user !== '大家') {
|
||||
// 对特定对象说话
|
||||
const fromHtml = clickableUser(msg.from_user, '#000099');
|
||||
const fromHtml = clickableUser(msg.from_user, '#000099', nameClass);
|
||||
const toHtml = clickableUser(msg.to_user, '#000099');
|
||||
const verbStr = msg.action ?
|
||||
buildActionStr(msg.action, fromHtml, toHtml) :
|
||||
@@ -2359,7 +2619,7 @@
|
||||
html = `${headImg}${verbStr}<span class="msg-content" style="color: ${fontColor}">${messageBodyHtml}</span>`;
|
||||
} else {
|
||||
// 对大家说话
|
||||
const fromHtml = clickableUser(msg.from_user, '#000099');
|
||||
const fromHtml = clickableUser(msg.from_user, '#000099', nameClass);
|
||||
const verbStr = msg.action ?
|
||||
buildActionStr(msg.action, fromHtml, '大家') :
|
||||
`${fromHtml}对大家说:`;
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
#shop-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
/* 顶部 tab 栏高度约 26px,底部状态栏约 22px */
|
||||
top: 26px;
|
||||
/* 顶部 tab 栏高度约 56px,底部状态栏约 22px */
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
@@ -19,6 +19,9 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.shop-tab.active { color: #e2e8f0 !important; border-bottom-color: #818cf8 !important; }
|
||||
.shop-tab:hover { color: #c7d2fe; }
|
||||
|
||||
#shop-balance-bar {
|
||||
padding: 6px 8px;
|
||||
background: linear-gradient(135deg, #1e1b4b, #312e81);
|
||||
@@ -133,6 +136,21 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.shop-validity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
margin-top: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(79, 70, 229, .18);
|
||||
border: 1px solid rgba(129, 140, 248, .42);
|
||||
color: #c7d2fe;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 购买按钮 */
|
||||
.shop-btn {
|
||||
display: inline-flex;
|
||||
@@ -241,6 +259,37 @@
|
||||
margin-top: 5px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
/* ── 装扮卡片状态标签 ────────────── */
|
||||
.decoration-status {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.decoration-status.active { background: #065f46; color: #6ee7b7; }
|
||||
.decoration-expiry { font-size: 9px; color: #9ca3af; margin-top: 2px; }
|
||||
|
||||
.decoration-note {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 0 6px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid rgba(251, 191, 36, .35);
|
||||
border-radius: 8px;
|
||||
background: rgba(120, 53, 15, .28);
|
||||
color: #fde68a;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#shop-decorations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 5px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4338ca #0f0c29;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="shop-panel"
|
||||
@@ -254,14 +303,25 @@
|
||||
<span id="shop-week-badge"></span>
|
||||
</div>
|
||||
|
||||
{{-- Tab 导航 --}}
|
||||
<div id="shop-tabs" style="display:flex; border-bottom: 1px solid #3730a3; flex-shrink: 0;">
|
||||
<button class="shop-tab active" data-shop-tab="items" style="flex:1; padding: 6px; background: transparent; border: none; color: #a5b4fc; font-size: 11px; cursor: pointer; border-bottom: 2px solid transparent;">特效道具</button>
|
||||
<button class="shop-tab" data-shop-tab="decorations" style="flex:1; padding: 6px; background: transparent; border: none; color: #6b7280; font-size: 11px; cursor: pointer; border-bottom: 2px solid transparent;">个人装扮</button>
|
||||
</div>
|
||||
|
||||
{{-- Toast --}}
|
||||
<div id="shop-toast"></div>
|
||||
|
||||
{{-- 商品列表 --}}
|
||||
{{-- 特效道具列表 --}}
|
||||
<div id="shop-items-list">
|
||||
<div style="text-align:center;color:#6366f1;padding:20px 0;font-size:11px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 个人装扮列表 --}}
|
||||
<div id="shop-decorations-list">
|
||||
<div style="text-align:center;color:#6366f1;padding:20px 0;font-size:11px;">加载中…</div>
|
||||
</div>
|
||||
|
||||
{{-- 改名弹框 --}}
|
||||
<div id="rename-modal">
|
||||
<div id="rename-modal-inner">
|
||||
|
||||
Reference in New Issue
Block a user