feat(push): 暂存文件支持批量删除

推送页暂存列表增加多选、全选与批量删除确认,复用 local-file-ops 逐条删除。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-08 02:05:33 +08:00
parent 2b016f91af
commit 814262bd83
4 changed files with 150 additions and 20 deletions
@@ -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>
+80 -12
View File
@@ -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,
+8 -1
View File
@@ -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"
/>