feat: 过滤规则引入视频总长度和联合投稿 (#684)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2026-03-24 22:58:20 +08:00
committed by GitHub
parent 3a2df55314
commit 8cb59d6b2a
4 changed files with 103 additions and 46 deletions

View File

@@ -37,13 +37,22 @@ impl Evaluatable<usize> for Condition<usize> {
} }
} }
impl Evaluatable<&NaiveDateTime> for Condition<NaiveDateTime> { impl Evaluatable<NaiveDateTime> for Condition<NaiveDateTime> {
fn evaluate(&self, value: &NaiveDateTime) -> bool { fn evaluate(&self, value: NaiveDateTime) -> bool {
match self { match self {
Condition::Equals(expected) => expected == value, Condition::Equals(expected) => *expected == value,
Condition::GreaterThan(threshold) => value > threshold, Condition::GreaterThan(threshold) => value > *threshold,
Condition::LessThan(threshold) => value < threshold, Condition::LessThan(threshold) => value < *threshold,
Condition::Between(start, end) => value > start && value < end, Condition::Between(start, end) => value > *start && value < *end,
_ => false,
}
}
}
impl Evaluatable<bool> for Condition<bool> {
fn evaluate(&self, value: bool) -> bool {
match self {
Condition::Equals(expected) => *expected == value,
_ => false, _ => false,
} }
} }
@@ -65,13 +74,20 @@ impl FieldEvaluatable for RuleTarget {
.favtime .favtime
.try_as_ref() .try_as_ref()
.map(|fav_time| fav_time.and_utc().with_timezone(&Local).naive_local()) // 数据库中保存的一律是 utc 时间,转换为 local 时间再比较 .map(|fav_time| fav_time.and_utc().with_timezone(&Local).naive_local()) // 数据库中保存的一律是 utc 时间,转换为 local 时间再比较
.is_some_and(|fav_time| cond.evaluate(&fav_time)), .is_some_and(|fav_time| cond.evaluate(fav_time)),
RuleTarget::PubTime(cond) => video RuleTarget::PubTime(cond) => video
.pubtime .pubtime
.try_as_ref() .try_as_ref()
.map(|pub_time| pub_time.and_utc().with_timezone(&Local).naive_local()) .map(|pub_time| pub_time.and_utc().with_timezone(&Local).naive_local())
.is_some_and(|pub_time| cond.evaluate(&pub_time)), .is_some_and(|pub_time| cond.evaluate(pub_time)),
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()), RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
RuleTarget::SumVideoLength(cond) => pages
.iter()
.try_fold(0usize, |acc, page| {
page.duration.try_as_ref().map(|d| acc + *d as usize).ok_or(())
})
.is_ok_and(|total_length| cond.evaluate(total_length)),
RuleTarget::MultiUpper(cond) => cond.evaluate(video.staff.as_ref().is_some()),
RuleTarget::Not(inner) => !inner.evaluate(video, pages), RuleTarget::Not(inner) => !inner.evaluate(video, pages),
} }
} }
@@ -86,9 +102,13 @@ impl FieldEvaluatable for RuleTarget {
.tags .tags
.as_ref() .as_ref()
.is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))), .is_some_and(|tags| tags.0.iter().any(|tag| cond.evaluate(tag))),
RuleTarget::FavTime(cond) => cond.evaluate(&video.favtime.and_utc().with_timezone(&Local).naive_local()), RuleTarget::FavTime(cond) => cond.evaluate(video.favtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::PubTime(cond) => cond.evaluate(&video.pubtime.and_utc().with_timezone(&Local).naive_local()), RuleTarget::PubTime(cond) => cond.evaluate(video.pubtime.and_utc().with_timezone(&Local).naive_local()),
RuleTarget::PageCount(cond) => cond.evaluate(pages.len()), RuleTarget::PageCount(cond) => cond.evaluate(pages.len()),
RuleTarget::SumVideoLength(cond) => {
cond.evaluate(pages.iter().fold(0usize, |acc, page| acc + page.duration as usize))
}
RuleTarget::MultiUpper(cond) => cond.evaluate(video.staff.is_some()),
RuleTarget::Not(inner) => !inner.evaluate_model(video, pages), RuleTarget::Not(inner) => !inner.evaluate_model(video, pages),
} }
} }

View File

