|
|
<?php
|
|
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
use App\Models\Activity;
|
|
|
use App\Models\ActivityDay;
|
|
|
use App\Models\DictItem;
|
|
|
use App\Models\Reservation;
|
|
|
use App\Models\StudyTour;
|
|
|
use App\Models\TicketGrabEvent;
|
|
|
use App\Models\Venue;
|
|
|
use App\Support\CalendarDateFormat;
|
|
|
use Carbon\Carbon;
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
|
|
|
class H5HomeController extends Controller
|
|
|
{
|
|
|
public function index(): JsonResponse
|
|
|
{
|
|
|
$stats = [
|
|
|
'reservation_total' => Reservation::query()->where('status', '!=', 'cancelled')->count(),
|
|
|
'verified_total' => Reservation::query()->where('status', 'verified')->count(),
|
|
|
'venue_total' => Venue::query()->visibleOnH5()->count(),
|
|
|
'activity_total' => Activity::query()->visibleOnH5()->count(),
|
|
|
/** 在馆实时总人数;接入真实客流前固定为 0,勿用随机数 */
|
|
|
'in_venue_total' => 0,
|
|
|
];
|
|
|
|
|
|
$banners = Activity::query()
|
|
|
->visibleOnH5()
|
|
|
->whereNotNull('cover_image')
|
|
|
->where('cover_image', '!=', '')
|
|
|
->orderBy('sort')
|
|
|
->orderByDesc('id')
|
|
|
->limit(5)
|
|
|
->get(['id', 'title', 'summary', 'cover_image'])
|
|
|
->map(fn ($a) => [
|
|
|
'id' => $a->id,
|
|
|
'title' => $a->title,
|
|
|
'summary' => $a->summary,
|
|
|
'image' => $a->cover_image,
|
|
|
])
|
|
|
->values();
|
|
|
|
|
|
/**
|
|
|
* 在馆 Top3:接入按场馆真实在馆人数前,不返回用「预约票量」汇总的替代数据,避免与「在馆」混淆。
|
|
|
* 需恢复时:按 reservations 或客流表重查,见 git 历史。
|
|
|
*/
|
|
|
$topLiveVenues = [];
|
|
|
|
|
|
$venueTypeColors = DictItem::query()
|
|
|
->where('dict_type', 'venue_type')
|
|
|
->where('is_active', true)
|
|
|
->pluck('item_remark', 'item_value');
|
|
|
|
|
|
// 全部场馆列表(不过滤经纬度,用于列表展示)
|
|
|
$allVenues = Venue::query()
|
|
|
->visibleOnH5()
|
|
|
->orderBy('sort')
|
|
|
->orderByDesc('id')
|
|
|
->get()
|
|
|
->map(function ($v) use ($venueTypeColors) {
|
|
|
$p = $v->toArray();
|
|
|
$types = $p['venue_types'] ?? null;
|
|
|
$firstType = (is_array($types) && count($types)) ? (string) ($types[0] ?? '') : ($p['venue_type'] ?? '');
|
|
|
$raw = $venueTypeColors->get($firstType);
|
|
|
$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 [
|
|
|
'id' => (int) $p['id'],
|
|
|
'name' => $p['name'],
|
|
|
'sort' => (int) ($p['sort'] ?? 0),
|
|
|
'district' => $p['district'],
|
|
|
'address' => $p['address'],
|
|
|
'lat' => $p['lat'] ? (float) $p['lat'] : null,
|
|
|
'lng' => $p['lng'] ? (float) $p['lng'] : null,
|
|
|
'image' => $p['cover_image'],
|
|
|
'venue_type' => $p['venue_type'],
|
|
|
'venue_types' => is_array($types) ? array_values($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),
|
|
|
'venue_type_color' => $color,
|
|
|
];
|
|
|
})
|
|
|
->values();
|
|
|
|
|
|
// 地图场馆:只返回有经纬度的(用于地图标点)
|
|
|
$mapVenues = $allVenues->filter(function ($v) {
|
|
|
return $v['lat'] !== null && $v['lng'] !== null;
|
|
|
})->values();
|
|
|
|
|
|
$actRows = Activity::query()
|
|
|
->with('venue:id,name')
|
|
|
->with('activityDays')
|
|
|
->visibleOnH5()
|
|
|
->get(['id', 'venue_id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address', 'tags', 'sort'])
|
|
|
->map(function ($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,
|
|
|
'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),
|
|
|
'is_bookable' => $isBookable,
|
|
|
'tags' => array_values($a->tags ?? []),
|
|
|
];
|
|
|
});
|
|
|
$tgRows = TicketGrabEvent::query()
|
|
|
->with('venues')
|
|
|
->visibleOnH5()
|
|
|
->get(['id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address', 'tags', 'sort', 'booking_start_at', 'booking_end_at'])
|
|
|
->map(function ($e) {
|
|
|
$first = $e->venues->first();
|
|
|
$t = now()->toDateString();
|
|
|
$bookable = $e->booking_start_at && $e->booking_end_at
|
|
|
&& $t >= $e->booking_start_at->toDateString()
|
|
|
&& $t <= $e->booking_end_at->toDateString()
|
|
|
&& (! $e->end_at || $e->end_at->toDateString() >= $t);
|
|
|
|
|
|
return [
|
|
|
'list_kind' => 'ticket_grab',
|
|
|
'id' => $e->id,
|
|
|
'title' => $e->title,
|
|
|
'summary' => $e->summary,
|
|
|
'image' => $e->cover_image,
|
|
|
'venue_name' => $first?->name,
|
|
|
'address' => $e->address,
|
|
|
'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),
|
|
|
'is_bookable' => $bookable,
|
|
|
'can_grab_today' => $e->canGrabToday(),
|
|
|
'venue_count' => $e->venues->count(),
|
|
|
'tags' => array_values($e->tags ?? []),
|
|
|
];
|
|
|
});
|
|
|
$controller = $this;
|
|
|
$today = Carbon::now((string) config('app.timezone'))->toDateString();
|
|
|
$hotActivities = $actRows->merge($tgRows)->sort(function (array $x, array $y) use ($today, $controller) {
|
|
|
$sx = $controller->homeScheduleRank($x['start_at'] ?? null, $x['end_at'] ?? null, $today);
|
|
|
$sy = $controller->homeScheduleRank($y['start_at'] ?? null, $y['end_at'] ?? null, $today);
|
|
|
if ($sx !== $sy) {
|
|
|
return $sx <=> $sy;
|
|
|
}
|
|
|
$a = $x['start_at'] ?? '';
|
|
|
$b = $y['start_at'] ?? '';
|
|
|
if ($a !== $b) {
|
|
|
if ($a === '') {
|
|
|
return 1;
|
|
|
}
|
|
|
if ($b === '') {
|
|
|
return -1;
|
|
|
}
|
|
|
|
|
|
return strcmp((string) $a, (string) $b);
|
|
|
}
|
|
|
|
|
|
return (int) $y['id'] <=> (int) $x['id'];
|
|
|
})->values()->take(5)->values();
|
|
|
|
|
|
$rankings = $hotActivities->take(2)->values();
|
|
|
|
|
|
$activeStudyTours = StudyTour::query()
|
|
|
->where('is_active', true)
|
|
|
->orderBy('sort')
|
|
|
->orderByDesc('id')
|
|
|
->limit(3)
|
|
|
->get(['id', 'name', 'tags', 'venue_ids', 'intro_html', 'cover_image']);
|
|
|
|
|
|
$venueMap = Venue::query()
|
|
|
->whereIn('id', $activeStudyTours->pluck('venue_ids')->flatten()->filter()->values()->all())
|
|
|
->get(['id', 'name', 'cover_image'])
|
|
|
->keyBy('id');
|
|
|
|
|
|
$studyTours = $activeStudyTours->map(function ($row) use ($venueMap) {
|
|
|
$venueIds = collect($row->venue_ids ?? [])->values();
|
|
|
$venueNames = $venueIds->map(fn ($id) => $venueMap->get($id)?->name)->filter()->values();
|
|
|
$fallbackCover = $venueIds->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,
|
|
|
'cover_image' => $tourCover !== '' ? $tourCover : $fallbackCover,
|
|
|
];
|
|
|
})->values();
|
|
|
|
|
|
return response()->json([
|
|
|
'stats' => $stats,
|
|
|
'banners' => $banners,
|
|
|
'top_live_venues' => $topLiveVenues,
|
|
|
'all_venues' => $allVenues,
|
|
|
'map_venues' => $mapVenues,
|
|
|
'rankings' => $rankings,
|
|
|
'hot_activities' => $hotActivities,
|
|
|
'study_tours' => $studyTours,
|
|
|
'venue_dicts' => [
|
|
|
'district' => DictItem::activeOptions('district'),
|
|
|
'venue_type' => DictItem::activeVenueTypeOptionsWithColor(),
|
|
|
'venue_appointment_type' => DictItem::activeOptions('venue_appointment_type'),
|
|
|
'venue_booking_mode' => DictItem::activeOptions('venue_booking_mode'),
|
|
|
'venue_open_mode' => DictItem::activeOptions('venue_open_mode'),
|
|
|
'ticket_type' => DictItem::activeOptions('ticket_type'),
|
|
|
],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 与活动列表 H5 混合排序:0 进行中/未结束, 1 未开始, 2 已结束.
|
|
|
*/
|
|
|
private function homeScheduleRank(?string $startIso, ?string $endIso, string $today): int
|
|
|
{
|
|
|
$s = $startIso ? Carbon::parse($startIso)->toDateString() : null;
|
|
|
$e = $endIso ? Carbon::parse($endIso)->toDateString() : null;
|
|
|
if (! $e && ! $s) {
|
|
|
return 0;
|
|
|
}
|
|
|
if ($e && $e < $today) {
|
|
|
return 2;
|
|
|
}
|
|
|
if ($s && $s > $today) {
|
|
|
return 1;
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
}
|
|
|
}
|