You've already forked lubo_comment_query
直播日历
This commit is contained in:
@@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
‹ {{$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">
|
||||||
|
‹ {{$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月")}} ›
|
||||||
|
</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月")}} ›
|
||||||
|
</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">·</span>
|
||||||
|
<span>另有 {{$unparsed_count}} 条稿件标题未包含可解析日期(剪辑/合集等),未纳入日历</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
@include("common.footer")
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user