同步功能更新

This commit is contained in:
Yeomai committed 2026-10-18 16:50:55 +08:00
1 parent 21f0dc2830
commit 1d2d16e710
11 files changed
+268 -2

No files matched your search

+23
View File
@@ -2,6 +2,7 @@
import type { FileItem, FileListResponse } from '~/types/file' import type { FileItem, FileListResponse } from '~/types/file'
import type { LibraryStatsData } from '~/types/media' import type { LibraryStatsData } from '~/types/media'
import { formatFileSize } from '~/composables/useFileSize' import { formatFileSize } from '~/composables/useFileSize'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** 文件库(file)与视频库结构一致,保留参数以便复用 */ /** 文件库(file)与视频库结构一致,保留参数以便复用 */
@@ -32,6 +33,7 @@ const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([]) const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false) const deleting = ref(false)
const batchTagOpen = ref(false) const batchTagOpen = ref(false)
const syncOpen = ref(false)
const currentBackend = ref('all') const currentBackend = ref('all')
const currentSource = ref('all') const currentSource = ref('all')
@@ -42,6 +44,7 @@ const activeUntaggedOnly = ref(false)
const { viewMode } = useLibraryView(props.kind) const { viewMode } = useLibraryView(props.kind)
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
const pageText = computed(() => props.kind === 'video' const pageText = computed(() => props.kind === 'video'
? { ? {
@@ -263,6 +266,10 @@ function openBatchTags() {
batchTagOpen.value = true batchTagOpen.value = true
} }
function openSync() {
syncOpen.value = true
}
function requestDelete(item: FileItem) { function requestDelete(item: FileItem) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
deleteTargetKeys.value = [item.key] deleteTargetKeys.value = [item.key]
@@ -471,6 +478,16 @@ defineExpose({ refresh })
:label="t('library.batchDelete')" :label="t('library.batchDelete')"
@click="requestBatchDelete" @click="requestBatchDelete"
/> />
<UButton
v-if="selectedCount > 0"
icon="i-lucide-cloud-upload"
variant="outline"
color="neutral"
size="sm"
:disabled="!selectedCount || loading"
:label="t('storage.backup.syncSelectedAction')"
@click="openSync"
/>
<div class="flex gap-0.5 rounded-lg border border-default p-0.5"> <div class="flex gap-0.5 rounded-lg border border-default p-0.5">
<UButton <UButton
icon="i-lucide-layout-grid" icon="i-lucide-layout-grid"
@@ -700,5 +717,11 @@ defineExpose({ refresh })
:selected-count="selectedCount" :selected-count="selectedCount"
@confirm="confirmBatchTags" @confirm="confirmBatchTags"
/> />
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="file"
/>
</div> </div>
</template> </template>
+23
View File
@@ -2,6 +2,7 @@
import type { FileItem, FileListResponse } from '~/types/file' import type { FileItem, FileListResponse } from '~/types/file'
import type { LibraryStatsData } from '~/types/media' import type { LibraryStatsData } from '~/types/media'
import { formatFileSize } from '~/composables/useFileSize' import { formatFileSize } from '~/composables/useFileSize'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
const { isAuthenticated, handleAuthError } = useAuth() const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast() const toast = useToast()
@@ -24,6 +25,7 @@ const stats = ref<LibraryStatsData | null>(null)
const selectedKeys = ref<Set<string>>(new Set()) const selectedKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false) const deleteModalOpen = ref(false)
const batchTagOpen = ref(false) const batchTagOpen = ref(false)
const syncOpen = ref(false)
const deleteTargetKeys = ref<string[]>([]) const deleteTargetKeys = ref<string[]>([])
const deleting = ref(false) const deleting = ref(false)
const previewOpen = ref(false) const previewOpen = ref(false)
@@ -40,6 +42,7 @@ const activeUntaggedOnly = ref(false)
const { viewMode } = useLibraryView('video') const { viewMode } = useLibraryView('video')
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
const backendItems = computed(() => [ const backendItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' }, { label: t('stats.filterStorageAll'), value: 'all' },
@@ -246,6 +249,10 @@ function openBatchTags() {
batchTagOpen.value = true batchTagOpen.value = true
} }
function openSync() {
syncOpen.value = true
}
async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') { async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
const keys = [...selectedKeys.value] const keys = [...selectedKeys.value]
if (!keys.length || !tagIds.length) return if (!keys.length || !tagIds.length) return
@@ -470,6 +477,16 @@ defineExpose({ refresh })
:label="t('library.batchDelete')" :label="t('library.batchDelete')"
@click="requestBatchDelete" @click="requestBatchDelete"
/> />
<UButton
v-if="selectedCount > 0"
icon="i-lucide-cloud-upload"
variant="outline"
color="neutral"
size="sm"
:disabled="!selectedCount || loading"
:label="t('storage.backup.syncSelectedAction')"
@click="openSync"
/>
<div class="flex gap-0.5 rounded-lg border border-default p-0.5"> <div class="flex gap-0.5 rounded-lg border border-default p-0.5">
<UButton <UButton
icon="i-lucide-layout-grid" icon="i-lucide-layout-grid"
@@ -743,6 +760,12 @@ defineExpose({ refresh })
@confirm="confirmBatchTags" @confirm="confirmBatchTags"
/> />
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="video"
/>
<VideoPreviewModal <VideoPreviewModal
v-model:open="previewOpen" v-model:open="previewOpen"
:item="previewItem" :item="previewItem"
@@ -14,11 +14,13 @@ const props = withDefaults(defineProps<{
loading?: boolean loading?: boolean
showBatchDelete?: boolean showBatchDelete?: boolean
showBatchTags?: boolean showBatchTags?: boolean
showBatchSync?: boolean
}>(), { }>(), {
selectedCount: 0, selectedCount: 0,
loading: false, loading: false,
showBatchDelete: true, showBatchDelete: true,
showBatchTags: false, showBatchTags: false,
showBatchSync: false,
tagIds: () => [], tagIds: () => [],
tags: () => [], tags: () => [],
tagMode: 'or', tagMode: 'or',
@@ -37,6 +39,7 @@ const emit = defineEmits<{
'reset': [] 'reset': []
'batch-delete': [] 'batch-delete': []
'batch-tags': [] 'batch-tags': []
'batch-sync': []
'manage-tags': [] 'manage-tags': []
}>() }>()
@@ -155,6 +158,20 @@ function viewButtonClass(active: boolean) {
{{ t('stats.batchDelete') }} {{ t('stats.batchDelete') }}
</UButton> </UButton>
<UButton
v-if="showBatchSync"
type="button"
icon="i-lucide-cloud-upload"
size="sm"
variant="outline"
color="neutral"
class="shrink-0"
:disabled="!selectedCount || loading"
@click="emit('batch-sync')"
>
{{ t('storage.backup.syncSelectedAction') }}
</UButton>
<div <div
class="inline-flex shrink-0 overflow-hidden rounded-md border border-default" class="inline-flex shrink-0 overflow-hidden rounded-md border border-default"
role="group" role="group"
+27
View File
@@ -28,6 +28,14 @@ const targetBaseUrl = ref('')
const token = ref('') const token = ref('')
const conflictMode = ref<'skip' | 'overwrite'>('skip') const conflictMode = ref<'skip' | 'overwrite'>('skip')
const direction = ref<SiteSyncDirection>('push') const direction = ref<SiteSyncDirection>('push')
const scope = ref<'all' | 'image' | 'video' | 'file'>('all')
const scopeItems = computed(() => [
{ value: 'all' as const, label: t('storage.backup.siteSyncScopeAll') },
{ value: 'image' as const, label: t('storage.backup.siteSyncScopeImage') },
{ value: 'video' as const, label: t('storage.backup.siteSyncScopeVideo') },
{ value: 'file' as const, label: t('storage.backup.siteSyncScopeFile') }
])
watch(() => props.resumableJob, (job) => { watch(() => props.resumableJob, (job) => {
if (!job) return if (!job) return
@@ -99,6 +107,7 @@ function submit(resume = false) {
token: token.value.trim(), token: token.value.trim(),
conflictMode: conflictMode.value, conflictMode: conflictMode.value,
direction: direction.value, direction: direction.value,
scope: scope.value,
...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {}) ...(resume && props.resumableJob ? { resumeJobId: props.resumableJob.id } : {})
}) })
token.value = '' token.value = ''
@@ -132,6 +141,10 @@ function setConflictMode(mode: 'skip' | 'overwrite') {
function setDirection(value: SiteSyncDirection) { function setDirection(value: SiteSyncDirection) {
direction.value = value direction.value = value
} }
function setScope(value: 'all' | 'image' | 'video' | 'file') {
scope.value = value
}
</script> </script>
<template> <template>
@@ -247,6 +260,20 @@ function setDirection(value: SiteSyncDirection) {
</div> </div>
</div> </div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncContent') }}</span>
<UButton
v-for="item in scopeItems"
:key="item.value"
size="xs"
:variant="scope === item.value ? 'solid' : 'ghost'"
:color="scope === item.value ? 'primary' : 'neutral'"
:label="item.label"
:disabled="busy || disabled"
@click="setScope(item.value)"
/>
</div>
<UAlert <UAlert
v-if="activeJob" v-if="activeJob"
class="mt-4" class="mt-4"
@@ -0,0 +1,119 @@
<script setup lang="ts">
const props = defineProps<{
open: boolean
keys: string[]
scope: 'image' | 'video' | 'file'
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const { t } = useI18n()
const { startSiteSync } = useBackup()
const targetBaseUrl = ref('')
const token = ref('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const busy = ref(false)
const canSubmit = computed(() =>
!busy.value
&& props.keys.length > 0
&& Boolean(targetBaseUrl.value.trim())
&& Boolean(token.value.trim())
)
function setConflict(mode: 'skip' | 'overwrite') {
conflictMode.value = mode
}
async function submit() {
if (!canSubmit.value) return
busy.value = true
try {
await startSiteSync({
targetBaseUrl: targetBaseUrl.value.trim(),
token: token.value.trim(),
conflictMode: conflictMode.value,
direction: 'push',
scope: props.scope,
keys: props.keys
})
emit('update:open', false)
} catch {
// useBackup 已提示错误
} finally {
busy.value = false
}
}
</script>
<template>
<UModal
:open="open"
:title="t('storage.backup.syncSelectedTitle')"
:description="t('storage.backup.syncSelectedSubtitle', { n: keys.length })"
@update:open="emit('update:open', $event)"
>
<template #body>
<div class="space-y-3">
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncTarget') }}</label>
<UInput
v-model="targetBaseUrl"
placeholder="https://target.example.com"
size="sm"
class="w-full"
/>
</div>
<div class="space-y-1.5">
<label class="text-xs text-muted">{{ t('storage.backup.siteSyncToken') }}</label>
<UInput
v-model="token"
type="password"
:placeholder="t('storage.backup.siteSyncTokenPlaceholder')"
size="sm"
class="w-full"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-muted">{{ t('storage.backup.siteSyncConflict') }}</span>
<UButton
size="xs"
:variant="conflictMode === 'skip' ? 'solid' : 'ghost'"
:color="conflictMode === 'skip' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncSkip')"
@click="setConflict('skip')"
/>
<UButton
size="xs"
:variant="conflictMode === 'overwrite' ? 'solid' : 'ghost'"
:color="conflictMode === 'overwrite' ? 'primary' : 'neutral'"
:label="t('storage.backup.siteSyncOverwrite')"
@click="setConflict('overwrite')"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="emit('update:open', false)"
/>
<UButton
:label="t('storage.backup.syncSelectedStart')"
icon="i-lucide-cloud-upload"
color="primary"
:loading="busy"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</UModal>
</template>
+15
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImageItem } from '~/types/image' import type { ImageItem } from '~/types/image'
import SiteSyncSelectedDialog from '~/components/storage/SiteSyncSelectedDialog.vue'
interface StatsResponse { interface StatsResponse {
uploadToday: number uploadToday: number
@@ -74,6 +75,7 @@ const previewOpen = ref(false)
const previewImage = ref<ImageItem | null>(null) const previewImage = ref<ImageItem | null>(null)
const { viewMode } = useLibraryView('image') const { viewMode } = useLibraryView('image')
const batchTagOpen = ref(false) const batchTagOpen = ref(false)
const syncOpen = ref(false)
const currentUploadSource = ref('all') const currentUploadSource = ref('all')
const currentStorageBackend = ref('all') const currentStorageBackend = ref('all')
@@ -93,6 +95,7 @@ const storageBackendItems = computed(() => [
]) ])
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const syncKeys = computed(() => [...selectedKeys.value])
function onWindowScroll() { function onWindowScroll() {
showScrollTop.value = window.scrollY > 400 showScrollTop.value = window.scrollY > 400
@@ -266,6 +269,10 @@ function goToTagSettings() {
void navigateTo({ path: '/settings', query: { tab: 'tags' } }) void navigateTo({ path: '/settings', query: { tab: 'tags' } })
} }
function openSync() {
syncOpen.value = true
}
function updateSelectedKeys(keys: Set<string>) { function updateSelectedKeys(keys: Set<string>) {
selectedKeys.value = keys selectedKeys.value = keys
} }
@@ -495,6 +502,7 @@ watch(isAuthenticated, async (authed, prev) => {
:loading="galleryLoading" :loading="galleryLoading"
:show-batch-delete="true" :show-batch-delete="true"
:show-batch-tags="selectedCount > 0" :show-batch-tags="selectedCount > 0"
:show-batch-sync="selectedCount > 0"
@update:storage-backend="handleStorageBackendChange" @update:storage-backend="handleStorageBackendChange"
@update:upload-source="handleUploadSourceChange" @update:upload-source="handleUploadSourceChange"
@update:tag-ids="handleTagIdsChange" @update:tag-ids="handleTagIdsChange"
@@ -505,6 +513,7 @@ watch(isAuthenticated, async (authed, prev) => {
@reset="handleResetFilters" @reset="handleResetFilters"
@batch-delete="requestBatchDelete" @batch-delete="requestBatchDelete"
@batch-tags="batchTagOpen = true" @batch-tags="batchTagOpen = true"
@batch-sync="openSync"
@manage-tags="goToTagSettings" @manage-tags="goToTagSettings"
/> />
</div> </div>
@@ -591,6 +600,12 @@ watch(isAuthenticated, async (authed, prev) => {
@confirm="confirmBatchTags" @confirm="confirmBatchTags"
/> />
<SiteSyncSelectedDialog
v-model:open="syncOpen"
:keys="syncKeys"
scope="image"
/>
<UButton <UButton
v-show="showScrollTop" v-show="showScrollTop"
icon="i-lucide-arrow-up" icon="i-lucide-arrow-up"
+4
View File
@@ -60,6 +60,10 @@ export interface SiteSyncRequest {
token: string token: string
conflictMode: 'skip' | 'overwrite' conflictMode: 'skip' | 'overwrite'
direction: SiteSyncDirection direction: SiteSyncDirection
/** 同步内容:全站或某一类媒体 */
scope?: 'all' | 'image' | 'video' | 'file'
/** 指定资源(同一类型) */
keys?: string[]
resumeJobId?: string resumeJobId?: string
abandonJobId?: string abandonJobId?: string
} }
+9
View File
@@ -702,6 +702,15 @@
"siteSyncPush": "Push to target", "siteSyncPush": "Push to target",
"siteSyncPull": "Pull from target", "siteSyncPull": "Pull from target",
"siteSyncConflict": "Conflict policy:", "siteSyncConflict": "Conflict policy:",
"siteSyncContent": "Content:",
"siteSyncScopeAll": "Everything",
"siteSyncScopeImage": "Images only",
"siteSyncScopeVideo": "Videos only",
"siteSyncScopeFile": "Files only",
"syncSelectedTitle": "Sync selected",
"syncSelectedSubtitle": "Push the {n} selected item(s) to another PicHost",
"syncSelectedStart": "Start sync",
"syncSelectedAction": "Sync to site",
"siteSyncSkip": "Skip existing", "siteSyncSkip": "Skip existing",
"siteSyncOverwrite": "Overwrite existing", "siteSyncOverwrite": "Overwrite existing",
"siteSyncResume": "Resume last sync", "siteSyncResume": "Resume last sync",
+9
View File
@@ -702,6 +702,15 @@
"siteSyncPush": "推送到目标站点", "siteSyncPush": "推送到目标站点",
"siteSyncPull": "从目标站点拉取", "siteSyncPull": "从目标站点拉取",
"siteSyncConflict": "冲突策略:", "siteSyncConflict": "冲突策略:",
"siteSyncContent": "同步内容:",
"siteSyncScopeAll": "全站",
"siteSyncScopeImage": "仅图库",
"siteSyncScopeVideo": "仅视频库",
"siteSyncScopeFile": "仅文件库",
"syncSelectedTitle": "同步选中资源",
"syncSelectedSubtitle": "将选中的 {n} 项推送到另一台 PicHost",
"syncSelectedStart": "开始同步",
"syncSelectedAction": "同步给目标站点",
"siteSyncSkip": "跳过已存在", "siteSyncSkip": "跳过已存在",
"siteSyncOverwrite": "覆盖已存在", "siteSyncOverwrite": "覆盖已存在",
"siteSyncResume": "继续上次同步", "siteSyncResume": "继续上次同步",
+9 -1
View File
@@ -12,6 +12,8 @@ interface SiteSyncBody {
token?: string token?: string
conflictMode?: 'skip' | 'overwrite' conflictMode?: 'skip' | 'overwrite'
direction?: 'push' | 'pull' direction?: 'push' | 'pull'
scope?: 'all' | 'image' | 'video' | 'file'
keys?: string[]
resumeJobId?: string resumeJobId?: string
abandonJobId?: string abandonJobId?: string
} }
@@ -49,7 +51,13 @@ export default defineEventHandler(async (event) => {
job = startSiteSyncJob({ job = startSiteSyncJob({
targetBaseUrl: targetUrl.toString(), targetBaseUrl: targetUrl.toString(),
conflictMode, conflictMode,
direction direction,
scope: body?.scope === 'image' || body?.scope === 'video' || body?.scope === 'file'
? body.scope
: 'all',
...(Array.isArray(body?.keys) && body.keys.length
? { keys: body.keys.filter(key => typeof key === 'string') }
: {})
}, token, user.id) }, token, user.id)
} }
return { job: serializeBackupJob(job) } return { job: serializeBackupJob(job) }
+13 -1
View File
@@ -58,8 +58,14 @@ export interface SiteSyncOptions {
targetBaseUrl: string targetBaseUrl: string
conflictMode: 'skip' | 'overwrite' conflictMode: 'skip' | 'overwrite'
direction: SiteSyncDirection direction: SiteSyncDirection
/** 同步内容:全站或某一类媒体 */
scope?: SiteSyncScope
/** 指定资源(与 scope 互斥,限定同一类型) */
keys?: string[]
} }
export type SiteSyncScope = 'all' | 'image' | 'video' | 'file'
const tokens = (globalThis as typeof globalThis & { const tokens = (globalThis as typeof globalThis & {
__pichostSiteSyncTokens?: Map<string, string> __pichostSiteSyncTokens?: Map<string, string>
}).__pichostSiteSyncTokens ?? new Map<string, string>() }).__pichostSiteSyncTokens ?? new Map<string, string>()
@@ -948,7 +954,13 @@ export async function runSiteSyncJob(
const targetUsers = new Set(inventory.users) const targetUsers = new Set(inventory.users)
const targetMedia = new Set(inventory.mediaKeys) const targetMedia = new Set(inventory.mediaKeys)
const targetObjects = new Map(inventory.objects.map(item => [item.key, item.size])) const targetObjects = new Map(inventory.objects.map(item => [item.key, item.size]))
const rows = listAllImageIndexRows() const allRows = listAllImageIndexRows()
const wantedKeys = new Set(options.keys ?? [])
const rows = allRows.filter((row) => {
if (wantedKeys.size) return wantedKeys.has(row.key)
const scope = options.scope ?? 'all'
return scope === 'all' || row.media_type === scope
})
const ownerMap = listUserIdUsernameMap() const ownerMap = listUserIdUsernameMap()
const job = getBackupJob(jobId) const job = getBackupJob(jobId)
const checkpoint = job?.payload ?? {} const checkpoint = job?.payload ?? {}