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.

474 lines
19 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\DictItem;
use App\Models\StudyTour;
use App\Models\Venue;
use App\Support\HtmlToPlainText;
use App\Support\VenueVerifyPortalPin;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class VenueController extends Controller
{
public function store(Request $request): JsonResponse
{
$user = $request->user();
abort_unless($user, 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]);
}
VenueVerifyPortalPin::ensure($venue->fresh());
return response()->json($venue->fresh(), 201);
}
/**
* 超级管理员导出全部场馆为 CSVUTF-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<string, array<string, string>> 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<string, array<string, string>> $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<string, array<string, string>> $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<int, array<string, mixed>>|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();
abort_unless($user, 403, '无权限');
$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']);
}
/** 场馆修改后直接生效,不再进入待审核 */
$data['audit_status'] = Venue::AUDIT_APPROVED;
$data['audit_remark'] = null;
$data['last_approved_snapshot'] = null;
$venue->fill($data)->save();
VenueVerifyPortalPin::ensure($venue->fresh());
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();
VenueVerifyPortalPin::ensure($venue->fresh());
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();
abort_unless($user, 403, '无权限');
if ($user->isSuperAdmin()) {
return;
}
$allowed = $user->venues()->where('venues.id', $venueId)->exists();
abort_unless($allowed, 403, '仅可操作已绑定场馆');
}
}