feat: add batch controls for video sources and bump version to 2.10.4
Some checks failed
Build Main Binary / build-binary (push) Failing after 0s
Build Main Docs / Build documentation (push) Has been cancelled
Check / Run backend checks (push) Has been cancelled
Check / Run frontend checks (push) Has been cancelled

This commit is contained in:
2026-02-10 17:19:39 +08:00
parent 628d0f9ce7
commit 30f3745ac3
6 changed files with 183 additions and 9 deletions

6
Cargo.lock generated
View File

@@ -353,7 +353,7 @@ dependencies = [
[[package]]
name = "bili_sync"
version = "2.10.3"
version = "2.10.4"
dependencies = [
"anyhow",
"arc-swap",
@@ -412,7 +412,7 @@ dependencies = [
[[package]]
name = "bili_sync_entity"
version = "2.10.3"
version = "2.10.4"
dependencies = [
"derivative",
"regex",
@@ -423,7 +423,7 @@ dependencies = [
[[package]]
name = "bili_sync_migration"
version = "2.10.3"
version = "2.10.4"
dependencies = [
"sea-orm-migration",
]

View File

@@ -4,7 +4,7 @@ default-members = ["crates/bili_sync"]
resolver = "2"
[workspace.package]
version = "2.10.3"
version = "2.10.4"
authors = ["amtoaer <amtoaer@gmail.com>"]
license = "MIT"
description = "由 Rust & Tokio 驱动的哔哩哔哩同步工具"

View File

@@ -1,7 +1,7 @@
# bili-sync 是什么?
> [!TIP]
> 当前最新程序版本为 v2.10.3,文档将始终与最新程序版本保持一致。
> 当前最新程序版本为 v2.10.4,文档将始终与最新程序版本保持一致。
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "bili-sync-web",
"version": "2.10.3",
"version": "2.10.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bili-sync-web",
"version": "2.10.3",
"version": "2.10.4",
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4"

View File

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

View File

@@ -32,6 +32,7 @@
let videoSourcesData: VideoSourcesDetailsResponse | null = null;
let loading = false;
let activeTab = 'favorites';
let batchSaving = false;
// 添加对话框状态
let showAddDialog = false;
@@ -70,6 +71,12 @@
let favoriteForm = { fid: '' };
let collectionForm = { sid: '', mid: '', collection_type: '2' }; // 默认为合集
let submissionForm = { upper_id: '' };
let selectedIds: Record<string, number[]> = {
favorites: [],
collections: [],
submissions: [],
watch_later: []
};
const TAB_CONFIG = {
favorites: { label: '收藏夹', icon: HeartIcon },
@@ -84,6 +91,7 @@
try {
const response = await api.getVideoSourcesDetails();
videoSourcesData = response.data;
selectedIds = { favorites: [], collections: [], submissions: [], watch_later: [] };
} catch (error) {
toast.error('加载视频源失败', {
description: (error as ApiError).message
@@ -194,6 +202,10 @@
] as VideoSourceDetail[];
sources.splice(removeIdx, 1);
videoSourcesData = { ...videoSourcesData };
selectedIds = {
...selectedIds,
[removeType]: (selectedIds[removeType] ?? []).filter((id) => id !== removeSource.id)
};
}
showRemoveDialog = false;
toast.success('删除视频源成功');
@@ -214,6 +226,105 @@
return videoSourcesData[tabValue as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
}
function isSelected(tab: string, id: number): boolean {
return selectedIds[tab]?.includes(id) ?? false;
}
function toggleSelect(tab: string, id: number, checked: boolean) {
const current = selectedIds[tab] ?? [];
selectedIds = {
...selectedIds,
[tab]: checked ? [...new Set([...current, id])] : current.filter((item) => item !== id)
};
}
function toggleSelectAll(tab: string, sourceIds: number[], checked: boolean) {
selectedIds = {
...selectedIds,
[tab]: checked ? [...sourceIds] : []
};
}
async function batchUpdateSource(
tab: string,
changes: { enabled?: boolean; useDynamicApi?: boolean }
) {
if (!videoSourcesData) return;
const ids = selectedIds[tab] ?? [];
if (ids.length === 0) {
toast.error('请先勾选要操作的视频源');
return;
}
const sources = getSourcesForTab(tab).filter((source) => ids.includes(source.id));
if (sources.length === 0) {
toast.error('未找到可更新的视频源');
return;
}
batchSaving = true;
try {
const results = await Promise.allSettled(
sources.map((source) =>
api.updateVideoSource(tab, source.id, {
path: source.path,
enabled: changes.enabled ?? source.enabled,
rule: source.rule,
useDynamicApi:
changes.useDynamicApi === undefined
? source.useDynamicApi
: changes.useDynamicApi
})
)
);
let successCount = 0;
let failedCount = 0;
const ruleDisplayMap = new Map<number, string>();
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount += 1;
ruleDisplayMap.set(sources[index].id, result.value.data.ruleDisplay);
} else {
failedCount += 1;
}
});
if (successCount > 0) {
const list = videoSourcesData[tab as keyof VideoSourcesDetailsResponse] as VideoSourceDetail[];
videoSourcesData = {
...videoSourcesData,
[tab]: list.map((item) => {
if (!ids.includes(item.id)) return item;
return {
...item,
enabled: changes.enabled ?? item.enabled,
useDynamicApi:
changes.useDynamicApi === undefined ? item.useDynamicApi : changes.useDynamicApi,
ruleDisplay: ruleDisplayMap.get(item.id) ?? item.ruleDisplay
};
})
};
selectedIds = {
...selectedIds,
[tab]: []
};
}
if (failedCount > 0) {
toast.warning('批量更新部分失败', {
description: `成功 ${successCount} 条,失败 ${failedCount} 条`
});
} else {
toast.success(`批量更新成功,共 ${successCount} 条`);
}
} catch (error) {
toast.error('批量更新失败', {
description: (error as ApiError).message
});
} finally {
batchSaving = false;
}
}
// 打开添加对话框
function openAddDialog(type: 'favorites' | 'collections' | 'submissions') {
addDialogType = type;
@@ -299,9 +410,46 @@
</Tabs.List>
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
{@const sources = getSourcesForTab(key)}
{@const sourceIds = sources.map((source) => source.id)}
{@const selectedCount = (selectedIds[key] ?? []).length}
<Tabs.Content value={key} class="mt-6">
<div class="mb-4 flex items-center justify-between">
<div></div>
<div class="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onclick={() => batchUpdateSource(key, { enabled: true })}
disabled={batchSaving || selectedCount === 0}
>
批量启用
</Button>
<Button
size="sm"
variant="outline"
onclick={() => batchUpdateSource(key, { enabled: false })}
disabled={batchSaving || selectedCount === 0}
>
批量禁用
</Button>
{#if key === 'submissions'}
<Button
size="sm"
variant="outline"
onclick={() => batchUpdateSource(key, { useDynamicApi: true })}
disabled={batchSaving || selectedCount === 0}
>
批量开动态 API
</Button>
<Button
size="sm"
variant="outline"
onclick={() => batchUpdateSource(key, { useDynamicApi: false })}
disabled={batchSaving || selectedCount === 0}
>
批量关动态 API
</Button>
{/if}
</div>
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
<Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2">
<PlusIcon class="h-4 w-4" />
@@ -314,6 +462,19 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[5%]">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
checked={sources.length > 0 && selectedCount === sources.length}
onchange={(event) =>
toggleSelectAll(
key,
sourceIds,
(event.currentTarget as HTMLInputElement).checked
)}
/>
</Table.Head>
<Table.Head class="w-[20%]">名称</Table.Head>
<Table.Head class="w-[30%]">下载路径</Table.Head>
<Table.Head class="w-[15%]">过滤规则</Table.Head>
@@ -324,6 +485,19 @@
<Table.Body>
{#each sources as source, index (index)}
<Table.Row>
<Table.Cell>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
checked={isSelected(key, source.id)}
onchange={(event) =>
toggleSelect(
key,
source.id,
(event.currentTarget as HTMLInputElement).checked
)}
/>
</Table.Cell>
<Table.Cell class="font-medium">{source.name}</Table.Cell>
<Table.Cell>
<div