diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 6529092..5260354 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -41,6 +41,9 @@ enum CurrencySource: string /** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */ case POSITION_REWARD = 'position_reward'; + /** 每日签到奖励(连续签到按规则发放) */ + case SIGN_IN = 'sign_in'; + /** AI赠送福利(用户向AI祈求获得的随机奖励) */ case AI_GIFT = 'ai_gift'; @@ -48,7 +51,6 @@ enum CurrencySource: string case GIFT_SENT = 'gift_sent'; // ─── 以后新增活动,在这里加一行即可,数据库无需变更 ─────────── - // case SIGN_IN = 'sign_in'; // 每日签到 // case TASK_REWARD = 'task_reward'; // 任务奖励 // case PVP_WIN = 'pvp_win'; // PVP 胜利奖励 @@ -153,6 +155,7 @@ enum CurrencySource: string self::SHOP_BUY => '商城购买', self::ADMIN_ADJUST => '管理员调整', self::POSITION_REWARD => '职务奖励', + self::SIGN_IN => '每日签到', self::AI_GIFT => 'AI赠送', self::GIFT_SENT => '发红包', self::MARRY_CHARM => '结婚魅力加成', diff --git a/app/Http/Controllers/Admin/ShopItemController.php b/app/Http/Controllers/Admin/ShopItemController.php index eda5470..6b8fd22 100644 --- a/app/Http/Controllers/Admin/ShopItemController.php +++ b/app/Http/Controllers/Admin/ShopItemController.php @@ -101,7 +101,7 @@ class ShopItemController extends Controller 'icon' => 'required|string|max:20', 'description' => 'nullable|string|max:500', 'price' => 'required|integer|min:0', - 'type' => 'required|in:instant,duration,one_time,ring,auto_fishing', + 'type' => 'required|in:instant,duration,one_time,ring,auto_fishing,'.ShopItem::TYPE_SIGN_REPAIR, 'duration_days' => 'nullable|integer|min:0', 'duration_minutes' => 'nullable|integer|min:0', 'intimacy_bonus' => 'nullable|integer|min:0', diff --git a/app/Http/Controllers/Admin/SignInRewardRuleController.php b/app/Http/Controllers/Admin/SignInRewardRuleController.php new file mode 100644 index 0000000..f1c6725 --- /dev/null +++ b/app/Http/Controllers/Admin/SignInRewardRuleController.php @@ -0,0 +1,96 @@ +orderBy('sort_order') + ->orderBy('streak_days') + ->get(); + + return view('admin.sign-in-rules.index', compact('rules')); + } + + /** + * 方法功能:新增签到奖励规则。 + */ + public function store(SaveSignInRewardRuleRequest $request): RedirectResponse + { + SignInRewardRule::query()->create($this->payload($request)); + + return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已创建。'); + } + + /** + * 方法功能:更新签到奖励规则。 + */ + public function update(SaveSignInRewardRuleRequest $request, SignInRewardRule $signInRewardRule): RedirectResponse + { + $signInRewardRule->update($this->payload($request)); + + return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已更新。'); + } + + /** + * 方法功能:切换签到奖励规则启用状态。 + */ + public function toggle(SignInRewardRule $signInRewardRule): JsonResponse + { + $signInRewardRule->update(['is_enabled' => ! $signInRewardRule->is_enabled]); + + return response()->json([ + 'ok' => true, + 'is_enabled' => $signInRewardRule->is_enabled, + 'message' => $signInRewardRule->is_enabled ? '规则已启用。' : '规则已停用。', + ]); + } + + /** + * 方法功能:删除签到奖励规则。 + */ + public function destroy(SignInRewardRule $signInRewardRule): RedirectResponse + { + $signInRewardRule->delete(); + + return redirect()->route('admin.sign-in-rules.index')->with('success', '签到奖励规则已删除。'); + } + + /** + * 方法功能:整理后台表单提交的数据。 + * + * @return array + */ + private function payload(SaveSignInRewardRuleRequest $request): array + { + $data = $request->validated(); + $data['is_enabled'] = $request->boolean('is_enabled'); + + foreach (['identity_badge_code', 'identity_badge_name', 'identity_badge_icon', 'identity_badge_color'] as $field) { + $data[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null; + } + + return $data; + } +} diff --git a/app/Http/Controllers/DailySignInController.php b/app/Http/Controllers/DailySignInController.php new file mode 100644 index 0000000..32074a3 --- /dev/null +++ b/app/Http/Controllers/DailySignInController.php @@ -0,0 +1,293 @@ +signInService->status($user); + + return response()->json([ + 'status' => 'success', + 'data' => $this->formatStatusPayload($user, $status), + ]); + } + + /** + * 方法功能:查询指定月份的签到日历与补签卡状态。 + */ + public function calendar(DailySignInCalendarRequest $request): JsonResponse + { + /** @var User $user */ + $user = Auth::user(); + + return response()->json([ + 'status' => 'success', + 'data' => $this->signInService->calendar($user, $request->validated('month')), + ]); + } + + /** + * 方法功能:领取今日签到奖励并同步聊天室在线名单。 + */ + public function claim(ClaimDailySignInRequest $request): JsonResponse + { + /** @var User $user */ + $user = Auth::user(); + $roomId = $request->validated('room_id'); + $roomId = $roomId === null ? null : (int) $roomId; + + $dailySignIn = $this->signInService->claim($user, $roomId); + if (! $dailySignIn->wasRecentlyCreated) { + return response()->json([ + 'status' => 'error', + 'message' => '今日已签到,请明天再来。', + 'data' => $this->formatClaimPayload($user->fresh(), $dailySignIn), + ], 422); + } + + $freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']); + $presencePayload = $this->presenceService->build($freshUser); + $this->refreshOnlinePresence($freshUser, $presencePayload); + + if ($roomId !== null && $this->chatState->isUserInRoom($roomId, $freshUser->username)) { + $this->broadcastSignInNotice($freshUser, $dailySignIn, $roomId); + } + + return response()->json([ + 'status' => 'success', + 'message' => $this->buildSuccessMessage($dailySignIn), + 'data' => $this->formatClaimPayload($freshUser, $dailySignIn, $presencePayload), + ]); + } + + /** + * 方法功能:使用补签卡补签历史漏签日期。 + */ + public function makeup(MakeupDailySignInRequest $request): JsonResponse + { + /** @var User $user */ + $user = Auth::user(); + $roomId = $request->validated('room_id'); + $roomId = $roomId === null ? null : (int) $roomId; + + $dailySignIn = $this->signInService->makeup($user, (string) $request->validated('target_date'), $roomId); + $refreshedSignIn = $dailySignIn->fresh(); + $latestSignIn = DailySignIn::query() + ->where('user_id', $user->id) + ->latest('sign_in_date') + ->first(); + $currentStreakDays = (int) ($latestSignIn?->streak_days ?? $refreshedSignIn?->streak_days ?? 0); + + $freshUser = $user->fresh(['vipLevel', 'activePosition.position.department']); + $presencePayload = $this->presenceService->build($freshUser); + $this->refreshOnlinePresence($freshUser, $presencePayload); + + return response()->json([ + 'status' => 'success', + 'message' => '补签成功,'.$refreshedSignIn?->sign_in_date?->format('Y-m-d').' 已补签,当前连续签到 '.$currentStreakDays.' 天。', + 'data' => $this->formatClaimPayload($freshUser, $refreshedSignIn, $presencePayload, $currentStreakDays), + ]); + } + + /** + * 方法功能:刷新用户当前所在房间的 Redis 在线载荷并广播名单更新。 + * + * @param array $presencePayload + */ + private function refreshOnlinePresence(User $user, array $presencePayload): void + { + foreach ($this->chatState->getUserRooms($user->username) as $activeRoomId) { + // 签到身份会展示在在线名单里,必须立即回写 Redis 载荷。 + $this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload); + broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload)); + } + } + + /** + * 方法功能:向当前聊天室广播签到成功通知。 + */ + private function broadcastSignInNotice(User $user, DailySignIn $dailySignIn, int $roomId): void + { + $message = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '签到播报', + 'to_user' => '大家', + 'content' => $this->buildNoticeContent($user, $dailySignIn), + 'is_secret' => false, + 'font_color' => '#0f766e', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + + $this->chatState->pushMessage($roomId, $message); + broadcast(new MessageSent($roomId, $message)); + } + + /** + * 方法功能:生成聊天室内的签到播报内容。 + */ + private function buildNoticeContent(User $user, DailySignIn $dailySignIn): string + { + $rewardText = $this->buildRewardText($dailySignIn); + $identityText = $dailySignIn->identity_badge_name + ? ',获得身份 '.e($dailySignIn->identity_badge_name) + : ''; + + $quickButton = ''; + + return '【'.e($user->username).'】完成今日签到,连续签到 ' + .$dailySignIn->streak_days.' 天,获得 '.$rewardText.$identityText.'。'.$quickButton; + } + + /** + * 方法功能:生成本机签到成功提示文案。 + */ + private function buildSuccessMessage(DailySignIn $dailySignIn): string + { + return '签到成功,连续签到 '.$dailySignIn->streak_days.' 天,获得 '.$this->buildRewardText($dailySignIn).'。'; + } + + /** + * 方法功能:按实际签到奖励快照生成奖励描述。 + */ + private function buildRewardText(DailySignIn $dailySignIn): string + { + $items = []; + if ($dailySignIn->gold_reward > 0) { + $items[] = $dailySignIn->gold_reward.' 金币'; + } + if ($dailySignIn->exp_reward > 0) { + $items[] = $dailySignIn->exp_reward.' 经验'; + } + if ($dailySignIn->charm_reward > 0) { + $items[] = $dailySignIn->charm_reward.' 魅力'; + } + + return $items === [] ? '签到记录' : implode(' + ', $items); + } + + /** + * 方法功能:格式化状态查询响应载荷。 + * + * @param array $status + * @return array + */ + private function formatStatusPayload(User $user, array $status): array + { + return [ + 'signed_today' => $status['signed_today'], + 'can_claim' => $status['can_claim'], + 'current_streak_days' => $status['current_streak_days'], + 'claimable_streak_days' => $status['claimable_streak_days'], + 'preview_rule' => $status['matched_rule']?->only([ + 'streak_days', + 'gold_reward', + 'exp_reward', + 'charm_reward', + 'identity_badge_name', + 'identity_badge_icon', + 'identity_badge_color', + 'identity_duration_days', + ]), + 'identity' => $this->formatIdentityPayload($status['current_identity']), + 'user' => [ + 'jjb' => (int) $user->jjb, + ], + ]; + } + + /** + * 方法功能:格式化签到领取响应载荷。 + * + * @param array|null $presencePayload + * @return array + */ + private function formatClaimPayload(User $user, DailySignIn $dailySignIn, ?array $presencePayload = null, ?int $currentStreakDays = null): array + { + $identity = $user->currentSignInIdentity(); + + return [ + 'sign_in' => [ + 'id' => $dailySignIn->id, + 'sign_in_date' => $dailySignIn->sign_in_date?->toDateString(), + 'is_makeup' => (bool) $dailySignIn->is_makeup, + 'streak_days' => (int) $dailySignIn->streak_days, + 'gold_reward' => (int) $dailySignIn->gold_reward, + 'exp_reward' => (int) $dailySignIn->exp_reward, + 'charm_reward' => (int) $dailySignIn->charm_reward, + ], + 'current_streak_days' => $currentStreakDays ?? (int) $dailySignIn->streak_days, + 'identity' => $this->formatIdentityPayload($identity), + 'presence' => $presencePayload ?? $this->presenceService->build($user), + 'user' => [ + 'jjb' => (int) $user->jjb, + 'gold' => (int) $user->jjb, + ], + ]; + } + + /** + * 方法功能:格式化签到身份数据供前端展示。 + * + * @return array|null + */ + private function formatIdentityPayload(?UserIdentityBadge $identity): ?array + { + if ($identity === null) { + return null; + } + + return [ + 'key' => $identity->badge_code, + 'label' => $identity->badge_name, + 'name' => $identity->badge_name, + 'icon' => $identity->badge_icon ?? '✅', + 'color' => $identity->badge_color ?? '#0f766e', + 'expires_at' => $identity->expires_at?->toIso8601String(), + 'streak_days' => (int) data_get($identity->metadata, 'streak_days', 0), + ]; + } +} diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index 2d03e33..b68eb33 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -10,6 +10,7 @@ namespace App\Http\Controllers; use App\Events\EffectBroadcast; use App\Events\MessageSent; use App\Models\ShopItem; +use App\Models\UserPurchase; use App\Services\ShopService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -47,8 +48,10 @@ class ShopController extends Controller 'charm_bonus' => $item->charm_bonus, ]); + $signRepairCard = $items->firstWhere('type', ShopItem::TYPE_SIGN_REPAIR); + // 统计背包中各戒指持有数量 - $ringCounts = \App\Models\UserPurchase::query() + $ringCounts = UserPurchase::query() ->where('user_id', $user->id) ->where('status', 'active') ->whereHas('item', fn ($q) => $q->where('type', 'ring')) @@ -64,6 +67,8 @@ class ShopController extends Controller 'has_rename_card' => $this->shopService->hasRenameCard($user), 'ring_counts' => $ringCounts, 'auto_fishing_minutes_left' => $this->shopService->getActiveAutoFishingMinutesLeft($user), + 'sign_repair_card_count' => $this->shopService->getSignRepairCardCount($user), + 'sign_repair_card_item' => $signRepairCard, ]); } @@ -74,24 +79,33 @@ class ShopController extends Controller * - recipient 接收者用户名(传 "all" 或留空则全员可见) * - message 公屏赠言(可选) * - * @param Request $request 含 item_id, recipient?, message? + * @param Request $request 含 item_id, recipient?, message?, quantity? */ public function buy(Request $request): JsonResponse { - $request->validate(['item_id' => 'required|integer|exists:shop_items,id']); + $request->validate([ + 'item_id' => 'required|integer|exists:shop_items,id', + 'quantity' => 'nullable|integer|min:1|max:99', + ]); $item = ShopItem::find($request->item_id); if (! $item->is_active) { return response()->json(['status' => 'error', 'message' => '该商品已下架。'], 400); } - $result = $this->shopService->buyItem(Auth::user(), $item); + $quantity = (int) $request->input('quantity', 1); + $result = $this->shopService->buyItem(Auth::user(), $item, $quantity); if (! $result['ok']) { return response()->json(['status' => 'error', 'message' => $result['message']], 400); } - $response = ['status' => 'success', 'message' => $result['message']]; + $response = [ + 'status' => 'success', + 'message' => $result['message'], + 'quantity' => $result['quantity'] ?? 1, + 'total_price' => $result['total_price'] ?? $item->price, + ]; // ── 单次特效卡:广播给指定用户或全员 ──────────────────────── if (isset($result['play_effect'])) { @@ -176,6 +190,7 @@ class ShopController extends Controller 'one_time' => "🎫 【{$user->username}】购买了「{$item->name}」道具!", 'ring' => "💍 【{$user->username}】在商店购买了一枚「{$item->name}」,不知道打算送给谁呢?", 'auto_fishing' => "🎣 【{$user->username}】购买了「{$item->name}」,开启了 {$fishDuration} 的自动钓鱼模式!", + ShopItem::TYPE_SIGN_REPAIR => "🗓️ 【{$user->username}】购买了 {$quantity} 张「{$item->name}」,准备把漏掉的签到补回来!", default => "🛒 【{$user->username}】购买了「{$item->name}」。", }; diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 6810962..aa740e0 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -126,6 +126,18 @@ class UserController extends Controller ->all(); $data['vip']['Name'] = $targetUser->vipName(); $data['vip']['Icon'] = $targetUser->vipIcon(); + $signIdentity = $targetUser->currentSignInIdentity(); + $latestSignIn = $targetUser->dailySignIns()->first(); + $data['sign_in'] = [ + 'streak_days' => (int) ($latestSignIn?->streak_days ?? 0), + 'identity' => $signIdentity ? [ + 'key' => $signIdentity->badge_code, + 'label' => $signIdentity->badge_name, + 'icon' => $signIdentity->badge_icon ?? '✅', + 'color' => $signIdentity->badge_color ?? '#0f766e', + 'expires_at' => $signIdentity->expires_at?->toIso8601String(), + ] : null, + ]; // 拥有封禁IP(level_banip)或踢人以上权限的管理,可以查看IP和归属地 $levelBanIp = (int) Sysparam::getValue('level_banip', '15'); diff --git a/app/Http/Requests/Admin/SaveSignInRewardRuleRequest.php b/app/Http/Requests/Admin/SaveSignInRewardRuleRequest.php new file mode 100644 index 0000000..b2f5add --- /dev/null +++ b/app/Http/Requests/Admin/SaveSignInRewardRuleRequest.php @@ -0,0 +1,74 @@ +|string> + */ + public function rules(): array + { + $ruleId = $this->route('signInRewardRule')?->id; + + return [ + 'streak_days' => [ + 'required', + 'integer', + 'min:1', + 'max:3650', + Rule::unique('sign_in_reward_rules', 'streak_days')->ignore($ruleId), + ], + 'gold_reward' => ['required', 'integer', 'min:0', 'max:999999999'], + 'exp_reward' => ['required', 'integer', 'min:0', 'max:999999999'], + 'charm_reward' => ['required', 'integer', 'min:0', 'max:999999999'], + 'identity_badge_code' => ['nullable', 'string', 'max:50'], + 'identity_badge_name' => ['nullable', 'string', 'max:50'], + 'identity_badge_icon' => ['nullable', 'string', 'max:120'], + 'identity_badge_color' => ['nullable', 'string', 'max:20'], + 'identity_duration_days' => ['required', 'integer', 'min:0', 'max:3650'], + 'sort_order' => ['required', 'integer', 'min:0', 'max:999999'], + 'is_enabled' => ['nullable', 'boolean'], + ]; + } + + /** + * 方法功能:返回签到奖励规则表单的中文字段名。 + * + * @return array + */ + public function attributes(): array + { + return [ + 'streak_days' => '连续签到天数', + 'gold_reward' => '金币奖励', + 'exp_reward' => '经验奖励', + 'charm_reward' => '魅力奖励', + 'identity_badge_name' => '身份名称', + 'identity_duration_days' => '身份有效天数', + ]; + } +} diff --git a/app/Http/Requests/ClaimDailySignInRequest.php b/app/Http/Requests/ClaimDailySignInRequest.php new file mode 100644 index 0000000..43a0f39 --- /dev/null +++ b/app/Http/Requests/ClaimDailySignInRequest.php @@ -0,0 +1,50 @@ +user() !== null; + } + + /** + * 方法功能:返回每日签到领取参数校验规则。 + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'room_id' => ['nullable', 'integer', 'exists:rooms,id'], + ]; + } + + /** + * 方法功能:返回每日签到领取的中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'room_id.exists' => '当前聊天室不存在,请刷新页面后再签到。', + ]; + } +} diff --git a/app/Http/Requests/DailySignInCalendarRequest.php b/app/Http/Requests/DailySignInCalendarRequest.php new file mode 100644 index 0000000..2d4d6eb --- /dev/null +++ b/app/Http/Requests/DailySignInCalendarRequest.php @@ -0,0 +1,49 @@ +user() !== null; + } + + /** + * 方法功能:返回签到日历查询规则。 + * + * @return array + */ + public function rules(): array + { + return [ + 'month' => ['nullable', 'date_format:Y-m'], + ]; + } + + /** + * 方法功能:返回签到日历查询的中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'month.date_format' => '月份格式不正确。', + ]; + } +} diff --git a/app/Http/Requests/MakeupDailySignInRequest.php b/app/Http/Requests/MakeupDailySignInRequest.php new file mode 100644 index 0000000..4a44868 --- /dev/null +++ b/app/Http/Requests/MakeupDailySignInRequest.php @@ -0,0 +1,55 @@ +user() !== null; + } + + /** + * 方法功能:返回补签请求的校验规则。 + * + * @return array + */ + public function rules(): array + { + return [ + 'target_date' => ['required', 'date', 'before:today', 'after_or_equal:'.Carbon::today()->startOfMonth()->toDateString()], + 'room_id' => ['nullable', 'integer', 'exists:rooms,id'], + ]; + } + + /** + * 方法功能:返回补签请求的中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'target_date.required' => '请选择要补签的日期。', + 'target_date.date' => '补签日期格式不正确。', + 'target_date.before' => '只能补签今天之前的漏签日期。', + 'target_date.after_or_equal' => '补签卡只能补签本月的未签到日期。', + 'room_id.exists' => '当前聊天室不存在,请刷新页面后再补签。', + ]; + } +} diff --git a/app/Models/DailySignIn.php b/app/Models/DailySignIn.php new file mode 100644 index 0000000..bfb6620 --- /dev/null +++ b/app/Models/DailySignIn.php @@ -0,0 +1,82 @@ + */ + use HasFactory; + + /** + * 允许批量赋值的字段。 + * + * @var array + */ + protected $fillable = [ + 'user_id', + 'room_id', + 'is_makeup', + 'makeup_purchase_id', + 'makeup_at', + 'sign_in_date', + 'streak_days', + 'reward_rule_id', + 'gold_reward', + 'exp_reward', + 'charm_reward', + 'identity_badge_code', + 'identity_badge_name', + 'identity_badge_icon', + 'identity_badge_color', + ]; + + /** + * 属性类型转换。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'room_id' => 'integer', + 'is_makeup' => 'boolean', + 'makeup_purchase_id' => 'integer', + 'makeup_at' => 'datetime', + 'sign_in_date' => 'date', + 'streak_days' => 'integer', + 'reward_rule_id' => 'integer', + 'gold_reward' => 'integer', + 'exp_reward' => 'integer', + 'charm_reward' => 'integer', + ]; + } + + /** + * 关联:签到记录所属用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 关联:本次签到命中的奖励规则。 + */ + public function rewardRule(): BelongsTo + { + return $this->belongsTo(SignInRewardRule::class, 'reward_rule_id'); + } +} diff --git a/app/Models/ShopItem.php b/app/Models/ShopItem.php index 501779a..8fb67a5 100644 --- a/app/Models/ShopItem.php +++ b/app/Models/ShopItem.php @@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class ShopItem extends Model { + public const TYPE_SIGN_REPAIR = 'sign_repair'; + protected $table = 'shop_items'; protected $fillable = [ @@ -41,6 +43,14 @@ class ShopItem extends Model return $this->type === 'auto_fishing'; } + /** + * 是否为签到补签卡。 + */ + public function isSignRepairCard(): bool + { + return $this->type === self::TYPE_SIGN_REPAIR; + } + /** * 是否为特效类商品(instant 或 duration,slug 以 once_ 或 week_ 开头) */ diff --git a/app/Models/SignInRewardRule.php b/app/Models/SignInRewardRule.php new file mode 100644 index 0000000..a583966 --- /dev/null +++ b/app/Models/SignInRewardRule.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + /** + * 允许批量赋值的字段。 + * + * @var array + */ + protected $fillable = [ + 'streak_days', + 'gold_reward', + 'exp_reward', + 'charm_reward', + 'identity_badge_code', + 'identity_badge_name', + 'identity_badge_icon', + 'identity_badge_color', + 'identity_duration_days', + 'is_enabled', + 'sort_order', + ]; + + /** + * 属性类型转换。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'streak_days' => 'integer', + 'gold_reward' => 'integer', + 'exp_reward' => 'integer', + 'charm_reward' => 'integer', + 'identity_duration_days' => 'integer', + 'is_enabled' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + /** + * 关联:命中过该规则的签到记录。 + */ + public function dailySignIns(): HasMany + { + return $this->hasMany(DailySignIn::class, 'reward_rule_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e52826b..648e285 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -252,6 +252,49 @@ class User extends Authenticatable return $this->hasMany(VipPaymentOrder::class, 'user_id')->latest('id'); } + /** + * 关联:用户每日签到记录。 + */ + public function dailySignIns(): HasMany + { + return $this->hasMany(DailySignIn::class, 'user_id')->latest('sign_in_date'); + } + + /** + * 关联:用户全部身份徽章。 + */ + public function identityBadges(): HasMany + { + return $this->hasMany(UserIdentityBadge::class, 'user_id')->latest('acquired_at'); + } + + /** + * 关联:用户当前启用的签到身份徽章。 + */ + public function currentSignInIdentityBadge(): HasOne + { + return $this->hasOne(UserIdentityBadge::class, 'user_id') + ->where('source', UserIdentityBadge::SOURCE_SIGN_IN) + ->where('is_active', true) + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->latestOfMany('acquired_at'); + } + + /** + * 获取当前签到身份徽章辅助方法。 + */ + public function currentSignInIdentity(): ?UserIdentityBadge + { + if ($this->relationLoaded('currentSignInIdentityBadge')) { + return $this->getRelation('currentSignInIdentityBadge'); + } + + return $this->currentSignInIdentityBadge()->first(); + } + // ── 职务相关关联 ────────────────────────────────────────────────────── /** diff --git a/app/Models/UserIdentityBadge.php b/app/Models/UserIdentityBadge.php new file mode 100644 index 0000000..6671721 --- /dev/null +++ b/app/Models/UserIdentityBadge.php @@ -0,0 +1,65 @@ + */ + use HasFactory; + + public const SOURCE_SIGN_IN = 'sign_in'; + + /** + * 允许批量赋值的字段。 + * + * @var array + */ + protected $fillable = [ + 'user_id', + 'source', + 'badge_code', + 'badge_name', + 'badge_icon', + 'badge_color', + 'acquired_at', + 'expires_at', + 'is_active', + 'metadata', + ]; + + /** + * 属性类型转换。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'acquired_at' => 'datetime', + 'expires_at' => 'datetime', + 'is_active' => 'boolean', + 'metadata' => 'array', + ]; + } + + /** + * 关联:身份徽章所属用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/ChatUserPresenceService.php b/app/Services/ChatUserPresenceService.php index d99e1b7..7ba8869 100644 --- a/app/Services/ChatUserPresenceService.php +++ b/app/Services/ChatUserPresenceService.php @@ -51,6 +51,16 @@ class ChatUserPresenceService $payload['daily_status_expires_at'] = $activeStatus['expires_at']; } + $signIdentity = $user->currentSignInIdentity(); + if ($signIdentity !== null) { + $payload['sign_identity_key'] = $signIdentity->badge_code; + $payload['sign_identity_label'] = $signIdentity->badge_name; + $payload['sign_identity_icon'] = $signIdentity->badge_icon ?? '✅'; + $payload['sign_identity_color'] = $signIdentity->badge_color ?? '#0f766e'; + $payload['sign_identity_expires_at'] = $signIdentity->expires_at?->toIso8601String(); + $payload['sign_identity_streak_days'] = (int) data_get($signIdentity->metadata, 'streak_days', 0); + } + return $payload; } diff --git a/app/Services/ShopService.php b/app/Services/ShopService.php index b84176d..33afb87 100644 --- a/app/Services/ShopService.php +++ b/app/Services/ShopService.php @@ -19,13 +19,19 @@ class ShopService /** * 购买商品入口:扣金币、按类型分发处理 * - * @return array{ok:bool, message:string, play_effect?:string} + * @return array{ok:bool, message:string, play_effect?:string, quantity?:int, total_price?:int} */ - public function buyItem(User $user, ShopItem $item): array + public function buyItem(User $user, ShopItem $item, int $quantity = 1): array { + if ($quantity !== 1 && $item->type !== ShopItem::TYPE_SIGN_REPAIR) { + return ['ok' => false, 'message' => '该商品暂不支持批量购买。']; + } + + $totalPrice = $item->price * $quantity; + // 校验金币是否足够 - if ($user->jjb < $item->price) { - return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"]; + if ($user->jjb < $totalPrice) { + return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"]; } return match ($item->type) { @@ -34,6 +40,7 @@ class ShopService 'one_time' => $this->buyRenameCard($user, $item), 'ring' => $this->buyRing($user, $item), 'auto_fishing' => $this->buyAutoFishingCard($user, $item), + ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity), default => ['ok' => false, 'message' => '未知商品类型'], }; } @@ -243,6 +250,39 @@ class ShopService ]; } + /** + * 购买签到补签卡:扣金币并写入可消耗背包记录。 + * + * @return array{ok:bool, message:string, quantity:int, total_price:int} + */ + public function buySignRepairCard(User $user, ShopItem $item, int $quantity = 1): array + { + $quantity = max(1, min(99, $quantity)); + $totalPrice = $item->price * $quantity; + + DB::transaction(function () use ($user, $item, $quantity, $totalPrice): void { + // 补签卡支持一次购买多张,扣款必须按总价执行。 + $user->decrement('jjb', $totalPrice); + + for ($i = 0; $i < $quantity; $i++) { + // 每张补签卡保留独立购买记录,补签时逐张消耗。 + UserPurchase::create([ + 'user_id' => $user->id, + 'shop_item_id' => $item->id, + 'status' => 'active', + 'price_paid' => $item->price, + ]); + } + }); + + return [ + 'ok' => true, + 'message' => "🗓️ {$item->name} × {$quantity} 购买成功!可在签到日历中补签漏掉的日期。", + 'quantity' => $quantity, + 'total_price' => $totalPrice, + ]; + } + /** * 获取用户当前激活的改名卡(是否持有未用改名卡) */ @@ -254,6 +294,18 @@ class ShopService ->exists(); } + /** + * 获取用户可用补签卡数量。 + */ + public function getSignRepairCardCount(User $user): int + { + return UserPurchase::query() + ->where('user_id', $user->id) + ->where('status', 'active') + ->whereHas('shopItem', fn ($q) => $q->where('type', ShopItem::TYPE_SIGN_REPAIR)) + ->count(); + } + /** * 购买自动钓鱼卡:手刺金币,写入 active 记录,到期时间 = 现在 + duration_minutes。 * diff --git a/app/Services/SignInService.php b/app/Services/SignInService.php new file mode 100644 index 0000000..ffc9067 --- /dev/null +++ b/app/Services/SignInService.php @@ -0,0 +1,521 @@ +where('user_id', $user->id) + ->whereDate('sign_in_date', $today) + ->first(); + + $latestSignIn = DailySignIn::query() + ->where('user_id', $user->id) + ->latest('sign_in_date') + ->first(); + + $signedToday = $todaySignIn !== null; + $claimableStreakDays = $signedToday + ? (int) $todaySignIn->streak_days + : $this->calculateNextStreakDays($latestSignIn, $today); + + return [ + 'signed_today' => $signedToday, + 'can_claim' => ! $signedToday, + 'current_streak_days' => (int) ($latestSignIn?->streak_days ?? 0), + 'claimable_streak_days' => $claimableStreakDays, + 'today_sign_in' => $todaySignIn, + 'latest_sign_in' => $latestSignIn, + 'matched_rule' => $this->matchRewardRule($claimableStreakDays), + 'current_identity' => $user->currentSignInIdentity(), + ]; + } + + /** + * 查询指定月份的签到日历状态。 + * + * @return array + */ + public function calendar(User $user, ?string $month = null): array + { + $monthStart = $month + ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() + : today()->startOfMonth(); + $monthEnd = $monthStart->copy()->endOfMonth(); + $today = today(); + // 补签卡只允许修补当前自然月,查看历史月份时不再开放补签入口。 + $isCurrentMonth = $monthStart->isSameMonth($today); + + $signIns = DailySignIn::query() + ->where('user_id', $user->id) + ->whereBetween('sign_in_date', [$monthStart->toDateString(), $monthEnd->toDateString()]) + ->get() + ->keyBy(fn (DailySignIn $signIn): string => $signIn->sign_in_date->toDateString()); + + $days = []; + for ($date = $monthStart->copy(); $date->lte($monthEnd); $date->addDay()) { + $dateKey = $date->toDateString(); + $signIn = $signIns->get($dateKey); + $isFuture = $date->gt($today); + + $days[] = [ + 'date' => $dateKey, + 'day' => (int) $date->day, + 'weekday' => (int) $date->dayOfWeek, + 'signed' => $signIn !== null, + 'is_today' => $date->isSameDay($today), + 'is_future' => $isFuture, + 'can_makeup' => $isCurrentMonth && $signIn === null && $date->lt($today), + 'is_makeup' => (bool) ($signIn?->is_makeup ?? false), + 'streak_days' => (int) ($signIn?->streak_days ?? 0), + 'reward_text' => $signIn ? $this->buildRewardText($signIn) : '', + ]; + } + + return [ + 'month' => $monthStart->format('Y-m'), + 'month_label' => $monthStart->format('Y年n月'), + 'prev_month' => $monthStart->copy()->subMonth()->format('Y-m'), + 'next_month' => $monthStart->copy()->addMonth()->format('Y-m'), + 'days' => $days, + 'reward_rules' => $this->rewardRulePreview(), + 'makeup_card_count' => $this->availableSignRepairCardCount($user), + 'sign_repair_card_item' => $this->activeSignRepairCard()?->only(['id', 'name', 'slug', 'icon', 'price', 'description']), + ]; + } + + /** + * 领取每日签到奖励。 + */ + public function claim(User $user, ?int $roomId = null): DailySignIn + { + return DB::transaction(function () use ($user, $roomId): DailySignIn { + $today = today(); + + // 锁定用户行,将同一用户的并发签到串行化,避免“查无今日记录”时并发重复发奖。 + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); + + $existingSignIn = DailySignIn::query() + ->where('user_id', $lockedUser->id) + ->whereDate('sign_in_date', $today) + ->lockForUpdate() + ->first(); + + if ($existingSignIn !== null) { + return $existingSignIn->load('rewardRule'); + } + + $latestSignIn = DailySignIn::query() + ->where('user_id', $lockedUser->id) + ->whereDate('sign_in_date', '<', $today) + ->latest('sign_in_date') + ->lockForUpdate() + ->first(); + + $streakDays = $this->calculateNextStreakDays($latestSignIn, $today); + $rewardRule = $this->matchRewardRule($streakDays); + + $dailySignIn = DailySignIn::query()->create([ + 'user_id' => $lockedUser->id, + 'room_id' => $roomId, + 'sign_in_date' => $today->toDateString(), + 'streak_days' => $streakDays, + 'reward_rule_id' => $rewardRule?->id, + 'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0), + 'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0), + 'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0), + 'identity_badge_code' => $rewardRule?->identity_badge_code, + 'identity_badge_name' => $rewardRule?->identity_badge_name, + 'identity_badge_icon' => $rewardRule?->identity_badge_icon, + 'identity_badge_color' => $rewardRule?->identity_badge_color, + ]); + + $this->grantRewards($lockedUser, $dailySignIn, $roomId); + $this->refreshSignInIdentityBadge($lockedUser, $rewardRule, $streakDays); + + return $dailySignIn->load('rewardRule'); + }); + } + + /** + * 使用补签卡补签指定历史日期。 + */ + public function makeup(User $user, string $targetDate, ?int $roomId = null): DailySignIn + { + return DB::transaction(function () use ($user, $targetDate, $roomId): DailySignIn { + $date = Carbon::parse($targetDate)->startOfDay(); + + if ($date->greaterThanOrEqualTo(today())) { + throw ValidationException::withMessages(['target_date' => '只能补签今天之前的漏签日期。']); + } + + // 后端再次限制补签月份,避免绕过前端日历直接提交历史月份。 + if (! $date->isSameMonth(today())) { + throw ValidationException::withMessages(['target_date' => '补签卡只能补签本月的未签到日期。']); + } + + // 锁定用户行,保证补签卡消耗和签到记录创建不会并发重复执行。 + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); + + $existingSignIn = DailySignIn::query() + ->where('user_id', $lockedUser->id) + ->whereDate('sign_in_date', $date) + ->lockForUpdate() + ->first(); + + if ($existingSignIn !== null) { + throw ValidationException::withMessages(['target_date' => '这一天已经签到过,不能重复补签。']); + } + + $repairCard = $this->nextAvailableSignRepairCard($lockedUser); + if ($repairCard === null) { + throw ValidationException::withMessages(['target_date' => '没有可用补签卡,请先购买补签卡。']); + } + + $latestBeforeTarget = DailySignIn::query() + ->where('user_id', $lockedUser->id) + ->whereDate('sign_in_date', '<', $date) + ->latest('sign_in_date') + ->lockForUpdate() + ->first(); + $streakDays = $this->calculateNextStreakDays($latestBeforeTarget, $date); + + $dailySignIn = DailySignIn::query()->create([ + 'user_id' => $lockedUser->id, + 'room_id' => $roomId, + 'is_makeup' => true, + 'makeup_purchase_id' => $repairCard->id, + 'makeup_at' => now(), + 'sign_in_date' => $date->toDateString(), + 'streak_days' => $streakDays, + 'reward_rule_id' => null, + 'gold_reward' => 0, + 'exp_reward' => 0, + 'charm_reward' => 0, + 'identity_badge_code' => null, + 'identity_badge_name' => null, + 'identity_badge_icon' => null, + 'identity_badge_color' => null, + ]); + + // 补签卡与补签记录必须在同一事务里完成状态流转。 + $repairCard->update(['status' => 'used', 'used_at' => now()]); + $this->recalculateFutureStreaks($lockedUser, $date); + $currentStreakDays = $this->latestStreakDays($lockedUser); + $rewardRule = $this->matchRewardRule($currentStreakDays); + $dailySignIn->update([ + 'reward_rule_id' => $rewardRule?->id, + 'gold_reward' => (int) ($rewardRule?->gold_reward ?? 0), + 'exp_reward' => (int) ($rewardRule?->exp_reward ?? 0), + 'charm_reward' => (int) ($rewardRule?->charm_reward ?? 0), + 'identity_badge_code' => $rewardRule?->identity_badge_code, + 'identity_badge_name' => $rewardRule?->identity_badge_name, + 'identity_badge_icon' => $rewardRule?->identity_badge_icon, + 'identity_badge_color' => $rewardRule?->identity_badge_color, + ]); + $this->grantRewards( + $lockedUser, + $dailySignIn->fresh(), + $roomId, + "补签 {$date->toDateString()}:当前连续签到 {$currentStreakDays} 天" + ); + $this->refreshIdentityFromLatestSignIn($lockedUser); + + return $dailySignIn->fresh('rewardRule'); + }); + } + + /** + * 计算下一次签到应获得的连续天数。 + */ + private function calculateNextStreakDays(?DailySignIn $latestSignIn, ?Carbon $targetDate = null): int + { + $targetDate ??= today(); + + if ($latestSignIn === null) { + return 1; + } + + $yesterday = $targetDate->copy()->subDay()->toDateString(); + + if ($latestSignIn->sign_in_date?->toDateString() === $yesterday) { + // 签到满一年后开启新周期,下一天重新从第 1 天计算。 + if ((int) $latestSignIn->streak_days >= $this->daysInSignInYear($latestSignIn)) { + return 1; + } + + return (int) $latestSignIn->streak_days + 1; + } + + return 1; + } + + /** + * 按签到记录所在年份计算当年天数,闰年为 366 天。 + */ + private function daysInSignInYear(DailySignIn $dailySignIn): int + { + return $dailySignIn->sign_in_date?->isLeapYear() ? 366 : 365; + } + + /** + * 匹配当前连续天数可用的最高奖励规则。 + */ + private function matchRewardRule(int $streakDays): ?SignInRewardRule + { + return SignInRewardRule::query() + ->where('is_enabled', true) + ->where('streak_days', '<=', $streakDays) + ->orderByDesc('streak_days') + ->first(); + } + + /** + * 按签到记录快照发放金币、经验和魅力。 + */ + private function grantRewards(User $user, DailySignIn $dailySignIn, ?int $roomId, ?string $remark = null): void + { + $remark ??= "每日签到:连续 {$dailySignIn->streak_days} 天"; + + $this->currencyService->change($user, 'gold', $dailySignIn->gold_reward, CurrencySource::SIGN_IN, $remark, $roomId); + $this->currencyService->change($user, 'exp', $dailySignIn->exp_reward, CurrencySource::SIGN_IN, $remark, $roomId); + $this->currencyService->change($user, 'charm', $dailySignIn->charm_reward, CurrencySource::SIGN_IN, $remark, $roomId); + } + + /** + * 刷新用户当前签到身份徽章。 + */ + private function refreshSignInIdentityBadge(User $user, ?SignInRewardRule $rewardRule, int $streakDays): void + { + UserIdentityBadge::query() + ->where('user_id', $user->id) + ->where('source', UserIdentityBadge::SOURCE_SIGN_IN) + ->where('is_active', true) + ->update(['is_active' => false]); + + if ($rewardRule?->identity_badge_code === null || $rewardRule->identity_badge_name === null) { + return; + } + + // 同一来源同一徽章唯一,重复命中时更新状态和连续天数快照即可。 + UserIdentityBadge::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'source' => UserIdentityBadge::SOURCE_SIGN_IN, + 'badge_code' => $rewardRule->identity_badge_code, + ], + [ + 'badge_name' => $rewardRule->identity_badge_name, + 'badge_icon' => $rewardRule->identity_badge_icon, + 'badge_color' => $rewardRule->identity_badge_color, + 'acquired_at' => now(), + // 身份有效期由后台规则控制,0 表示长期有效。 + 'expires_at' => $rewardRule->identity_duration_days > 0 + ? now()->addDays($rewardRule->identity_duration_days) + : null, + 'is_active' => true, + 'metadata' => [ + 'streak_days' => $streakDays, + 'reward_rule_id' => $rewardRule->id, + ], + ] + ); + } + + /** + * 按日期向后重算连续天数,补齐断点后让后续日历展示同步变化。 + */ + private function recalculateFutureStreaks(User $user, Carbon $fromDate): void + { + $records = DailySignIn::query() + ->where('user_id', $user->id) + ->whereDate('sign_in_date', '>=', $fromDate) + ->orderBy('sign_in_date') + ->lockForUpdate() + ->get(); + + $previous = DailySignIn::query() + ->where('user_id', $user->id) + ->whereDate('sign_in_date', '<', $fromDate) + ->latest('sign_in_date') + ->lockForUpdate() + ->first(); + + $previousDate = $previous?->sign_in_date; + $previousStreak = (int) ($previous?->streak_days ?? 0); + + $records->each(function (DailySignIn $record) use (&$previousDate, &$previousStreak): void { + $streakDays = $previousDate?->copy()->addDay()->toDateString() === $record->sign_in_date?->toDateString() + ? $previousStreak + 1 + : 1; + + if ((int) $record->streak_days !== $streakDays) { + // 只重算连续天数快照,不追溯变更已发放奖励流水。 + $record->update(['streak_days' => $streakDays]); + } + + $previousDate = $record->sign_in_date; + $previousStreak = $streakDays; + }); + } + + /** + * 使用最新签到记录刷新当前签到身份。 + */ + private function refreshIdentityFromLatestSignIn(User $user): void + { + $latestSignIn = DailySignIn::query() + ->where('user_id', $user->id) + ->latest('sign_in_date') + ->first(); + + if ($latestSignIn === null) { + return; + } + + $this->refreshSignInIdentityBadge($user, $this->matchRewardRule((int) $latestSignIn->streak_days), (int) $latestSignIn->streak_days); + } + + /** + * 查询用户最新签到记录的连续天数。 + */ + private function latestStreakDays(User $user): int + { + return (int) DailySignIn::query() + ->where('user_id', $user->id) + ->latest('sign_in_date') + ->value('streak_days'); + } + + /** + * 查询用户下一张可消耗的补签卡。 + */ + private function nextAvailableSignRepairCard(User $user): ?UserPurchase + { + return UserPurchase::query() + ->where('user_id', $user->id) + ->where('status', 'active') + ->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR)) + ->oldest() + ->lockForUpdate() + ->first(); + } + + /** + * 查询用户可用补签卡数量。 + */ + private function availableSignRepairCardCount(User $user): int + { + return UserPurchase::query() + ->where('user_id', $user->id) + ->where('status', 'active') + ->whereHas('shopItem', fn ($query) => $query->where('type', ShopItem::TYPE_SIGN_REPAIR)) + ->count(); + } + + /** + * 查询当前上架的补签卡商品。 + */ + private function activeSignRepairCard(): ?ShopItem + { + return ShopItem::query() + ->where('type', ShopItem::TYPE_SIGN_REPAIR) + ->where('is_active', true) + ->orderBy('sort_order') + ->first(); + } + + /** + * 查询启用中的签到奖励档位,供前台展示目标奖励。 + * + * @return array> + */ + private function rewardRulePreview(): array + { + return SignInRewardRule::query() + ->where('is_enabled', true) + ->orderBy('streak_days') + ->get() + ->map(fn (SignInRewardRule $rule): array => [ + 'streak_days' => (int) $rule->streak_days, + 'gold_reward' => (int) $rule->gold_reward, + 'exp_reward' => (int) $rule->exp_reward, + 'charm_reward' => (int) $rule->charm_reward, + 'identity_badge_name' => $rule->identity_badge_name, + 'identity_badge_icon' => $rule->identity_badge_icon, + 'identity_badge_color' => $rule->identity_badge_color, + 'identity_duration_days' => (int) $rule->identity_duration_days, + ]) + ->all(); + } + + /** + * 生成日历内展示的签到奖励文本。 + */ + private function buildRewardText(DailySignIn $dailySignIn): string + { + $items = []; + if ($dailySignIn->gold_reward > 0) { + $items[] = $dailySignIn->gold_reward.'金'; + } + if ($dailySignIn->exp_reward > 0) { + $items[] = $dailySignIn->exp_reward.'经验'; + } + if ($dailySignIn->charm_reward > 0) { + $items[] = $dailySignIn->charm_reward.'魅力'; + } + + return $items === [] ? '已记录' : implode(' + ', $items); + } +} diff --git a/database/factories/DailySignInFactory.php b/database/factories/DailySignInFactory.php new file mode 100644 index 0000000..dea8143 --- /dev/null +++ b/database/factories/DailySignInFactory.php @@ -0,0 +1,42 @@ + + */ +class DailySignInFactory extends Factory +{ + /** + * 定义默认每日签到测试数据。 + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'room_id' => null, + 'sign_in_date' => today(), + 'streak_days' => 1, + 'reward_rule_id' => null, + 'gold_reward' => 0, + 'exp_reward' => 0, + 'charm_reward' => 0, + 'identity_badge_code' => null, + 'identity_badge_name' => null, + 'identity_badge_icon' => null, + 'identity_badge_color' => null, + ]; + } +} diff --git a/database/factories/SignInRewardRuleFactory.php b/database/factories/SignInRewardRuleFactory.php new file mode 100644 index 0000000..e52538b --- /dev/null +++ b/database/factories/SignInRewardRuleFactory.php @@ -0,0 +1,40 @@ + + */ +class SignInRewardRuleFactory extends Factory +{ + /** + * 定义默认签到奖励规则测试数据。 + * + * @return array + */ + public function definition(): array + { + return [ + 'streak_days' => fake()->unique()->numberBetween(1, 30), + 'gold_reward' => 100, + 'exp_reward' => 10, + 'charm_reward' => 0, + 'identity_badge_code' => null, + 'identity_badge_name' => null, + 'identity_badge_icon' => null, + 'identity_badge_color' => '#0f766e', + 'identity_duration_days' => 0, + 'is_enabled' => true, + 'sort_order' => 0, + ]; + } +} diff --git a/database/factories/UserIdentityBadgeFactory.php b/database/factories/UserIdentityBadgeFactory.php new file mode 100644 index 0000000..7684cfb --- /dev/null +++ b/database/factories/UserIdentityBadgeFactory.php @@ -0,0 +1,40 @@ + + */ +class UserIdentityBadgeFactory extends Factory +{ + /** + * 定义默认身份徽章测试数据。 + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'source' => UserIdentityBadge::SOURCE_SIGN_IN, + 'badge_code' => 'sign_in_beginner', + 'badge_name' => '签到新星', + 'badge_icon' => null, + 'badge_color' => '#0f766e', + 'acquired_at' => now(), + 'expires_at' => null, + 'is_active' => true, + 'metadata' => [], + ]; + } +} diff --git a/database/migrations/2026_04_24_212900_create_sign_in_reward_rules_table.php b/database/migrations/2026_04_24_212900_create_sign_in_reward_rules_table.php new file mode 100644 index 0000000..53c55f4 --- /dev/null +++ b/database/migrations/2026_04_24_212900_create_sign_in_reward_rules_table.php @@ -0,0 +1,185 @@ +id(); + $table->unsignedInteger('streak_days')->unique()->comment('连续签到天数门槛'); + $table->unsignedInteger('gold_reward')->default(0)->comment('金币奖励'); + $table->unsignedInteger('exp_reward')->default(0)->comment('经验奖励'); + $table->unsignedInteger('charm_reward')->default(0)->comment('魅力奖励'); + $table->string('identity_badge_code', 50)->nullable()->comment('身份徽章编码'); + $table->string('identity_badge_name', 50)->nullable()->comment('身份徽章名称'); + $table->string('identity_badge_icon', 120)->nullable()->comment('身份徽章图标'); + $table->string('identity_badge_color', 20)->nullable()->comment('身份徽章文字颜色'); + $table->unsignedSmallInteger('identity_duration_days')->default(0)->comment('身份有效天数,0表示永久'); + $table->boolean('is_enabled')->default(true)->index()->comment('是否启用'); + $table->unsignedInteger('sort_order')->default(0)->index()->comment('后台排序值'); + $table->timestamps(); + + $table->index(['is_enabled', 'streak_days'], 'idx_sign_in_rules_enabled_streak'); + }); + + DB::table('sign_in_reward_rules')->insert($this->defaultRules()); + } + + /** + * 回滚:删除每日签到奖励规则表。 + */ + public function down(): void + { + Schema::dropIfExists('sign_in_reward_rules'); + } + + /** + * 默认连续签到奖励规则。 + * + * @return array> + */ + private function defaultRules(): array + { + $now = now(); + + return [ + [ + 'streak_days' => 1, + 'gold_reward' => 100, + 'exp_reward' => 10, + 'charm_reward' => 0, + 'identity_badge_code' => 'sign_in_new_star', + 'identity_badge_name' => '签到新星', + 'identity_badge_icon' => '✅', + 'identity_badge_color' => '#0f766e', + 'identity_duration_days' => 7, + 'is_enabled' => true, + 'sort_order' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'streak_days' => 3, + 'gold_reward' => 180, + 'exp_reward' => 18, + 'charm_reward' => 1, + 'identity_badge_code' => 'sign_in_spark', + 'identity_badge_name' => '三日星火', + 'identity_badge_icon' => '✨', + 'identity_badge_color' => '#2563eb', + 'identity_duration_days' => 10, + 'is_enabled' => true, + 'sort_order' => 3, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'streak_days' => 7, + 'gold_reward' => 350, + 'exp_reward' => 35, + 'charm_reward' => 3, + 'identity_badge_code' => 'sign_in_flame', + 'identity_badge_name' => '七日恒星', + 'identity_badge_icon' => '🔥', + 'identity_badge_color' => '#dc2626', + 'identity_duration_days' => 30, + 'is_enabled' => true, + 'sort_order' => 7, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'streak_days' => 14, + 'gold_reward' => 600, + 'exp_reward' => 60, + 'charm_reward' => 5, + 'identity_badge_code' => 'sign_in_moon_guard', + 'identity_badge_name' => '半月守望', + 'identity_badge_icon' => '🌙', + 'identity_badge_color' => '#7c3aed', + 'identity_duration_days' => 45, + 'is_enabled' => true, + 'sort_order' => 14, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'streak_days' => 30, + 'gold_reward' => 1200, + 'exp_reward' => 120, + 'charm_reward' => 8, + 'identity_badge_code' => 'sign_in_month_master', + 'identity_badge_name' => '月签达人', + 'identity_badge_icon' => '🏆', + 'identity_badge_color' => '#ca8a04', + 'identity_duration_days' => 60, + 'is_enabled' => true, + 'sort_order' => 30, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'streak_days' => 60, + 'gold_reward' => 2200, + 'exp_reward' => 220, + 'charm_reward' => 14, + 'identity_badge_code' => 'sign_in_diamond', + 'identity_badge_name' => '双月钻冕', + 'identity_badge_icon' => '💎', + 'identity_badge_color' => '#0891b2', + 'identity_duration_days' => 90, + 'is_enabled' => true, + 'sort_order' => 60, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'streak_days' => 100, + 'gold_reward' => 4000, + 'exp_reward' => 400, + 'charm_reward' => 25, + 'identity_badge_code' => 'sign_in_legend', + 'identity_badge_name' => '百日传奇', + 'identity_badge_icon' => '👑', + 'identity_badge_color' => '#9333ea', + 'identity_duration_days' => 180, + 'is_enabled' => true, + 'sort_order' => 100, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'streak_days' => 365, + 'gold_reward' => 12000, + 'exp_reward' => 1200, + 'charm_reward' => 100, + 'identity_badge_code' => 'sign_in_year_guardian', + 'identity_badge_name' => '周年守护者', + 'identity_badge_icon' => '🌟', + 'identity_badge_color' => '#e11d48', + 'identity_duration_days' => 0, + 'is_enabled' => true, + 'sort_order' => 365, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]; + } +}; diff --git a/database/migrations/2026_04_24_212900_create_user_identity_badges_table.php b/database/migrations/2026_04_24_212900_create_user_identity_badges_table.php new file mode 100644 index 0000000..30df76c --- /dev/null +++ b/database/migrations/2026_04_24_212900_create_user_identity_badges_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID'); + $table->string('source', 30)->default('sign_in')->comment('徽章来源'); + $table->string('badge_code', 50)->comment('徽章编码'); + $table->string('badge_name', 50)->comment('徽章名称'); + $table->string('badge_icon', 120)->nullable()->comment('徽章图标'); + $table->string('badge_color', 20)->nullable()->comment('徽章文字颜色'); + $table->timestamp('acquired_at')->nullable()->comment('获得时间'); + $table->timestamp('expires_at')->nullable()->index()->comment('过期时间,空表示长期有效'); + $table->boolean('is_active')->default(true)->index()->comment('是否当前启用'); + $table->json('metadata')->nullable()->comment('扩展信息'); + $table->timestamps(); + + $table->unique(['user_id', 'source', 'badge_code'], 'uniq_user_identity_badge'); + $table->index(['user_id', 'source', 'is_active'], 'idx_user_identity_badges_current'); + }); + } + + /** + * 回滚:删除用户身份徽章表。 + */ + public function down(): void + { + Schema::dropIfExists('user_identity_badges'); + } +}; diff --git a/database/migrations/2026_04_24_212901_create_daily_sign_ins_table.php b/database/migrations/2026_04_24_212901_create_daily_sign_ins_table.php new file mode 100644 index 0000000..34c27bc --- /dev/null +++ b/database/migrations/2026_04_24_212901_create_daily_sign_ins_table.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('用户ID'); + $table->unsignedBigInteger('room_id')->nullable()->index()->comment('签到所在房间ID'); + $table->date('sign_in_date')->comment('签到日期'); + $table->unsignedInteger('streak_days')->comment('连续签到天数'); + $table->foreignId('reward_rule_id')->nullable()->constrained('sign_in_reward_rules')->nullOnDelete()->comment('命中奖励规则ID'); + $table->unsignedInteger('gold_reward')->default(0)->comment('实际发放金币'); + $table->unsignedInteger('exp_reward')->default(0)->comment('实际发放经验'); + $table->unsignedInteger('charm_reward')->default(0)->comment('实际发放魅力'); + $table->string('identity_badge_code', 50)->nullable()->comment('本次刷新身份徽章编码'); + $table->string('identity_badge_name', 50)->nullable()->comment('本次刷新身份徽章名称'); + $table->string('identity_badge_icon', 120)->nullable()->comment('本次刷新身份徽章图标'); + $table->string('identity_badge_color', 20)->nullable()->comment('本次刷新身份徽章颜色'); + $table->timestamps(); + + $table->unique(['user_id', 'sign_in_date'], 'uniq_user_daily_sign_in'); + $table->index(['sign_in_date', 'streak_days'], 'idx_daily_sign_ins_date_streak'); + }); + } + + /** + * 回滚:删除每日签到记录表。 + */ + public function down(): void + { + Schema::dropIfExists('daily_sign_ins'); + } +}; diff --git a/database/migrations/2026_04_24_212902_add_makeup_fields_to_daily_sign_ins_table.php b/database/migrations/2026_04_24_212902_add_makeup_fields_to_daily_sign_ins_table.php new file mode 100644 index 0000000..d263019 --- /dev/null +++ b/database/migrations/2026_04_24_212902_add_makeup_fields_to_daily_sign_ins_table.php @@ -0,0 +1,37 @@ +boolean('is_makeup')->default(false)->after('room_id')->comment('是否通过补签卡补签'); + $table->foreignId('makeup_purchase_id')->nullable()->after('is_makeup')->constrained('user_purchases')->nullOnDelete()->comment('消耗的补签卡购买记录ID'); + $table->timestamp('makeup_at')->nullable()->after('makeup_purchase_id')->comment('补签完成时间'); + }); + } + + /** + * 方法功能:回滚补签字段。 + */ + public function down(): void + { + Schema::table('daily_sign_ins', function (Blueprint $table): void { + $table->dropConstrainedForeignId('makeup_purchase_id'); + $table->dropColumn(['is_makeup', 'makeup_at']); + }); + } +}; diff --git a/database/migrations/2026_04_24_212903_add_sign_repair_type_to_shop_items_table.php b/database/migrations/2026_04_24_212903_add_sign_repair_type_to_shop_items_table.php new file mode 100644 index 0000000..2e1db54 --- /dev/null +++ b/database/migrations/2026_04_24_212903_add_sign_repair_type_to_shop_items_table.php @@ -0,0 +1,55 @@ +updateOrInsert( + ['slug' => 'sign_repair_card'], + [ + 'name' => '补签卡', + 'description' => '用于补签本月漏掉的未签到日期,每张可补签 1 天;不能补签上月或更早日期。', + 'icon' => '🗓️', + 'price' => 10000, + 'type' => 'sign_repair', + 'duration_days' => 0, + 'duration_minutes' => 0, + 'intimacy_bonus' => 0, + 'charm_bonus' => 0, + 'sort_order' => 35, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + /** + * 方法功能:回滚补签卡商品类型。 + */ + public function down(): void + { + DB::table('shop_items')->where('slug', 'sign_repair_card')->delete(); + + if (DB::getDriverName() === 'mysql') { + DB::statement("UPDATE `shop_items` SET `type` = 'one_time' WHERE `type` = 'sign_repair'"); + DB::statement("ALTER TABLE `shop_items` MODIFY `type` ENUM('instant','duration','one_time','ring','auto_fishing') NOT NULL COMMENT '道具类型'"); + } + } +}; diff --git a/database/migrations/2026_04_24_224611_update_sign_repair_card_defaults.php b/database/migrations/2026_04_24_224611_update_sign_repair_card_defaults.php new file mode 100644 index 0000000..f78db10 --- /dev/null +++ b/database/migrations/2026_04_24_224611_update_sign_repair_card_defaults.php @@ -0,0 +1,41 @@ +where('slug', 'sign_repair_card') + ->update([ + 'price' => 10000, + 'description' => '用于补签本月漏掉的未签到日期,每张可补签 1 天;不能补签上月或更早日期。', + 'updated_at' => now(), + ]); + } + + /** + * 方法功能:回滚补签卡默认价格和旧说明,方便本地调试回退。 + */ + public function down(): void + { + DB::table('shop_items') + ->where('slug', 'sign_repair_card') + ->update([ + 'price' => 1200, + 'description' => '用于补签漏掉的历史日期,每张可补签 1 天。', + 'updated_at' => now(), + ]); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6864035..328bddf 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,26 +2,21 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +/** + * 类功能:统一调度项目基础数据填充器。 + */ class DatabaseSeeder extends Seeder { use WithoutModelEvents; /** - * Seed the application's database. + * 方法功能:执行系统参数和默认房间等基础数据填充。 */ public function run(): void { - // User::factory(10)->create(); - - // User::factory()->create([ - // 'name' => 'Test User', - // 'email' => 'test@example.com', - // ]); - $this->call([ SysParamSeeder::class, RoomSeeder::class, diff --git a/public/css/chat.css b/public/css/chat.css index 1cf691a..c31f6d8 100644 --- a/public/css/chat.css +++ b/public/css/chat.css @@ -427,6 +427,7 @@ a:hover { } .user-item .user-name { + min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; @@ -435,6 +436,13 @@ a:hover { font-weight: bold; } +.user-item .user-badge-slot { + flex-shrink: 0; + max-width: 78px; + min-width: 0; + overflow: hidden; +} + .user-item .user-sex { font-size: 10px; } diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index 0f39a07..2c53ded 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -80,6 +80,10 @@ class="block px-4 py-3 rounded-md transition {{ request()->routeIs('admin.autoact.*') ? 'bg-indigo-600 font-bold' : 'hover:bg-white/10' }}"> {!! '🎲 随机事件' !!} + + {!! '✅ 签到奖励' !!} + {!! '👑 VIP 会员等级' !!} diff --git a/resources/views/admin/shop/index.blade.php b/resources/views/admin/shop/index.blade.php index 3c3ad0b..aca8955 100644 --- a/resources/views/admin/shop/index.blade.php +++ b/resources/views/admin/shop/index.blade.php @@ -20,6 +20,7 @@ 'one_time' => ['label' => '一次性道具', 'color' => 'bg-yellow-100 text-yellow-700'], 'ring' => ['label' => '求婚戒指', 'color' => 'bg-rose-100 text-rose-700'], 'auto_fishing' => ['label' => '自动钓鱼卡', 'color' => 'bg-emerald-100 text-emerald-700'], + 'sign_repair' => ['label' => '签到补签卡', 'color' => 'bg-teal-100 text-teal-700'], ]; $isSuperAdmin = Auth::id() === 1; @endphp @@ -283,6 +284,7 @@ + diff --git a/resources/views/admin/sign-in-rules/index.blade.php b/resources/views/admin/sign-in-rules/index.blade.php new file mode 100644 index 0000000..a7ccdf9 --- /dev/null +++ b/resources/views/admin/sign-in-rules/index.blade.php @@ -0,0 +1,169 @@ +@extends('admin.layouts.app') + +@section('title', '签到奖励管理') + +@section('content') +
+
+
+