@@ -30,6 +30,8 @@ pub enum RuleTarget {
FavTime(Condition<DateTime>), FavTime(Condition<DateTime>),
PubTime(Condition<DateTime>), PubTime(Condition<DateTime>),
PageCount(Condition<usize>), PageCount(Condition<usize>),
SumVideoLength(Condition<usize>),
MultiUpper(Condition<bool>),
Not(Box<RuleTarget>), Not(Box<RuleTarget>),
} }
@@ -63,6 +65,8 @@ impl Display for RuleTarget {
RuleTarget::FavTime(_) => "收藏时间", RuleTarget::FavTime(_) => "收藏时间",
RuleTarget::PubTime(_) => "发布时间", RuleTarget::PubTime(_) => "发布时间",
RuleTarget::PageCount(_) => "视频分页数量", RuleTarget::PageCount(_) => "视频分页数量",
RuleTarget::SumVideoLength(_) => "视频总时长",
RuleTarget::MultiUpper(_) => "联合投稿",
RuleTarget::Not(inner) => { RuleTarget::Not(inner) => {
if depth == 0 { if depth == 0 {
get_field_name(inner, depth + 1) get_field_name(inner, depth + 1)
@@ -79,14 +83,16 @@ impl Display for RuleTarget {
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => { RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
write!(f, "{}不{}", field_name, cond) write!(f, "{}不{}", field_name, cond)
} }
RuleTarget::PageCount(cond) => write!(f, "{}不{}", field_name, cond), RuleTarget::PageCount(cond) | RuleTarget::SumVideoLength(cond) => write!(f, "{}不{}", field_name, cond),
RuleTarget::MultiUpper(cond) => write!(f, "{}不{}", field_name, cond),
RuleTarget::Not(_) => write!(f, "格式化失败"), RuleTarget::Not(_) => write!(f, "格式化失败"),
}, },
RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}{}", field_name, cond), RuleTarget::Title(cond) | RuleTarget::Tags(cond) => write!(f, "{}{}", field_name, cond),
RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => { RuleTarget::FavTime(cond) | RuleTarget::PubTime(cond) => {
write!(f, "{}{}", field_name, cond) write!(f, "{}{}", field_name, cond)
} }
RuleTarget::PageCount(cond) => write!(f, "{}{}", field_name, cond), RuleTarget::PageCount(cond) | RuleTarget::SumVideoLength(cond) => write!(f, "{}{}", field_name, cond),
RuleTarget::MultiUpper(cond) => write!(f, "{}{}", field_name, cond),
} }
} }
} }

View File

