boolean('include_ticket_grab')) { return $this->mixedActivities($request); } $size = max(1, min(30, (int) $request->input('page_size', 10))); $rows = Activity::query() ->with(['venue:id,name,lat,lng', 'activityDays']) ->visibleOnH5() ->when($request->filled('keyword'), function ($q) use ($request) { $keyword = trim((string) $request->input('keyword')); $q->where('title', 'like', "%{$keyword}%"); }) ->when($request->filled('schedule_status'), function ($q) use ($request) { $st = trim((string) $request->input('schedule_status')); if (in_array($st, ['not_started', 'ongoing', 'ended'], true)) { $q->whereComputedScheduleStatus($st); } }) ->orderByScheduleStatusPriority() ->paginate($size); $rows->getCollection()->transform(function ($a) { $isBookable = $a->activityDays->contains( fn (ActivityDay $d) => $d->isCurrentlyBookable() ); return [ 'id' => $a->id, 'title' => $a->title, 'summary' => $a->summary, 'image' => $a->cover_image, 'venue_name' => $a->venue?->name, 'address' => $a->address, 'lat' => $a->lat, 'lng' => $a->lng, 'venue_lat' => $a->venue?->lat, 'venue_lng' => $a->venue?->lng, 'start_at' => optional($a->start_at)?->toIso8601String(), 'end_at' => optional($a->end_at)?->toIso8601String(), 'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at), 'registered_count' => (int) ($a->registered_count ?? 0), 'tags' => array_values($a->tags ?? []), 'is_bookable' => $isBookable, ]; }); return response()->json($rows); } public function activityDetail(Request $request, int $id): JsonResponse { $a = Activity::query() ->with([ 'venue:id,name,address,lat,lng,open_time,district,venue_type,venue_types,ticket_type,unit_name,contact_phone', 'activityDays', ]) ->visibleOnH5() ->findOrFail($id); $isBookable = $a->activityDays->contains( fn (ActivityDay $d) => $d->isCurrentlyBookable() ); $wechatUser = $this->authWechatUser($request); $myReservedDayIds = []; if ($wechatUser) { $myReservedDayIds = Reservation::query() ->where('activity_id', $a->id) ->where('status', '!=', 'cancelled') ->whereNotNull('activity_day_id') ->where(function ($sub) use ($wechatUser) { $sub->where('wechat_user_id', $wechatUser->id); $p = trim((string) ($wechatUser->phone ?? '')); if ($p !== '' && preg_match('/^1\d{10}$/', $p)) { $sub->orWhere('visitor_phone', $p); } }) ->pluck('activity_day_id') ->map(fn ($rowId) => (int) $rowId) ->all(); } $mySet = array_fill_keys($myReservedDayIds, true); $bookingDays = $a->activityDays ->values() ->map(function (ActivityDay $d) use ($mySet) { $already = isset($mySet[$d->id]); return $d->toH5BookingDayArray($already); }); return response()->json([ 'id' => $a->id, 'title' => $a->title, 'summary' => $a->summary, 'detail_html' => $a->detail_html, 'image' => $a->cover_image, 'gallery_media' => $a->gallery_media ?? [], 'carousel' => $this->buildGalleryCarousel($a), 'venue' => $a->venue, 'address' => $a->address, 'contact_phone' => $a->contact_phone, 'lat' => $a->lat, 'lng' => $a->lng, 'start_at' => optional($a->start_at)?->toIso8601String(), 'end_at' => optional($a->end_at)?->toIso8601String(), 'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at), 'registered_count' => (int) ($a->registered_count ?? 0), 'tags' => array_values($a->tags ?? []), 'reservation_notice' => $a->reservation_notice, 'is_bookable' => $isBookable, 'venue_type_color' => $this->resolveVenueTypeColor($a->venue?->venue_type, $a->venue?->venue_types), 'booking_days' => $bookingDays, ]); } public function venues(Request $request): JsonResponse { $query = Venue::query() ->visibleOnH5() ->orderBy('sort') ->orderByDesc('id'); // 只返回纳入人数统计的场馆(用于客流量统计页面) if ($request->boolean('only_included_in_stats')) { $query->where('is_included_in_stats', true); } $rows = $query->get() ->map(function (Venue $v) { // 直接使用 toArray,因为 visibleOnH5 已经过滤了审核状态和启用状态 $p = $v->toArray(); return [ 'id' => $p['id'], 'name' => $p['name'], 'sort' => (int) ($p['sort'] ?? 0), 'district' => $p['district'], 'address' => $p['address'], 'lat' => $p['lat'], 'lng' => $p['lng'], 'cover_image' => $p['cover_image'], 'open_time' => $p['open_time'], 'venue_type' => $p['venue_type'], 'venue_types' => is_array($p['venue_types'] ?? null) ? array_values($p['venue_types']) : [], 'ticket_type' => $p['ticket_type'], 'appointment_type' => $p['appointment_type'], 'booking_mode' => $p['booking_mode'] ?? null, 'open_mode' => $p['open_mode'] ?? null, 'is_included_in_stats' => (bool) ($p['is_included_in_stats'] ?? false), ]; }) ->values(); return response()->json($rows); } public function venueDetail(int $id): JsonResponse { $v = Venue::query()->find($id); if ($v === null) { return response()->json(['message' => '场馆不存在'], 404); } $merged = $v->toH5Payload(); if ($merged === null) { return response()->json(['message' => '场馆不存在'], 404); } $display = Venue::make($merged); $display->id = $v->id; $display->exists = true; $payload = $display->toArray(); unset($payload['audit_status'], $payload['audit_remark'], $payload['last_approved_snapshot']); $payload['carousel'] = $this->buildGalleryCarousel($display); $payload['live_people_count'] = (int) ($display->live_people_count ?? 0); $payload['venue_type_color'] = $this->resolveVenueTypeColor($display->venue_type, $display->venue_types); $payload['activities'] = Activity::query() ->where('venue_id', $v->id) ->visibleOnH5() ->orderForH5Listing() ->limit(50) ->get(['id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address']) ->map(function (Activity $a) { return [ 'id' => $a->id, 'title' => $a->title, 'summary' => $a->summary, 'cover_image' => $a->cover_image, 'start_at' => optional($a->start_at)?->toIso8601String(), 'end_at' => optional($a->end_at)?->toIso8601String(), 'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at), 'registered_count' => (int) ($a->registered_count ?? 0), 'address' => $a->address, ]; }) ->values() ->all(); return response()->json($payload); } /** * 轮播素材:gallery_media(可含图片/视频);为空时用封面图兜底。 * * @return array */ private function buildGalleryCarousel(Venue|Activity|StudyTour $model): array { $items = []; $seen = []; foreach ($model->gallery_media ?? [] as $m) { if (! is_array($m)) { continue; } $url = trim((string) ($m['url'] ?? '')); if ($url === '' || isset($seen[$url])) { continue; } $type = $m['type'] ?? 'image'; if (! in_array($type, ['image', 'video'], true)) { $type = 'image'; } $seen[$url] = true; $items[] = ['type' => $type, 'url' => $url]; } if ($items === [] && $model->cover_image) { $url = trim((string) $model->cover_image); if ($url !== '') { $items[] = ['type' => 'image', 'url' => $url]; } } return $items; } /** * 与首页地图场馆一致:字典 venue_type 的 item_remark 为色值。 * * @param array|null $venueTypes */ private function resolveVenueTypeColor(?string $venueType, $venueTypes = null): string { $first = $venueType; if (is_array($venueTypes) && count($venueTypes)) { $first = (string) ($venueTypes[0] ?? ''); } if ($first === null || $first === '') { return '#05c9ac'; } $raw = DictItem::query() ->where('dict_type', 'venue_type') ->where('is_active', true) ->where('item_value', $first) ->value('item_remark'); $color = '#05c9ac'; if (is_string($raw) && trim($raw) !== '') { $t = trim($raw); if (! str_starts_with($t, '#')) { $t = '#'.$t; } if (preg_match('/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/', $t)) { $color = $t; } } return $color; } public function studyTourDetail(int $id): JsonResponse { $row = StudyTour::query()->where('is_active', true)->findOrFail($id); $venueIds = collect($row->venue_ids ?? [])->filter()->values(); $venueMap = Venue::query() ->whereIn('id', $venueIds->all()) ->get(['id', 'name', 'district', 'address', 'cover_image', 'lat', 'lng', 'ticket_type', 'venue_type', 'venue_types']) ->keyBy('id'); $venues = $venueIds ->map(function ($id) use ($venueMap) { $v = $venueMap->get($id); if ($v === null) { return null; } $arr = $v->toArray(); $arr['venue_type_color'] = $this->resolveVenueTypeColor($v->venue_type, is_array($v->venue_types) ? $v->venue_types : null); if (is_array($arr['venue_types'] ?? null)) { $arr['venue_types'] = array_values($arr['venue_types']); } return $arr; }) ->filter() ->values(); return response()->json([ 'id' => $row->id, 'name' => $row->name, 'tags' => array_values($row->tags ?? []), 'image' => $row->cover_image, 'gallery_media' => $row->gallery_media ?? [], 'carousel' => $this->buildGalleryCarousel($row), 'intro_html' => $row->intro_html, 'venues' => $venues, ]); } public function studyTours(Request $request): JsonResponse { $rows = StudyTour::query() ->where('is_active', true) ->when($request->filled('keyword'), function ($q) use ($request) { $keyword = trim((string) $request->input('keyword')); $q->where('name', 'like', "%{$keyword}%"); }) ->orderBy('sort') ->orderByDesc('id') ->get(['id', 'name', 'tags', 'venue_ids', 'intro_html', 'cover_image', 'gallery_media']); $venueIds = $rows->pluck('venue_ids')->flatten()->filter()->values()->all(); $venueMap = Venue::query() ->whereIn('id', $venueIds) ->get(['id', 'name', 'district', 'address', 'cover_image', 'lat', 'lng']) ->keyBy('id'); $payload = $rows->map(function ($row) use ($venueMap) { $ids = collect($row->venue_ids ?? [])->filter()->values(); $firstVenue = $ids->isNotEmpty() ? $venueMap->get($ids->first()) : null; $venueNames = $ids->map(fn ($id) => $venueMap->get($id)?->name)->filter()->values(); $fallbackCover = $ids->map(fn ($id) => $venueMap->get($id)?->cover_image)->filter()->first(); $tourCover = trim((string) ($row->cover_image ?? '')); return [ 'id' => $row->id, 'name' => $row->name, 'tags' => array_values($row->tags ?? []), 'venue_names' => $venueNames->all(), 'cover_image' => $tourCover !== '' ? $tourCover : ($fallbackCover ?: $firstVenue?->cover_image), 'first_address' => $firstVenue?->address, 'first_district' => $firstVenue?->district, 'venue_count' => $ids->count(), ]; })->values(); return response()->json($payload); } public function venueDicts(): JsonResponse { return response()->json([ 'district' => DictItem::activeOptions('district'), 'venue_type' => DictItem::activeVenueTypeOptionsWithColor(), 'venue_appointment_type' => DictItem::activeOptions('venue_appointment_type'), 'venue_open_mode' => DictItem::activeOptions('venue_open_mode'), 'ticket_type' => DictItem::activeOptions('ticket_type'), ]); } private function authWechatUser(Request $request): ?WechatUser { $token = $request->bearerToken(); if (!$token) { return null; } $accessToken = PersonalAccessToken::findToken($token); if (!$accessToken || ! ($accessToken->tokenable instanceof WechatUser)) { return null; } return $accessToken->tokenable; } public function mixedActivities(Request $request): JsonResponse { $size = max(1, min(30, (int) $request->input('page_size', 10))); $page = max(1, (int) $request->input('page', 1)); $keyword = $request->filled('keyword') ? trim((string) $request->input('keyword')) : null; $schedule = $request->filled('schedule_status') ? trim((string) $request->input('schedule_status')) : null; if ($schedule && ! in_array($schedule, ['not_started', 'ongoing', 'ended'], true)) { $schedule = null; } $aQuery = Activity::query() ->with(['venue:id,name,lat,lng', 'activityDays']) ->visibleOnH5(); if ($keyword !== null && $keyword !== '') { $aQuery->where('title', 'like', "%{$keyword}%"); } if ($schedule) { $aQuery->whereComputedScheduleStatus($schedule); } $activities = $aQuery->get(); $tQuery = TicketGrabEvent::query() ->with('venues') ->visibleOnH5(); if ($keyword !== null && $keyword !== '') { $tQuery->where('title', 'like', "%{$keyword}%"); } if ($schedule) { $tQuery->whereComputedScheduleStatus($schedule); } $grabs = $tQuery->get(); $items = $activities->map(function (Activity $a) { $isBookable = $a->activityDays->contains( fn (ActivityDay $d) => $d->isCurrentlyBookable() ); return [ 'list_kind' => 'activity', 'id' => $a->id, 'title' => $a->title, 'summary' => $a->summary, 'image' => $a->cover_image, 'venue_name' => $a->venue?->name, 'address' => $a->address, 'lat' => $a->lat, 'lng' => $a->lng, 'venue_lat' => $a->venue?->lat, 'venue_lng' => $a->venue?->lng, 'start_at' => optional($a->start_at)?->toIso8601String(), 'end_at' => optional($a->end_at)?->toIso8601String(), 'schedule_status' => Activity::computeScheduleStatusFromBounds($a->start_at, $a->end_at), 'registered_count' => (int) ($a->registered_count ?? 0), 'tags' => array_values($a->tags ?? []), 'is_bookable' => $isBookable, ]; })->merge($grabs->map(function (TicketGrabEvent $e) { $firstVenue = $e->venues->first(); return [ 'list_kind' => 'ticket_grab', 'id' => $e->id, 'title' => $e->title, 'summary' => $e->summary, 'image' => $e->cover_image, 'venue_name' => $firstVenue?->name, 'address' => $e->address, 'lat' => null, 'lng' => null, 'venue_lat' => $firstVenue?->lat, 'venue_lng' => $firstVenue?->lng, 'start_at' => CalendarDateFormat::ymdFromDatetime($e->start_at), 'end_at' => CalendarDateFormat::ymdFromDatetime($e->end_at), 'schedule_status' => TicketGrabEvent::computeScheduleStatusFromBounds($e->start_at, $e->end_at), 'registered_count' => (int) ($e->registered_count ?? 0), 'tags' => array_values($e->tags ?? []), 'is_bookable' => $this->heuristicTicketGrabBookable($e), 'can_grab_today' => $e->canGrabToday(), 'venue_count' => $e->venues->count(), ]; }))->values(); $items = $items->sort(function (array $x, array $y) { $t = Carbon::now((string) config('app.timezone'))->toDateString(); $sx = $this->scheduleRankForSort( $x['start_at'] ? Carbon::parse($x['start_at'])->toDateString() : null, $x['end_at'] ? Carbon::parse($x['end_at'])->toDateString() : null, $t ); $sy = $this->scheduleRankForSort( $y['start_at'] ? Carbon::parse($y['start_at'])->toDateString() : null, $y['end_at'] ? Carbon::parse($y['end_at'])->toDateString() : null, $t ); if ($sx !== $sy) { return $sx <=> $sy; } $aStart = $x['start_at'] ?? ''; $bStart = $y['start_at'] ?? ''; if ($aStart !== $bStart) { if ($aStart === '' || $aStart === null) { return 1; } if ($bStart === '' || $bStart === null) { return -1; } return $aStart <=> $bStart; } return (int) $y['id'] <=> (int) $x['id']; })->values(); $total = $items->count(); $slice = $items->forPage($page, $size)->values(); $paginator = new LengthAwarePaginator( $slice, $total, $size, $page, [ 'path' => $request->url(), 'query' => $request->query(), ], ); return response()->json($paginator); } /** * 0=进行中, 1=未开始, 2=已结束(与 orderByScheduleStatusPriority 同序). */ private function scheduleRankForSort(?string $startD, ?string $endD, string $today): int { if (! $endD && ! $startD) { return 0; } if ($endD && $endD < $today) { return 2; } if ($startD && $startD > $today) { return 1; } return 0; } private function heuristicTicketGrabBookable(TicketGrabEvent $e): bool { $t = now()->toDateString(); if (! $e->booking_start_at || ! $e->booking_end_at) { return false; } if ($t < $e->booking_start_at->toDateString() || $t > $e->booking_end_at->toDateString()) { return false; } if ($e->end_at && $e->end_at->toDateString() < $t) { return false; } return true; } }