✅ 签到奖励管理

+

按连续签到天数配置金币、经验、魅力和专属身份。

+
+
+ +
+ + + + + + + + + + + + + + + @forelse ($rules as $rule) + + + + + + + + + + + @empty + + + + @endforelse + +
连续天数金币经验魅力身份有效期状态操作
第 {{ $rule->streak_days }} 天+{{ $rule->gold_reward }}+{{ $rule->exp_reward }}+{{ $rule->charm_reward }} + @if ($rule->identity_badge_name) + + {{ $rule->identity_badge_icon ?: '✅' }} + {{ $rule->identity_badge_name }} + +
{{ $rule->identity_badge_code }}
+ @else + 未配置身份 + @endif +
+ {{ $rule->identity_duration_days > 0 ? $rule->identity_duration_days . ' 天' : '永久' }} + + + + +
+ @csrf + @method('DELETE') + +
+
暂无签到奖励规则。
+
+ +
+
+

➕ 新增签到档位

+
+
+ @csrf + @include('admin.sign-in-rules.partials.form-fields', ['rule' => null]) +
+ + +
+
+
+
+ + + + +@endsection diff --git a/resources/views/admin/sign-in-rules/partials/form-fields.blade.php b/resources/views/admin/sign-in-rules/partials/form-fields.blade.php new file mode 100644 index 0000000..84835e2 --- /dev/null +++ b/resources/views/admin/sign-in-rules/partials/form-fields.blade.php @@ -0,0 +1,66 @@ +@php + $prefix = $prefix ?? ''; +@endphp + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 999ff22..a7afbce 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -95,6 +95,10 @@ refreshAllUrl: "{{ route('command.refresh_all') }}", chatPreferencesUrl: "{{ route('user.update_chat_preferences') }}", dailyStatusUpdateUrl: "{{ route('user.update_daily_status') }}", + dailySignInStatusUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.status') ? route('daily-sign-in.status') : null), + dailySignInCalendarUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.calendar') ? route('daily-sign-in.calendar') : null), + dailySignInClaimUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.claim') ? route('daily-sign-in.claim') : null), + dailySignInMakeupUrl: @json(\Illuminate\Support\Facades\Route::has('daily-sign-in.makeup') ? route('daily-sign-in.makeup') : null), userJjb: {{ (int) $user->jjb }}, // 当前用户金币(求婚前金额预检查用) myGold: {{ (int) $user->jjb }}, // 赠金币面板显示余额用(赠送成功后前端更新) chatPreferences: @json($user->chat_preferences ?? []), @@ -207,6 +211,7 @@ @include('chat.partials.marriage-modals') {{-- 节日福利弹窗 --}} @include('chat.partials.holiday-modal') + @include('chat.partials.daily-sign-in-modal') {{-- ═══════════ 游戏面板(partials/games/ 子目录,各自独立,包含 CSS + HTML + JS) ═══════════ --}} @include('chat.partials.games.baccarat-panel') diff --git a/resources/views/chat/partials/daily-sign-in-modal.blade.php b/resources/views/chat/partials/daily-sign-in-modal.blade.php new file mode 100644 index 0000000..724c062 --- /dev/null +++ b/resources/views/chat/partials/daily-sign-in-modal.blade.php @@ -0,0 +1,338 @@ +{{-- + 文件功能:每日签到日历弹窗。 + 展示当月签到状态、今日签到入口、补签卡数量,并支持快速购买补签卡。 +--}} + + + +
+
+
+
✅ 每日签到
+ +
+
+
+
+ 连续 0 天 + 加载中... +
+
+ 补签卡 0 张 + 可在商店购买 +
+
+
+ + +
+
+ +
本月
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ 连续奖励目标 + 当前 0 天 +
+
+
+
+
+
diff --git a/resources/views/chat/partials/layout/input-bar.blade.php b/resources/views/chat/partials/layout/input-bar.blade.php index 21ce367..13a14d2 100644 --- a/resources/views/chat/partials/layout/input-bar.blade.php +++ b/resources/views/chat/partials/layout/input-bar.blade.php @@ -186,6 +186,8 @@ $welcomeMessages = [
快捷入口
+
+

签到
🛒
商店
👑
会员
💾
存点
diff --git a/resources/views/chat/partials/layout/toolbar.blade.php b/resources/views/chat/partials/layout/toolbar.blade.php index 2f31122..cc8088c 100644 --- a/resources/views/chat/partials/layout/toolbar.blade.php +++ b/resources/views/chat/partials/layout/toolbar.blade.php @@ -1204,7 +1204,7 @@ async function generateWechatBindCode() { { label: '🎭 道具', desc: '', - type: 'one_time' + type: 'tools' }, ]; @@ -1212,7 +1212,7 @@ async function generateWechatBindCode() { list.innerHTML = ''; groups.forEach(g => { - const items = data.items.filter(i => i.type === g.type); + const items = data.items.filter(i => g.type === 'tools' ? ['one_time', 'sign_repair'].includes(i.type) : i.type === g.type); if (!items.length) return; // 分组标题(独占一整行) @@ -1235,6 +1235,9 @@ async function generateWechatBindCode() { const effName = effItem ? effItem.name : effKey; groupSuffix = ` ✅ 已激活:${effName}`; + } else if (g.type === 'tools' && (data.sign_repair_card_count || 0) > 0) { + groupSuffix = + ` 🎫 可用 ${data.sign_repair_card_count} 张`; } header.innerHTML = `${g.label}${groupSuffix}${g.desc ? ` ${g.desc}` : ''}`; list.appendChild(header); @@ -1295,10 +1298,15 @@ async function generateWechatBindCode() { btn.className = 'shop-btn'; btn.innerHTML = `💰 ${Number(item.price).toLocaleString()}`; btn.onclick = async () => { + let quantity = 1; + if (item.type === 'sign_repair') { + quantity = await window.promptSignRepairQuantity?.(item); + if (quantity === null || quantity === undefined) return; + } const confirmMsg = - `确认花费 💰 ${Number(item.price).toLocaleString()} 金币购买\n【${item.name}】吗?`; + `确认花费 💰 ${Number(item.price * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ' × ' + quantity : ''} 吗?`; const ok = await window.chatDialog.confirm(confirmMsg, '确认购买'); - if (ok) buyItem(item.id, item.name, item.price, 'all', ''); + if (ok) buyItem(item.id, item.name, item.price, 'all', '', quantity); }; } card.appendChild(btn); @@ -1341,7 +1349,7 @@ async function generateWechatBindCode() { }; /** 购买商品(最终执行) */ - window.buyItem = function(itemId, name, price, recipient, message) { + window.buyItem = function(itemId, name, price, recipient, message, quantity = 1) { const roomId = window.chatContext?.roomId ?? 0; fetch('{{ route('shop.buy') }}', { method: 'POST', @@ -1354,6 +1362,7 @@ async function generateWechatBindCode() { item_id: itemId, recipient: recipient || 'all', message: message || '', + quantity: quantity || 1, room_id: roomId, }), }) diff --git a/resources/views/chat/partials/scripts.blade.php b/resources/views/chat/partials/scripts.blade.php index 293ad67..b476109 100644 --- a/resources/views/chat/partials/scripts.blade.php +++ b/resources/views/chat/partials/scripts.blade.php @@ -423,6 +423,544 @@ renderUserList(); } + /** + * 从服务端响应中提取最新金币余额。 + * + * @param {Record} data 接口响应数据 + * @returns {number|null} + */ + function resolveDailySignInGoldBalance(data) { + const candidates = [ + data?.data?.user?.jjb, + data?.data?.user?.gold, + data?.data?.presence?.jjb, + data?.data?.presence?.gold, + data?.data?.my_jjb, + data?.data?.new_jjb, + data?.data?.balance, + data?.my_jjb, + data?.new_jjb, + data?.balance, + ]; + + for (const candidate of candidates) { + const amount = Number(candidate); + + if (Number.isFinite(amount)) { + return amount; + } + } + + return null; + } + + /** + * 从签到响应中提取当前用户最新在线载荷。 + * + * @param {Record} data 接口响应数据 + * @returns {Record|null} + */ + function resolveDailySignInPresencePayload(data) { + const candidates = [ + data?.data?.presence, + data?.data?.online_user, + data?.data?.onlineUser, + data?.data?.user_payload, + data?.data?.userPayload, + data?.data?.user, + data?.presence, + data?.online_user, + data?.onlineUser, + ]; + + return candidates.find(payload => payload && typeof payload === 'object') || null; + } + + /** + * 从签到响应中提取签到身份字段。 + * + * @param {Record} data 接口响应数据 + * @returns {Record} + */ + function resolveDailySignInIdentityPayload(data) { + const identity = data?.data?.identity || data?.data?.sign_identity || data?.identity || data?.sign_identity; + + if (!identity || typeof identity !== 'object') { + return {}; + } + + return { + sign_identity_key: identity.key ?? identity.sign_identity_key ?? identity.code ?? '', + sign_identity_label: identity.label ?? identity.name ?? identity.sign_identity_label ?? '', + sign_identity_icon: identity.icon ?? identity.sign_identity_icon ?? '', + sign_identity_color: identity.color ?? identity.sign_identity_color ?? undefined, + sign_identity_bg_color: identity.bg_color ?? identity.background_color ?? identity.sign_identity_bg_color ?? undefined, + sign_identity_border_color: identity.border_color ?? identity.sign_identity_border_color ?? undefined, + }; + } + + /** + * 将签到成功结果同步到金币余额与在线名单。 + * + * @param {Record} data 接口响应数据 + */ + function applyDailySignInResult(data) { + const balance = resolveDailySignInGoldBalance(data); + const payload = resolveDailySignInPresencePayload(data); + const identityPayload = resolveDailySignInIdentityPayload(data); + const username = window.chatContext?.username; + + if (balance !== null && window.chatContext) { + window.chatContext.userJjb = balance; + window.chatContext.myGold = balance; + } + + if (username) { + hydrateOnlineUserPayload(username, { + ...(payload || {}), + ...identityPayload, + username, + }); + } + + renderUserList(); + } + + window.dailySignInState = { + month: null, + prevMonth: null, + nextMonth: null, + repairCardItem: null, + repairCardCount: 0, + rewardRules: [], + status: null, + }; + + /** + * 打开每日签到日历弹窗。 + */ + window.openDailySignInModal = async function openDailySignInModal() { + const modal = document.getElementById('daily-sign-modal'); + + if (!window.chatContext?.dailySignInCalendarUrl || !modal) { + window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b'); + return; + } + + modal.style.display = 'flex'; + await Promise.all([ + loadDailySignInStatus(), + loadDailySignInCalendar(window.dailySignInState.month), + ]); + }; + + /** + * 关闭每日签到日历弹窗。 + */ + window.closeDailySignInModal = function closeDailySignInModal() { + const modal = document.getElementById('daily-sign-modal'); + + if (modal) { + modal.style.display = 'none'; + } + }; + + /** + * 快速签到入口:打开日历,让用户能看到当月签到和补签状态。 + */ + window.quickDailySignIn = async function quickDailySignIn() { + await window.openDailySignInModal(); + }; + + /** + * 拉取今日签到状态。 + */ + async function loadDailySignInStatus() { + const statusUrl = window.chatContext?.dailySignInStatusUrl; + + if (!statusUrl) { + return; + } + + const response = await fetch(statusUrl, { + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + }, + }); + const data = await response.json(); + + if (!response.ok || data?.status === 'error') { + throw new Error(data?.message || '签到状态加载失败'); + } + + window.dailySignInState.status = data.data || {}; + renderDailySignInStatus(); + } + + /** + * 拉取并渲染指定月份签到日历。 + * + * @param {string|null|undefined} month 月份 YYYY-MM + */ + window.loadDailySignInCalendar = async function loadDailySignInCalendar(month) { + const calendarUrl = window.chatContext?.dailySignInCalendarUrl; + + if (!calendarUrl) { + return; + } + + const url = new URL(calendarUrl, window.location.origin); + if (month) { + url.searchParams.set('month', month); + } + + const response = await fetch(url.toString(), { + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + }, + }); + const data = await response.json(); + + if (!response.ok || data?.status === 'error') { + throw new Error(data?.message || '签到日历加载失败'); + } + + const payload = data.data || {}; + window.dailySignInState.month = payload.month || month || null; + window.dailySignInState.prevMonth = payload.prev_month || null; + window.dailySignInState.nextMonth = payload.next_month || null; + window.dailySignInState.repairCardItem = payload.sign_repair_card_item || null; + window.dailySignInState.repairCardCount = Number(payload.makeup_card_count || 0); + window.dailySignInState.rewardRules = Array.isArray(payload.reward_rules) ? payload.reward_rules : []; + renderDailySignInCalendar(payload); + renderDailySignInStatus(); + renderDailySignInRewardRules(); + }; + + /** + * 渲染签到状态摘要。 + */ + function renderDailySignInStatus() { + const status = window.dailySignInState.status || {}; + const streakEl = document.getElementById('daily-sign-streak'); + const previewEl = document.getElementById('daily-sign-preview'); + const cardCountEl = document.getElementById('daily-sign-card-count'); + const cardPriceEl = document.getElementById('daily-sign-card-price'); + const claimBtn = document.getElementById('daily-sign-claim-btn'); + const buyBtn = document.getElementById('daily-sign-buy-card-btn'); + const cardItem = window.dailySignInState.repairCardItem; + + if (streakEl) { + streakEl.textContent = `连续 ${Number(status.current_streak_days || 0)} 天`; + } + if (previewEl) { + const rule = status.preview_rule || {}; + const parts = []; + if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward} 金币`); + if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward} 经验`); + if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward} 魅力`); + previewEl.textContent = status.signed_today ? '今日已签到' : `今日可领:${parts.join(' + ') || '签到奖励'}`; + } + if (cardCountEl) { + cardCountEl.textContent = `补签卡 ${window.dailySignInState.repairCardCount || 0} 张`; + } + if (cardPriceEl) { + cardPriceEl.textContent = cardItem ? `${cardItem.icon || '🗓️'} ${cardItem.name}:${Number(cardItem.price || 0).toLocaleString()} 金币` : '补签卡暂未上架'; + } + if (claimBtn) { + claimBtn.disabled = !!status.signed_today; + claimBtn.textContent = status.signed_today ? '今日已签到' : '今日签到'; + claimBtn.style.opacity = status.signed_today ? '0.55' : '1'; + claimBtn.style.cursor = status.signed_today ? 'not-allowed' : 'pointer'; + } + if (buyBtn) { + buyBtn.disabled = !cardItem?.id; + buyBtn.style.opacity = cardItem?.id ? '1' : '0.55'; + buyBtn.style.cursor = cardItem?.id ? 'pointer' : 'not-allowed'; + } + } + + /** + * 渲染签到月历。 + * + * @param {Record} payload 日历响应数据 + */ + function renderDailySignInCalendar(payload) { + const grid = document.getElementById('daily-sign-calendar-grid'); + const label = document.getElementById('daily-sign-month-label'); + + if (!grid) { + return; + } + + if (label) { + label.textContent = payload.month_label || payload.month || '本月'; + } + + const days = Array.isArray(payload.days) ? payload.days : []; + grid.innerHTML = ''; + + const firstWeekday = Number(days[0]?.weekday || 0); + for (let i = 0; i < firstWeekday; i += 1) { + const blank = document.createElement('div'); + blank.className = 'daily-sign-day blank'; + grid.appendChild(blank); + } + + days.forEach(day => { + const cell = document.createElement('button'); + cell.type = 'button'; + cell.className = 'daily-sign-day'; + if (day.signed) cell.classList.add('signed'); + if (day.can_makeup) cell.classList.add('missed'); + if (day.is_today) cell.classList.add('today'); + if (day.is_future) cell.classList.add('future'); + + const stateText = day.signed + ? `${day.is_makeup ? '补签' : '已签'} ${day.streak_days || ''}天` + : (day.is_future ? '未到' : (day.is_today ? '今天' : '漏签')); + + cell.innerHTML = `${day.day}${escapeHtml(stateText)}`; + cell.title = day.reward_text || stateText; + if (day.can_makeup) { + cell.onclick = () => makeupDailySignIn(day.date); + } + grid.appendChild(cell); + }); + } + + /** + * 渲染连续签到奖励目标列表。 + */ + function renderDailySignInRewardRules() { + const list = document.getElementById('daily-sign-rewards-list'); + const progress = document.getElementById('daily-sign-reward-progress'); + + if (!list) { + return; + } + + const currentDays = Number(window.dailySignInState.status?.current_streak_days || 0); + const rules = window.dailySignInState.rewardRules || []; + + if (progress) { + progress.textContent = `当前 ${currentDays} 天`; + } + + if (!rules.length) { + list.innerHTML = '
暂无奖励规则
'; + return; + } + + list.innerHTML = rules.map(rule => { + const streakDays = Number(rule.streak_days || 0); + const parts = []; + if (Number(rule.gold_reward || 0) > 0) parts.push(`${rule.gold_reward}金`); + if (Number(rule.exp_reward || 0) > 0) parts.push(`${rule.exp_reward}经验`); + if (Number(rule.charm_reward || 0) > 0) parts.push(`${rule.charm_reward}魅力`); + + const icon = escapeHtml(rule.identity_badge_icon || '✅'); + const name = escapeHtml(rule.identity_badge_name || '签到奖励'); + const color = escapeHtml(rule.identity_badge_color || '#0f766e'); + const activeClass = currentDays >= streakDays ? ' active' : ''; + const distanceText = currentDays >= streakDays ? '已达成' : `还差 ${Math.max(streakDays - currentDays, 0)} 天`; + const rewardText = escapeHtml(parts.join(' + ') || '签到记录'); + + return ` +
+
+ 第 ${streakDays} 天 + ${icon} +
+
${name}
+
${rewardText}
+
+ `; + }).join(''); + } + + /** + * 在日历弹窗中领取今日签到。 + */ + window.claimDailySignInFromModal = async function claimDailySignInFromModal() { + const claimUrl = window.chatContext?.dailySignInClaimUrl; + + if (!claimUrl) { + window.chatDialog?.alert('签到入口暂未开放,请稍后再试。', '提示', '#f59e0b'); + return; + } + + try { + const response = await fetch(claimUrl, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + room_id: window.chatContext?.roomId ?? null, + }), + }); + const data = await response.json(); + + if (!response.ok || data?.status === 'error' || data?.ok === false) { + throw new Error(data?.message || '签到失败'); + } + + applyDailySignInResult(data); + await Promise.all([ + loadDailySignInStatus(), + loadDailySignInCalendar(window.dailySignInState.month), + ]); + renderDailySignInRewardRules(); + window.chatToast?.show({ + title: '签到成功', + message: data?.message || '今日签到奖励已到账。', + icon: data?.data?.sign_identity_icon || data?.data?.identity?.icon || '✅', + color: '#16a34a', + duration: 3200, + }); + } catch (error) { + window.chatDialog?.alert(error.message || '签到失败,请稍后重试。', '签到失败', '#cc4444'); + } + }; + + /** + * 使用补签卡补签指定日期。 + * + * @param {string} targetDate 目标日期 YYYY-MM-DD + */ + async function makeupDailySignIn(targetDate) { + const makeupUrl = window.chatContext?.dailySignInMakeupUrl; + + if (!makeupUrl) { + return; + } + + const ok = await window.chatDialog?.confirm(`确认使用 1 张补签卡补签 ${targetDate} 吗?`, '确认补签'); + if (!ok) { + return; + } + + try { + const response = await fetch(makeupUrl, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + target_date: targetDate, + room_id: window.chatContext?.roomId ?? null, + }), + }); + const data = await response.json(); + + if (!response.ok || data?.status === 'error' || data?.ok === false) { + const firstError = data?.errors ? Object.values(data.errors).flat()[0] : null; + throw new Error(firstError || data?.message || '补签失败'); + } + + applyDailySignInResult(data); + await Promise.all([ + loadDailySignInStatus(), + loadDailySignInCalendar(window.dailySignInState.month), + ]); + renderDailySignInRewardRules(); + window.chatToast?.show({ + title: '补签成功', + message: data?.message || '补签已完成。', + icon: '🗓️', + color: '#0f766e', + duration: 3200, + }); + } catch (error) { + window.chatDialog?.alert(error.message || '补签失败,请稍后重试。', '补签失败', '#cc4444'); + } + } + + /** + * 询问补签卡购买数量。 + * + * @param {Record} item 补签卡商品 + * @returns {Promise} + */ + window.promptSignRepairQuantity = async function promptSignRepairQuantity(item) { + const unitPrice = Number(item?.price || 0); + const ruleText = item?.description || '补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。'; + const promptPromise = window.chatDialog?.prompt( + `请输入要购买的补签卡数量(1-99):\n单价 ${unitPrice.toLocaleString()} 金币\n说明:${ruleText}`, + '1', + '购买补签卡', + '#0f766e' + ); + const inputEl = document.getElementById('global-dialog-input'); + const previousInputStyle = inputEl?.getAttribute('style') || ''; + + if (inputEl) { + inputEl.style.minHeight = '40px'; + inputEl.style.height = '40px'; + inputEl.style.resize = 'none'; + inputEl.style.overflow = 'hidden'; + } + + const rawQuantity = await promptPromise; + + if (inputEl) { + inputEl.setAttribute('style', previousInputStyle); + } + + if (rawQuantity === null || rawQuantity === undefined) { + return null; + } + + const quantity = Number.parseInt(String(rawQuantity).trim(), 10); + if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) { + window.chatDialog?.alert('购买数量必须是 1 到 99 之间的整数。', '数量不正确', '#cc4444'); + return null; + } + + return quantity; + }; + + /** + * 在签到弹窗内快速购买补签卡。 + */ + window.buyDailySignRepairCard = async function buyDailySignRepairCard() { + const item = window.dailySignInState.repairCardItem; + + if (!item?.id) { + window.chatDialog?.alert('补签卡暂未上架。', '提示', '#f59e0b'); + return; + } + + const quantity = await window.promptSignRepairQuantity(item); + if (quantity === null) { + return; + } + + const totalPrice = Number(item.price || 0) * quantity; + const ok = await window.chatDialog?.confirm(`确认花费 ${totalPrice.toLocaleString()} 金币购买【${item.name}】× ${quantity} 吗?\n说明:补签卡只能补签本月未签到日期。`, '购买补签卡'); + if (!ok) { + return; + } + + if (typeof window.buyItem === 'function') { + window.buyItem(item.id, item.name, item.price, 'all', '', quantity); + setTimeout(() => { + loadDailySignInCalendar(window.dailySignInState.month); + loadDailySignInStatus(); + }, 900); + return; + } + + window.openShopModal?.(); + }; + /** * 设置新的当日状态。 * @@ -1512,7 +2050,7 @@ } /** - * 构建原有徽标(职务 / 管理员 / VIP)。 + * 构建职务 / 管理员徽标。 * * @param {Record} user 用户在线载荷 * @param {string} username 用户名 @@ -1531,15 +2069,25 @@ return `🎖️`; } - if (user.vip_icon) { - const vipColor = user.vip_color || '#f59e0b'; - const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP')); - const safeVipIcon = escapeHtml(String(user.vip_icon || '👑')); + return ''; + } - return `${safeVipIcon}`; + /** + * 构建用户 VIP 徽标。 + * + * @param {Record} user 用户在线载荷 + * @returns {string} + */ + function buildUserVipBadgeHtml(user) { + if (!user.vip_icon) { + return ''; } - return ''; + const vipColor = user.vip_color || '#f59e0b'; + const safeVipTitle = escapeHtml(String(user.vip_name || 'VIP')); + const safeVipIcon = escapeHtml(String(user.vip_icon || '👑')); + + return `${safeVipIcon}`; } /** @@ -1559,30 +2107,65 @@ const safeTooltip = escapeHtml(`${status.group} · ${status.label}`); return ` - - ${safeIcon} - ${safeLabel} + ${safeIcon} + ${safeLabel} `; } /** - * 按 3 秒节奏在原有徽标与状态徽标之间切换。 + * 构建签到身份徽标。 + * + * @param {Record} user 用户在线载荷 + * @returns {string} + */ + function buildUserSignIdentityBadgeHtml(user) { + const identityKey = String(user.sign_identity_key ?? user.sign_identity ?? ''); + const identityLabel = String(user.sign_identity_label ?? user.sign_identity_name ?? ''); + const identityIcon = String(user.sign_identity_icon ?? ''); + + if (!identityKey || !identityLabel || !identityIcon) { + return ''; + } + + const color = String(user.sign_identity_color || '#0f766e'); + const bgColor = String(user.sign_identity_bg_color || '#ccfbf1'); + const borderColor = String(user.sign_identity_border_color || '#5eead4'); + const safeIcon = escapeHtml(identityIcon); + const safeLabel = escapeHtml(identityLabel); + const safeTooltip = escapeHtml(`签到 · ${identityLabel}`); + + return ` + + ${safeIcon} + ${safeLabel} + + `; + } + + /** + * 按 3 秒节奏在签到身份、状态、职务/管理、VIP 徽标之间轮换。 * * @param {Record} user 用户在线载荷 * @param {string} username 用户名 * @returns {string} */ function buildUserBadgeHtml(user, username) { - const statusBadge = buildUserStatusBadgeHtml(user); - const primaryBadge = buildUserPrimaryBadgeHtml(user, username); + const badges = [ + buildUserSignIdentityBadgeHtml(user), + buildUserStatusBadgeHtml(user), + buildUserPrimaryBadgeHtml(user, username), + buildUserVipBadgeHtml(user), + ].filter(Boolean); - if (statusBadge && primaryBadge) { - return userBadgeRotationTick % 2 === 0 ? statusBadge : primaryBadge; + if (badges.length === 0) { + return ''; } - return statusBadge || primaryBadge; + return badges[userBadgeRotationTick % badges.length]; } /** @@ -1603,9 +2186,9 @@ }); } - // 名单中“状态 / 原徽标”双轨展示时,每 3 秒只刷新徽标槽位,不重建头像行。 + // 名单中多种徽标轮换展示时,每 3 秒只刷新徽标槽位,不重建头像行。 window.setInterval(() => { - userBadgeRotationTick = (userBadgeRotationTick + 1) % 2; + userBadgeRotationTick = (userBadgeRotationTick + 1) % 4; refreshRenderedUserBadges(); syncDailyStatusUi(); }, 3000); diff --git a/resources/views/chat/partials/shop-panel.blade.php b/resources/views/chat/partials/shop-panel.blade.php index c03ed03..be277cb 100644 --- a/resources/views/chat/partials/shop-panel.blade.php +++ b/resources/views/chat/partials/shop-panel.blade.php @@ -344,7 +344,7 @@ { label: '🎭 道具', desc: '', - type: 'one_time' + type: 'tools' }, ]; @@ -352,7 +352,7 @@ itemsEl.innerHTML = ''; groups.forEach(g => { - const items = data.items.filter(i => i.type === g.type); + const items = data.items.filter(i => g.type === 'tools' ? ['one_time', 'sign_repair'].includes(i.type) : i.type === g.type); if (!items.length) return; const section = document.createElement('div'); @@ -417,7 +417,7 @@ } else { btn.className = 'shop-btn'; btn.innerHTML = `💰 ${Number(item.price).toLocaleString()}`; - btn.onclick = () => buyItem(item.id, item.name, item.price); + btn.onclick = () => buyItem(item.id, item.name, item.price, item.type); } row.appendChild(btn); card.appendChild(row); @@ -457,14 +457,23 @@ } /** 购买商品 */ - window.buyItem = function(itemId, name, price) { - // 使用全局弹窗替代原生 confirm(),通过 .then() 注册回调确保事件正确触发 - window.chatDialog.confirm( - `确定花费 ${Number(price).toLocaleString()} 金币购买【${name}】吗?`, - '确认购买', - '#336699' - ).then(ok => { - if (!ok) return; + window.buyItem = async function(itemId, name, price, typeOrRecipient = '', message = '', presetQuantity = null) { + const knownTypes = ['instant', 'duration', 'one_time', 'ring', 'auto_fishing', 'sign_repair']; + const type = knownTypes.includes(typeOrRecipient) ? typeOrRecipient : ''; + const recipient = type === '' ? (typeOrRecipient || 'all') : 'all'; + let quantity = Number(presetQuantity || 1); + + if (type === 'sign_repair') { + quantity = await window.promptSignRepairQuantity?.({ + name, + price, + description: '补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。', + }); + if (quantity === null || quantity === undefined) return; + } + + const signRepairNotice = type === 'sign_repair' ? '\n说明:补签卡只能补签本月未签到日期。' : ''; + const submitPurchase = () => { fetch('{{ route('shop.buy') }}', { method: 'POST', headers: { @@ -473,7 +482,10 @@ 'X-CSRF-TOKEN': _csrf() }, body: JSON.stringify({ - item_id: itemId + item_id: itemId, + recipient, + message: message || '', + quantity: quantity || 1 }), }) .then(r => r.json()) @@ -494,6 +506,21 @@ } }) .catch(() => showShopToast('⚠ 网络异常,请重试', false)); + }; + + if (presetQuantity !== null) { + submitPurchase(); + return; + } + + // 使用全局弹窗替代原生 confirm(),通过 .then() 注册回调确保事件正确触发 + window.chatDialog.confirm( + `确定花费 ${Number(price * quantity).toLocaleString()} 金币购买【${name}】${quantity > 1 ? ' × ' + quantity : ''} 吗?${signRepairNotice}`, + '确认购买', + '#336699' + ).then(ok => { + if (!ok) return; + submitPurchase(); }); }; diff --git a/resources/views/chat/partials/user-actions.blade.php b/resources/views/chat/partials/user-actions.blade.php index fb2db32..8caf3c6 100644 --- a/resources/views/chat/partials/user-actions.blade.php +++ b/resources/views/chat/partials/user-actions.blade.php @@ -692,6 +692,20 @@
+
+ + + + + +
+
加入:
diff --git a/resources/views/rooms/index.blade.php b/resources/views/rooms/index.blade.php index 906b098..4943946 100644 --- a/resources/views/rooms/index.blade.php +++ b/resources/views/rooms/index.blade.php @@ -277,6 +277,25 @@ } }">
+ @php + $currentSignIdentity = Auth::user()->currentSignInIdentity(); + $latestSignIn = Auth::user()->dailySignIns()->first(); + @endphp +
+
每日签到
+
+ 连续签到 {{ (int) ($latestSignIn?->streak_days ?? 0) }} 天 + @if ($currentSignIdentity) + + {{ $currentSignIdentity->badge_icon ?? '✅' }} + {{ $currentSignIdentity->badge_name }} + + @else + 暂无签到身份 + @endif +
+