feat: add batch controls for video sources and bump version to 2.10.4
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 驱动的哔哩哔哩同步工具"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# bili-sync 是什么?
|
||||
|
||||
> [!TIP]
|
||||
> 当前最新程序版本为 v2.10.3,文档将始终与最新程序版本保持一致。
|
||||
> 当前最新程序版本为 v2.10.4,文档将始终与最新程序版本保持一致。
|
||||
|
||||
bili-sync 是一款专为 NAS 用户编写的哔哩哔哩同步工具。
|
||||
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user