input('page_size', 10))); $rows = Activity::query() ->with('venue:id,name,lat,lng') ->where('is_active', true) ->when($request->filled('keyword'), function ($q) use ($request) { $keyword = trim((string) $request->input('keyword')); $q->where('title', 'like', "%{$keyword}%"); }) ->orderForH5Listing() ->paginate($size); $rows->getCollection()->transform(function ($a) { 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(), 'registered_count' => (int) ($a->registered_count ?? 0), 'tags' => array_values($a->tags ?? []), ]; }); return response()->json($rows); } public function activityDetail(int $id): JsonResponse { $a = Activity::query() ->with([ 'venue:id,name,address,lat,lng,open_time,district,venue_type,ticket_type,unit_name', 'activityDays', ]) ->where('is_active', true) ->findOrFail($id); $isBookable = $a->activityDays->contains( fn (ActivityDay $d) => $d->isCurrentlyBookable() ); 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, 'lat' => $a->lat, 'lng' => $a->lng, 'start_at' => optional($a->start_at)?->toIso8601String(), 'end_at' => optional($a->end_at)?->toIso8601String(), '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), ]); } public function venues(): JsonResponse { $rows = Venue::query() ->where('is_active', true) ->orderBy('sort') ->orderByDesc('id') ->get(['id', 'name', 'district', 'address', 'lat', 'lng', 'cover_image', 'open_time']); return response()->json($rows); } public function venueDetail(int $id): JsonResponse { $v = Venue::query()->find($id); if ($v === null) { return response()->json(['message' => '场馆不存在'], 404); } if (! $v->is_active) { return response()->json(['message' => '场馆已下架'], 404); } $payload = $v->toArray(); $payload['carousel'] = $this->buildGalleryCarousel($v); $payload['live_people_count'] = (int) ($v->live_people_count ?? 0); $payload['venue_type_color'] = $this->resolveVenueTypeColor($v->venue_type); return response()->json($payload); } /** * 轮播素材:gallery_media(可含图片/视频);为空时用封面图兜底。 * * @return array */ private function buildGalleryCarousel(Venue|Activity $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 为色值 */ private function resolveVenueTypeColor(?string $venueType): string { if ($venueType === null || $venueType === '') { return '#05c9ac'; } $raw = DictItem::query() ->where('dict_type', 'venue_type') ->where('is_active', true) ->where('item_value', $venueType) ->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']) ->keyBy('id'); $venues = $venueIds->map(fn ($id) => $venueMap->get($id))->filter()->values(); return response()->json([ 'id' => $row->id, 'name' => $row->name, 'tags' => array_values($row->tags ?? []), 'intro_html' => $row->intro_html, 'venues' => $venues, ]); } public function studyTours(): JsonResponse { $rows = StudyTour::query() ->where('is_active', true) ->orderBy('sort') ->orderByDesc('id') ->get(['id', 'name', 'tags', 'venue_ids', 'intro_html']); $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(); $cover = $ids->map(fn ($id) => $venueMap->get($id)?->cover_image)->filter()->first(); return [ 'id' => $row->id, 'name' => $row->name, 'tags' => array_values($row->tags ?? []), 'venue_names' => $venueNames->all(), 'cover_image' => $cover ?: $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::activeOptions('venue_type'), 'ticket_type' => DictItem::activeOptions('ticket_type'), ]); } }