feat: enhance torrent state scheduling and display

Signed-off-by: Qi HU <github@spcsky.com>
This commit is contained in:
Qi HU
2025-12-07 12:57:08 +08:00
parent f098ef72b5
commit 2346afa291
13 changed files with 436 additions and 47 deletions

View File

@@ -5,21 +5,18 @@ namespace App\Filament\Resources\System;
use Filament\Schemas\Schema;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use App\Filament\Resources\System\TorrentStateResource\Pages\ManageTorrentStates;
use App\Filament\Resources\System\TorrentStateResource\Pages;
use App\Filament\Resources\System\TorrentStateResource\RelationManagers;
use App\Models\Setting;
use App\Models\Torrent;
use App\Models\TorrentState;
use Filament\Forms;
use Carbon\Carbon;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Filament\Tables;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Nexus\Database\NexusDB;
class TorrentStateResource extends Resource
{
@@ -46,13 +43,28 @@ class TorrentStateResource extends Resource
return $schema
->components([
Select::make('global_sp_state')
->options(Torrent::listPromotionTypes(true))
->options(function () {
$options = Torrent::listPromotionTypes(true);
unset($options[Torrent::PROMOTION_NORMAL]);
return $options;
})
->label(__('label.torrent_state.global_sp_state'))
->required(),
DateTimePicker::make('begin')
->label(__('label.begin')),
->label(__('label.begin'))
->required(),
DateTimePicker::make('deadline')
->label(__('label.deadline')),
->label(__('label.deadline'))
->required()
->after('begin')
->validationMessages([
'after' => __('label.torrent_state.deadline_after_begin'),
]),
Textarea::make('remark')
->label(__('label.comment'))
->rows(2)
->columnSpanFull()
->maxLength(255),
])->columns(1);
}
@@ -63,21 +75,63 @@ class TorrentStateResource extends Resource
TextColumn::make('global_sp_state_text')->label(__('label.torrent_state.global_sp_state')),
TextColumn::make('begin')->label(__('label.begin')),
TextColumn::make('deadline')->label(__('label.deadline')),
TextColumn::make('promotion_status')
->label(__('label.torrent_state.status'))
->state(function (TorrentState $record) {
$now = Carbon::now();
$begin = $record->begin ? Carbon::parse($record->begin) : null;
$deadline = $record->deadline ? Carbon::parse($record->deadline) : null;
if ($deadline && $deadline->lt($now)) {
return 'expired';
}
if ($begin && $begin->gt($now)) {
return 'upcoming';
}
return 'ongoing';
})
->formatStateUsing(function (string $state) {
return match ($state) {
'expired' => __('label.torrent_state.status_expired'),
'upcoming' => __('label.torrent_state.status_upcoming'),
default => __('label.torrent_state.status_ongoing'),
};
})
->badge()
->sortable(query: function (Builder $query, string $direction): Builder {
$now = Carbon::now()->toDateTimeString();
// expired=0, ongoing=1, upcoming=2
return $query->orderByRaw(
"CASE
WHEN deadline IS NOT NULL AND deadline < ? THEN 0
WHEN begin IS NOT NULL AND begin > ? THEN 2
ELSE 1
END {$direction}",
[$now, $now]
);
})
->color(fn (string $state) => match ($state) {
'expired' => 'danger',
'upcoming' => 'info',
default => 'success',
})
->icon(fn (string $state) => match ($state) {
'expired' => 'heroicon-o-x-circle',
'upcoming' => 'heroicon-o-clock',
default => 'heroicon-o-check-circle',
})
->iconPosition('before'),
TextColumn::make('remark')->label(__('label.comment'))->limit(50),
])
->filters([
//
])
->recordActions([
EditAction::make()->after(function () {
do_log("cache_del: global_promotion_state");
NexusDB::cache_del(Setting::TORRENT_GLOBAL_STATE_CACHE_KEY);
do_log("publish_model_event: global_promotion_state_updated");
publish_model_event("global_promotion_state_updated", 0);
}),
// Tables\Actions\DeleteAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
// Tables\Actions\DeleteBulkAction::make(),
DeleteBulkAction::make(),
]);
}

View File

