feat(push): 暂存文件支持批量删除
推送页暂存列表增加多选、全选与批量删除确认,复用 local-file-ops 逐条删除。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,3 +36,4 @@ cd frontend && npm run build
|
||||
- **普通文件上传**(`/sync/upload-files`)不限制后缀,`.zip` 等按原文件写入暂存;需解压时用「ZIP 解压上传」
|
||||
- 预览:≤1MB 文件,最多 4KB 内容
|
||||
- 删除/重命名后刷新列表并更新顶部文件数/大小
|
||||
- **批量删除**:勾选多项 →「删除选中」;表头全选当前目录
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
<template>
|
||||
<v-card elevation="0" border rounded="lg" class="mb-4">
|
||||
<v-card-title class="d-flex align-center text-subtitle-1">
|
||||
<v-card-title class="d-flex align-center text-subtitle-1 flex-wrap ga-2">
|
||||
暂存文件
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="selectedCount > 0"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
:loading="deleteLoading"
|
||||
@click="$emit('batch-delete')"
|
||||
>
|
||||
删除选中 ({{ selectedCount }})
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="$emit('refresh')">
|
||||
刷新
|
||||
</v-btn>
|
||||
@@ -17,6 +28,16 @@
|
||||
<v-table v-else density="compact" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 44px">
|
||||
<v-checkbox-btn
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
:disabled="!entries.length"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="$emit('toggle-select-all', $event)"
|
||||
/>
|
||||
</th>
|
||||
<th>名称</th>
|
||||
<th class="text-end" style="width: 100px">大小</th>
|
||||
<th class="text-end" style="width: 160px">操作</th>
|
||||
@@ -24,16 +45,24 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="relativePath">
|
||||
<td colspan="3">
|
||||
<td colspan="4">
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-arrow-up" @click="$emit('go-root')">
|
||||
返回根目录
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!entries.length">
|
||||
<td colspan="3" class="text-medium-emphasis text-center py-4">目录为空</td>
|
||||
<td colspan="4" class="text-medium-emphasis text-center py-4">目录为空</td>
|
||||
</tr>
|
||||
<tr v-for="entry in entries" :key="entry.name">
|
||||
<td @click.stop>
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(entry.name)"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="$emit('toggle-select', entry.name, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
class="d-flex align-center"
|
||||
@@ -90,16 +119,29 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog :model-value="deleteOpen" max-width="420" @update:model-value="$emit('update:deleteOpen', $event)">
|
||||
<v-dialog :model-value="deleteOpen" max-width="480" @update:model-value="$emit('update:deleteOpen', $event)">
|
||||
<v-card border>
|
||||
<v-card-title>确认删除</v-card-title>
|
||||
<v-card-text>
|
||||
确定删除「{{ deleteTarget?.name }}」?{{ deleteTarget?.is_dir ? '将删除整个目录及其内容。' : '' }}
|
||||
<template v-if="deleteTargets.length === 1">
|
||||
确定删除「{{ deleteTargets[0].name }}」?{{ deleteTargets[0].is_dir ? '将删除整个目录及其内容。' : '' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mb-2">确定删除以下 {{ deleteTargets.length }} 项?</div>
|
||||
<ul class="delete-target-list pl-4 mb-0">
|
||||
<li v-for="item in deleteTargets.slice(0, 8)" :key="item.name">
|
||||
{{ item.name }}{{ item.is_dir ? '(目录)' : '' }}
|
||||
</li>
|
||||
<li v-if="deleteTargets.length > 8">… 等共 {{ deleteTargets.length }} 项</li>
|
||||
</ul>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('update:deleteOpen', false)">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" :loading="deleteLoading" @click="$emit('confirm-delete')">删除</v-btn>
|
||||
<v-btn color="error" variant="flat" :loading="deleteLoading" @click="$emit('confirm-delete')">
|
||||
{{ deleteTargets.length > 1 ? `删除 ${deleteTargets.length} 项` : '删除' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -114,11 +156,15 @@ defineProps<{
|
||||
relativePath: string
|
||||
entries: StagingEntry[]
|
||||
loading: boolean
|
||||
selectedCount: number
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
isSelected: (name: string) => boolean
|
||||
renameOpen: boolean
|
||||
renameValue: string
|
||||
renameLoading: boolean
|
||||
deleteOpen: boolean
|
||||
deleteTarget: StagingEntry | null
|
||||
deleteTargets: StagingEntry[]
|
||||
deleteLoading: boolean
|
||||
}>()
|
||||
|
||||
@@ -129,6 +175,9 @@ defineEmits<{
|
||||
preview: [entry: StagingEntry]
|
||||
rename: [entry: StagingEntry]
|
||||
delete: [entry: StagingEntry]
|
||||
'batch-delete': []
|
||||
'toggle-select': [name: string, selected: boolean]
|
||||
'toggle-select-all': [selected: boolean]
|
||||
'confirm-rename': []
|
||||
'confirm-delete': []
|
||||
'update:renameOpen': [value: boolean]
|
||||
@@ -141,4 +190,9 @@ defineEmits<{
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delete-target-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,11 +32,47 @@ export function usePushStaging(uploadedSource: Ref<UploadedSource | null>) {
|
||||
const renameValue = ref('')
|
||||
const renameLoading = ref(false)
|
||||
const deleteOpen = ref(false)
|
||||
const deleteTarget = ref<StagingEntry | null>(null)
|
||||
const deleteTargets = ref<StagingEntry[]>([])
|
||||
const deleteLoading = ref(false)
|
||||
const selectedNames = ref<Set<string>>(new Set())
|
||||
|
||||
const stagingRoot = () => uploadedSource.value?.sourcePath ?? ''
|
||||
|
||||
const selectedCount = computed(() => selectedNames.value.size)
|
||||
|
||||
const allSelected = computed(() => {
|
||||
const list = entries.value
|
||||
return list.length > 0 && list.every(e => selectedNames.value.has(e.name))
|
||||
})
|
||||
|
||||
const someSelected = computed(() => selectedNames.value.size > 0 && !allSelected.value)
|
||||
|
||||
function clearSelection() {
|
||||
selectedNames.value = new Set()
|
||||
}
|
||||
|
||||
function isSelected(name: string): boolean {
|
||||
return selectedNames.value.has(name)
|
||||
}
|
||||
|
||||
function toggleSelect(name: string, selected: boolean) {
|
||||
const next = new Set(selectedNames.value)
|
||||
if (selected) {
|
||||
next.add(name)
|
||||
} else {
|
||||
next.delete(name)
|
||||
}
|
||||
selectedNames.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll(selected: boolean) {
|
||||
if (!selected) {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
selectedNames.value = new Set(entries.value.map(e => e.name))
|
||||
}
|
||||
|
||||
const relativePath = computed(() => {
|
||||
const root = stagingRoot()
|
||||
const cur = currentPath.value
|
||||
@@ -70,6 +106,7 @@ export function usePushStaging(uploadedSource: Ref<UploadedSource | null>) {
|
||||
const res = await http.post<BrowseLocalResponse>('/sync/browse-local', { path })
|
||||
currentPath.value = res.path
|
||||
entries.value = res.entries ?? []
|
||||
clearSelection()
|
||||
} catch (e: unknown) {
|
||||
entries.value = []
|
||||
snackbar(e instanceof Error ? e.message : '加载暂存目录失败', 'error')
|
||||
@@ -123,28 +160,50 @@ export function usePushStaging(uploadedSource: Ref<UploadedSource | null>) {
|
||||
}
|
||||
|
||||
function openDelete(entry: StagingEntry) {
|
||||
deleteTarget.value = entry
|
||||
deleteTargets.value = [entry]
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
function openBatchDelete() {
|
||||
const targets = entries.value.filter(e => selectedNames.value.has(e.name))
|
||||
if (!targets.length) {
|
||||
snackbar('请先选择要删除的项', 'warning')
|
||||
return
|
||||
}
|
||||
deleteTargets.value = targets
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const entry = deleteTarget.value
|
||||
if (!entry || !currentPath.value) {
|
||||
const targets = deleteTargets.value
|
||||
if (!targets.length || !currentPath.value) {
|
||||
deleteOpen.value = false
|
||||
return
|
||||
}
|
||||
deleteLoading.value = true
|
||||
let ok = 0
|
||||
let fail = 0
|
||||
try {
|
||||
await http.post('/sync/local-file-ops', {
|
||||
operation: 'delete',
|
||||
path: joinHostPath(currentPath.value, entry.name),
|
||||
})
|
||||
snackbar('已删除')
|
||||
for (const entry of targets) {
|
||||
try {
|
||||
await http.post('/sync/local-file-ops', {
|
||||
operation: 'delete',
|
||||
path: joinHostPath(currentPath.value, entry.name),
|
||||
})
|
||||
ok++
|
||||
} catch {
|
||||
fail++
|
||||
}
|
||||
}
|
||||
deleteOpen.value = false
|
||||
clearSelection()
|
||||
await loadDirectory(currentPath.value)
|
||||
await refreshStagingStats()
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '删除失败', 'error')
|
||||
if (fail > 0) {
|
||||
snackbar(`已删除 ${ok} 项,${fail} 项失败`, 'warning')
|
||||
} else {
|
||||
snackbar(targets.length > 1 ? `已删除 ${ok} 项` : '已删除')
|
||||
}
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
@@ -175,6 +234,7 @@ export function usePushStaging(uploadedSource: Ref<UploadedSource | null>) {
|
||||
} else {
|
||||
entries.value = []
|
||||
currentPath.value = ''
|
||||
clearSelection()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -196,10 +256,18 @@ export function usePushStaging(uploadedSource: Ref<UploadedSource | null>) {
|
||||
renameLoading,
|
||||
renameTarget,
|
||||
openDelete,
|
||||
openBatchDelete,
|
||||
confirmDelete,
|
||||
deleteOpen,
|
||||
deleteLoading,
|
||||
deleteTarget,
|
||||
deleteTargets,
|
||||
selectedCount,
|
||||
allSelected,
|
||||
someSelected,
|
||||
isSelected,
|
||||
toggleSelect,
|
||||
toggleSelectAll,
|
||||
clearSelection,
|
||||
previewFile,
|
||||
previewOpen,
|
||||
previewLoading,
|
||||
|
||||
@@ -23,11 +23,15 @@
|
||||
:relative-path="staging.relativePath"
|
||||
:entries="staging.entries"
|
||||
:loading="staging.loading"
|
||||
:selected-count="staging.selectedCount"
|
||||
:all-selected="staging.allSelected"
|
||||
:some-selected="staging.someSelected"
|
||||
:is-selected="staging.isSelected"
|
||||
v-model:rename-open="staging.renameOpen"
|
||||
v-model:rename-value="staging.renameValue"
|
||||
:rename-loading="staging.renameLoading"
|
||||
v-model:delete-open="staging.deleteOpen"
|
||||
:delete-target="staging.deleteTarget"
|
||||
:delete-targets="staging.deleteTargets"
|
||||
:delete-loading="staging.deleteLoading"
|
||||
@refresh="staging.refreshCurrent()"
|
||||
@go-root="staging.goRoot"
|
||||
@@ -35,6 +39,9 @@
|
||||
@preview="staging.previewFile"
|
||||
@rename="staging.openRename"
|
||||
@delete="staging.openDelete"
|
||||
@batch-delete="staging.openBatchDelete"
|
||||
@toggle-select="staging.toggleSelect"
|
||||
@toggle-select-all="staging.toggleSelectAll"
|
||||
@confirm-rename="staging.confirmRename"
|
||||
@confirm-delete="staging.confirmDelete"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user