feat: complete trash restore and batch actions

This commit is contained in:
2026-06-05 12:43:30 +08:00
parent 10b287de7b
commit 1fa009b1e2
10 changed files with 397 additions and 14 deletions
+103 -7
View File
@@ -102,6 +102,9 @@
let linkNote = ''
let linkStatus = ''
let trashInfo = null
let trashCount = 0
let trashSelectedIds = []
let trashFolderId = ''
let showCreateNode = false
let newNodeTitle = ''
let createInNode = null
@@ -237,6 +240,7 @@
loading = false
loadSyncStatus()
refreshTrashCount()
})
onDestroy(() => {
@@ -278,7 +282,7 @@
} else if (id === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || []
} else if (id === 'trash') {
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '' }
await refreshTrash()
} else if (id === 'journal') {
await loadJournal()
} else if (id === 'activity') {
@@ -882,6 +886,7 @@
try {
await wailsCall('DeleteNode', node.id)
await reloadTreePreservingExpanded()
await refreshTrashCount()
if (selectedNode && selectedNode.id === node.id) {
selectedNode = null
}
@@ -1168,6 +1173,73 @@
}
}
async function refreshTrash() {
trashInfo = await wailsCall('ListTrash') || { nodes: [], entries: [], trashPath: '', count: 0 }
trashCount = trashInfo.count || 0
trashSelectedIds = trashSelectedIds.filter(id => (trashInfo.nodes || []).some(node => node.id === id))
if (trashFolderId && !(trashInfo.nodes || []).some(node => node.id === trashFolderId)) {
trashFolderId = ''
}
}
async function refreshTrashCount() {
try { trashCount = await wailsCall('TrashCount') || 0 } catch (e) { trashCount = 0 }
}
function trashVisibleNodes() {
const nodes = trashInfo?.nodes || []
if (!trashFolderId) return nodes.filter(node => !node.parentId || !nodes.some(other => other.id === node.parentId))
return nodes.filter(node => node.parentId === trashFolderId)
}
function toggleTrashSelection(id) {
trashSelectedIds = trashSelectedIds.includes(id)
? trashSelectedIds.filter(existing => existing !== id)
: [...trashSelectedIds, id]
}
function trashSelectionOr(id) {
return trashSelectedIds.length > 0 ? trashSelectedIds : [id]
}
async function restoreTrash(ids) {
try {
await wailsCall('RestoreTrashNodesJSON', JSON.stringify(ids))
await reloadTreePreservingExpanded()
await refreshTrash()
} catch (e) { error = String(e) }
}
async function purgeTrash(ids) {
openConfirm({
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + ids.length + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('PurgeTrashNodesJSON', JSON.stringify(ids))
await refreshTrash()
} catch (e) { error = String(e) }
}
})
}
async function emptyTrash() {
openConfirm({
title: t('delete.confirmTitle'),
message: t('trash.empty') + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('EmptyTrash')
await refreshTrash()
} catch (e) { error = String(e) }
}
})
}
function openSuggestionWorklogModal(s) {
acceptingSuggestion = s
editingWorklogEntry = null
@@ -2116,6 +2188,9 @@
{#if view.id === 'journal' && suggestionCount > 0}
<span class="nav-badge">{suggestionCount}</span>
{/if}
{#if view.id === 'trash' && trashCount > 0}
<span class="nav-badge">{trashCount}</span>
{/if}
</button>
{/each}
</div>
@@ -2654,7 +2729,14 @@
<h2>{t('nav.trash')}</h2>
<p>{trashInfo?.trashPath || ''}</p>
</div>
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
<div class="trash-actions">
{#if trashSelectedIds.length > 0}
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectedIds)}>{t('trash.restore')} ({trashSelectedIds.length})</button>
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectedIds)}>{t('common.delete')} ({trashSelectedIds.length})</button>
{/if}
<button class="btn btn-sm btn-danger" on:click={emptyTrash}>{t('trash.emptyTrash')}</button>
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
</div>
</div>
{#if !trashInfo || ((trashInfo.nodes || []).length === 0 && (trashInfo.entries || []).length === 0)}
<div class="empty-state">
@@ -2667,13 +2749,24 @@
{#if (trashInfo.nodes || []).length === 0}
<p class="trash-empty-line">{t('common.empty')}</p>
{:else}
{#each trashInfo.nodes as node}
<div class="trash-row">
{#if trashFolderId}
<button class="btn btn-sm back-btn" on:click={() => { trashFolderId = ''; trashSelectedIds = [] }}>{t('common.backLabel')}</button>
{/if}
{#each trashVisibleNodes() as node}
<div class="trash-row" class:selected={trashSelectedIds.includes(node.id)}>
<input type="checkbox" checked={trashSelectedIds.includes(node.id)} on:change={() => toggleTrashSelection(node.id)} />
<div class="trash-row-main">
<span class="trash-row-title">{node.title}</span>
<span class="trash-row-meta">{nodeKindLabel(node.type)} · {formatDate(node.deletedAt)}</span>
<span class="trash-row-meta">{node.nodePath || nodeKindLabel(node.type)} · {formatDate(node.deletedAt)}</span>
</div>
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
<div class="trash-row-actions">
{#if node.type !== 'file' && node.type !== 'note'}
<button class="btn btn-sm" on:click={() => { trashFolderId = node.id; trashSelectedIds = [] }}>{t('common.open')}</button>
{/if}
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectionOr(node.id))}>{t('trash.restore')}</button>
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectionOr(node.id))}>{t('common.delete')}</button>
</div>
</div>
{/each}
{/if}
@@ -3527,14 +3620,17 @@
.trash-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.trash-header h2 { margin: 0 0 6px; }
.trash-header p { margin: 0; color: #8888a0; font-size: 12px; }
.trash-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
.trash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.trash-section { min-width: 0; }
.trash-section h3 { margin: 0 0 10px; font-size: 13px; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; }
.trash-row { padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.trash-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; }
.trash-row.selected { border-color: #6366f1; background: #20203a; }
.trash-row-main { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
.trash-row-title { min-width: 0; color: #e4e4ef; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-meta { flex-shrink: 0; color: #8888a0; font-size: 12px; }
.trash-row-path { display: block; margin-top: 4px; color: #707088; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-path { grid-column: 2 / -1; display: block; color: #707088; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
/* Journal screen */
+2
View File
@@ -55,6 +55,8 @@ export default {
'capture.dropOverlayGlobal': 'Will be added to global Inbox',
'trash.openFolder': 'Open trash folder',
'trash.empty': 'Trash is empty',
'trash.emptyTrash': 'Empty trash',
'trash.restore': 'Restore',
'trash.deletedNodes': 'Deleted items',
'trash.physicalEntries': 'Files in .verstak/trash',
'tab.overview': 'Overview',
+2
View File
@@ -58,6 +58,8 @@ export default {
'trash.openFolder': 'Открыть папку корзины',
'trash.empty': 'Корзина пуста',
'trash.emptyTrash': 'Очистить корзину',
'trash.restore': 'Восстановить',
'trash.deletedNodes': 'Удаленные элементы',
'trash.physicalEntries': 'Файлы в .verstak/trash',