|
|
<?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);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 超级管理员导出全部场馆为 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<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, '仅可操作已绑定场馆');
|
|
|
}
|
|
|
}
|