Recycled files action

This commit is contained in:
2025-07-27 12:30:13 +08:00
parent e7e6c258e2
commit 4c0e0b5ee9
5 changed files with 192 additions and 11 deletions

View File

@@ -8,8 +8,8 @@
value-field="id"
label-field="name"
:placeholder="props.placeholder || 'Select a file pool to upload'"
:size="props.size || 'large'"
clearable
size="large"
/>
</template>
@@ -29,6 +29,7 @@ import { formatBytes } from '@/views/format'
const props = defineProps<{
modelValue: string | null
placeholder?: string | undefined
size?: 'tiny' | 'small' | 'medium' | 'large' | undefined
}>()
const emit = defineEmits(['update:modelValue', 'update:pool'])

View File

@@ -1,12 +1,30 @@
<template>
<section class="h-full px-5 py-4">
<div class="flex flex-col gap-3 mb-3">
<div class="flex items-center gap-4 mb-3">
<file-pool-select
v-model="filePool"
placeholder="Filter by file pool"
size="medium"
class="max-w-[480px]"
@update:pool="fetchFiles"
/>
<div class="flex items-center gap-2.5">
<n-switch size="large" v-model:value="showRecycled">
<template #checked>Recycled</template>
<template #unchecked>Unrecycled</template>
</n-switch>
<n-button
@click="askDeleteRecycledFiles"
v-if="showRecycled"
type="error"
circle
size="small"
>
<n-icon>
<delete-sweep-round />
</n-icon>
</n-button>
</div>
</div>
<n-data-table
remote
@@ -32,6 +50,8 @@ import {
useDialog,
useMessage,
useLoadingBar,
NSwitch,
NTooltip,
} from 'naive-ui'
import {
AudioFileRound,
@@ -39,8 +59,9 @@ import {
VideoFileRound,
FileDownloadOutlined,
DeleteRound,
DeleteSweepRound,
} from '@vicons/material'
import { h, onMounted, ref } from 'vue'
import { h, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { formatBytes } from '../format'
import FilePoolSelect from '@/components/FilePoolSelect.vue'
@@ -48,7 +69,9 @@ import FilePoolSelect from '@/components/FilePoolSelect.vue'
const router = useRouter()
const files = ref<any[]>([])
const filePool = ref<string | null>(null)
const showRecycled = ref(false)
const tableColumns: DataTableColumns<any> = [
{
@@ -98,6 +121,28 @@ const tableColumns: DataTableColumns<any> = [
return formatBytes(row.size)
},
},
{
title: 'Pool',
key: 'pool',
render(row: any) {
return h(
NTooltip,
{},
{
default: () => h('span', row.pool.id),
trigger: () => h('span', row.pool.name),
},
)
},
},
{
title: 'Expired At',
key: 'expired_at',
render(row: any) {
if (!row.expired_at) return 'Keep-alive'
return new Date(row.expired_at).toLocaleString()
},
},
{
title: 'Uploaded At',
key: 'created_at',
@@ -156,7 +201,7 @@ async function fetchFiles() {
loading.value = true
const pag = tablePagination.value
const response = await fetch(
`/api/files/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}${filePool.value ? '&pool=' + filePool.value : ''}`,
`/api/files/me?take=${pag.pageSize}&offset=${(pag.page! - 1) * pag.pageSize!}&recycled=${showRecycled.value}${filePool.value ? '&pool=' + filePool.value : ''}`,
)
if (!response.ok) {
throw new Error('Network response was not ok')
@@ -172,6 +217,12 @@ async function fetchFiles() {
}
onMounted(() => fetchFiles())
watch(showRecycled, () => {
tablePagination.value.itemCount = 0
tablePagination.value.page = 1
fetchFiles()
})
function handlePageChange(page: number) {
tablePagination.value.page = page
fetchFiles()
@@ -183,10 +234,10 @@ const dialog = useDialog()
const messageDialog = useMessage()
const loadingBar = useLoadingBar()
async function askDeleteFile(file: any) {
function askDeleteFile(file: any) {
dialog.warning({
title: 'Confirm',
content: `Are you sure you want delete ${file.name}?`,
content: `Are you sure you want delete ${file.name}? This will delete the stored file data immediately, there is no return.`,
positiveText: 'Sure',
negativeText: 'Not Sure',
draggable: true,
@@ -214,4 +265,37 @@ async function deleteFile(file: any) {
messageDialog.error('Failed to delete file: ' + (error as Error).message)
}
}
function askDeleteRecycledFiles() {
dialog.warning({
title: 'Confirm',
content: `Are you sure you want to delete all ${tablePagination.value.itemCount} marked recycled file(s) by system?`,
positiveText: 'Sure',
negativeText: 'Not Sure',
draggable: true,
onPositiveClick: () => {
deleteRecycledFiles()
},
})
}
async function deleteRecycledFiles() {
try {
loadingBar.start()
const response = await fetch('/api/files/me/recycle', {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
const resp = await response.json()
tablePagination.value.page = 1
await fetchFiles()
loadingBar.finish()
messageDialog.success(`Recycled files deleted successfully, deleted count: ${resp.count}`)
} catch (error) {
loadingBar.error()
messageDialog.error('Failed to delete recycled files: ' + (error as Error).message)
}
}
</script>

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
@@ -136,6 +137,7 @@ public class FileController(
[HttpGet("me")]
public async Task<ActionResult<List<CloudFile>>> GetMyFiles(
[FromQuery] Guid? pool,
[FromQuery] bool recycled = false,
[FromQuery] int offset = 0,
[FromQuery] int take = 20
)
@@ -144,6 +146,7 @@ public class FileController(
var accountId = Guid.Parse(currentUser.Id);
var query = db.Files
.Where(e => e.IsMarkedRecycle == recycled)
.Where(e => e.AccountId == accountId)
.Include(e => e.Pool)
.OrderByDescending(e => e.CreatedAt)
@@ -182,4 +185,24 @@ public class FileController(
return NoContent();
}
[Authorize]
[HttpDelete("me/recycle")]
public async Task<ActionResult> DeleteMyRecycledFiles()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var count = await fs.DeleteAccountRecycledFilesAsync(accountId);
return Ok(new { Count = count });
}
[Authorize]
[HttpDelete("recycle")]
[RequiredPermission("maintenance", "files.delete.recycle")]
public async Task<ActionResult> DeleteAllRecycledFiles()
{
var count = await fs.DeleteAllRecycledFilesAsync();
return Ok(new { Count = count });
}
}

View File

@@ -7,7 +7,7 @@ namespace DysonNetwork.Drive.Storage;
[ApiController]
[Route("/api/pools")]
public class FilePoolController(AppDatabase db) : ControllerBase
public class FilePoolController(AppDatabase db, FileService fs) : ControllerBase
{
[HttpGet]
[Authorize]
@@ -28,4 +28,19 @@ public class FilePoolController(AppDatabase db) : ControllerBase
return Ok(pools);
}
[Authorize]
[HttpDelete("{id:guid}/recycle")]
public async Task<ActionResult> DeleteFilePoolRecycledFiles(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var pool = await fs.GetPoolAsync(id);
if (pool is null) return NotFound();
if (!currentUser.IsSuperuser && pool.AccountId != accountId) return Unauthorized();
var count = await fs.DeletePoolRecycledFilesAsync(id);
return Ok(new { Count = count });
}
}

View File

@@ -370,7 +370,7 @@ public class FileService(
if (File.Exists(thumbnailPath))
{
uploads.Add((thumbnailPath, ".thumbnail.webp", "image/webp", true));
uploads.Add((thumbnailPath, ".thumbnail", "image/webp", true));
hasThumbnail = true;
}
else
@@ -548,7 +548,7 @@ public class FileService(
await DeleteFileDataAsync(file);
}
public async Task DeleteFileDataAsync(CloudFile file)
private async Task DeleteFileDataAsync(CloudFile file)
{
if (file.StorageId is null) return;
if (!file.PoolId.HasValue) return;
@@ -581,7 +581,6 @@ public class FileService(
if (file.HasCompression)
{
// Also remove the compressed version if it exists
try
{
await client.RemoveObjectAsync(
@@ -594,6 +593,20 @@ public class FileService(
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
}
}
if (file.HasThumbnail)
{
try
{
await client.RemoveObjectAsync(
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".thumbnail")
);
}
catch
{
// Ignore errors when deleting thumbnail
logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
}
}
}
public async Task<FilePool?> GetPoolAsync(Guid destination)
@@ -735,6 +748,51 @@ public class FileService(
if (fieldName.EndsWith("-data")) return true;
return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
}
public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId)
{
var files = await db.Files
.Where(f => f.AccountId == accountId && f.IsMarkedRecycle)
.ToListAsync();
var count = files.Count;
var tasks = files.Select(DeleteFileDataAsync);
await Task.WhenAll(tasks);
var fileIds = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIds);
db.RemoveRange(files);
await db.SaveChangesAsync();
return count;
}
public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId)
{
var files = await db.Files
.Where(f => f.PoolId == poolId && f.IsMarkedRecycle)
.ToListAsync();
var count = files.Count;
var tasks = files.Select(DeleteFileDataAsync);
await Task.WhenAll(tasks);
var fileIds = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIds);
db.RemoveRange(files);
await db.SaveChangesAsync();
return count;
}
public async Task<int> DeleteAllRecycledFilesAsync()
{
var files = await db.Files
.Where(f => f.IsMarkedRecycle)
.ToListAsync();
var count = files.Count;
var tasks = files.Select(DeleteFileDataAsync);
await Task.WhenAll(tasks);
var fileIds = files.Select(f => f.Id).ToList();
await _PurgeCacheRangeAsync(fileIds);
db.RemoveRange(files);
await db.SaveChangesAsync();
return count;
}
}
/// <summary>