mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-07-07 15:41:46 +08:00
feat: 实现视频的筛选规则 (#457)
This commit is contained in:
@@ -21,7 +21,8 @@ import type {
|
||||
DashBoardResponse,
|
||||
SysInfo,
|
||||
TaskStatus,
|
||||
ResetRequest
|
||||
ResetRequest,
|
||||
UpdateVideoSourceResponse
|
||||
} from './types';
|
||||
import { wsManager } from './ws';
|
||||
|
||||
@@ -212,8 +213,8 @@ class ApiClient {
|
||||
type: string,
|
||||
id: number,
|
||||
request: UpdateVideoSourceRequest
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
return this.put<boolean>(`/video-sources/${type}/${id}`, request);
|
||||
): Promise<ApiResponse<UpdateVideoSourceResponse>> {
|
||||
return this.put<UpdateVideoSourceResponse>(`/video-sources/${type}/${id}`, request);
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ApiResponse<Config>> {
|
||||
|
||||
479
web/src/lib/components/rule-editor.svelte
Normal file
479
web/src/lib/components/rule-editor.svelte
Normal file
@@ -0,0 +1,479 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import type { Rule, RuleTarget, Condition } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
rule: Rule | null;
|
||||
onRuleChange: (rule: Rule | null) => void;
|
||||
}
|
||||
|
||||
let { rule, onRuleChange }: Props = $props();
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
{ value: 'title', label: '标题' },
|
||||
{ value: 'tags', label: '标签' },
|
||||
{ value: 'favTime', label: '收藏时间' },
|
||||
{ value: 'pubTime', label: '发布时间' },
|
||||
{ value: 'pageCount', label: '视频分页数量' }
|
||||
];
|
||||
|
||||
const getOperatorOptions = (field: string) => {
|
||||
switch (field) {
|
||||
case 'title':
|
||||
case 'tags':
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'prefix', label: '以...开头' },
|
||||
{ value: 'suffix', label: '以...结尾' },
|
||||
{ value: 'matchesRegex', label: '匹配正则' }
|
||||
];
|
||||
case 'pageCount':
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'greaterThan', label: '大于' },
|
||||
{ value: 'lessThan', label: '小于' },
|
||||
{ value: 'between', label: '范围' }
|
||||
];
|
||||
case 'favTime':
|
||||
case 'pubTime':
|
||||
return [
|
||||
{ value: 'equals', label: '等于' },
|
||||
{ value: 'greaterThan', label: '晚于' },
|
||||
{ value: 'lessThan', label: '早于' },
|
||||
{ value: 'between', label: '时间范围' }
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
interface LocalCondition {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
value2?: string;
|
||||
isNot: boolean;
|
||||
}
|
||||
|
||||
interface LocalAndGroup {
|
||||
conditions: LocalCondition[];
|
||||
}
|
||||
|
||||
let localRule: LocalAndGroup[] = $state([]);
|
||||
|
||||
onMount(() => {
|
||||
if (rule && rule.length > 0) {
|
||||
localRule = rule.map((andGroup) => ({
|
||||
conditions: andGroup.map((target) => convertRuleTargetToLocal(target))
|
||||
}));
|
||||
} else {
|
||||
localRule = [];
|
||||
}
|
||||
});
|
||||
|
||||
function convertRuleTargetToLocal(target: RuleTarget<string | number | Date>): LocalCondition {
|
||||
if (typeof target.rule === 'object' && 'field' in target.rule) {
|
||||
// 嵌套的 not
|
||||
const innerCondition = convertRuleTargetToLocal(target.rule);
|
||||
return {
|
||||
...innerCondition,
|
||||
isNot: true
|
||||
};
|
||||
}
|
||||
const condition = target.rule as Condition<string | number | Date>;
|
||||
let value = '';
|
||||
let value2 = '';
|
||||
if (Array.isArray(condition.value)) {
|
||||
value = String(condition.value[0] || '');
|
||||
value2 = String(condition.value[1] || '');
|
||||
} else {
|
||||
value = String(condition.value || '');
|
||||
}
|
||||
return {
|
||||
field: target.field,
|
||||
operator: condition.operator,
|
||||
value,
|
||||
value2,
|
||||
isNot: false
|
||||
};
|
||||
}
|
||||
|
||||
function convertLocalToRule(): Rule | null {
|
||||
if (localRule.length === 0) return null;
|
||||
return localRule.map((andGroup) =>
|
||||
andGroup.conditions.map((condition) => {
|
||||
let value: string | number | Date | (string | number | Date)[];
|
||||
if (condition.field === 'pageCount') {
|
||||
if (condition.operator === 'between') {
|
||||
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
|
||||
} else {
|
||||
value = parseInt(condition.value) || 0;
|
||||
}
|
||||
} else if (condition.field === 'favTime' || condition.field === 'pubTime') {
|
||||
if (condition.operator === 'between') {
|
||||
value = [condition.value, condition.value2 || ''];
|
||||
} else {
|
||||
value = condition.value;
|
||||
}
|
||||
} else {
|
||||
if (condition.operator === 'between') {
|
||||
value = [condition.value, condition.value2 || ''];
|
||||
} else {
|
||||
value = condition.value;
|
||||
}
|
||||
}
|
||||
const conditionObj: Condition<string | number | Date> = {
|
||||
operator: condition.operator,
|
||||
value
|
||||
};
|
||||
|
||||
let target: RuleTarget<string | number | Date> = {
|
||||
field: condition.field,
|
||||
rule: conditionObj
|
||||
};
|
||||
if (condition.isNot) {
|
||||
target = {
|
||||
field: 'not',
|
||||
rule: target
|
||||
};
|
||||
}
|
||||
return target;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function addAndGroup() {
|
||||
localRule.push({ conditions: [] });
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function removeAndGroup(index: number) {
|
||||
localRule.splice(index, 1);
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function addCondition(groupIndex: number) {
|
||||
localRule[groupIndex].conditions.push({
|
||||
field: 'title',
|
||||
operator: 'contains',
|
||||
value: '',
|
||||
isNot: false
|
||||
});
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function removeCondition(groupIndex: number, conditionIndex: number) {
|
||||
localRule[groupIndex].conditions.splice(conditionIndex, 1);
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function updateCondition(
|
||||
groupIndex: number,
|
||||
conditionIndex: number,
|
||||
field: string,
|
||||
value: string
|
||||
) {
|
||||
const condition = localRule[groupIndex].conditions[conditionIndex];
|
||||
if (field === 'field') {
|
||||
condition.field = value;
|
||||
const operators = getOperatorOptions(value);
|
||||
condition.operator = operators[0]?.value || 'equals';
|
||||
condition.value = '';
|
||||
condition.value2 = '';
|
||||
} else if (field === 'operator') {
|
||||
condition.operator = value;
|
||||
// 如果切换到/从 between 操作符,重置值
|
||||
if (value === 'between') {
|
||||
condition.value2 = condition.value2 || '';
|
||||
}
|
||||
} else if (field === 'value') {
|
||||
condition.value = value;
|
||||
} else if (field === 'value2') {
|
||||
condition.value2 = value;
|
||||
} else if (field === 'isNot') {
|
||||
condition.isNot = value === 'true';
|
||||
}
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
|
||||
function clearRules() {
|
||||
localRule = [];
|
||||
onRuleChange?.(convertLocalToRule());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-sm font-medium">过滤规则</Label>
|
||||
<div class="flex gap-2">
|
||||
{#if localRule.length > 0}
|
||||
<Button size="sm" variant="outline" onclick={clearRules}>清空规则</Button>
|
||||
{/if}
|
||||
<Button size="sm" onclick={addAndGroup}>
|
||||
<PlusIcon class="mr-1 h-3 w-3" />
|
||||
添加规则组
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if localRule.length === 0}
|
||||
<div class="border-muted-foreground/25 rounded-lg border-2 border-dashed p-8 text-center">
|
||||
<p class="text-muted-foreground mb-4 text-sm">暂无过滤规则,将下载所有视频</p>
|
||||
<Button size="sm" onclick={addAndGroup}>
|
||||
<PlusIcon class="mr-1 h-3 w-3" />
|
||||
添加第一个规则组
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each localRule as andGroup, groupIndex (groupIndex)}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="secondary">规则组 {groupIndex + 1}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => removeAndGroup(groupIndex)}
|
||||
class="h-7 w-7 p-0"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3">
|
||||
{#each andGroup.conditions as condition, conditionIndex (conditionIndex)}
|
||||
<div class="space-y-3 rounded-lg border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant="secondary">条件 {conditionIndex + 1}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => removeCondition(groupIndex, conditionIndex)}
|
||||
class="h-7 w-7 p-0"
|
||||
>
|
||||
<MinusIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 取反选项 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`not-${groupIndex}-${conditionIndex}`}
|
||||
checked={condition.isNot}
|
||||
onCheckedChange={(checked) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'isNot',
|
||||
checked ? 'true' : 'false'
|
||||
)}
|
||||
/>
|
||||
<Label for={`not-${groupIndex}-${conditionIndex}`} class="text-sm">
|
||||
取反(NOT)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<!-- 字段和操作符 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 字段选择 -->
|
||||
<div>
|
||||
<Label class="text-muted-foreground text-xs">字段</Label>
|
||||
<select
|
||||
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"
|
||||
value={condition.field}
|
||||
onchange={(e) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
|
||||
>
|
||||
{#each FIELD_OPTIONS as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 操作符选择 -->
|
||||
<div>
|
||||
<Label class="text-muted-foreground text-xs">操作符</Label>
|
||||
<select
|
||||
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"
|
||||
value={condition.operator}
|
||||
onchange={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'operator',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
>
|
||||
{#each getOperatorOptions(condition.field) as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 值输入 -->
|
||||
<div>
|
||||
<Label class="text-muted-foreground text-xs">值</Label>
|
||||
{#if condition.operator === 'between'}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#if condition.field === 'pageCount'}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最小值"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最大值"
|
||||
class="h-9"
|
||||
value={condition.value2 || ''}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value2',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="开始时间"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value + ':00' // 前端选择器只能精确到分钟,此处附加额外的 :00 以满足后端传参条件
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="结束时间"
|
||||
class="h-9"
|
||||
value={condition.value2 || ''}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value2',
|
||||
e.currentTarget.value + ':00'
|
||||
)}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="起始值"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="结束值"
|
||||
class="h-9"
|
||||
value={condition.value2 || ''}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value2',
|
||||
e.currentTarget.value
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if condition.field === 'pageCount'}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="输入数值"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
|
||||
/>
|
||||
{:else if condition.field === 'favTime' || condition.field === 'pubTime'}
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="选择时间"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
'value',
|
||||
e.currentTarget.value + ':00'
|
||||
)}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入文本"
|
||||
class="h-9"
|
||||
value={condition.value}
|
||||
oninput={(e) =>
|
||||
updateCondition(groupIndex, conditionIndex, 'value', e.currentTarget.value)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => addCondition(groupIndex)}
|
||||
class="w-full"
|
||||
>
|
||||
<PlusIcon class="mr-1 h-3 w-3" />
|
||||
添加条件
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if localRule.length > 0}
|
||||
<div class="text-muted-foreground bg-muted/50 rounded p-3 text-xs">
|
||||
<p class="mb-1 font-medium">规则说明:</p>
|
||||
<ul class="space-y-1">
|
||||
<li>• 多个规则组之间是"或"的关系,同一规则组内的条件是"且"的关系</li>
|
||||
<li>
|
||||
• 规则内配置的时间不包含时区,在处理时会默认应用<strong>服务器时区</strong
|
||||
>,不受浏览器影响
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
17
web/src/lib/components/ui/popover/index.ts
Normal file
17
web/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import Content from './popover-content.svelte';
|
||||
import Trigger from './popover-trigger.svelte';
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose
|
||||
};
|
||||
29
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
29
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = 'center',
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: PopoverPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn('', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -168,11 +168,27 @@ export interface InsertSubmissionRequest {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Rule 相关类型
|
||||
export interface Condition<T> {
|
||||
operator: string;
|
||||
value: T | T[];
|
||||
}
|
||||
|
||||
export interface RuleTarget<T> {
|
||||
field: string;
|
||||
rule: Condition<T> | RuleTarget<T>;
|
||||
}
|
||||
|
||||
export type AndGroup = RuleTarget<string | number | Date>[];
|
||||
export type Rule = AndGroup[];
|
||||
|
||||
// 视频源详细信息类型
|
||||
export interface VideoSourceDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
rule?: Rule | null;
|
||||
ruleDisplay?: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -188,6 +204,7 @@ export interface VideoSourcesDetailsResponse {
|
||||
export interface UpdateVideoSourceRequest {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
rule?: Rule | null;
|
||||
}
|
||||
|
||||
// 配置相关类型
|
||||
@@ -295,3 +312,7 @@ export interface TaskStatus {
|
||||
last_finish: Date | null;
|
||||
next_run: Date | null;
|
||||
}
|
||||
|
||||
export interface UpdateVideoSourceResponse {
|
||||
ruleDisplay?: string;
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import EditIcon from '@lucide/svelte/icons/edit';
|
||||
import SaveIcon from '@lucide/svelte/icons/save';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse } from '$lib/types';
|
||||
import type { ApiError, VideoSourceDetail, VideoSourcesDetailsResponse, Rule } from '$lib/types';
|
||||
import api from '$lib/api';
|
||||
import RuleEditor from '$lib/components/rule-editor.svelte';
|
||||
|
||||
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
|
||||
let loading = false;
|
||||
@@ -29,19 +29,25 @@
|
||||
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
||||
let adding = false;
|
||||
|
||||
// 编辑对话框状态
|
||||
let showEditDialog = false;
|
||||
let editingSource: VideoSourceDetail | null = null;
|
||||
let editingType = '';
|
||||
let editingIdx: number = 0;
|
||||
let saving = false;
|
||||
|
||||
// 编辑表单数据
|
||||
let editForm = {
|
||||
path: '',
|
||||
enabled: false,
|
||||
rule: null as Rule | null
|
||||
};
|
||||
|
||||
// 表单数据
|
||||
let favoriteForm = { fid: '', path: '' };
|
||||
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
|
||||
let submissionForm = { upper_id: '', path: '' };
|
||||
|
||||
type ExtendedVideoSource = VideoSourceDetail & {
|
||||
type: string;
|
||||
originalIndex: number;
|
||||
editing?: boolean;
|
||||
editingPath?: string;
|
||||
editingEnabled?: boolean;
|
||||
};
|
||||
|
||||
const TAB_CONFIG = {
|
||||
favorites: { label: '收藏夹', icon: HeartIcon },
|
||||
collections: { label: '合集 / 列表', icon: FolderIcon },
|
||||
@@ -64,75 +70,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
source.editing = true;
|
||||
source.editingPath = source.path;
|
||||
source.editingEnabled = source.enabled;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
// 打开编辑对话框
|
||||
function openEditDialog(type: string, source: VideoSourceDetail, idx: number) {
|
||||
editingSource = source;
|
||||
editingType = type;
|
||||
editingIdx = idx;
|
||||
editForm = {
|
||||
path: source.path,
|
||||
enabled: source.enabled,
|
||||
rule: source.rule || null
|
||||
};
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
function cancelEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
// 保存编辑
|
||||
async function saveEdit() {
|
||||
if (!editingSource) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
source.editing = false;
|
||||
source.editingPath = undefined;
|
||||
source.editingEnabled = undefined;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
}
|
||||
|
||||
async function saveEdit(type: string, index: number) {
|
||||
if (!videoSourcesData) return;
|
||||
const sources = videoSourcesData[type as keyof VideoSourcesDetailsResponse];
|
||||
if (!sources?.[index]) return;
|
||||
|
||||
const source = sources[index] as ExtendedVideoSource;
|
||||
if (!source.editingPath?.trim()) {
|
||||
if (!editForm.path?.trim()) {
|
||||
toast.error('路径不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await api.updateVideoSource(type, source.id, {
|
||||
path: source.editingPath,
|
||||
enabled: source.editingEnabled ?? false
|
||||
let response = await api.updateVideoSource(editingType, editingSource.id, {
|
||||
path: editForm.path,
|
||||
enabled: editForm.enabled,
|
||||
rule: editForm.rule
|
||||
});
|
||||
|
||||
source.path = source.editingPath;
|
||||
source.enabled = source.editingEnabled ?? false;
|
||||
source.editing = false;
|
||||
source.editingPath = undefined;
|
||||
source.editingEnabled = undefined;
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
// 更新本地数据
|
||||
if (videoSourcesData && editingSource) {
|
||||
const sources = videoSourcesData[
|
||||
editingType as keyof VideoSourcesDetailsResponse
|
||||
] as VideoSourceDetail[];
|
||||
sources[editingIdx] = {
|
||||
...sources[editingIdx],
|
||||
path: editForm.path,
|
||||
enabled: editForm.enabled,
|
||||
rule: editForm.rule,
|
||||
ruleDisplay: response.data.ruleDisplay
|
||||
};
|
||||
videoSourcesData = { ...videoSourcesData };
|
||||
}
|
||||
|
||||
showEditDialog = false;
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
toast.error('保存失败', {
|
||||
description: (error as ApiError).message
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSourcesForTab(tabValue: string): ExtendedVideoSource[] {
|
||||
function getSourcesForTab(tabValue: string): VideoSourceDetail[] {
|
||||
if (!videoSourcesData) return [];
|
||||
const sources = videoSourcesData[
|
||||
tabValue as keyof VideoSourcesDetailsResponse
|
||||
] as VideoSourceDetail[];
|
||||
// 直接返回原始数据的引用,只添加必要的属性
|
||||
return sources.map((source, originalIndex) => {
|
||||
// 使用类型断言来扩展 VideoSourceDetail
|
||||
const extendedSource = source as ExtendedVideoSource;
|
||||
extendedSource.type = tabValue;
|
||||
extendedSource.originalIndex = originalIndex;
|
||||
return extendedSource;
|
||||
});
|
||||
return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
|
||||
}
|
||||
|
||||
// 打开添加对话框
|
||||
@@ -238,80 +234,60 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[30%] md:w-[25%]">名称</Table.Head>
|
||||
<Table.Head class="w-[30%] md:w-[40%]">下载路径</Table.Head>
|
||||
<Table.Head class="w-[25%] md:w-[20%]">状态</Table.Head>
|
||||
<Table.Head class="w-[15%] text-right sm:w-[12%]">操作</Table.Head>
|
||||
<Table.Head class="w-[25%]">名称</Table.Head>
|
||||
<Table.Head class="w-[35%]">下载路径</Table.Head>
|
||||
<Table.Head class="w-[15%]">筛选规则</Table.Head>
|
||||
<Table.Head class="w-[15%]">状态</Table.Head>
|
||||
<Table.Head class="w-[10%] text-right">操作</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each sources as source, index (index)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-[30%] font-medium md:w-[25%]">{source.name}</Table.Cell>
|
||||
<Table.Cell class="w-[30%] md:w-[40%]">
|
||||
{#if source.editing}
|
||||
<input
|
||||
bind:value={source.editingPath}
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="输入下载路径"
|
||||
/>
|
||||
<Table.Cell class="font-medium">{source.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<code
|
||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{source.path || '未设置'}
|
||||
</code>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if source.rule && source.rule.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<div class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800">
|
||||
{source.rule.length} 条规则
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p class="text-xs">{source.ruleDisplay}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<code
|
||||
class="bg-muted text-muted-foreground inline-flex h-8 items-center rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{source.path || '未设置'}
|
||||
</code>
|
||||
<span class="text-muted-foreground text-sm"></span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[25%] md:w-[20%]">
|
||||
{#if source.editing}
|
||||
<div class="flex h-8 items-center">
|
||||
<Switch bind:checked={source.editingEnabled} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<Switch checked={source.enabled} disabled />
|
||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{source.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Table.Cell>
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<Switch checked={source.enabled} disabled />
|
||||
<span class="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{source.enabled ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-[15%] text-right sm:w-[12%]">
|
||||
{#if source.editing}
|
||||
<div
|
||||
class="flex flex-col items-end justify-end gap-1 sm:flex-row sm:items-center"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => saveEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="保存"
|
||||
>
|
||||
<SaveIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => cancelEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="取消"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => startEdit(key, source.originalIndex)}
|
||||
class="h-7 w-7 p-0 sm:h-8 sm:w-8"
|
||||
title="编辑"
|
||||
>
|
||||
<EditIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Table.Cell class="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openEditDialog(key, source, index)}
|
||||
class="h-8 w-8 p-0"
|
||||
title="编辑"
|
||||
>
|
||||
<EditIcon class="h-3 w-3" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
@@ -352,11 +328,50 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<Dialog.Root bind:open={showEditDialog}>
|
||||
<Dialog.Content class="max-h-[85vh] w-4xl !max-w-none overflow-y-auto">
|
||||
<Dialog.Title class="text-lg font-semibold">
|
||||
编辑视频源: {editingSource?.name || ''}
|
||||
</Dialog.Title>
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- 下载路径 -->
|
||||
<div>
|
||||
<Label for="edit-path" class="text-sm font-medium">下载路径</Label>
|
||||
<Input
|
||||
id="edit-path"
|
||||
type="text"
|
||||
bind:value={editForm.path}
|
||||
placeholder="请输入下载路径,例如:/path/to/download"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 启用状态 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch bind:checked={editForm.enabled} />
|
||||
<Label class="text-sm font-medium">启用此视频源</Label>
|
||||
</div>
|
||||
|
||||
<!-- 规则编辑器 -->
|
||||
<div>
|
||||
<RuleEditor rule={editForm.rule} onRuleChange={(rule) => (editForm.rule = rule)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={() => (showEditDialog = false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onclick={saveEdit} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- 添加对话框 -->
|
||||
<Dialog.Root bind:open={showAddDialog}>
|
||||
<Dialog.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
|
||||
<Dialog.Content
|
||||
class="data-[state=open]:animate-content-show bg-background fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-md outline-none"
|
||||
>
|
||||
<Dialog.Content>
|
||||
<Dialog.Title class="text-lg font-semibold">
|
||||
{#if addDialogType === 'favorites'}
|
||||
添加收藏夹
|
||||
|
||||
Reference in New Issue
Block a user