中文主题名 => item_value */ public const THEME_LABEL_TO_VALUE = [ '自然生态' => 'theme_nature_ecology', '科技产业' => 'theme_tech_industry', '苏工苏艺' => 'theme_su_craft', '生命健康' => 'theme_life_health', '弘扬科学家精神' => 'theme_scientist_spirit', ]; private const TICKET_TYPE_LABEL = [ '免费' => 'free', '收费' => 'paid', ]; private const APPOINTMENT_LABEL = [ '仅团队' => 'team_only', '个人团队均可' => 'individual_and_team', ]; private const OPEN_MODE_LABEL = [ '常态化全时开放' => 'fulltime', '常态化定时开放' => 'scheduled', '预约开放' => 'appointment', ]; public function buildTemplateSpreadsheet(): Spreadsheet { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('导入数据'); // 列顺序与后台「新增/编辑场馆」表单一致(不含封面、轮播、地图选点) $headers = [ '场馆名称*', '主题*', '行政区*', '预约类型', '门票类型', '开放模式', '所属单位', '预约方式', '参观形式', '开放时间', '咨询预约时间', '咨询预约联系电话', '排序', '启用', '场馆地址', '经度', '纬度', '门票说明', '场馆详情', '预约须知', ]; foreach (range(1, count($headers)) as $i) { $col = Coordinate::stringFromColumnIndex($i); $sheet->setCellValue($col . '1', $headers[$i - 1]); } $sheet->setCellValue( 'A2', '说明:主题多个请用英文逗号分隔;启用填「是」或「否」;经纬度可留空,导入后请在列表编辑中通过「地图选点」填写;经度与纬度须成对填写或同时留空。' ); $opt = $spreadsheet->createSheet(); $opt->setTitle('选项'); $districts = DictItem::query() ->where('dict_type', 'district') ->where('is_active', true) ->orderBy('sort') ->orderBy('id') ->pluck('item_label') ->all(); $opt->setCellValue('A1', '行政区'); $r = 2; foreach ($districts as $d) { $opt->setCellValue('A' . $r, $d); $r++; } $lastD = max(2, $r - 1); $opt->setCellValue('B1', '主题'); $tr = 2; foreach (array_keys(self::THEME_LABEL_TO_VALUE) as $label) { $opt->setCellValue('B' . $tr, $label); $tr++; } $lastT = max(2, $tr - 1); $opt->setCellValue('C1', '门票类型'); $opt->setCellValue('C2', '免费'); $opt->setCellValue('C3', '收费'); $opt->setCellValue('D1', '预约类型'); $opt->setCellValue('D2', '仅团队'); $opt->setCellValue('D3', '个人团队均可'); $opt->setCellValue('E1', '开放模式'); $opt->setCellValue('E2', '常态化全时开放'); $opt->setCellValue('E3', '常态化定时开放'); $opt->setCellValue('E4', '预约开放'); // B=主题 C=行政区 D=预约类型 E=门票类型 F=开放模式(与表头列位一致) $this->applyListValidation($sheet, 'B2:B5000', '=选项!$B$2:$B$' . $lastT); $this->applyListValidation($sheet, 'C2:C5000', '=选项!$A$2:$A$' . $lastD); $this->applyListValidation($sheet, 'D2:D5000', '=选项!$D$2:$D$3'); $this->applyListValidation($sheet, 'E2:E5000', '=选项!$C$2:$C$3'); $this->applyListValidation($sheet, 'F2:F5000', '=选项!$E$2:$E$4'); $spreadsheet->setActiveSheetIndex(0); return $spreadsheet; } private function applyListValidation(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, string $range, string $formula): void { $validation = new DataValidation(); $validation->setType(DataValidation::TYPE_LIST); $validation->setErrorStyle(DataValidation::STYLE_INFORMATION); $validation->setAllowBlank(true); $validation->setShowInputMessage(true); $validation->setShowErrorMessage(true); $validation->setShowDropDown(true); $validation->setFormula1($formula); $sheet->setDataValidation($range, $validation); } /** * @return array{rows: array>, summary: array{total: int, valid: int, invalid: int}} */ public function previewFromPath(string $path): array { $spreadsheet = IOFactory::load($path); $sheet = $spreadsheet->getSheet(0); $highestRow = (int) $sheet->getHighestRow(); $out = []; $valid = 0; $invalid = 0; for ($row = 2; $row <= $highestRow; $row++) { $cells = []; for ($col = 1; $col <= 20; $col++) { $colLetter = Coordinate::stringFromColumnIndex($col); $cells[] = trim((string) $sheet->getCell($colLetter . $row)->getValue()); } if ($this->rowIsEmpty($cells)) { continue; } $parsed = $this->normalizeRowFromCells($cells); $errors = $this->validatePayload($parsed); $ok = $errors === []; if ($ok) { $valid++; } else { $invalid++; } $out[] = [ 'row_index' => $row, 'ok' => $ok, 'errors' => $errors, 'payload' => $parsed, ]; } return [ 'rows' => $out, 'summary' => [ 'total' => count($out), 'valid' => $valid, 'invalid' => $invalid, ], ]; } /** * @param array $cells * @return array */ public function normalizeRowFromCells(array $cells): array { $g = fn (int $i) => isset($cells[$i]) ? trim($cells[$i]) : ''; $themeRaw = str_replace(',', ',', $g(1)); $themeParts = array_filter(array_map('trim', explode(',', $themeRaw)), fn ($s) => $s !== ''); $venueTypes = []; foreach ($themeParts as $part) { if (isset(self::THEME_LABEL_TO_VALUE[$part])) { $venueTypes[] = self::THEME_LABEL_TO_VALUE[$part]; } } // 与表头:3 预约类型、4 门票类型、5 开放模式 一致(从 0 起算列索引) $rawApp = $g(3); $rawTicket = $g(4); $rawOpen = $g(5); $ticketType = $rawTicket === '' ? null : (self::TICKET_TYPE_LABEL[$rawTicket] ?? null); $appointmentType = $rawApp === '' ? null : (self::APPOINTMENT_LABEL[$rawApp] ?? null); $openMode = $rawOpen === '' ? null : (self::OPEN_MODE_LABEL[$rawOpen] ?? null); $lng = $this->parseFloat($g(15)); $lat = $this->parseFloat($g(16)); $sort = $g(12) === '' ? 0 : (int) $g(12); return [ 'name' => $g(0), 'venue_types' => $venueTypes, 'district' => $g(2), 'appointment_type' => $appointmentType, 'ticket_type' => $ticketType, 'open_mode' => $openMode, 'unit_name' => $g(6) === '' ? null : $g(6), 'booking_method' => $g(7) === '' ? null : $g(7), 'visit_form' => $g(8) === '' ? null : $g(8), 'open_time' => $g(9) === '' ? null : $g(9), 'consultation_hours' => $g(10) === '' ? null : $g(10), 'contact_phone' => $g(11) === '' ? null : $g(11), 'sort' => $sort, 'is_active' => $this->parseBool($g(13)), 'address' => $g(14) === '' ? null : $g(14), 'lng' => $lng, 'lat' => $lat, 'ticket_content' => $g(17) === '' ? null : $g(17), 'detail_html' => $g(18) === '' ? null : $g(18), 'reservation_notice' => $g(19) === '' ? null : $g(19), '_raw_appointment_label' => $rawApp, '_raw_open_mode_label' => $rawOpen, '_raw_ticket_type_label' => $rawTicket, ]; } /** * @param array $data * @return list */ public function validatePayload(array $data): array { $errors = []; if (trim((string) ($data['name'] ?? '')) === '') { $errors[] = '场馆名称不能为空'; } if (empty($data['venue_types']) || ! is_array($data['venue_types'])) { $errors[] = '主题无效或为空(须为模板「选项」表中的主题名称,多个用英文逗号分隔)'; } if (trim((string) ($data['district'] ?? '')) === '') { $errors[] = '行政区不能为空'; } $lng = $data['lng'] ?? null; $lat = $data['lat'] ?? null; $hasPartialCoord = ($lng === null) !== ($lat === null); if ($hasPartialCoord) { $errors[] = '经度与纬度须同时填写或同时留空(留空时请在列表编辑中通过地图选点补充)'; } if (($data['_raw_ticket_type_label'] ?? '') !== '' && ($data['ticket_type'] ?? null) === null) { $errors[] = '门票类型须为「免费」或「收费」或留空'; } if (($data['_raw_appointment_label'] ?? '') !== '' && ($data['appointment_type'] ?? null) === null) { $errors[] = '预约类型须为「仅团队」「个人团队均可」或留空'; } if (($data['_raw_open_mode_label'] ?? '') !== '' && ($data['open_mode'] ?? null) === null) { $errors[] = '开放模式须为三种选项之一或留空'; } $v = Validator::make($this->stripInternalKeys($data), [ 'name' => ['required', 'string', 'max:120'], 'venue_types' => ['required', 'array', 'min:1'], 'venue_types.*' => ['string', 'max:80'], 'unit_name' => ['nullable', 'string', 'max:120'], 'district' => ['required', 'string', 'max:80'], 'ticket_type' => ['nullable', 'string', 'max:80'], 'appointment_type' => ['nullable', 'string', 'max:40'], 'open_mode' => ['nullable', 'string', 'max:40'], 'open_time' => ['nullable', 'string', 'max:120'], 'ticket_content' => ['nullable', 'string'], 'booking_method' => ['nullable', 'string'], 'visit_form' => ['nullable', 'string'], 'consultation_hours' => ['nullable', 'string'], 'detail_html' => ['nullable', 'string'], 'reservation_notice' => ['nullable', 'string'], 'address' => ['nullable', 'string', 'max:255'], 'contact_phone' => ['nullable', 'string', 'max:20'], 'lat' => ['nullable', 'numeric'], 'lng' => ['nullable', 'numeric'], 'sort' => ['nullable', 'integer', 'min:0'], 'is_active' => ['boolean'], ]); if ($v->fails()) { foreach ($v->errors()->all() as $msg) { $errors[] = $msg; } } return array_values(array_unique($errors)); } /** * @param array $data * @return array */ public function stripInternalKeys(array $data): array { foreach (array_keys($data) as $k) { if (is_string($k) && str_starts_with($k, '_')) { unset($data[$k]); } } return $data; } /** * @param array $payload */ public function createVenueFromPayload(array $payload, User $user): Venue { $clean = $this->stripInternalKeys($payload); $auditStatus = $user->isSuperAdmin() ? Venue::AUDIT_APPROVED : Venue::AUDIT_PENDING; $venue = Venue::create($clean + [ 'cover_image' => null, 'gallery_media' => [], 'detail_html' => $clean['detail_html'] ?? null, 'live_people_count' => 0, 'audit_status' => $auditStatus, 'audit_remark' => null, 'last_approved_snapshot' => null, ]); if (! $user->isSuperAdmin()) { $user->venues()->syncWithoutDetaching([$venue->id]); } return $venue->fresh(); } private function rowIsEmpty(array $cells): bool { foreach ($cells as $c) { if (trim((string) $c) !== '') { return false; } } return true; } private function parseFloat(?string $s): ?float { if ($s === null || trim($s) === '') { return null; } $n = (float) str_replace([',', ' '], '', $s); return is_finite($n) ? $n : null; } private function parseBool(string $s): bool { $t = mb_strtolower(trim($s)); if ($t === '' || in_array($t, ['1', '是', 'y', 'yes', 'true', '启用'], true)) { return true; } if (in_array($t, ['0', '否', 'n', 'no', 'false', '禁用'], true)) { return false; } return true; } }