@@ -3,9 +3,8 @@
namespace App\Filament\Resources\System\TorrentStateResource\Pages;
use App\Filament\Resources\System\TorrentStateResource;
use Filament\Pages\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ManageRecords;
use Nexus\Database\NexusDB;
class ManageTorrentStates extends ManageRecords
{
@@ -14,7 +13,7 @@ class ManageTorrentStates extends ManageRecords
protected function getHeaderActions(): array
{
return [
// Actions\CreateAction::make(),
CreateAction::make(),
];
}

View File

@@ -4,17 +4,282 @@ namespace App\Models;
use App\Models\Traits\NexusActivityLogTrait;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Request;
use Illuminate\Validation\ValidationException;
use Nexus\Database\NexusDB;
class TorrentState extends NexusModel
{
use NexusActivityLogTrait;
protected $fillable = ['global_sp_state', 'deadline', 'begin'];
protected $fillable = ['global_sp_state', 'deadline', 'begin', 'remark'];
protected $table = 'torrents_state';
protected $casts = [
'begin' => 'datetime',
'deadline' => 'datetime',
];
protected static function booted()
{
parent::booted();
static::saving(function (TorrentState $state) {
$state->validateTimeRange();
$state->ensureNoOverlap();
});
static::saved(function () {
static::flushCache();
});
static::deleted(function () {
static::flushCache();
});
}
public function getGlobalSpStateTextAttribute()
{
return Torrent::$promotionTypes[$this->global_sp_state]['text'] ?? '';
}
public function scopeActive(Builder $query, ?Carbon $moment = null): Builder
{
$moment = $moment ?? Carbon::now();
return $query
->where('global_sp_state', '!=', Torrent::PROMOTION_NORMAL)
->where(function (Builder $query) use ($moment) {
$query->whereNull('begin')->orWhere('begin', '<=', $moment);
})
->where(function (Builder $query) use ($moment) {
$query->whereNull('deadline')->orWhere('deadline', '>=', $moment);
})
->orderBy('begin')
->orderBy('id');
}
public function scopeUpcoming(Builder $query, ?Carbon $moment = null): Builder
{
$moment = $moment ?? Carbon::now();
return $query
->where('global_sp_state', '!=', Torrent::PROMOTION_NORMAL)
->whereNotNull('begin')
->where('begin', '>', $moment)
->orderBy('begin')
->orderBy('id');
}
public static function current(?Carbon $moment = null): ?self
{
return self::query()->active($moment)->first();
}
public static function next(?Carbon $moment = null): ?self
{
return self::query()->upcoming($moment)->first();
}
public static function cachedStates(): array
{
return NexusDB::remember(Setting::TORRENT_GLOBAL_STATE_CACHE_KEY, 600, function () {
return self::query()
->where('global_sp_state', '!=', Torrent::PROMOTION_NORMAL)
->orderByRaw('begin is null')
->orderBy('begin')
->orderBy('id')
->get()
->toArray();
});
}
public static function flushCache(): void
{
do_log("cache_del: " . Setting::TORRENT_GLOBAL_STATE_CACHE_KEY);
NexusDB::cache_del(Setting::TORRENT_GLOBAL_STATE_CACHE_KEY);
do_log("publish_model_event: global_promotion_state_updated");
publish_model_event("global_promotion_state_updated", 0);
}
public static function resolveTimeline(?Carbon $moment = null): array
{
$moment = $moment ?? Carbon::now();
$states = self::cachedStates();
$current = null;
$upcoming = null;
foreach ($states as $state) {
$begin = self::parseDateTimeValue($state['begin'] ?? null);
$deadline = self::parseDateTimeValue($state['deadline'] ?? null);
$hasBegun = !$begin || $begin->lessThanOrEqualTo($moment);
$notExpired = !$deadline || $deadline->greaterThanOrEqualTo($moment);
if ($hasBegun && $notExpired) {
if (!$current) {
$current = $state;
}
continue;
}
if ($begin && $begin->greaterThan($moment)) {
if (!$upcoming) {
$upcoming = $state;
continue;
}
$upcomingBegin = self::parseDateTimeValue($upcoming['begin'] ?? null);
if ($upcomingBegin && $begin->lessThan($upcomingBegin)) {
$upcoming = $state;
}
}
}
return [
'current' => $current,
'upcoming' => $upcoming,
];
}
protected function validateTimeRange(): void
{
$begin = self::parseDateTimeValue($this->begin);
$deadline = self::parseDateTimeValue($this->deadline);
if ($begin && $deadline && $deadline->lessThanOrEqualTo($begin)) {
throw ValidationException::withMessages([
self::errorFieldKey('deadline') => __('label.torrent_state.deadline_after_begin'),
]);
}
}
protected function ensureNoOverlap(): void
{
self::validateNoOverlap($this->attributesToArray(), $this->id);
}
protected function getRangeForComparison(TorrentState $state): array
{
$min = Carbon::createFromTimestamp(0);
$max = Carbon::create(9999, 12, 31, 23, 59, 59);
$begin = self::parseDateTimeValue($state->begin) ?? $min;
$deadline = self::parseDateTimeValue($state->deadline) ?? $max;
return [
'begin' => $begin,
'end' => $deadline,
];
}
protected static function parseDateTimeValue(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
if (empty($value) || $value === '0000-00-00 00:00:00') {
return null;
}
return Carbon::parse($value);
}
public static function validateNoOverlap(array $attributes, ?int $ignoreId = null): void
{
$globalState = (int) Arr::get($attributes, 'global_sp_state', Torrent::PROMOTION_NORMAL);
if ($globalState === Torrent::PROMOTION_NORMAL) {
return;
}
$range = self::getRangeForArray($attributes);
$conflicts = self::query()
->where('global_sp_state', '!=', Torrent::PROMOTION_NORMAL)
->when($ignoreId, fn (Builder $query) => $query->whereKeyNot($ignoreId))
->get(['id', 'begin', 'deadline']);
$beginConflict = $conflicts->first(function (TorrentState $state) use ($range) {
$other = $state->getRangeForComparison($state);
return $range['begin']->greaterThanOrEqualTo($other['begin']) && $range['begin']->lessThanOrEqualTo($other['end']);
});
$endConflict = $conflicts->first(function (TorrentState $state) use ($range) {
$other = $state->getRangeForComparison($state);
return $range['end']->greaterThanOrEqualTo($other['begin']) && $range['end']->lessThanOrEqualTo($other['end']);
});
$coverageConflict = $conflicts->first(function (TorrentState $state) use ($range) {
$other = $state->getRangeForComparison($state);
return $range['begin']->lt($other['begin']) && $range['end']->gt($other['end']);
});
if ($beginConflict || $endConflict || $coverageConflict) {
$errors = [];
if ($beginConflict) {
$errors[self::errorFieldKey('begin')] = self::buildOverlapMessage($beginConflict);
}
if ($endConflict) {
$errors[self::errorFieldKey('deadline')] = self::buildOverlapMessage($endConflict);
}
if (empty($errors) && $coverageConflict) {
$msg = self::buildOverlapMessage($coverageConflict);
$errors[self::errorFieldKey('begin')] = $msg;
$errors[self::errorFieldKey('deadline')] = $msg;
}
if (empty($errors)) {
$msg = __('label.torrent_state.time_overlaps');
$errors[self::errorFieldKey('begin')] = $msg;
$errors[self::errorFieldKey('deadline')] = $msg;
}
throw ValidationException::withMessages($errors);
}
}
protected static function getRangeForArray(array $attributes): array
{
$min = Carbon::createFromTimestamp(0);
$max = Carbon::create(9999, 12, 31, 23, 59, 59);
$begin = self::parseDateTimeValue($attributes['begin'] ?? null) ?? $min;
$deadline = self::parseDateTimeValue($attributes['deadline'] ?? null) ?? $max;
return [
'begin' => $begin,
'end' => $deadline,
];
}
protected static function errorFieldKey(string $field): string
{
$prefix = 'mountedActions.0.data.';
return $prefix . $field;
}
protected static function buildOverlapMessage(TorrentState $conflict): string
{
$begin = self::parseDateTimeValue($conflict->begin);
$deadline = self::parseDateTimeValue($conflict->deadline);
$beginText = $begin ? $begin->toDateTimeString() : '-∞';
$deadlineText = $deadline ? $deadline->toDateTimeString() : '∞';
return __('label.torrent_state.time_overlaps_with', [
'id' => $conflict->id,
'begin' => $beginText,
'end' => $deadlineText,
]);
}
}

View File

@@ -42,7 +42,7 @@ class TorrentStatePolicy extends BasePolicy
*/
public function create(User $user)
{
return false;
return $this->can($user);
}
/**
@@ -66,7 +66,7 @@ class TorrentStatePolicy extends BasePolicy
*/
public function delete(User $user, TorrentState $torrentState)
{
return $this->can($user);
}
/**
@@ -78,7 +78,7 @@ class TorrentStatePolicy extends BasePolicy
*/
public function restore(User $user, TorrentState $torrentState)
{
return $this->can($user);
}
/**
@@ -90,7 +90,7 @@ class TorrentStatePolicy extends BasePolicy
*/
public function forceDelete(User $user, TorrentState $torrentState)
{
//
return $this->can($user);
}
private function can(User $user)