Merge remote-tracking branch 'upstream/main'
Some checks failed
Build Main Docs / Build documentation (push) Has been cancelled
Build Main Binary / build-binary (push) Has been cancelled
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled

# Conflicts:
#	.github/workflows/build-binary.yaml
#	.github/workflows/build-doc.yaml
#	.github/workflows/pr-check.yaml
#	.github/workflows/release-build.yaml
#	rust-toolchain.toml
This commit is contained in:
2026-04-01 02:00:04 +08:00
32 changed files with 481 additions and 220 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "bili-sync-web",
"version": "2.10.4",
"version": "2.11.0",
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.2",

View File

@@ -5,6 +5,7 @@
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 * as Select from '$lib/components/ui/select/index.js';
import { PlusIcon, MinusIcon, XIcon } from '@lucide/svelte/icons';
import type { Rule, RuleTarget, Condition } from '$lib/types';
import { onMount } from 'svelte';
@@ -21,7 +22,9 @@
{ value: 'tags', label: '标签' },
{ value: 'favTime', label: '收藏时间' },
{ value: 'pubTime', label: '发布时间' },
{ value: 'pageCount', label: '视频分页数量' }
{ value: 'pageCount', label: '视频分页数量' },
{ value: 'sumVideoLength', label: '视频总时长' },
{ value: 'multiUpper', label: '联合投稿' }
];
const getOperatorOptions = (field: string) => {
@@ -37,6 +40,7 @@
{ value: 'matchesRegex', label: '匹配正则' }
];
case 'pageCount':
case 'sumVideoLength':
return [
{ value: 'equals', label: '等于' },
{ value: 'greaterThan', label: '大于' },
@@ -51,6 +55,8 @@
{ value: 'lessThan', label: '早于' },
{ value: 'between', label: '时间范围' }
];
case 'multiUpper':
return [{ value: 'equals', label: '等于' }];
default:
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) {
// 嵌套的 not
const innerCondition = convertRuleTargetToLocal(target.rule);
@@ -93,10 +101,10 @@
let value = '';
let value2 = '';
if (Array.isArray(condition.value)) {
value = String(condition.value[0] || '');
value2 = String(condition.value[1] || '');
value = String(condition.value[0] ?? '');
value2 = String(condition.value[1] ?? '');
} else {
value = String(condition.value || '');
value = String(condition.value ?? '');
}
return {
field: target.field,
@@ -111,8 +119,8 @@
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') {
let value: string | number | boolean | Date | (string | number | boolean | Date)[];
if (condition.field === 'pageCount' || condition.field === 'sumVideoLength') {
if (condition.operator === 'between') {
value = [parseInt(condition.value) || 0, parseInt(condition.value2 || '0') || 0];
} else {
@@ -124,6 +132,8 @@
} else {
value = condition.value;
}
} else if (condition.field === 'multiUpper') {
value = condition.value === 'true';
} else {
if (condition.operator === 'between') {
value = [condition.value, condition.value2 || ''];
@@ -131,12 +141,12 @@
value = condition.value;
}
}
const conditionObj: Condition<string | number | Date> = {
const conditionObj: Condition<string | number | boolean | Date> = {
operator: condition.operator,
value
};
let target: RuleTarget<string | number | Date> = {
let target: RuleTarget<string | number | boolean | Date> = {
field: condition.field,
rule: conditionObj
};
@@ -187,7 +197,7 @@
condition.field = value;
const operators = getOperatorOptions(value);
condition.operator = operators[0]?.value || 'equals';
condition.value = '';
condition.value = value === 'multiUpper' ? 'false' : '';
condition.value2 = '';
} else if (field === 'operator') {
condition.operator = value;
@@ -290,36 +300,43 @@
<!-- 字段选择 -->
<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"
<Select.Root
type="single"
value={condition.field}
onchange={(e) =>
updateCondition(groupIndex, conditionIndex, 'field', e.currentTarget.value)}
onValueChange={(v) => updateCondition(groupIndex, conditionIndex, 'field', v)}
>
{#each FIELD_OPTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<Select.Trigger class="w-full">
{FIELD_OPTIONS.find((o) => o.value === condition.field)?.label ??
condition.field}
</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>
<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"
<Select.Root
type="single"
value={condition.operator}
onchange={(e) =>
updateCondition(
groupIndex,
conditionIndex,
'operator',
e.currentTarget.value
)}
onValueChange={(v) =>
updateCondition(groupIndex, conditionIndex, 'operator', v)}
>
{#each getOperatorOptions(condition.field) as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<Select.Trigger class="w-full">
{getOperatorOptions(condition.field).find(
(o) => o.value === condition.operator
)?.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>
@@ -328,10 +345,11 @@
<Label class="text-muted-foreground text-xs"></Label>
{#if condition.operator === 'between'}
<div class="grid grid-cols-2 gap-2">
{#if condition.field === 'pageCount'}
{#if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
<Input
type="number"
placeholder="最小值"
placeholder={'最小值' +
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
class="h-9"
value={condition.value}
oninput={(e) =>
@@ -344,7 +362,8 @@
/>
<Input
type="number"
placeholder="最大值"
placeholder={'最大值' +
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
class="h-9"
value={condition.value2 || ''}
oninput={(e) =>
@@ -411,10 +430,11 @@
/>
{/if}
</div>
{:else if condition.field === 'pageCount'}
{:else if condition.field === 'pageCount' || condition.field === 'sumVideoLength'}
<Input
type="number"
placeholder="输入数值"
placeholder={'输入数值' +
(condition.field === 'sumVideoLength' ? '(单位:秒)' : '')}
class="h-9"
value={condition.value}
oninput={(e) =>
@@ -434,6 +454,20 @@
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}
<Input
type="text"

View File

@@ -210,7 +210,7 @@ export interface 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 interface VideoSourceDetail {
@@ -308,6 +308,7 @@ export interface WebhookNotifier {
type: 'webhook';
url: string;
template?: string | null;
headers?: Record<string, string> | null;
}
export type Notifier = TelegramNotifier | WebhookNotifier;

View File

@@ -5,8 +5,6 @@
import { toast } from 'svelte-sonner';
import type { Notifier } from '$lib/types';
const jsonExample = '{"text": "您的消息内容"}';
export let notifier: Notifier | null = null;
export let onSave: (notifier: Notifier) => void;
export let onCancel: () => void;
@@ -16,6 +14,7 @@
let chatId = '';
let webhookUrl = '';
let webhookTemplate = '';
let webhookHeaders: { key: string; value: string }[] = [];
// 初始化表单
$: {
@@ -28,6 +27,11 @@
type = 'webhook';
webhookUrl = notifier.url;
webhookTemplate = notifier.template || '';
if (notifier.headers) {
webhookHeaders = Object.entries(notifier.headers).map(([key, value]) => ({ key, value }));
} else {
webhookHeaders = [];
}
}
} else {
type = 'telegram';
@@ -35,11 +39,11 @@
chatId = '';
webhookUrl = '';
webhookTemplate = '';
webhookHeaders = [];
}
}
function handleSave() {
// 验证表单
if (type === 'telegram') {
if (!botToken.trim()) {
toast.error('请输入 Bot Token');
@@ -62,7 +66,6 @@
return;
}
// 简单的 URL 验证
try {
new URL(webhookUrl.trim());
} catch {
@@ -70,10 +73,20 @@
return;
}
const headers: Record<string, string> = {};
for (const { key, value } of webhookHeaders) {
const trimmedKey = key.trim();
const trimmedValue = value.trim();
if (trimmedKey && trimmedValue) {
headers[trimmedKey] = trimmedValue;
}
}
const newNotifier: Notifier = {
type: 'webhook',
url: webhookUrl.trim(),
template: webhookTemplate.trim() || null
template: webhookTemplate.trim() || null,
headers: Object.keys(headers).length > 0 ? headers : null
};
onSave(newNotifier);
}
@@ -111,11 +124,7 @@
{:else if type === 'webhook'}
<div class="space-y-2">
<Label for="webhook-url">Webhook URL</Label>
<Input id="webhook-url" placeholder="https://example.com/webhook" bind:value={webhookUrl} />
<p class="text-muted-foreground text-xs">
接收通知的 Webhook 地址<br />
格式示例:{jsonExample}
</p>
<Input id="webhook-url" placeholder="请输入 Webhook 地址" bind:value={webhookUrl} />
</div>
<div class="space-y-2">
<Label for="webhook-template">模板(可选)</Label>
@@ -127,7 +136,48 @@
></textarea>
<p class="text-muted-foreground text-xs">
用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br />
可用变量:<code class="text-xs">message</code>(通知内容)
可用变量:<code class="text-xs">message</code>(通知内容)<code class="text-xs"
>image_url</code
>(封面图片地址,无图时为 null
</p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>自定义请求头(可选)</Label>
<Button
variant="ghost"
size="sm"
onclick={() => (webhookHeaders = [...webhookHeaders, { key: '', value: '' }])}
>
+ 添加请求头
</Button>
</div>
{#each webhookHeaders as header, index (index)}
<div class="flex items-center gap-2">
<Input
placeholder="Header 名称(例如 Authorization"
bind:value={header.key}
class="flex-1"
/>
<Input
placeholder="Header 值"
bind:value={header.value}
class="flex-1"
type={header.key.toLowerCase() === 'authorization' ? 'password' : 'text'}
/>
<Button
variant="ghost"
size="sm"
onclick={() => (webhookHeaders = webhookHeaders.filter((_, i) => i !== index))}
class="h-10 px-2"
>
×
</Button>
</div>
{/each}
<p class="text-muted-foreground text-xs">
添加自定义请求头例如Authorization: Bearer your_token
</p>
</div>
{/if}