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.
341 lines
13 KiB
341 lines
13 KiB
|
2 weeks ago
|
<?php
|
||
|
|
|
||
|
|
namespace App\Http\Controllers\Admin;
|
||
|
|
|
||
|
|
use App\Http\Controllers\Controller;
|
||
|
|
use App\Models\Course;
|
||
|
|
use App\Models\CourseMedia;
|
||
|
|
use App\Models\DictItem;
|
||
|
|
use App\Models\DictType;
|
||
|
|
use App\Support\ApiResponse;
|
||
|
|
use App\Support\ScheduleProgressStatus;
|
||
|
|
use App\Support\CourseCheckinDaySync;
|
||
|
|
use Illuminate\Http\JsonResponse;
|
||
|
|
use Illuminate\Http\Request;
|
||
|
|
use Illuminate\Validation\Rule;
|
||
|
|
|
||
|
|
class CourseController extends Controller
|
||
|
|
{
|
||
|
|
use ApiResponse;
|
||
|
|
|
||
|
|
public function index(Request $request): JsonResponse
|
||
|
|
{
|
||
|
|
$query = Course::query()
|
||
|
|
->with(['courseSystemItem', 'courseTypeItem', 'coverMedia', 'promoMedia'])
|
||
|
|
->withCount(['signups']);
|
||
|
|
|
||
|
|
if ($kw = $request->query('keyword')) {
|
||
|
|
$query->where(function ($q) use ($kw) {
|
||
|
|
$q->where('title', 'like', "%{$kw}%")
|
||
|
|
->orWhere('code', 'like', "%{$kw}%")
|
||
|
|
->orWhere('code_prefix', 'like', "%{$kw}%");
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if ($request->filled('course_system_dict_item_id')) {
|
||
|
|
$query->where('course_system_dict_item_id', (int) $request->query('course_system_dict_item_id'));
|
||
|
|
}
|
||
|
|
if ($request->filled('course_type_dict_item_id')) {
|
||
|
|
$query->where('course_type_dict_item_id', (int) $request->query('course_type_dict_item_id'));
|
||
|
|
}
|
||
|
|
if ($request->filled('progress_status')) {
|
||
|
|
$query->where('progress_status', (int) $request->query('progress_status'));
|
||
|
|
}
|
||
|
|
if ($request->filled('published')) {
|
||
|
|
$query->where('published', (int) $request->query('published'));
|
||
|
|
}
|
||
|
|
|
||
|
|
$paginator = $query
|
||
|
|
->orderByDesc('id')
|
||
|
|
->paginate((int) $request->query('page_size', 20))
|
||
|
|
->withQueryString();
|
||
|
|
|
||
|
|
$paginator->getCollection()->transform(fn (Course $c) => $this->serializeCourseList($c));
|
||
|
|
|
||
|
|
return $this->paginated($paginator);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function store(Request $request): JsonResponse
|
||
|
|
{
|
||
|
|
if (! DictType::query()->where('code', 'course_system')->where('status', 1)->exists()
|
||
|
|
|| ! DictType::query()->where('code', 'course_type')->where('status', 1)->exists()) {
|
||
|
|
return $this->fail('字典「课程体系」或「课程类型」未配置,请在后台维护数据字典或执行 CourseDictionarySeeder', 422);
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = $this->validatedCourse($request);
|
||
|
|
$data['progress_status'] = ScheduleProgressStatus::resolve(
|
||
|
|
$data['teach_start_date'] ?? null,
|
||
|
|
$data['teach_end_date'] ?? null,
|
||
|
|
$data['signup_start_date'] ?? null,
|
||
|
|
$data['signup_end_date'] ?? null,
|
||
|
|
);
|
||
|
|
$course = Course::query()->create($data);
|
||
|
|
CourseCheckinDaySync::ensureCourseCode($course);
|
||
|
|
CourseCheckinDaySync::syncForCourse($course->fresh());
|
||
|
|
|
||
|
|
return $this->ok(['id' => $course->id], '已创建');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function show(int $course): JsonResponse
|
||
|
|
{
|
||
|
|
$model = Course::query()
|
||
|
|
->with(['courseSystemItem', 'courseTypeItem', 'autoAddTeacherItem', 'coverMedia', 'promoMedia', 'news'])
|
||
|
|
->withCount(['signups'])
|
||
|
|
->findOrFail($course);
|
||
|
|
|
||
|
|
return $this->ok($this->serializeCourseDetail($model));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function update(Request $request, int $course): JsonResponse
|
||
|
|
{
|
||
|
|
$model = Course::query()->findOrFail($course);
|
||
|
|
$data = $this->validatedCourse($request, partial: true);
|
||
|
|
$model->fill($data);
|
||
|
|
$model->progress_status = ScheduleProgressStatus::resolve(
|
||
|
|
$model->teach_start_date?->toDateString(),
|
||
|
|
$model->teach_end_date?->toDateString(),
|
||
|
|
$model->signup_start_date?->toDateString(),
|
||
|
|
$model->signup_end_date?->toDateString(),
|
||
|
|
);
|
||
|
|
$model->save();
|
||
|
|
CourseCheckinDaySync::ensureCourseCode($model);
|
||
|
|
CourseCheckinDaySync::syncForCourse($model->fresh());
|
||
|
|
|
||
|
|
return $this->ok(null, '已保存');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function updateShelf(Request $request, int $course): JsonResponse
|
||
|
|
{
|
||
|
|
$data = $request->validate([
|
||
|
|
'published' => ['required', 'integer', 'in:0,1'],
|
||
|
|
]);
|
||
|
|
$model = Course::query()->findOrFail($course);
|
||
|
|
$model->published = (int) $data['published'];
|
||
|
|
$model->save();
|
||
|
|
|
||
|
|
return $this->ok(null, '已更新发布状态');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function destroy(int $course): JsonResponse
|
||
|
|
{
|
||
|
|
Course::query()->findOrFail($course)->delete();
|
||
|
|
|
||
|
|
return $this->ok(null, '已删除');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array<string, mixed>
|
||
|
|
*/
|
||
|
|
protected function validatedCourse(Request $request, bool $partial = false): array
|
||
|
|
{
|
||
|
|
$courseId = $partial ? (int) $request->route('course') : null;
|
||
|
|
|
||
|
|
$systemTypeId = DictType::query()->where('code', 'course_system')->where('status', 1)->value('id');
|
||
|
|
$courseTypeTypeId = DictType::query()->where('code', 'course_type')->where('status', 1)->value('id');
|
||
|
|
$yesNoTypeId = DictType::query()->where('code', 'yes_no')->where('status', 1)->value('id');
|
||
|
|
|
||
|
|
$codeRules = ['nullable', 'string', 'max:64'];
|
||
|
|
if ($partial) {
|
||
|
|
array_unshift($codeRules, 'sometimes');
|
||
|
|
$codeRules[] = Rule::unique('courses', 'code')->ignore($courseId);
|
||
|
|
} else {
|
||
|
|
$codeRules[] = Rule::unique('courses', 'code');
|
||
|
|
}
|
||
|
|
|
||
|
|
$rules = [
|
||
|
|
'code' => $codeRules,
|
||
|
|
'code_prefix' => ['nullable', 'string', 'max:32'],
|
||
|
|
'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:255'],
|
||
|
|
'course_system_dict_item_id' => $this->dictItemRules($systemTypeId, $partial ? 'sometimes' : 'required'),
|
||
|
|
'course_type_dict_item_id' => $this->dictItemRules($courseTypeTypeId, $partial ? 'sometimes' : 'required'),
|
||
|
|
'teach_start_date' => ['nullable', 'date'],
|
||
|
|
'teach_end_date' => $partial
|
||
|
|
? ['nullable', 'date']
|
||
|
|
: ['nullable', 'date', 'after_or_equal:teach_start_date'],
|
||
|
|
'location' => ['nullable', 'string', 'max:255'],
|
||
|
|
'teach_start_time' => ['nullable', 'date_format:H:i'],
|
||
|
|
'teach_end_time' => ['nullable', 'date_format:H:i'],
|
||
|
|
'recruit_targets' => ['nullable', 'array'],
|
||
|
|
'recruit_targets.*' => ['string', 'max:255'],
|
||
|
|
'main_speakers' => ['nullable', 'array'],
|
||
|
|
'main_speakers.*.teacher_id' => ['nullable', 'integer'],
|
||
|
|
'main_speakers.*.name' => ['required_with:main_speakers', 'string', 'max:64'],
|
||
|
|
'main_speakers.*.title' => ['nullable', 'string', 'max:64'],
|
||
|
|
'main_speakers.*.university' => ['nullable', 'string', 'max:255'],
|
||
|
|
'main_speakers.*.remark' => ['nullable', 'string', 'max:255'],
|
||
|
|
'signup_start_date' => ['nullable', 'date'],
|
||
|
|
'signup_end_date' => $partial
|
||
|
|
? ['nullable', 'date']
|
||
|
|
: ['nullable', 'date', 'after_or_equal:signup_start_date'],
|
||
|
|
'auto_add_teacher_dict_item_id' => $this->dictItemRules($yesNoTypeId, 'nullable'),
|
||
|
|
'capacity' => ['nullable', 'integer', 'min:0'],
|
||
|
|
'cover_media_id' => array_merge(
|
||
|
|
[$partial ? 'sometimes' : 'nullable', 'integer'],
|
||
|
|
[Rule::exists('course_media', 'id')->where('category', 'covers')]
|
||
|
|
),
|
||
|
|
'promo_media_id' => array_merge(
|
||
|
|
[$partial ? 'sometimes' : 'nullable', 'integer'],
|
||
|
|
[Rule::exists('course_media', 'id')->where('category', 'promos')]
|
||
|
|
),
|
||
|
|
'news_id' => [$partial ? 'sometimes' : 'nullable', 'integer', 'exists:news,id'],
|
||
|
|
'news_link_url' => ['nullable', 'string', 'max:512'],
|
||
|
|
'intro_html' => ['nullable', 'string'],
|
||
|
|
'signup_form_schema' => ['nullable', 'array'],
|
||
|
|
'published' => ['nullable', 'integer', 'in:0,1'],
|
||
|
|
'remark' => ['nullable', 'string'],
|
||
|
|
'sort' => ['nullable', 'integer'],
|
||
|
|
];
|
||
|
|
|
||
|
|
return $request->validate($rules);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|string>
|
||
|
|
*/
|
||
|
|
protected function dictItemRules(?int $dictTypeId, string $presence): array
|
||
|
|
{
|
||
|
|
if (! $dictTypeId) {
|
||
|
|
return match ($presence) {
|
||
|
|
'required' => ['required', 'integer'],
|
||
|
|
'sometimes' => ['sometimes', 'nullable', 'integer'],
|
||
|
|
default => ['nullable', 'integer'],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
$exists = Rule::exists('dict_items', 'id')->where(
|
||
|
|
fn ($q) => $q->where('dict_type_id', $dictTypeId)->where('status', 1)
|
||
|
|
);
|
||
|
|
|
||
|
|
return match ($presence) {
|
||
|
|
'required' => ['required', 'integer', $exists],
|
||
|
|
'sometimes' => ['sometimes', 'nullable', 'integer', $exists],
|
||
|
|
default => ['nullable', 'integer', $exists],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array<string, mixed>
|
||
|
|
*/
|
||
|
|
protected function serializeCourseList(Course $c): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'id' => $c->id,
|
||
|
|
'code' => $c->code,
|
||
|
|
'signin_code' => CourseCheckinDaySync::courseSigninCode($c),
|
||
|
|
'code_prefix' => $c->code_prefix,
|
||
|
|
'title' => $c->title,
|
||
|
|
'course_system_dict_item_id' => $c->course_system_dict_item_id,
|
||
|
|
'course_type_dict_item_id' => $c->course_type_dict_item_id,
|
||
|
|
'course_system_item' => $this->serializeDictItem($c->courseSystemItem),
|
||
|
|
'course_type_item' => $this->serializeDictItem($c->courseTypeItem),
|
||
|
|
'teach_start_date' => $c->teach_start_date?->toDateString(),
|
||
|
|
'teach_end_date' => $c->teach_end_date?->toDateString(),
|
||
|
|
'location' => $c->location,
|
||
|
|
'teach_start_time' => $this->formatTimeValue($c->teach_start_time),
|
||
|
|
'teach_end_time' => $this->formatTimeValue($c->teach_end_time),
|
||
|
|
'recruit_targets' => $c->recruit_targets ?? [],
|
||
|
|
'main_speakers' => $c->main_speakers ?? [],
|
||
|
|
'signup_start_date' => $c->signup_start_date?->toDateString(),
|
||
|
|
'signup_end_date' => $c->signup_end_date?->toDateString(),
|
||
|
|
'capacity' => (int) $c->capacity,
|
||
|
|
'progress_status' => ScheduleProgressStatus::resolve(
|
||
|
|
$c->teach_start_date?->toDateString(),
|
||
|
|
$c->teach_end_date?->toDateString(),
|
||
|
|
$c->signup_start_date?->toDateString(),
|
||
|
|
$c->signup_end_date?->toDateString(),
|
||
|
|
),
|
||
|
|
'published' => (int) $c->published,
|
||
|
|
'signups_count' => (int) ($c->signups_count ?? 0),
|
||
|
|
'created_at' => $c->created_at?->toIso8601String(),
|
||
|
|
'cover' => $this->serializeCourseMedia($c->coverMedia)
|
||
|
|
?? $this->legacyUrlAsMedia($c->cover_url, 'covers'),
|
||
|
|
'promo' => $this->serializeCourseMedia($c->promoMedia)
|
||
|
|
?? $this->legacyUrlAsMedia($c->promo_url, 'promos'),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array<string, mixed>
|
||
|
|
*/
|
||
|
|
protected function serializeCourseDetail(Course $c): array
|
||
|
|
{
|
||
|
|
$row = $this->serializeCourseList($c);
|
||
|
|
$row['auto_add_teacher_dict_item_id'] = $c->auto_add_teacher_dict_item_id;
|
||
|
|
$row['auto_add_teacher_item'] = $this->serializeDictItem($c->autoAddTeacherItem);
|
||
|
|
$row['news_id'] = $c->news_id;
|
||
|
|
$row['news'] = $c->news ? [
|
||
|
|
'id' => $c->news->id,
|
||
|
|
'title' => $c->news->title,
|
||
|
|
'status' => (int) $c->news->status,
|
||
|
|
] : null;
|
||
|
|
$row['news_link_url'] = $c->news_link_url;
|
||
|
|
$row['intro_html'] = $c->intro_html;
|
||
|
|
$row['signup_form_schema'] = $c->signup_form_schema;
|
||
|
|
$row['remark'] = $c->remark;
|
||
|
|
|
||
|
|
return $row;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array<string, mixed>|null
|
||
|
|
*/
|
||
|
|
protected function serializeCourseMedia(?CourseMedia $media): ?array
|
||
|
|
{
|
||
|
|
if (! $media) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $media->toApiArray();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 旧数据仅有 URL 字符串时的兼容展示(无 media 主键)。
|
||
|
|
*
|
||
|
|
* @return array<string, mixed>|null
|
||
|
|
*/
|
||
|
|
protected function legacyUrlAsMedia(?string $url, string $category): ?array
|
||
|
|
{
|
||
|
|
if ($url === null || $url === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'id' => null,
|
||
|
|
'disk' => 'public',
|
||
|
|
'path' => null,
|
||
|
|
'url' => $url,
|
||
|
|
'category' => $category,
|
||
|
|
'original_name' => null,
|
||
|
|
'mime_type' => null,
|
||
|
|
'size_bytes' => null,
|
||
|
|
'created_at' => null,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @return array{id:int,label:string,value:string}|null
|
||
|
|
*/
|
||
|
|
protected function serializeDictItem(?DictItem $item): ?array
|
||
|
|
{
|
||
|
|
if (! $item) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'id' => $item->id,
|
||
|
|
'label' => $item->label,
|
||
|
|
'value' => $item->value,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function formatTimeValue(mixed $time): ?string
|
||
|
|
{
|
||
|
|
if ($time === null || $time === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$str = (string) $time;
|
||
|
|
|
||
|
|
return strlen($str) >= 5 ? substr($str, 0, 5) : $str;
|
||
|
|
}
|
||
|
|
}
|