You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

301 lines
13 KiB

4 weeks ago
<?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;
2 weeks ago
use App\Models\TicketGrabEvent;
4 weeks ago
use App\Models\Venue;
2 weeks ago
use App\Support\ActivityH5View;
2 weeks ago
use App\Support\CalendarDateFormat;
4 weeks ago
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(),
3 weeks ago
'venue_total' => Venue::query()->visibleOnH5()->count(),
'activity_total' => Activity::query()->visibleOnH5()->count(),
2 weeks ago
/** 在馆实时总人数;接入真实客流前固定为 0勿用随机数 */
'in_venue_total' => 0,
4 weeks ago
];
$banners = Activity::query()
3 weeks ago
->visibleOnH5()
4 weeks ago
->whereNotNull('cover_image')
->where('cover_image', '!=', '')
->orderBy('sort')
1 week ago
->orderByTicketNoteFreeBeforePaid()
4 weeks ago
->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();
2 weeks ago
/**
* 在馆 Top3接入按场馆真实在馆人数前不返回用「预约票量」汇总的替代数据避免与「在馆」混淆。
* 需恢复时:按 reservations 或客流表重查,见 git 历史。
*/
$topLiveVenues = [];
4 weeks ago
$venueTypeColors = DictItem::query()
->where('dict_type', 'venue_type')
->where('is_active', true)
->pluck('item_remark', 'item_value');
2 weeks ago
// 全部场馆列表(不过滤经纬度,用于列表展示)
$allVenues = Venue::query()
3 weeks ago
->visibleOnH5()
4 weeks ago
->orderBy('sort')
->orderByDesc('id')
3 weeks ago
->get()
4 weeks ago
->map(function ($v) use ($venueTypeColors) {
2 weeks ago
$p = $v->toArray();
3 weeks ago
$types = $p['venue_types'] ?? null;
$firstType = (is_array($types) && count($types)) ? (string) ($types[0] ?? '') : ($p['venue_type'] ?? '');
3 weeks ago
$raw = $venueTypeColors->get($firstType);
4 weeks ago
$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 [
3 weeks ago
'id' => (int) $p['id'],
'name' => $p['name'],
3 weeks ago
'sort' => (int) ($p['sort'] ?? 0),
3 weeks ago
'district' => $p['district'],
'address' => $p['address'],
2 weeks ago
'lat' => $p['lat'] ? (float) $p['lat'] : null,
'lng' => $p['lng'] ? (float) $p['lng'] : null,
3 weeks ago
'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'],
2 weeks ago
'booking_mode' => $p['booking_mode'] ?? null,
3 weeks ago
'open_mode' => $p['open_mode'] ?? null,
2 weeks ago
'is_included_in_stats' => (bool) ($p['is_included_in_stats'] ?? false),
4 weeks ago
'venue_type_color' => $color,
];
})
->values();
2 weeks ago
// 地图场馆:只返回有经纬度的(用于地图标点)
$mapVenues = $allVenues->filter(function ($v) {
return $v['lat'] !== null && $v['lng'] !== null;
})->values();
2 weeks ago
$actRows = Activity::query()
2 weeks ago
->with('venue:id,name,cover_image')
4 weeks ago
->with('activityDays')
3 weeks ago
->visibleOnH5()
1 week ago
->orderByHotThenScheduleStatusPriority()
1 week ago
->get(['id', 'venue_id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address', 'tags', 'sort', 'behind_scenes_media', 'is_hot', 'offline_reservation_method', 'reservation_type'])
4 weeks ago
->map(function ($a) {
7 days ago
$t = (string) ($a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE);
$online = $t === '' || $t === Activity::RESERVATION_TYPE_ONLINE;
$isBookable = $online && $a->activityDays->contains(
4 weeks ago
fn (ActivityDay $d) => $d->isCurrentlyBookable()
);
7 days ago
return [
'list_kind' => 'activity',
'id' => $a->id,
'title' => $a->title,
'summary' => $a->summary,
'image' => ActivityH5View::listCover($a),
'venue_name' => $a->venue?->name,
'address' => $a->address,
'start_at' => optional($a->start_at)?->toIso8601String(),
'end_at' => optional($a->end_at)?->toIso8601String(),
7 days ago
'schedule_status' => Activity::computeScheduleStatusForDisplay($a),
7 days ago
'registered_count' => (int) ($a->registered_count ?? 0),
'is_bookable' => $isBookable,
'all_slots_full' => $a->areAllActivityDaySlotsExhausted(),
'booking_closed_not_full' => $online && $a->isBookingClosedWithRemainingCapacity(),
'tags' => array_values($a->tags ?? []),
'is_hot' => (bool) ($a->is_hot ?? false),
'has_behind_scenes' => $this->activityHasBehindScenes($a),
'offline_reservation_method' => (string) ($a->offline_reservation_method ?? ''),
'reservation_type' => (string) ($a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE),
];
2 weeks ago
});
$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;
7 days ago
$hotActivities = $actRows
->merge($tgRows)
->filter(fn (array $row) => ($row['schedule_status'] ?? '') !== 'ended')
->values()
->sort(function (array $x, array $y) use ($controller) {
$hx = (($x['list_kind'] ?? 'activity') === 'activity' && ! empty($x['is_hot'])) ? 0 : 1;
$hy = (($y['list_kind'] ?? 'activity') === 'activity' && ! empty($y['is_hot'])) ? 0 : 1;
if ($hx !== $hy) {
return $hx <=> $hy;
2 weeks ago
}
7 days ago
$sx = $controller->homeScheduleRankFromStatus($x['schedule_status'] ?? null);
$sy = $controller->homeScheduleRankFromStatus($y['schedule_status'] ?? null);
if ($sx !== $sy) {
return $sx <=> $sy;
2 weeks ago
}
7 days ago
$a = $x['start_at'] ?? '';
$b = $y['start_at'] ?? '';
if ($a !== $b) {
if ($a === '') {
return 1;
}
if ($b === '') {
return -1;
}
2 weeks ago
7 days ago
return strcmp((string) $a, (string) $b);
}
2 weeks ago
7 days ago
$tx = $this->homeActivityTicketPaidRank($x);
$ty = $this->homeActivityTicketPaidRank($y);
if ($tx !== $ty) {
return $tx <=> $ty;
}
1 week ago
7 days ago
return (int) $y['id'] <=> (int) $x['id'];
})
->values()
->take(5)
->values();
4 weeks ago
$rankings = $hotActivities->take(2)->values();
$activeStudyTours = StudyTour::query()
1 week ago
->where('is_on_shelf', true)
4 weeks ago
->orderBy('sort')
->orderByDesc('id')
->limit(3)
3 weeks ago
->get(['id', 'name', 'tags', 'venue_ids', 'intro_html', 'cover_image']);
4 weeks ago
$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();
3 weeks ago
$fallbackCover = $venueIds->map(fn ($id) => $venueMap->get($id)?->cover_image)->filter()->first();
$tourCover = trim((string) ($row->cover_image ?? ''));
4 weeks ago
return [
'id' => $row->id,
'name' => $row->name,
'tags' => array_values($row->tags ?? []),
'venue_names' => $venueNames,
3 weeks ago
'cover_image' => $tourCover !== '' ? $tourCover : $fallbackCover,
4 weeks ago
];
})->values();
return response()->json([
'stats' => $stats,
'banners' => $banners,
'top_live_venues' => $topLiveVenues,
2 weeks ago
'all_venues' => $allVenues,
4 weeks ago
'map_venues' => $mapVenues,
'rankings' => $rankings,
'hot_activities' => $hotActivities,
'study_tours' => $studyTours,
4 weeks ago
'venue_dicts' => [
'district' => DictItem::activeOptions('district'),
3 weeks ago
'venue_type' => DictItem::activeVenueTypeOptionsWithColor(),
3 weeks ago
'venue_appointment_type' => DictItem::activeOptions('venue_appointment_type'),
2 weeks ago
'venue_booking_mode' => DictItem::activeOptions('venue_booking_mode'),
3 weeks ago
'venue_open_mode' => DictItem::activeOptions('venue_open_mode'),
4 weeks ago
'ticket_type' => DictItem::activeOptions('ticket_type'),
],
4 weeks ago
]);
}
2 weeks ago
1 week ago
private function activityHasBehindScenes(Activity $a): bool
{
$m = $a->behind_scenes_media;
if (! is_array($m)) {
return false;
}
foreach ($m as $row) {
if (is_array($row) && trim((string) ($row['url'] ?? '')) !== '') {
return true;
}
}
return false;
}
2 weeks ago
/**
1 week ago
* 与 SQL orderByTicketNoteFreeBeforePaid 一致:活动收费门票说明排后,抢票等非活动为 0。
*/
private function homeActivityTicketPaidRank(array $row): int
{
if (($row['list_kind'] ?? '') !== 'activity') {
return 0;
}
return (($row['offline_reservation_method'] ?? '') === Activity::TICKET_PAID) ? 1 : 0;
}
/**
7 days ago
* 0=进行中 1=未开始 2=已结束,与 {@see Activity::computeScheduleStatusForDisplay} 口径一致。
* (热门区已过滤 ended此处仅用于未开始/进行中 的先后。)
2 weeks ago
*/
7 days ago
private function homeScheduleRankFromStatus(?string $scheduleStatus): int
2 weeks ago
{
7 days ago
return match ($scheduleStatus) {
'not_started' => 1,
'ended' => 2,
default => 0,
};
2 weeks ago
}
4 weeks ago
}