直播日历

This commit is contained in:
2026-07-29 09:10:18 +08:00
parent 7cbc240d63
commit b7ad3fda49
6 changed files with 208 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- 视频评论查询和搜索 - 视频评论查询和搜索
- 节目管理和视频关联 - 节目管理和视频关联
- 弹幕数据管理(支持B站、西瓜视频、抖音) - 弹幕数据管理(支持B站、西瓜视频、抖音)
- 直播日历(/calendar,按稿件标题中的直播日期展示月历)
- WebAuthn 身份验证 - WebAuthn 身份验证
- 文件上传和管理 - 文件上传和管理
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Models\Videos;
use App\Util\VideoCalendarUtil;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
class CalendarController extends BaseController
{
public function index(Request $request)
{
$month_param = $request->get("month", "");
if (preg_match("/^(\d{4})-(0[1-9]|1[0-2])$/", $month_param, $matches)) {
$month = Carbon::createFromDate((int)$matches[1], (int)$matches[2], 1)->startOfMonth();
} else {
$month = Carbon::now()->startOfMonth();
}
$videos = Videos::query()->orderBy("created_at")->get(["id", "bvid", "title", "total_duration"]);
$by_date = VideoCalendarUtil::group_by_live_date($videos);
$parsed_count = array_sum(array_map("sizeof", $by_date));
$weeks = [];
$current_week = array_fill(0, 7, null);
$cursor = $month->copy();
$month_end = $month->copy()->endOfMonth();
while ($cursor->lte($month_end)) {
$day_index = $cursor->dayOfWeekIso - 1; // 周一为一周之始
$date_key = $cursor->format("Y-m-d");
$current_week[$day_index] = [
"day" => $cursor->day,
"videos" => $by_date[$date_key] ?? [],
"is_today" => $cursor->isToday(),
];
if ($day_index === 6) {
$weeks[] = $current_week;
$current_week = array_fill(0, 7, null);
}
$cursor->addDay();
}
if (sizeof(array_filter($current_week)) > 0) {
$weeks[] = $current_week;
}
$month_prefix = $month->format("Y-m");
$live_days = sizeof(array_filter(array_keys($by_date), function (string $date) use ($month_prefix) {
return strpos($date, $month_prefix) === 0;
}));
$min_month = sizeof($by_date) > 0 ? Carbon::createFromFormat("Y-m-d", array_key_first($by_date))->startOfMonth() : $month->copy();
$max_month = sizeof($by_date) > 0 ? Carbon::createFromFormat("Y-m-d", array_key_last($by_date))->startOfMonth() : $month->copy();
$prev_month = $month->copy()->subMonth();
$next_month = $month->copy()->addMonth();
return view("calendar.index", [
"weeks" => $weeks,
"month" => $month,
"is_current_month" => $month->eq(Carbon::now()->startOfMonth()),
"live_days" => $live_days,
"unparsed_count" => sizeof($videos) - $parsed_count,
"prev_month" => $prev_month,
"next_month" => $next_month,
"has_prev" => $prev_month->gte($min_month),
"has_next" => $next_month->lte($max_month),
]);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Util;
use Carbon\Carbon;
class VideoCalendarUtil
{
const TITLE_DATE_PATTERN = "/直播于\s*(?:(\d{4})_(\d{1,2})_(\d{1,2})|(\d{4})(\d{2})(\d{2}))/";
/**
* 从稿件标题解析直播日期,支持「直播于 20260111」与「直播于 2019_01_25」两种格式。
* 无法解析或日期非法时返回 null
*/
public static function parse_live_date(?string $title): ?Carbon
{
if (!$title || !preg_match(static::TITLE_DATE_PATTERN, $title, $matches)) {
return null;
}
if ($matches[1] !== "") {
[$year, $month, $day] = [(int)$matches[1], (int)$matches[2], (int)$matches[3]];
} else {
[$year, $month, $day] = [(int)$matches[4], (int)$matches[5], (int)$matches[6]];
}
if (!checkdate($month, $day, $year)) {
return null;
}
return Carbon::createFromDate($year, $month, $day)->startOfDay();
}
/**
* 按直播日期分组稿件,返回 ['Y-m-d' => [video, ...]],按日期升序。
* 标题无法解析的稿件不会进入结果。
*/
public static function group_by_live_date(iterable $videos): array
{
$grouped = [];
foreach ($videos as $video) {
$date = static::parse_live_date($video->title ?? null);
if ($date === null) {
continue;
}
$grouped[$date->format("Y-m-d")][] = $video;
}
ksort($grouped);
return $grouped;
}
}
+86
View File
@@ -0,0 +1,86 @@
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>直播日历 - 录播节目查询</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="{{ mix('/css/app.css') }}" rel="stylesheet"/>
</head>
<body class="bg-gray-50 dark:bg-gray-900 min-h-screen flex flex-col text-gray-900 dark:text-gray-100">
@include("common.header")
<main class="flex-grow container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="max-w-5xl mx-auto">
<!-- Month Navigation -->
<div class="flex items-center justify-between mb-6">
@if($has_prev)
<a href="/calendar?month={{$prev_month->format("Y-m")}}" class="inline-flex items-center px-3 py-2 rounded-lg text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-gray-800 transition-colors">
&lsaquo; {{$prev_month->format("Y年n月")}}
</a>
@else
<span class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-300 dark:text-gray-600 cursor-not-allowed">
&lsaquo; {{$prev_month->format("Y年n月")}}
</span>
@endif
<div class="text-center">
<h1 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white">{{$month->format("Y年n月")}}</h1>
@if(!$is_current_month)
<a href="/calendar" class="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300">回到本月</a>
@endif
</div>
@if($has_next)
<a href="/calendar?month={{$next_month->format("Y-m")}}" class="inline-flex items-center px-3 py-2 rounded-lg text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-gray-800 transition-colors">
{{$next_month->format("Y年n月")}} &rsaquo;
</a>
@else
<span class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-300 dark:text-gray-600 cursor-not-allowed">
{{$next_month->format("Y年n月")}} &rsaquo;
</span>
@endif
</div>
<!-- Calendar Grid -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<div class="grid grid-cols-7 gap-px bg-gray-200 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700">
@foreach(["", "", "", "", "", "", ""] as $weekday)
<div class="bg-gray-50 dark:bg-gray-700 py-2 text-center text-xs sm:text-sm font-semibold text-gray-500 dark:text-gray-300">{{$weekday}}</div>
@endforeach
</div>
<div class="grid grid-cols-7 gap-px bg-gray-200 dark:bg-gray-700">
@foreach($weeks as $week)
@foreach($week as $cell)
@if($cell === null)
<div class="bg-gray-50 dark:bg-gray-900 min-h-[3.5rem] sm:min-h-[5.5rem]"></div>
@else
<div class="bg-white dark:bg-gray-800 min-h-[3.5rem] sm:min-h-[5.5rem] p-1 sm:p-2">
<div class="mb-1">
@if($cell["is_today"])
<span class="inline-flex items-center justify-center w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-indigo-600 text-white text-xs sm:text-sm font-bold">{{$cell["day"]}}</span>
@else
<span class="text-xs sm:text-sm {{sizeof($cell["videos"]) > 0 ? "font-bold text-gray-900 dark:text-white" : "text-gray-400 dark:text-gray-500"}}">{{$cell["day"]}}</span>
@endif
</div>
@foreach($cell["videos"] as $video)
<a href="/video/{{$video->id}}" title="{{$video->title}}" class="block mb-1 rounded bg-indigo-50 dark:bg-indigo-900/40 hover:bg-indigo-100 dark:hover:bg-indigo-900 px-1 py-0.5 text-center text-[10px] sm:text-xs text-indigo-700 dark:text-indigo-300 leading-tight transition-colors">
{{$video->total_duration ?? "录播"}}
</a>
@endforeach
</div>
@endif
@endforeach
@endforeach
</div>
</div>
<!-- Stats -->
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
<span>本月直播 {{$live_days}} </span>
@if($unparsed_count > 0)
<span class="mx-1">&middot;</span>
<span>另有 {{$unparsed_count}} 条稿件标题未包含可解析日期(剪辑/合集等),未纳入日历</span>
@endif
</div>
</div>
</main>
@include("common.footer")
</body>
</html>
+2
View File
@@ -25,6 +25,7 @@
<!-- Navigation Links --> <!-- Navigation Links -->
<a href="/comments" class="{{ (request()->is('comments', '/', 'video/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">节目单查询</a> <a href="/comments" class="{{ (request()->is('comments', '/', 'video/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">节目单查询</a>
<a href="/danmakus" class="{{ (request()->is('danmakus', 'danmakus/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">稿件查询</a> <a href="/danmakus" class="{{ (request()->is('danmakus', 'danmakus/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">稿件查询</a>
<a href="/calendar" class="{{ (request()->is('calendar')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">直播日历</a>
<a href="/programs" class="{{ (request()->is('programs', 'programs/*/video')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">节目查询</a> <a href="/programs" class="{{ (request()->is('programs', 'programs/*/video')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">节目查询</a>
@auth("web") @auth("web")
<a href="/construct/programs" class="{{ (request()->is('construct/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">建设</a> <a href="/construct/programs" class="{{ (request()->is('construct/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} px-3 py-2 rounded-md text-sm font-medium transition-colors">建设</a>
@@ -63,6 +64,7 @@
<div class="space-y-1 px-2 pt-2 pb-3 bg-gray-50 dark:bg-gray-800 border-t dark:border-gray-700"> <div class="space-y-1 px-2 pt-2 pb-3 bg-gray-50 dark:bg-gray-800 border-t dark:border-gray-700">
<a href="/comments" class="{{ (request()->is('comments', '/', 'video/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">节目单查询</a> <a href="/comments" class="{{ (request()->is('comments', '/', 'video/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">节目单查询</a>
<a href="/danmakus" class="{{ (request()->is('danmakus', 'danmakus/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">稿件查询</a> <a href="/danmakus" class="{{ (request()->is('danmakus', 'danmakus/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">稿件查询</a>
<a href="/calendar" class="{{ (request()->is('calendar')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">直播日历</a>
<a href="/programs" class="{{ (request()->is('programs', 'programs/*/video')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">节目查询</a> <a href="/programs" class="{{ (request()->is('programs', 'programs/*/video')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">节目查询</a>
@auth("web") @auth("web")
<a href="/construct/programs" class="{{ (request()->is('construct/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">节目建设</a> <a href="/construct/programs" class="{{ (request()->is('construct/*')) ? 'bg-indigo-50 dark:bg-gray-700 text-indigo-700 dark:text-indigo-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-indigo-600 dark:hover:text-white' }} block px-3 py-2 rounded-md text-base font-medium">节目建设</a>
+1
View File
@@ -19,6 +19,7 @@ Route::get('/comments', ["\\App\\Http\\Controllers\\CommentQueryController","ind
Route::get('/video/{video}', ["\\App\\Http\\Controllers\\VideoQueryController","info"]); Route::get('/video/{video}', ["\\App\\Http\\Controllers\\VideoQueryController","info"]);
Route::get('/programs', ["\\App\\Http\\Controllers\\ProgramQueryController","index"]); Route::get('/programs', ["\\App\\Http\\Controllers\\ProgramQueryController","index"]);
Route::get('/programs/{program}/video', ["\\App\\Http\\Controllers\\ProgramQueryController","videos"]); Route::get('/programs/{program}/video', ["\\App\\Http\\Controllers\\ProgramQueryController","videos"]);
Route::get('/calendar', ["\\App\\Http\\Controllers\\CalendarController","index"]);
Route::get('/danmakus', ["\\App\\Http\\Controllers\\DanmakuQueryController","index"]); Route::get('/danmakus', ["\\App\\Http\\Controllers\\DanmakuQueryController","index"]);
Route::get('/danmakus/{bvid}', ["\\App\\Http\\Controllers\\DanmakuQueryController","specific_search"]); Route::get('/danmakus/{bvid}', ["\\App\\Http\\Controllers\\DanmakuQueryController","specific_search"]);
Route::get('/upload', ["\\App\\Http\\Controllers\\FileController","index"]); Route::get('/upload', ["\\App\\Http\\Controllers\\FileController","index"]);