|
|
<?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\TicketGrabEventVenue;
|
|
|
use App\Models\Venue;
|
|
|
use App\Models\WechatUser;
|
|
|
use App\Support\ActivityH5View;
|
|
|
use App\Support\CalendarDateFormat;
|
|
|
use Carbon\Carbon;
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
use Illuminate\Http\Request;
|
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
|
use Laravel\Sanctum\PersonalAccessToken;
|
|
|
|
|
|
class H5ContentController extends Controller
|
|
|
{
|
|
|
public function activities(Request $request): JsonResponse
|
|
|
{
|
|
|
if ($request->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,cover_image', '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);
|
|
|
}
|
|
|
})
|
|
|
->orderByHotThenScheduleStatusPriority()
|
|
|
->paginate($size);
|
|
|
|
|
|
$rows->getCollection()->transform(function ($a) {
|
|
|
$online = $this->activitySupportsOnlineBooking($a);
|
|
|
$isBookable = $online && $a->activityDays->contains(
|
|
|
fn (ActivityDay $d) => $d->isCurrentlyBookable()
|
|
|
);
|
|
|
|
|
|
return [
|
|
|
'id' => $a->id,
|
|
|
'title' => $a->title,
|
|
|
'summary' => $a->summary,
|
|
|
'image' => ActivityH5View::listCover($a),
|
|
|
'venue_name' => $a->venue?->name,
|
|
|
'address' => $a->location ?: $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::computeScheduleStatusForDisplay($a),
|
|
|
'registered_count' => (int) ($a->registered_count ?? 0),
|
|
|
'tags' => array_values($a->tags ?? []),
|
|
|
'is_bookable' => $isBookable,
|
|
|
'all_slots_full' => $a->areAllActivityDaySlotsExhausted(),
|
|
|
'booking_closed_not_full' => $online && $a->isBookingClosedWithRemainingCapacity(),
|
|
|
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
|
|
|
'offline_reservation_method' => (string) ($a->offline_reservation_method ?? ''),
|
|
|
'has_behind_scenes' => $this->activityHasBehindScenes($a),
|
|
|
'is_hot' => (bool) ($a->is_hot ?? false),
|
|
|
];
|
|
|
});
|
|
|
|
|
|
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,cover_image,gallery_media',
|
|
|
'activityDays',
|
|
|
])
|
|
|
->visibleOnH5()
|
|
|
->findOrFail($id);
|
|
|
|
|
|
$onlineBookable = $this->activitySupportsOnlineBooking($a);
|
|
|
$isBookable = $onlineBookable && $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);
|
|
|
});
|
|
|
|
|
|
$cover = ActivityH5View::listCover($a);
|
|
|
|
|
|
$payload = [
|
|
|
'id' => $a->id,
|
|
|
'title' => $a->title,
|
|
|
'summary' => $a->summary,
|
|
|
'detail_html' => $a->detail_html,
|
|
|
'image' => $cover,
|
|
|
'gallery_media' => $a->gallery_media ?? [],
|
|
|
'carousel' => ActivityH5View::buildActivityH5Carousel($a),
|
|
|
'venue' => $a->venue,
|
|
|
'location' => $a->location,
|
|
|
'check_in_meeting_point' => $a->check_in_meeting_point,
|
|
|
'address' => $a->location ?: $a->address,
|
|
|
'contact_name' => $a->contact_name,
|
|
|
'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::computeScheduleStatusForDisplay($a),
|
|
|
'registered_count' => (int) ($a->registered_count ?? 0),
|
|
|
'tags' => array_values($a->tags ?? []),
|
|
|
'reservation_notice' => $a->reservation_notice,
|
|
|
'is_bookable' => $isBookable,
|
|
|
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
|
|
|
'reservation_type_label' => $this->reservationTypeLabel($a->reservation_type),
|
|
|
'specific_time' => $a->specific_time,
|
|
|
'offline_reservation_method' => $a->offline_reservation_method,
|
|
|
'ticket_fee_note' => $a->ticket_fee_note,
|
|
|
'external_url' => $a->external_url,
|
|
|
'view_count' => (int) ($a->view_count ?? 0),
|
|
|
'external_link_click_count' => (int) ($a->external_link_click_count ?? 0),
|
|
|
'venue_type_color' => $this->resolveVenueTypeColor($a->venue?->venue_type, $a->venue?->venue_types),
|
|
|
'booking_days' => $onlineBookable ? $bookingDays : [],
|
|
|
'behind_scenes_media' => $this->normalizeBehindScenesForH5(is_array($a->behind_scenes_media) ? $a->behind_scenes_media : []),
|
|
|
'has_behind_scenes' => $this->activityHasBehindScenes($a),
|
|
|
];
|
|
|
|
|
|
if ($this->activitySupportsOnlineBooking($a)) {
|
|
|
$payload['registrations_preview'] = $this->buildActivityRegistrationsPreview($a);
|
|
|
}
|
|
|
|
|
|
return response()->json($payload);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 活动公开报名名单(仅线上预约活动;不含已取消)
|
|
|
*/
|
|
|
public function activityRegistrations(Request $request, int $id): JsonResponse
|
|
|
{
|
|
|
$activity = Activity::query()->visibleOnH5()->findOrFail($id);
|
|
|
if (! $this->activitySupportsOnlineBooking($activity)) {
|
|
|
return response()->json([
|
|
|
'activity_title' => $activity->title,
|
|
|
'total' => 0,
|
|
|
'current_page' => 1,
|
|
|
'last_page' => 1,
|
|
|
'data' => [],
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
$pageSize = max(1, min(50, (int) $request->input('page_size', 20)));
|
|
|
$page = max(1, (int) $request->input('page', 1));
|
|
|
|
|
|
$base = $this->activityRegistrationsBaseQuery($activity);
|
|
|
$total = (clone $base)->count();
|
|
|
$lastPage = $total > 0 ? (int) ceil($total / $pageSize) : 1;
|
|
|
|
|
|
$rows = (clone $base)
|
|
|
->with([
|
|
|
'wechatUser:id,avatar_url,nickname,real_name',
|
|
|
'activityDay',
|
|
|
])
|
|
|
->orderByDesc('id')
|
|
|
->forPage($page, $pageSize)
|
|
|
->get();
|
|
|
|
|
|
$data = $rows->map(fn (Reservation $r) => $this->mapReservationToH5Public($r, true))->values()->all();
|
|
|
|
|
|
return response()->json([
|
|
|
'activity_title' => $activity->title,
|
|
|
'total' => $total,
|
|
|
'current_page' => $page,
|
|
|
'last_page' => $lastPage,
|
|
|
'data' => $data,
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array{total: int, reservation_total: int, items: list<array<string, mixed>>}
|
|
|
*/
|
|
|
private function buildActivityRegistrationsPreview(Activity $activity): array
|
|
|
{
|
|
|
$base = $this->activityRegistrationsBaseQuery($activity);
|
|
|
$reservationTotal = (clone $base)->count();
|
|
|
|
|
|
$rows = (clone $base)
|
|
|
->with(['wechatUser:id,avatar_url,nickname,real_name'])
|
|
|
->orderByDesc('id')
|
|
|
->get();
|
|
|
|
|
|
$seen = [];
|
|
|
$items = [];
|
|
|
foreach ($rows as $r) {
|
|
|
$key = $this->reservationUserKey($r);
|
|
|
if (isset($seen[$key])) {
|
|
|
continue;
|
|
|
}
|
|
|
$seen[$key] = true;
|
|
|
$items[] = $this->mapReservationToH5Public($r, false);
|
|
|
if (count($items) >= 5) {
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
'total' => $reservationTotal,
|
|
|
'reservation_total' => $reservationTotal,
|
|
|
'items' => $items,
|
|
|
];
|
|
|
}
|
|
|
|
|
|
private function reservationUserKey(Reservation $r): string
|
|
|
{
|
|
|
if ($r->wechat_user_id) {
|
|
|
return 'w:'.(int) $r->wechat_user_id;
|
|
|
}
|
|
|
|
|
|
$phone = preg_replace('/\s+/', '', (string) $r->visitor_phone);
|
|
|
|
|
|
return 'p:'.strtolower($phone);
|
|
|
}
|
|
|
|
|
|
private function activityRegistrationsBaseQuery(Activity $activity)
|
|
|
{
|
|
|
return Reservation::query()
|
|
|
->where('activity_id', $activity->id)
|
|
|
->where(function ($q) {
|
|
|
$q->whereNull('reservation_kind')
|
|
|
->orWhere('reservation_kind', Reservation::KIND_ACTIVITY);
|
|
|
})
|
|
|
->where('status', '!=', 'cancelled');
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @return array<string, mixed>
|
|
|
*/
|
|
|
private function mapReservationToH5Public(Reservation $r, bool $withSession): array
|
|
|
{
|
|
|
$wu = $r->wechatUser;
|
|
|
$name = trim((string) $r->visitor_name);
|
|
|
if ($name === '' && $wu) {
|
|
|
$name = trim((string) ($wu->real_name ?? '')) ?: trim((string) ($wu->nickname ?? ''));
|
|
|
}
|
|
|
if ($name === '') {
|
|
|
$name = '用户';
|
|
|
}
|
|
|
$avatar = ($wu && $wu->avatar_url) ? (string) $wu->avatar_url : '';
|
|
|
|
|
|
$out = [
|
|
|
'id' => (int) $r->id,
|
|
|
'visitor_name' => $name,
|
|
|
'avatar_url' => $avatar,
|
|
|
'created_at' => $r->created_at?->toIso8601String(),
|
|
|
];
|
|
|
|
|
|
if ($withSession) {
|
|
|
$d = $r->activityDay;
|
|
|
if ($d) {
|
|
|
$timeText = $d->time_range_text;
|
|
|
if ($timeText === '' || $timeText === null) {
|
|
|
$timeText = $d->formatSessionTimeRangeZh();
|
|
|
}
|
|
|
$sessionName = trim((string) $d->session_name);
|
|
|
$out['session_name'] = $sessionName;
|
|
|
$out['session_time_text'] = $timeText;
|
|
|
$out['activity_date'] = $d->activity_date?->format('Y-m-d');
|
|
|
} else {
|
|
|
$out['session_name'] = null;
|
|
|
$out['session_time_text'] = null;
|
|
|
$out['activity_date'] = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
}
|
|
|
|
|
|
public function recordActivityView(Request $request, int $id): JsonResponse
|
|
|
{
|
|
|
$a = Activity::query()->visibleOnH5()->findOrFail($id);
|
|
|
$a->increment('view_count');
|
|
|
|
|
|
return response()->json([
|
|
|
'ok' => true,
|
|
|
'view_count' => (int) $a->fresh()->view_count,
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
public function recordActivityExternalLinkClick(Request $request, int $id): JsonResponse
|
|
|
{
|
|
|
$a = Activity::query()->visibleOnH5()->findOrFail($id);
|
|
|
$a->increment('external_link_click_count');
|
|
|
|
|
|
return response()->json([
|
|
|
'ok' => true,
|
|
|
'external_link_click_count' => (int) $a->fresh()->external_link_click_count,
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
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(Request $request, 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'] = ActivityH5View::buildGalleryCarousel($display);
|
|
|
$payload['live_people_count'] = (int) ($display->live_people_count ?? 0);
|
|
|
$payload['venue_type_color'] = $this->resolveVenueTypeColor($display->venue_type, $display->venue_types);
|
|
|
|
|
|
$eid = (int) $request->query('ticket_grab_event_id', 0);
|
|
|
if ($eid > 0) {
|
|
|
$p = TicketGrabEventVenue::query()
|
|
|
->where('ticket_grab_event_id', $eid)
|
|
|
->where('venue_id', $v->id)
|
|
|
->first();
|
|
|
if ($p === null) {
|
|
|
return response()->json(['message' => '该抢票活动未关联此场馆'], 404);
|
|
|
}
|
|
|
$venueOpenTimeForDaily = $payload['open_time'] ?? null;
|
|
|
$payload = $this->mergeTicketGrabVenueIntoH5Payload($payload, $p);
|
|
|
$vo = is_string($venueOpenTimeForDaily) ? trim($venueOpenTimeForDaily) : '';
|
|
|
$payload['venue_open_time'] = $vo !== '' ? $venueOpenTimeForDaily : null;
|
|
|
}
|
|
|
|
|
|
$payload['activities'] = Activity::query()
|
|
|
->where('venue_id', $v->id)
|
|
|
->visibleOnH5()
|
|
|
->orderByHotThenScheduleStatusPriority()
|
|
|
->with('activityDays')
|
|
|
->with('venue:id,cover_image')
|
|
|
->limit(50)
|
|
|
->get(['id', 'title', 'summary', 'cover_image', 'start_at', 'end_at', 'registered_count', 'address', 'location', 'venue_id', 'behind_scenes_media', 'is_hot', 'reservation_type'])
|
|
|
->map(function (Activity $a) {
|
|
|
return [
|
|
|
'id' => $a->id,
|
|
|
'title' => $a->title,
|
|
|
'summary' => $a->summary,
|
|
|
'cover_image' => ActivityH5View::listCover($a),
|
|
|
'start_at' => optional($a->start_at)?->toIso8601String(),
|
|
|
'end_at' => optional($a->end_at)?->toIso8601String(),
|
|
|
'schedule_status' => Activity::computeScheduleStatusForDisplay($a),
|
|
|
'registered_count' => (int) ($a->registered_count ?? 0),
|
|
|
'address' => $a->location ?: $a->address,
|
|
|
'has_behind_scenes' => $this->activityHasBehindScenes($a),
|
|
|
];
|
|
|
})
|
|
|
->values()
|
|
|
->all();
|
|
|
|
|
|
return response()->json($payload);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 抢票进馆页:主卡/详情区仅使用「抢票-参与场馆-编辑详情」的 pivot 字段,无值则为 null,不回落场馆主表。
|
|
|
* 轮播 carousel、封面、场馆名称等入参前已按场馆主表算好,在此方法外保持不变。
|
|
|
*
|
|
|
* @param array<string, mixed> $payload
|
|
|
* @return array<string, mixed>
|
|
|
*/
|
|
|
private function mergeTicketGrabVenueIntoH5Payload(array $payload, TicketGrabEventVenue $p): array
|
|
|
{
|
|
|
$s = function (?string $t): ?string {
|
|
|
if ($t === null) {
|
|
|
return null;
|
|
|
}
|
|
|
$t = trim($t);
|
|
|
|
|
|
return $t === '' ? null : $t;
|
|
|
};
|
|
|
|
|
|
$payload['open_time'] = $s(is_string($p->opening_hours) ? $p->opening_hours : null);
|
|
|
$payload['address'] = $s(is_string($p->address) ? $p->address : null);
|
|
|
$payload['lat'] = $p->lat === null ? null : (float) $p->lat;
|
|
|
$payload['lng'] = $p->lng === null ? null : (float) $p->lng;
|
|
|
$payload['unit_name'] = $s(is_string($p->unit_name) ? $p->unit_name : null);
|
|
|
$payload['contact_name'] = $s(is_string($p->contact_name) ? $p->contact_name : null);
|
|
|
$payload['contact_phone'] = $s(is_string($p->contact_phone) ? $p->contact_phone : null);
|
|
|
$payload['qr_verify_method'] = $s(is_string($p->qr_verify_method) ? $p->qr_verify_method : null);
|
|
|
$payload['verify_contact_info'] = $s(is_string($p->verify_contact_info) ? $p->verify_contact_info : null);
|
|
|
if (is_string($p->detail_html) && trim($p->detail_html) !== '') {
|
|
|
$payload['detail_html'] = $p->detail_html;
|
|
|
} else {
|
|
|
$payload['detail_html'] = null;
|
|
|
}
|
|
|
|
|
|
$payload['consultation_hours'] = null;
|
|
|
|
|
|
return $payload;
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param list<array<string, mixed>> $raw
|
|
|
* @return list<array{type: string, url: string}>
|
|
|
*/
|
|
|
private function normalizeBehindScenesForH5(array $raw): array
|
|
|
{
|
|
|
$out = [];
|
|
|
foreach ($raw as $row) {
|
|
|
if (! is_array($row)) {
|
|
|
continue;
|
|
|
}
|
|
|
$url = trim((string) ($row['url'] ?? ''));
|
|
|
if ($url === '') {
|
|
|
continue;
|
|
|
}
|
|
|
$out[] = ['type' => 'image', 'url' => $url];
|
|
|
}
|
|
|
|
|
|
return $out;
|
|
|
}
|
|
|
|
|
|
private function activitySupportsOnlineBooking(Activity $a): bool
|
|
|
{
|
|
|
$t = (string) ($a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE);
|
|
|
|
|
|
return $t === '' || $t === Activity::RESERVATION_TYPE_ONLINE;
|
|
|
}
|
|
|
|
|
|
private function reservationTypeLabel(?string $type): string
|
|
|
{
|
|
|
$t = trim((string) ($type ?? ''));
|
|
|
|
|
|
return match ($t) {
|
|
|
Activity::RESERVATION_TYPE_OFFLINE => '线下预约',
|
|
|
Activity::RESERVATION_TYPE_OFFLINE_VISIT => '线下预约',
|
|
|
Activity::RESERVATION_TYPE_OTHER => '外链跳转预约',
|
|
|
Activity::RESERVATION_TYPE_PHONE => '电话预约',
|
|
|
Activity::RESERVATION_TYPE_WECHAT_MP => '公众号预约',
|
|
|
Activity::RESERVATION_TYPE_NONE => '无需报名',
|
|
|
Activity::RESERVATION_TYPE_ONLINE => '需要报名',
|
|
|
'' => '需要报名',
|
|
|
default => $t,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 与首页地图场馆一致:字典 venue_type 的 item_remark 为色值。
|
|
|
*
|
|
|
* @param array<int, mixed>|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_on_shelf', 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' => ActivityH5View::buildGalleryCarousel($row),
|
|
|
'intro_html' => $row->intro_html,
|
|
|
'venues' => $venues,
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
public function studyTours(Request $request): JsonResponse
|
|
|
{
|
|
|
$rows = StudyTour::query()
|
|
|
->where('is_on_shelf', 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_booking_mode' => DictItem::activeOptions('venue_booking_mode'),
|
|
|
'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,cover_image', '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) {
|
|
|
$online = $this->activitySupportsOnlineBooking($a);
|
|
|
$isBookable = $online && $a->activityDays->contains(
|
|
|
fn (ActivityDay $d) => $d->isCurrentlyBookable()
|
|
|
);
|
|
|
|
|
|
return [
|
|
|
'list_kind' => 'activity',
|
|
|
'id' => $a->id,
|
|
|
'title' => $a->title,
|
|
|
'summary' => $a->summary,
|
|
|
'image' => ActivityH5View::listCover($a),
|
|
|
'venue_name' => $a->venue?->name,
|
|
|
'address' => $a->location ?: $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::computeScheduleStatusForDisplay($a),
|
|
|
'registered_count' => (int) ($a->registered_count ?? 0),
|
|
|
'tags' => array_values($a->tags ?? []),
|
|
|
'is_bookable' => $isBookable,
|
|
|
'all_slots_full' => $a->areAllActivityDaySlotsExhausted(),
|
|
|
'booking_closed_not_full' => $online && $a->isBookingClosedWithRemainingCapacity(),
|
|
|
'reservation_type' => $a->reservation_type ?? Activity::RESERVATION_TYPE_ONLINE,
|
|
|
'is_hot' => (bool) ($a->is_hot ?? false),
|
|
|
'has_behind_scenes' => $this->activityHasBehindScenes($a),
|
|
|
'offline_reservation_method' => (string) ($a->offline_reservation_method ?? ''),
|
|
|
];
|
|
|
})->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(),
|
|
|
'is_hot' => false,
|
|
|
'has_behind_scenes' => false,
|
|
|
];
|
|
|
}))->values();
|
|
|
|
|
|
$items = $items->sort(function (array $x, array $y) {
|
|
|
$t = Carbon::now((string) config('app.timezone'))->toDateString();
|
|
|
$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;
|
|
|
}
|
|
|
$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;
|
|
|
}
|
|
|
|
|
|
$tx = $this->mixedListActivityTicketPaidRank($x);
|
|
|
$ty = $this->mixedListActivityTicketPaidRank($y);
|
|
|
if ($tx !== $ty) {
|
|
|
return $tx <=> $ty;
|
|
|
}
|
|
|
|
|
|
return (int) $y['id'] <=> (int) $x['id'];
|
|
|
})->values();
|
|
|
|
|
|
if ($request->boolean('bookable_only')) {
|
|
|
$items = $items
|
|
|
->filter(fn (array $row) => ! empty($row['is_bookable']))
|
|
|
->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);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 与 {@see Activity::scopeOrderByTicketNoteFreeBeforePaid} 一致:仅活动行区分收费门票;抢票为 0。
|
|
|
*/
|
|
|
private function mixedListActivityTicketPaidRank(array $row): int
|
|
|
{
|
|
|
if (($row['list_kind'] ?? '') !== 'activity') {
|
|
|
return 0;
|
|
|
}
|
|
|
|
|
|
return (($row['offline_reservation_method'] ?? '') === Activity::TICKET_PAID) ? 1 : 0;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 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;
|
|
|
}
|
|
|
}
|