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.

468 lines
19 KiB

4 weeks ago
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
2 weeks ago
use App\Models\DictItem;
use App\Support\HtmlToPlainText;
3 weeks ago
use App\Models\StudyTour;
4 weeks ago
use App\Models\Venue;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
3 weeks ago
use Illuminate\Support\Facades\DB;
3 weeks ago
use Illuminate\Validation\Rule;
4 weeks ago
class VenueController extends Controller
{
public function store(Request $request): JsonResponse
{
3 weeks ago
$user = $request->user();
1 week ago
abort_unless($user, 403, '无权限');
4 weeks ago
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'venue_type' => ['nullable', 'string', 'max:80'],
3 weeks ago
'venue_types' => ['nullable', 'array'],
'venue_types.*' => ['string', 'max:80'],
4 weeks ago
'unit_name' => ['nullable', 'string', 'max:120'],
'district' => ['nullable', 'string', 'max:80'],
'ticket_type' => ['nullable', 'string', 'max:80'],
3 weeks ago
'appointment_type' => ['nullable', 'string', 'max:40'],
2 weeks ago
'booking_mode' => ['nullable', 'string', 'max:50', Rule::in(['team_only', 'all_required', 'team_required'])],
3 weeks ago
'open_mode' => ['nullable', 'string', 'max:40', Rule::in(['fulltime', 'scheduled', 'appointment'])],
3 weeks ago
'open_time' => ['nullable', 'string', 'max:65535'],
4 weeks ago
'reservation_notice' => ['nullable', 'string'],
3 weeks ago
'ticket_content' => ['nullable', 'string'],
'booking_method' => ['nullable', 'string'],
'visit_form' => ['nullable', 'string'],
'consultation_hours' => ['nullable', 'string'],
2 weeks ago
'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'],
4 weeks ago
'address' => ['nullable', 'string', 'max:255'],
3 weeks ago
'contact_phone' => ['nullable', 'string', 'max:255'],
4 weeks ago
'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'],
2 weeks ago
'is_included_in_stats' => ['boolean'],
4 weeks ago
]);
3 weeks ago
$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);
4 weeks ago
}
2 weeks ago
/**
* 超级管理员导出全部场馆为 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);
}
4 weeks ago
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'));
2 weeks ago
$bookingMode = trim((string) $request->string('booking_mode'));
3 weeks ago
$openMode = trim((string) $request->string('open_mode'));
2 weeks ago
$appointmentType = trim((string) $request->string('appointment_type'));
$bookingMethodFilled = $request->input('booking_method_filled');
4 weeks ago
$isActive = $request->input('is_active');
2 weeks ago
$isIncludedInStats = $request->input('is_included_in_stats');
3 weeks ago
$auditStatus = trim((string) $request->string('audit_status'));
4 weeks ago
if ($user->isSuperAdmin()) {
$query = Venue::query();
} else {
$query = $user->venues();
}
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
2 weeks ago
$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.'%');
4 weeks ago
});
}
if ($district !== '') {
$query->where('district', $district);
}
if ($venueType !== '') {
3 weeks ago
$query->where(function ($q) use ($venueType) {
$q->where('venue_type', $venueType)
->orWhereJsonContains('venue_types', $venueType);
});
4 weeks ago
}
if ($ticketType !== '') {
$query->where('ticket_type', $ticketType);
}
2 weeks ago
if ($bookingMode !== '') {
$query->where('booking_mode', $bookingMode);
}
3 weeks ago
if ($openMode !== '') {
$query->where('open_mode', $openMode);
}
2 weeks ago
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', '');
});
}
4 weeks ago
if ($isActive !== null && $isActive !== '') {
$query->where('is_active', (int) $isActive === 1);
}
2 weeks ago
if ($isIncludedInStats !== null && $isIncludedInStats !== '') {
2 weeks ago
$isIncluded = in_array($isIncludedInStats, [1, '1', true, 'true'], true);
$query->where('is_included_in_stats', $isIncluded);
2 weeks ago
}
3 weeks ago
if ($auditStatus !== '') {
$query->where('audit_status', $auditStatus);
}
4 weeks ago
$venues = $query->orderBy('venues.sort')->orderByDesc('venues.id')->get();
2 weeks ago
4 weeks ago
return response()->json($venues);
}
public function update(Request $request, int $id): JsonResponse
{
$venue = Venue::query()->findOrFail($id);
$this->ensureVenuePermission($request, $venue->id);
3 weeks ago
$user = $request->user();
4 weeks ago
$data = $request->validate([
'name' => ['sometimes', 'string', 'max:120'],
'venue_type' => ['nullable', 'string', 'max:80'],
3 weeks ago
'venue_types' => ['nullable', 'array'],
'venue_types.*' => ['string', 'max:80'],
4 weeks ago
'unit_name' => ['nullable', 'string', 'max:120'],
'district' => ['nullable', 'string', 'max:80'],
'ticket_type' => ['nullable', 'string', 'max:80'],
3 weeks ago
'appointment_type' => ['nullable', 'string', 'max:40'],
2 weeks ago
'booking_mode' => ['nullable', 'string', 'max:50', Rule::in(['team_only', 'all_required', 'team_required'])],
3 weeks ago
'open_mode' => ['nullable', 'string', 'max:40', Rule::in(['fulltime', 'scheduled', 'appointment'])],
3 weeks ago
'open_time' => ['nullable', 'string', 'max:65535'],
4 weeks ago
'reservation_notice' => ['nullable', 'string'],
3 weeks ago
'ticket_content' => ['nullable', 'string'],
'booking_method' => ['nullable', 'string'],
'visit_form' => ['nullable', 'string'],
'consultation_hours' => ['nullable', 'string'],
2 weeks ago
'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'],
4 weeks ago
'address' => ['nullable', 'string', 'max:255'],
3 weeks ago
'contact_phone' => ['nullable', 'string', 'max:255'],
4 weeks ago
'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'],
2 weeks ago
'is_included_in_stats' => ['boolean'],
4 weeks ago
]);
3 weeks ago
if (! $user->isSuperAdmin()) {
4 weeks ago
unset($data['sort']);
3 weeks ago
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;
4 weeks ago
}
$venue->fill($data)->save();
3 weeks ago
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());
4 weeks ago
}
3 weeks ago
/**
* 仅超级管理员。数据库外键会级联删除该场馆下的活动、预约关联等;并从研学线路的 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' => '删除成功']);
}
4 weeks ago
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, '仅可操作已绑定场馆');
}
}