user(); abort_unless($user && ($user->isSuperAdmin() || $user->role === 'venue_admin'), 403, '无权限'); $data = $request->validate([ 'name' => ['required', 'string', 'max:120'], 'venue_type' => ['nullable', 'string', 'max:80'], 'venue_types' => ['nullable', 'array'], 'venue_types.*' => ['string', 'max:80'], 'unit_name' => ['nullable', 'string', 'max:120'], 'district' => ['nullable', 'string', 'max:80'], 'ticket_type' => ['nullable', 'string', 'max:80'], 'appointment_type' => ['nullable', 'string', 'max:40'], 'booking_mode' => ['nullable', 'string', 'max:50', Rule::in(['team_only', 'all_required', 'team_required'])], 'open_mode' => ['nullable', 'string', 'max:40', Rule::in(['fulltime', 'scheduled', 'appointment'])], 'open_time' => ['nullable', 'string', 'max:65535'], 'reservation_notice' => ['nullable', 'string'], 'ticket_content' => ['nullable', 'string'], 'booking_method' => ['nullable', 'string'], 'visit_form' => ['nullable', 'string'], 'consultation_hours' => ['nullable', 'string'], 'booking_qr_media' => ['nullable', 'array', 'max:20'], 'booking_qr_media.*.type' => ['required_with:booking_qr_media', 'in:image'], 'booking_qr_media.*.url' => ['required_with:booking_qr_media', 'string', 'max:255'], 'address' => ['nullable', 'string', 'max:255'], 'contact_phone' => ['nullable', 'string', 'max:255'], 'lat' => ['nullable', 'numeric'], 'lng' => ['nullable', 'numeric'], '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'], 'detail_html' => ['nullable', 'string'], 'live_people_count' => ['nullable', 'integer', 'min:0'], 'sort' => ['nullable', 'integer', 'min:0'], 'is_active' => ['boolean'], 'is_included_in_stats' => ['boolean'], ]); $auditStatus = $user->isSuperAdmin() ? Venue::AUDIT_APPROVED : Venue::AUDIT_PENDING; $venue = Venue::create($data + [ 'is_active' => $data['is_active'] ?? true, 'audit_status' => $auditStatus, 'audit_remark' => null, 'last_approved_snapshot' => null, ]); if (! $user->isSuperAdmin()) { $user->venues()->syncWithoutDetaching([$venue->id]); } return response()->json($venue->fresh(), 201); } /** * 超级管理员导出全部场馆为 CSV(UTF-8 BOM,便于 Excel 打开)。 * 前 22 列与 {@see \App\Services\VenueImportService::buildTemplateSpreadsheet()} 表头、列顺序一致(主题为导入同款英文逗号拼接;预约方式去 HTML 标签为纯文本;场馆详情保留内容便于再导入为富文本)。 */ public function export(Request $request) { $user = $request->user(); abort_unless($user && $user->isSuperAdmin(), 403, '仅超级管理员可导出'); $dictMaps = $this->buildDictLabelMaps(); $rows = Venue::query()->orderBy('sort')->orderByDesc('id')->get(); $filename = '场馆导出-'.now()->format('Ymd-His').'.csv'; return response()->streamDownload(function () use ($rows, $dictMaps) { $out = fopen('php://output', 'w'); fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF)); // 与导入模板首行一致,另附系统用列(与 VenueImportService::$headers 顺序对齐) fputcsv($out, array_merge([ '场馆名称', '主题', '行政区', '预约类型', '门票类型', '预约模式', '开放模式', '所属单位', '预约方式', '参观形式', '开放时间', '咨询预约时间', '咨询预约联系电话', '排序', '启用', '纳入人数统计', '场馆地址', '经度', '纬度', '门票说明', '场馆详情', '预约须知', ], [ '编号', '预约二维码', '封面图', '轮播图与视频', '实时在馆人数', '审核状态', '审核备注', '创建时间', '更新时间', ])); foreach ($rows as $v) { fputcsv($out, array_merge([ $this->csvCell($v->name), $this->formatVenueThemeForExport($v, $dictMaps), $this->dictLabel($dictMaps, 'district', $v->district), $this->dictLabel($dictMaps, 'venue_appointment_type', $v->appointment_type), $this->dictLabel($dictMaps, 'ticket_type', $v->ticket_type), $this->dictLabel($dictMaps, 'venue_booking_mode', $v->booking_mode), $this->dictLabel($dictMaps, 'venue_open_mode', $v->open_mode), $this->csvCell($v->unit_name), $this->plainTextFromHtmlForCsv($v->booking_method), $this->csvCell($v->visit_form), $this->csvCell($v->open_time), $this->csvCell($v->consultation_hours), $this->csvCell($v->contact_phone), (int) ($v->sort ?? 0), $v->is_active ? '是' : '否', $v->is_included_in_stats ? '是' : '否', $this->csvCell($v->address), $v->lng, $v->lat, $this->csvCell($v->ticket_content), $this->csvCell($v->detail_html), $this->csvCell($v->reservation_notice), ], [ $v->id, $this->formatMediaUrlsFromArray($v->booking_qr_media, 'url'), $this->csvCell($v->cover_image), $this->formatMediaUrlsFromArray($v->gallery_media, 'url', true), (int) ($v->live_people_count ?? 0), $this->auditStatusLabelChinese($v->audit_status), $this->csvCell($v->audit_remark), $v->created_at?->format('Y-m-d H:i:s') ?? '', $v->updated_at?->format('Y-m-d H:i:s') ?? '', ])); } fclose($out); }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); } /** * @return array> dict_type => item_value => item_label */ private function buildDictLabelMaps(): array { $types = [ 'district', 'venue_type', 'ticket_type', 'venue_appointment_type', 'venue_booking_mode', 'venue_open_mode', ]; $rows = DictItem::query() ->whereIn('dict_type', $types) ->where('is_active', true) ->get(['dict_type', 'item_value', 'item_label']); $maps = array_fill_keys($types, []); foreach ($rows as $r) { $maps[$r->dict_type][(string) $r->item_value] = (string) $r->item_label; } return $maps; } /** * @param array> $maps */ private function dictLabel(array $maps, string $type, ?string $value): string { if ($value === null || $value === '') { return ''; } $key = (string) $value; return $maps[$type][$key] ?? $key; } /** * @param array> $maps */ private function formatVenueThemeForExport(Venue $v, array $maps): string { $list = $v->venue_types; if (is_array($list) && count($list) > 0) { $labels = []; foreach ($list as $tv) { $tv = (string) $tv; $labels[] = $this->dictLabel($maps, 'venue_type', $tv) ?: $tv; } $labels = array_values(array_unique($labels)); // 与导入模板「主题」一致:多个主题用英文逗号分隔(normalizeRowFromCells 按英文逗号拆分) return $this->csvCell(implode(',', $labels)); } return $this->csvCell($this->dictLabel($maps, 'venue_type', $v->venue_type)); } /** * @param array>|null $items */ private function formatMediaUrlsFromArray($items, string $key = 'url', bool $includeType = false): string { if (! is_array($items) || $items === []) { return ''; } $parts = []; foreach ($items as $it) { if (! is_array($it)) { continue; } $u = (string) ($it[$key] ?? ''); if ($u === '') { continue; } if ($includeType && isset($it['type'])) { $parts[] = (string) $it['type'].': '.$u; } else { $parts[] = $u; } } return $this->csvCell(implode(';', $parts)); } private function auditStatusLabelChinese(?string $s): string { return match ($s) { Venue::AUDIT_APPROVED => '已通过', Venue::AUDIT_PENDING => '待审核', Venue::AUDIT_REJECTED => '已退回', default => (string) ($s ?? ''), }; } private function plainTextFromHtmlForCsv(?string $html): string { if ($html === null || $html === '') { return ''; } $t = HtmlToPlainText::toSingleLine($html) ?? ''; if (function_exists('mb_strlen') && mb_strlen($t, 'UTF-8') > 10000) { $t = mb_substr($t, 0, 10000, 'UTF-8').'…'; } return $this->csvCell($t); } private function csvCell(?string $value): string { if ($value === null || $value === '') { return ''; } return str_replace(["\r\n", "\r", "\n"], ' ', $value); } public function index(Request $request): JsonResponse { $user = $request->user(); $keyword = trim((string) $request->string('keyword')); $district = trim((string) $request->string('district')); $venueType = trim((string) $request->string('venue_type')); $ticketType = trim((string) $request->string('ticket_type')); $bookingMode = trim((string) $request->string('booking_mode')); $openMode = trim((string) $request->string('open_mode')); $appointmentType = trim((string) $request->string('appointment_type')); $bookingMethodFilled = $request->input('booking_method_filled'); $isActive = $request->input('is_active'); $isIncludedInStats = $request->input('is_included_in_stats'); $auditStatus = trim((string) $request->string('audit_status')); if ($user->isSuperAdmin()) { $query = Venue::query(); } else { $query = $user->venues(); } if ($keyword !== '') { $query->where(function ($q) use ($keyword) { $q->where('name', 'like', '%'.$keyword.'%') ->orWhere('address', 'like', '%'.$keyword.'%') ->orWhere('unit_name', 'like', '%'.$keyword.'%') ->orWhere('open_time', 'like', '%'.$keyword.'%') ->orWhere('reservation_notice', 'like', '%'.$keyword.'%') ->orWhere('ticket_content', 'like', '%'.$keyword.'%') ->orWhere('booking_method', 'like', '%'.$keyword.'%') ->orWhere('visit_form', 'like', '%'.$keyword.'%') ->orWhere('consultation_hours', 'like', '%'.$keyword.'%'); }); } if ($district !== '') { $query->where('district', $district); } if ($venueType !== '') { $query->where(function ($q) use ($venueType) { $q->where('venue_type', $venueType) ->orWhereJsonContains('venue_types', $venueType); }); } if ($ticketType !== '') { $query->where('ticket_type', $ticketType); } if ($bookingMode !== '') { $query->where('booking_mode', $bookingMode); } if ($openMode !== '') { $query->where('open_mode', $openMode); } if ($appointmentType !== '') { $query->where('appointment_type', $appointmentType); } if ($bookingMethodFilled === '1' || $bookingMethodFilled === 1 || $bookingMethodFilled === true) { $query->whereNotNull('booking_method') ->where('booking_method', '!=', ''); } elseif ($bookingMethodFilled === '0' || $bookingMethodFilled === 0 || $bookingMethodFilled === false) { $query->where(function ($q) { $q->whereNull('booking_method') ->orWhere('booking_method', ''); }); } if ($isActive !== null && $isActive !== '') { $query->where('is_active', (int) $isActive === 1); } if ($isIncludedInStats !== null && $isIncludedInStats !== '') { $isIncluded = in_array($isIncludedInStats, [1, '1', true, 'true'], true); $query->where('is_included_in_stats', $isIncluded); } if ($auditStatus !== '') { $query->where('audit_status', $auditStatus); } $venues = $query->orderBy('venues.sort')->orderByDesc('venues.id')->get(); return response()->json($venues); } public function update(Request $request, int $id): JsonResponse { $venue = Venue::query()->findOrFail($id); $this->ensureVenuePermission($request, $venue->id); $user = $request->user(); $data = $request->validate([ 'name' => ['sometimes', 'string', 'max:120'], 'venue_type' => ['nullable', 'string', 'max:80'], 'venue_types' => ['nullable', 'array'], 'venue_types.*' => ['string', 'max:80'], 'unit_name' => ['nullable', 'string', 'max:120'], 'district' => ['nullable', 'string', 'max:80'], 'ticket_type' => ['nullable', 'string', 'max:80'], 'appointment_type' => ['nullable', 'string', 'max:40'], 'booking_mode' => ['nullable', 'string', 'max:50', Rule::in(['team_only', 'all_required', 'team_required'])], 'open_mode' => ['nullable', 'string', 'max:40', Rule::in(['fulltime', 'scheduled', 'appointment'])], 'open_time' => ['nullable', 'string', 'max:65535'], 'reservation_notice' => ['nullable', 'string'], 'ticket_content' => ['nullable', 'string'], 'booking_method' => ['nullable', 'string'], 'visit_form' => ['nullable', 'string'], 'consultation_hours' => ['nullable', 'string'], 'booking_qr_media' => ['nullable', 'array', 'max:20'], 'booking_qr_media.*.type' => ['required_with:booking_qr_media', 'in:image'], 'booking_qr_media.*.url' => ['required_with:booking_qr_media', 'string', 'max:255'], 'address' => ['nullable', 'string', 'max:255'], 'contact_phone' => ['nullable', 'string', 'max:255'], 'lat' => ['nullable', 'numeric'], 'lng' => ['nullable', 'numeric'], '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'], 'detail_html' => ['nullable', 'string'], 'live_people_count' => ['nullable', 'integer', 'min:0'], 'sort' => ['nullable', 'integer', 'min:0'], 'is_active' => ['boolean'], 'is_included_in_stats' => ['boolean'], ]); if (! $user->isSuperAdmin()) { unset($data['sort']); if ($venue->audit_status === Venue::AUDIT_APPROVED) { $venue->last_approved_snapshot = $venue->buildAuditSnapshot(); } $data['audit_status'] = Venue::AUDIT_PENDING; $data['audit_remark'] = null; } else { $data['audit_status'] = Venue::AUDIT_APPROVED; $data['audit_remark'] = null; $data['last_approved_snapshot'] = null; } $venue->fill($data)->save(); return response()->json($venue->fresh()); } public function approve(Request $request, int $id): JsonResponse { abort_unless($request->user()?->isSuperAdmin(), 403, '仅超级管理员可审核'); $venue = Venue::query()->findOrFail($id); $venue->audit_status = Venue::AUDIT_APPROVED; $venue->audit_remark = null; $venue->last_approved_snapshot = null; $venue->save(); return response()->json($venue->fresh()); } public function reject(Request $request, int $id): JsonResponse { abort_unless($request->user()?->isSuperAdmin(), 403, '仅超级管理员可审核'); $data = $request->validate([ 'remark' => ['nullable', 'string', 'max:2000'], ]); $venue = Venue::query()->findOrFail($id); $venue->audit_status = Venue::AUDIT_REJECTED; $venue->audit_remark = $data['remark'] ?? null; $venue->save(); return response()->json($venue->fresh()); } /** * 仅超级管理员。数据库外键会级联删除该场馆下的活动、预约关联等;并从研学线路的 venue_ids 中移除该馆。 */ public function destroy(Request $request, int $id): JsonResponse { abort_unless($request->user()?->isSuperAdmin(), 403, '仅超级管理员可删除场馆'); $venue = Venue::query()->findOrFail($id); DB::transaction(function () use ($venue) { $vid = (int) $venue->id; StudyTour::query()->orderBy('id')->chunk(50, function ($tours) use ($vid) { foreach ($tours as $tour) { $ids = $tour->venue_ids; if (! is_array($ids) || $ids === []) { continue; } $new = array_values(array_filter($ids, fn ($x) => (int) $x !== $vid)); if (count($new) !== count($ids)) { $tour->venue_ids = $new; $tour->save(); } } }); $venue->delete(); }); return response()->json(['message' => '删除成功']); } private function ensureVenuePermission(Request $request, int $venueId): void { $user = $request->user(); if ($user->isSuperAdmin()) { return; } $allowed = $user->venues()->where('venues.id', $venueId)->exists(); abort_unless($allowed, 403, '仅可操作已绑定场馆'); } }