where( fn ($q) => $q->where('dict_type', 'study_tour_season')->where('is_active', true) ); $required = $creating ? 'required' : 'sometimes'; return [ 'name' => [$required, 'string', 'max:120'], 'tags' => ['nullable', 'array'], 'tags.*' => ['string', 'max:50'], 'venue_items' => [$required, 'array', 'min:1'], 'venue_items.*.type' => ['required', 'in:system,custom'], 'venue_items.*.venue_id' => ['required_if:venue_items.*.type,system', 'nullable', 'integer', 'exists:venues,id'], 'venue_items.*.name' => ['required_if:venue_items.*.type,custom', 'nullable', 'string', 'max:120'], 'venue_items.*.address' => ['nullable', 'string', 'max:255'], 'venue_ids' => ['nullable', 'array'], 'venue_ids.*' => ['integer', 'exists:venues,id'], 'org_name' => ['nullable', 'string', 'max:200'], 'seasons' => ['nullable', 'array'], 'seasons.*' => ['string', 'max:50', $seasonRule], 'suitable_count' => ['nullable', 'string', 'max:50'], 'suitable_audience' => ['nullable', 'string', 'max:200'], 'duration' => ['nullable', 'string', 'max:100'], 'contact_person' => ['nullable', 'string', 'max:50'], 'contact_phones' => ['nullable', 'string', 'max:200'], 'cover_image' => ['nullable', 'string', 'max:255'], 'gallery_media' => ['nullable', 'array'], 'gallery_media.*.type' => ['required_with:gallery_media', 'in:image,video'], 'gallery_media.*.url' => ['required_with:gallery_media', 'string', 'max:255'], 'intro_html' => ['nullable', 'string'], 'route_plans' => ['nullable', 'array'], 'route_plans.*.date_label' => ['nullable', 'string', 'max:120'], 'route_plans.*.items' => ['nullable', 'array'], 'route_plans.*.items.*.time' => ['nullable', 'string', 'max:80'], 'route_plans.*.items.*.activity' => ['nullable', 'string', 'max:500'], 'route_plans.*.items.*.location' => ['nullable', 'string', 'max:255'], 'courses' => ['nullable', 'array'], 'courses.*.sort' => ['nullable', 'integer', 'min:0'], 'courses.*.name' => ['nullable', 'string', 'max:200'], 'courses.*.content' => ['nullable', 'string', 'max:2000'], 'fee_html' => ['nullable', 'string'], 'implementation_html' => ['nullable', 'string'], 'sort' => ['nullable', 'integer', 'min:0'], 'is_on_shelf' => ['sometimes', 'boolean'], ]; } public static function compactText(string $text): string { $text = trim($text); if ($text === '') { return ''; } $text = preg_replace('/[\x{00A0}\x{2002}\x{2003}\x{3000}]/u', ' ', $text) ?? $text; $text = preg_replace('/\s+/u', ' ', $text) ?? $text; return trim($text); } public static function compactMultilineText(string $text): string { $text = trim($text); if ($text === '') { return ''; } $lines = preg_split('/\R/u', $text) ?: []; $lines = array_values(array_filter( array_map(fn ($line) => self::compactText((string) $line), $lines), fn ($line) => $line !== '' )); return implode("\n", $lines); } public static function normalizeIncoming(array $data): array { foreach (['name', 'org_name', 'suitable_count', 'suitable_audience', 'duration', 'contact_person'] as $key) { if (array_key_exists($key, $data) && is_string($data[$key])) { $data[$key] = self::compactText($data[$key]); } } if (array_key_exists('tags', $data)) { $data['tags'] = array_values($data['tags'] ?? []); } if (array_key_exists('seasons', $data)) { $data['seasons'] = array_values($data['seasons'] ?? []); } if (array_key_exists('venue_items', $data)) { $data['venue_items'] = self::normalizeVenueItems($data['venue_items'] ?? []); $data['venue_ids'] = self::venueIdsFromItems($data['venue_items']); } elseif (array_key_exists('venue_ids', $data)) { $ids = array_values($data['venue_ids'] ?? []); $data['venue_ids'] = $ids; $data['venue_items'] = self::venueItemsFromIds($ids); } if (array_key_exists('route_plans', $data)) { $data['route_plans'] = self::normalizeRoutePlans($data['route_plans'] ?? []); } if (array_key_exists('courses', $data)) { $data['courses'] = self::normalizeCourses($data['courses'] ?? []); } if (array_key_exists('contact_phones', $data)) { $data['contact_phones'] = self::normalizeContactPhones((string) ($data['contact_phones'] ?? '')); } if (array_key_exists('gallery_media', $data)) { $data['gallery_media'] = array_values($data['gallery_media'] ?? []); } return $data; } public static function venueItemsForRecord(StudyTour $row): array { $items = $row->venue_items; if (is_array($items) && $items !== []) { return self::normalizeVenueItems($items); } return self::venueItemsFromIds($row->venue_ids ?? []); } public static function normalizeVenueItems(array $items): array { $out = []; foreach ($items as $item) { if (! is_array($item)) { continue; } $type = (string) ($item['type'] ?? ''); if ($type === 'system') { $venueId = (int) ($item['venue_id'] ?? 0); if ($venueId > 0) { $out[] = ['type' => 'system', 'venue_id' => $venueId]; } continue; } if ($type === 'custom') { $name = self::compactText((string) ($item['name'] ?? '')); if ($name === '') { continue; } $row = ['type' => 'custom', 'name' => $name]; $address = self::compactText((string) ($item['address'] ?? '')); if ($address !== '') { $row['address'] = $address; } $out[] = $row; } } return $out; } public static function venueItemsFromIds(array $ids): array { return collect($ids) ->map(fn ($id) => (int) $id) ->filter(fn ($id) => $id > 0) ->unique() ->values() ->map(fn ($id) => ['type' => 'system', 'venue_id' => $id]) ->all(); } public static function venueIdsFromItems(array $items): array { return collect(self::normalizeVenueItems($items)) ->filter(fn ($item) => ($item['type'] ?? '') === 'system') ->map(fn ($item) => (int) ($item['venue_id'] ?? 0)) ->filter(fn ($id) => $id > 0) ->values() ->all(); } public static function normalizeRoutePlans(array $plans): array { $out = []; foreach ($plans as $plan) { if (! is_array($plan)) { continue; } $items = []; foreach ($plan['items'] ?? [] as $item) { if (! is_array($item)) { continue; } $items[] = [ 'time' => self::compactText((string) ($item['time'] ?? '')), 'activity' => self::compactText((string) ($item['activity'] ?? '')), 'location' => self::compactText((string) ($item['location'] ?? '')), ]; } $out[] = [ 'date_label' => self::compactText((string) ($plan['date_label'] ?? '')), 'items' => $items, ]; } return $out; } public static function normalizeCourses(array $courses): array { $out = []; $sort = 1; foreach ($courses as $course) { if (! is_array($course)) { continue; } $name = self::compactText((string) ($course['name'] ?? '')); $content = self::compactText((string) ($course['content'] ?? '')); if ($name === '' && $content === '') { continue; } $out[] = [ 'sort' => (int) ($course['sort'] ?? $sort), 'name' => $name, 'content' => $content, ]; $sort++; } return $out; } public static function normalizeContactPhones(string $raw): string { $raw = str_replace(['—', '-', '–'], '-', $raw); $raw = str_replace([',', ',', ';', ';', '/', '|'], '、', $raw); $parts = preg_split('/[、\s]+/u', $raw) ?: []; $parts = array_values(array_filter(array_map('trim', $parts), fn ($p) => $p !== '')); return implode('、', $parts); } public static function contactPhoneList(?string $raw): array { $normalized = self::normalizeContactPhones((string) $raw); return $normalized === '' ? [] : explode('、', $normalized); } public static function applyListFilters(Builder $q, Request $request): void { if ($request->filled('keyword')) { $kw = trim((string) $request->input('keyword')); if ($kw !== '') { $q->where(function ($sub) use ($kw) { $sub->where('name', 'like', '%'.$kw.'%') ->orWhere('org_name', 'like', '%'.$kw.'%') ->orWhere('venue_items', 'like', '%'.$kw.'%'); }); } } if ($request->filled('venue_id')) { $vid = (int) $request->input('venue_id'); if ($vid > 0) { $q->where(function ($sub) use ($vid) { $sub->whereJsonContains('venue_ids', $vid) ->orWhere('venue_items', 'like', '%"venue_id":'.$vid.'%'); }); } } if ($request->has('is_on_shelf') && $request->input('is_on_shelf') !== '' && $request->input('is_on_shelf') !== null) { $raw = $request->input('is_on_shelf'); $on = in_array($raw, [1, '1', true, 'true', 'on', 'yes'], true); $off = in_array($raw, [0, '0', false, 'false', 'off', 'no'], true); if ($on || $off) { $q->where('is_on_shelf', $on); } } if ($request->filled('season')) { $season = trim((string) $request->input('season')); if ($season !== '') { $q->whereJsonContains('seasons', $season); } } if ($request->filled('suitable_audience')) { $audience = trim((string) $request->input('suitable_audience')); if ($audience !== '') { $q->where('suitable_audience', 'like', '%'.$audience.'%'); } } if ($request->filled('org_name')) { $org = trim((string) $request->input('org_name')); if ($org !== '') { $q->where('org_name', 'like', '%'.$org.'%'); } } } /** * @return array{venue_items: array>, map_venues: array>} */ public static function resolveVenueItemsForH5(StudyTour $row, callable $venueTypeColorResolver): array { $items = self::venueItemsForRecord($row); $systemIds = collect($items) ->filter(fn ($item) => ($item['type'] ?? '') === 'system') ->map(fn ($item) => (int) ($item['venue_id'] ?? 0)) ->filter(fn ($id) => $id > 0) ->values(); $venueMap = Venue::query() ->whereIn('id', $systemIds->all()) ->get(['id', 'name', 'district', 'address', 'cover_image', 'lat', 'lng', 'ticket_type', 'venue_type', 'venue_types']) ->keyBy('id'); $venueItems = []; $mapVenues = []; foreach ($items as $item) { if (($item['type'] ?? '') === 'system') { $vid = (int) ($item['venue_id'] ?? 0); $v = $venueMap->get($vid); if ($v === null) { continue; } $arr = $v->toArray(); $arr['type'] = 'system'; $arr['venue_type_color'] = $venueTypeColorResolver($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']); } $venueItems[] = $arr; $lat = (float) ($arr['lat'] ?? 0); $lng = (float) ($arr['lng'] ?? 0); if ($lat && $lng) { $mapVenues[] = $arr; } continue; } if (($item['type'] ?? '') === 'custom') { $venueItems[] = [ 'type' => 'custom', 'name' => (string) ($item['name'] ?? ''), 'address' => (string) ($item['address'] ?? ''), ]; } } return [ 'venue_items' => $venueItems, 'map_venues' => $mapVenues, ]; } public static function listVenueNames(StudyTour $row, $venueMap): array { return collect(self::venueItemsForRecord($row)) ->map(function ($item) use ($venueMap) { if (($item['type'] ?? '') === 'system') { $id = (int) ($item['venue_id'] ?? 0); $v = is_array($venueMap) ? ($venueMap[$id] ?? null) : $venueMap->get($id); return $v?->name; } if (($item['type'] ?? '') === 'custom') { return (string) ($item['name'] ?? ''); } return null; }) ->filter() ->values() ->all(); } public static function listCoverImage(StudyTour $row, $venueMap): ?string { $tourCover = trim((string) ($row->cover_image ?? '')); if ($tourCover !== '') { return $tourCover; } foreach (self::venueItemsForRecord($row) as $item) { if (($item['type'] ?? '') !== 'system') { continue; } $id = (int) ($item['venue_id'] ?? 0); $v = is_array($venueMap) ? ($venueMap[$id] ?? null) : $venueMap->get($id); $cover = trim((string) ($v?->cover_image ?? '')); if ($cover !== '') { return $cover; } } return null; } public static function h5ListPayload(StudyTour $row, $venueMap): array { $items = self::venueItemsForRecord($row); $firstSystemItem = collect($items)->first(fn ($item) => ($item['type'] ?? '') === 'system'); $firstSystemId = $firstSystemItem ? (int) ($firstSystemItem['venue_id'] ?? 0) : 0; $firstVenue = $firstSystemId > 0 ? (is_array($venueMap) ? ($venueMap[$firstSystemId] ?? null) : $venueMap->get($firstSystemId)) : null; return [ 'id' => $row->id, 'name' => $row->name, 'tags' => array_values($row->tags ?? []), 'org_name' => $row->org_name, 'seasons' => array_values($row->seasons ?? []), 'suitable_audience' => $row->suitable_audience, 'venue_names' => self::listVenueNames($row, $venueMap), 'cover_image' => self::listCoverImage($row, $venueMap), 'first_address' => $firstVenue?->address, 'first_district' => $firstVenue?->district, 'venue_count' => count($items), ]; } public static function h5DetailPayload(StudyTour $row, callable $venueTypeColorResolver): array { $venues = self::resolveVenueItemsForH5($row, $venueTypeColorResolver); $seasonLabels = collect($row->seasons ?? []) ->map(fn ($v) => DictItem::labelFor('study_tour_season', (string) $v) ?? (string) $v) ->filter() ->values() ->all(); $coverImage = self::listCoverImage($row, collect($venues['venue_items']) ->filter(fn ($item) => ($item['type'] ?? '') === 'system' && ! empty($item['id'])) ->map(fn ($item) => (object) [ 'id' => (int) $item['id'], 'cover_image' => $item['cover_image'] ?? null, ]) ->keyBy('id')); return [ 'id' => $row->id, 'name' => $row->name, 'tags' => array_values($row->tags ?? []), 'org_name' => $row->org_name, 'seasons' => array_values($row->seasons ?? []), 'season_labels' => $seasonLabels, 'suitable_count' => $row->suitable_count, 'suitable_audience' => $row->suitable_audience, 'duration' => $row->duration, 'contact_person' => $row->contact_person, 'contact_phones' => $row->contact_phones, 'contact_phone_list' => self::contactPhoneList($row->contact_phones), 'cover_image' => $coverImage, 'image' => $coverImage, 'gallery_media' => $row->gallery_media ?? [], 'carousel' => ActivityH5View::buildGalleryCarousel($row), 'intro_html' => $row->intro_html, 'route_plans' => array_values($row->route_plans ?? []), 'courses' => array_values($row->courses ?? []), 'fee_html' => $row->fee_html, 'implementation_html' => $row->implementation_html, 'venue_items' => $venues['venue_items'], 'map_venues' => $venues['map_venues'], 'venues' => $venues['map_venues'], ]; } }