@@ -5,6 +5,7 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import { PlusIcon, MinusIcon, XIcon } from '@lucide/svelte/icons'; import { PlusIcon, MinusIcon, XIcon } from '@lucide/svelte/icons';
import type { Rule, RuleTarget, Condition } from '$lib/types'; import type { Rule, RuleTarget, Condition } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -21,7 +22,9 @@
{ value: 'tags', label: '标签' }, { value: 'tags', label: '标签' },
{ value: 'favTime', label: '收藏时间' }, { value: 'favTime', label: '收藏时间' },
{ value: 'pubTime', label: '发布时间' }, { value: 'pubTime', label: '发布时间' },
{ value: 'pageCount', label: '视频分页数量' } { value: 'pageCount', label: '视频分页数量' },
{ value: 'sumVideoLength', label: '视频总时长' },
{ value: 'multiUpper', label: '联合投稿' }
]; ];
const getOperatorOptions = (field: string) => { const getOperatorOptions = (field: string) => {
@@ -37,6 +40,7 @@
{ value: 'matchesRegex', label: '匹配正则' } { value: 'matchesRegex', label: '匹配正则' }
]; ];
case 'pageCount': case 'pageCount':
case 'sumVideoLength':
return [ return [
{ value: 'equals', label: '等于' }, { value: 'equals', label: '等于' },
{ value: 'greaterThan', label: '大于' }, { value: 'greaterThan', label: '大于' },
@@ -51,6 +55,8 @@
{ value: 'lessThan', label: '早于' }, { value: 'lessThan', label: '早于' },
{ value: 'between', label: '时间范围' } { value: 'between', label: '时间范围' }
]; ];
case 'multiUpper':
return [{ value: 'equals', label: '等于' }];
default: default:
return []; return [];
} }
@@ -80,7 +86,9 @@
} }
}); });
function convertRuleTargetToLocal(target: RuleTarget<string | number | Date>): LocalCondition { function convertRuleTargetToLocal(
target: RuleTarget<string | number | boolean | Date>
): LocalCondition {
if (typeof target.rule === 'object' && 'field' in target.rule) { if (typeof target.rule === 'object' && 'field' in target.rule) {
// 嵌套的 not // 嵌套的 not
const innerCondition = convertRuleTargetToLocal(target.rule); const innerCondition = convertRuleTargetToLocal(target.rule);
@@ -93,10 +101,10 @@
let value = ''; let value = '';
let value2 = ''; let value2 = '';
if (Array.isArray(condition.value)) { if (Array.isArray(condition.value)) {
value = String(condition.value[0] || ''); value = String(condition.value[0] ?? '');
value2 = String(condition.value[1] || ''); value2 = String(condition.value[1] ?? '');
} else { } else {
value = String(condition.value || ''); value = String(condition.value ?? '');
} }
return { return {
field: target.field, field: target.field,
@@ -111,8 +119,8 @@
if (localRule.length === 0) return null; if (localRule.length === 0) return null;
return localRule.map((andGroup) => return localRule.map((andGroup) =>
andGroup.conditions.map((condition) => { andGroup.conditions.map((condition) => {
let value: string | number | Date | (string | number | Date)[]; let value: string | number | boolean | Date | (string | number | boolean | Date)[];
if (condition.field === 'pageCount') { if (condition.field === 'pageCount' || condition.field === 'sumVideoLength') {
if (condition.operator === 'between') { if (condition.operator === 'between') {
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0]; value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
} else { } else {
@@ -124,6 +132,8 @@
} else { } else {
value = condition.value; value = condition.value;
} }
} else if (condition.field === 'multiUpper') {
value = condition.value === 'true';
} else { } else {
if (condition.operator === 'between') { if (condition.operator === 'between') {
value = [condition.value, condition.value2 || '']; value = [condition.value, condition.value2 || ''];
@@ -131,12 +141,12 @@
value = condition.value; value = condition.value;
} }
} }
const conditionObj: Condition<string | number | Date> = { const conditionObj: Condition<string | number | boolean | Date> = {
operator: condition.operator, operator: condition.operator,
value value
}; };
let target: RuleTarget<string | number | Date> = { let target: RuleTarget<string | number | boolean | Date> = {
field: condition.field, field: condition.field,
rule: conditionObj rule: conditionObj
}; };
@@ -187,7 +197,7 @@
condition.field = value; condition.field = value;
const operators = getOperatorOptions(value); const operators = getOperatorOptions(value);
condition.operator = operators[0]?.value || 'equals'; condition.operator = operators[0]?.value || 'equals';
condition.value = ''; condition.value = value === 'multiUpper' ? 'false' : '';
condition.value2 = ''; condition.value2 = '';
} else if (field === 'operator') { } else if (field === 'operator') {
condition.operator = value; condition.operator = value;
@@ -290,36 +300,43 @@
<!-- 字段选择 --> <!-- 字段选择 -->
<div> <div>
<Label class="text-muted-foreground text-xs">字段</Label> <Label class="text-muted-foreground text-xs">字段</Label>
<select <Select.Root
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" type="single"
value={condition.field} value={condition.field}
onchange={(e) => onValueChange={(v) => updateCondition(groupIndex, conditionIndex, 'field', v)}
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
> >
{#each FIELD_OPTIONS as option (option.value)} <Select.Trigger class="w-full">
<option value={option.value}>{option.label}</option> {FIELD_OPTIONS.find((o) => o.value === condition.field)?.label ??
{/each} condition.field}
</select> </Select.Trigger>
<Select.Content>
{#each FIELD_OPTIONS as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div> </div>
<!-- 操作符选择 --> <!-- 操作符选择 -->
<div> <div>
<Label class="text-muted-foreground text-xs">操作符</Label> <Label class="text-muted-foreground text-xs">操作符</Label>
<select <Select.Root
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" type="single"
value={condition.operator} value={condition.operator}
onchange={(e) => onValueChange={(v) =>
updateCondition( updateCondition(groupIndex, conditionIndex, 'operator', v)}
groupIndex,
conditionIndex,
'operator',
e.currentTarget.value
)}
> >
{#each getOperatorOptions(condition.field) as option (option.value)} <Select.Trigger class="w-full">
<option value={option.value}>{option.label}</option> {getOperatorOptions(condition.field).find(
{/each} (o) => o.value === condition.operator
</select> )?.label ?? condition.operator}
</Select.Trigger>
<Select.Content>
{#each getOperatorOptions(condition.field) as option (option.value)}
<Select.Item value={option.value} label={option.label} />
{/each}
</Select.Content>
</Select.Root>
</div> </div>
</div> </div>
@@ -328,7 +345,7 @@
<Label class="text-muted-foreground text-xs"></Label> <Label class="text-muted-foreground text-xs"></Label>
{#if condition.operator === 'between'} {#if condition.operator === 'between'}
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
{#if condition.field === 'pageCount'} {#if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
<Input <Input
type="number" type="number"
placeholder="最小值" placeholder="最小值"
@@ -411,7 +428,7 @@
/> />
{/if} {/if}
</div> </div>
{:else if condition.field === 'pageCount'} {:else if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
<Input <Input
type="number" type="number"
placeholder="输入数值" placeholder="输入数值"
@@ -434,6 +451,20 @@
e.currentTarget.value + ':00' e.currentTarget.value + ':00'
)} )}
/> />
{:else if condition.field === 'multiUpper'}
<Select.Root
type="single"
value={condition.value}
onValueChange={(v) => updateCondition(groupIndex, conditionIndex, 'value', v)}
>
<Select.Trigger class="w-full">
{condition.value === 'true' ? 'true' : 'false'}
</Select.Trigger>
<Select.Content>
<Select.Item value="true" label="true" />
<Select.Item value="false" label="false" />
</Select.Content>
</Select.Root>
{:else} {:else}
<Input <Input
type="text" type="text"

View File

@@ -213,7 +213,7 @@ export interface RuleTarget<T> {
rule: Condition<T> | RuleTarget<T>; rule: Condition<T> | RuleTarget<T>;
} }
export type AndGroup = RuleTarget<string | number | Date>[]; export type AndGroup = RuleTarget<string | number | boolean | Date>[];
export type Rule = AndGroup[]; export type Rule = AndGroup[];
export interface VideoSourceDetail { export interface VideoSourceDetail {