fix: add localized message for download records not removed (#13757)

This commit is contained in:
2026-09-09 15:33:56 +08:00
committed by GitHub
parent eb0f5264d7
commit 7c1ddb5b4c
20 changed files with 224 additions and 31 deletions

View File

@@ -691,6 +691,28 @@ func (b *BaseApi) StopWget(c *gin.Context) {
helper.Success(c)
}
// @Tags File
// @Summary Remove finished download progress records without deleting files
// @Accept json
// @Param request body request.FileProcessRemoveReq true "request"
// @Success 200 {object} response.FileProcessKeys
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/wget/process/remove [post]
// @x-panel-log {"bodyKeys":["keys"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"移除已结束下载记录 [keys]","formatEN":"Remove finished download records [keys]"}
func (b *BaseApi) RemoveWgetRecords(c *gin.Context) {
var req request.FileProcessRemoveReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
keys, err := files.RemoveDownloadRecords(req.Keys)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, response.FileProcessKeys{Keys: keys})
}
// @Tags File
// @Summary Move file
// @Accept json

View File

@@ -158,6 +158,10 @@ type FileProcessReq struct {
Key string `json:"key"`
}
type FileProcessRemoveReq struct {
Keys []string `json:"keys" validate:"required,min=1,max=1000"`
}
type FileRoleUpdate struct {
Path string `json:"path" validate:"required"`
User string `json:"user" validate:"required"`

View File

@@ -3503,6 +3503,13 @@
"formatZH": "下载 url =\u003e [path]/[name]",
"formatEN": "Download url =\u003e [path]/[name]"
},
"/files/wget/process/remove": {
"bodyKeys": ["keys"],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "移除已结束下载记录 [keys]",
"formatEN": "Remove finished download records [keys]"
},
"/files/wget/stop": {
"bodyKeys": [
"key"

View File

@@ -43,6 +43,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
fileRouter.POST("/rename", baseApi.ChangeFileName)
fileRouter.POST("/wget", baseApi.WgetFile)
fileRouter.POST("/wget/stop", baseApi.StopWget)
fileRouter.POST("/wget/process/remove", baseApi.RemoveWgetRecords)
fileRouter.POST("/move", baseApi.MoveFile)
fileRouter.POST("/move/stop", baseApi.StopMoveFile)
fileRouter.GET("/download", baseApi.Download)

View File

@@ -612,10 +612,50 @@ func CancelDownload(key string) error {
return task.cleanupErr
}
func RemoveDownloadRecords(keys []string) ([]string, error) {
if len(keys) == 0 || len(keys) > 1000 {
return nil, errors.New("between 1 and 1000 download keys are required")
}
for _, key := range keys {
if !strings.HasPrefix(key, "file-wget-") || len(key) <= len("file-wget-") || len(key) > 128 {
return nil, errors.New("invalid download key")
}
}
downloadMu.Lock()
defer downloadMu.Unlock()
removed := make([]string, 0, len(keys))
seen := make(map[string]bool, len(keys))
for _, key := range keys {
if seen[key] {
continue
}
seen[key] = true
if _, active := downloadTasks[key]; active {
continue
}
value := global.CACHE.Get(key)
if value == "" {
removed = append(removed, key)
continue
}
var process Process
if err := json.Unmarshal([]byte(value), &process); err != nil {
continue
}
terminal := process.Status == "Success" || process.Status == "Failed" || process.Status == "Canceled"
legacySuccess := process.Status == "" && process.Percent == 100
if !terminal && !legacySuccess {
continue
}
global.CACHE.Del(key)
removed = append(removed, key)
}
return removed, nil
}
func downloadErrorDetail(err error) string {
var urlErr *url.Error
if errors.As(err, &urlErr) {
// Signed URLs and proxy credentials must not appear in progress messages or logs.
return urlErr.Err.Error()
}
return err.Error()

View File

@@ -3503,6 +3503,13 @@
"formatZH": "下载 url =\u003e [path]/[name]",
"formatEN": "Download url =\u003e [path]/[name]"
},
"/files/wget/process/remove": {
"bodyKeys": ["keys"],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "移除已结束下载记录 [keys]",
"formatEN": "Remove finished download records [keys]"
},
"/files/wget/stop": {
"bodyKeys": [
"key"

View File

@@ -154,6 +154,10 @@ export const stopWgetFile = (key: string, currentNode?: string) => {
return http.post('files/wget/stop', { key }, undefined, currentNode ? { CurrentNode: currentNode } : undefined);
};
export const removeWgetRecords = (keys: string[], currentNode: string) => {
return http.post<File.FileKeys>('files/wget/process/remove', { keys }, undefined, { CurrentNode: currentNode });
};
export const moveFile = (params: File.FileMove) => {
return http.post<File.File>('files/move', params);
};

View File

@@ -2494,6 +2494,7 @@ const message = {
downloadProcess: 'Download progress',
downloading: 'Downloading...',
stopWgetConfirm: 'Are you sure you want to stop this download task?',
downloadRecordsNotRemoved: 'Some records were not removed. Refresh and try again.',
infoDetail: 'File properties',
root: 'Root directory',
list: 'File list',

View File

@@ -2696,6 +2696,7 @@ const message = {
panelInstallDir: 'El directorio de instalación de 1Panel no puede eliminarse',
wgetTask: 'Tarea de descarga',
stopWgetConfirm: '¿Confirmar que desea detener esta tarea de descarga?',
downloadRecordsNotRemoved: 'No se eliminaron algunos registros. Actualice e inténtelo de nuevo.',
existFileTitle: 'Archivo con el mismo nombre',
existFileHelper: 'El archivo cargado contiene un archivo con el mismo nombre, ¿desea sobrescribirlo?',
existFileSize: 'Tamaño del archivo (nuevo -> viejo)',

View File

@@ -2471,6 +2471,7 @@ const message = {
downloadProcess: 'پیشرفت دانلود',
downloading: 'در حال دانلود...',
stopWgetConfirm: 'آیا مطمئن هستید که می‌خواهید این وظیفه دانلود را متوقف کنید؟',
downloadRecordsNotRemoved: 'برخی رکوردها حذف نشدند. صفحه را تازه‌سازی کرده و دوباره تلاش کنید.',
infoDetail: 'ویژگی‌های فایل',
root: 'دایرکتوری ریشه',
list: 'لیست فایل',

View File

@@ -2636,6 +2636,7 @@ const message = {
panelInstallDir: '1Panelインストールディレクトリは削除できません',
wgetTask: 'ダウンロードタスク',
stopWgetConfirm: 'このダウンロードタスクを停止しますか',
downloadRecordsNotRemoved: '一部の記録を削除できませんでした更新して再試行してください',
existFileTitle: '同名ファイルの警告',
existFileHelper: 'アップロードしたファイルに同じ名前のファイルが含まれています上書きしますか',
existFileSize: 'ファイルサイズ新しい -> 古い',

View File

@@ -2599,6 +2599,7 @@ const message = {
panelInstallDir: '1Panel 설치 디렉터리는 삭제할 수 없습니다.',
wgetTask: '다운로드 작업',
stopWgetConfirm: '이 다운로드 작업을 중지하시겠습니까?',
downloadRecordsNotRemoved: '일부 기록을 제거하지 못했습니다. 새로 고침 후 다시 시도하세요.',
existFileTitle: '동일한 이름의 파일 경고',
existFileHelper: '업로드한 파일에 동일한 이름의 파일이 포함되어 있습니다. 덮어쓰시겠습니까?',
existFileSize: '파일 크기 (새로운 -> 오래된)',

View File

@@ -2427,6 +2427,7 @@ const message = {
downloadProcess: 'ຄວາມຄືບໜ້າການດາວໂຫຼດ',
downloading: 'ກຳລັງດາວໂຫຼດ...',
stopWgetConfirm: 'ທ່ານແນ່ໃຈບໍວ່າຕ້ອງການຢຸດງານດາວໂຫຼດນີ້?',
downloadRecordsNotRemoved: 'ບາງບັນທຶກບໍ່ຖືກລຶບ. ກະລຸນາໂຫຼດໃໝ່ ແລະລອງອີກຄັ້ງ.',
infoDetail: 'ຄຸນສົມບັດໄຟລ໌',
root: 'ໄດເຣັກທໍຣີຮາກ (Root)',
list: 'ລາຍການໄຟລ໌',

View File

@@ -2695,6 +2695,7 @@ const message = {
panelInstallDir: 'Direktori pemasangan 1Panel tidak boleh dipadamkan',
wgetTask: 'Tugas Muat Turun',
stopWgetConfirm: 'Adakah anda pasti mahu menghentikan tugas muat turun ini?',
downloadRecordsNotRemoved: 'Sesetengah rekod tidak dibuang. Muat semula dan cuba lagi.',
existFileTitle: 'Amaran fail dengan nama yang sama',
existFileHelper: 'Fail yang dimuat naik mengandungi fail dengan nama yang sama. Adakah anda mahu menimpanya?',
existFileSize: 'Saiz fail (baru -> lama)',

View File

@@ -2694,6 +2694,7 @@ const message = {
panelInstallDir: 'O diretório de instalação do 1Panel não pode ser excluído',
wgetTask: 'Tarefa de Download',
stopWgetConfirm: 'Tem certeza de que deseja parar esta tarefa de download?',
downloadRecordsNotRemoved: 'Alguns registros não foram removidos. Atualize e tente novamente.',
existFileTitle: 'Aviso de arquivo com o mesmo nome',
existFileHelper: 'O arquivo enviado contém um arquivo com o mesmo nome. Deseja substituí-lo?',
existFileSize: 'Tamanho do arquivo (novo -> antigo)',

View File

@@ -2669,6 +2669,7 @@ const message = {
panelInstallDir: 'Директорию установки 1Panel нельзя удалить',
wgetTask: 'Задача загрузки',
stopWgetConfirm: 'Вы уверены, что хотите остановить эту задачу загрузки?',
downloadRecordsNotRemoved: 'Некоторые записи не удалены. Обновите страницу и повторите попытку.',
existFileTitle: 'Предупреждение о файле с тем же именем',
existFileHelper: 'Загруженный файл содержит файл с таким же именем. Заменить его?',
existFileSize: 'Размер файла (новый -> старый)',

View File

@@ -2684,6 +2684,7 @@ const message = {
panelInstallDir: '1Panel kurulum dizini silinemez',
wgetTask: 'İndirme Görevi',
stopWgetConfirm: 'Bu indirme görevini durdurmak istediğinizden emin misiniz?',
downloadRecordsNotRemoved: 'Bazı kayıtlar kaldırılamadı. Yenileyip tekrar deneyin.',
existFileTitle: 'Aynı ada sahip dosya uyarısı',
existFileHelper: 'Yüklenen dosya, aynı ada sahip bir dosya içeriyor, üzerine yazmak istiyor musunuz?',
existFileSize: 'Dosya boyutu (yeni -> eski)',

View File

@@ -2358,6 +2358,7 @@ const message = {
downloading: '正在下載...',
infoDetail: '檔案屬性',
stopWgetConfirm: '確認停止該下載任務?',
downloadRecordsNotRemoved: '部分紀錄未移除,請重新整理後重試。',
root: '根目錄',
list: '檔案列表',
sub: '子目錄',

View File

@@ -2388,6 +2388,7 @@ const message = {
downloadProcess: '下载进度',
downloading: '正在下载...',
stopWgetConfirm: '确认停止该下载任务?',
downloadRecordsNotRemoved: '部分记录未移除,请刷新后重试。',
infoDetail: '文件属性',
root: '根目录',
list: '文件列表',

View File

@@ -1,7 +1,7 @@
<template>
<DialogPro v-model="open" :title="$t('file.downloadProcess')" size="small" @close="handleClose">
<template #content>
<div class="space-y-4 p-4" :loading="loading">
<div v-loading="loading" class="space-y-4 p-4 min-h-[160px]">
<div
v-for="value in res"
:key="value.key"
@@ -9,8 +9,10 @@
:class="{ completed: getStatus(value) === 'Success' }"
>
<div class="flex items-center gap-3">
<div class="flex-1">
<MsgInfo :info="value.name" width="300" class="text-gray-700" />
<div class="flex-1 min-w-0">
<el-tooltip :content="value.name" placement="top">
<div class="truncate text-gray-700">{{ value.name }}</div>
</el-tooltip>
<div class="text-gray-500">
{{ getStatusText(value) }}
</div>
@@ -63,10 +65,9 @@
</template>
<script lang="ts" setup>
import { fileWgetKeys, stopWgetFile } from '@/api/modules/files';
import { fileWgetKeys, stopWgetFile, removeWgetRecords } from '@/api/modules/files';
import { computeSize } from '@/utils/size';
import { onBeforeUnmount, ref, watch } from 'vue';
import MsgInfo from '@/components/msg-info/index.vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { ElMessageBox } from 'element-plus';
import { MsgError, MsgSuccess } from '@/utils/message';
@@ -90,6 +91,11 @@ interface DownloadProcess {
const res = ref<DownloadProcess[]>([]);
const stoppingKeys = ref<string[]>([]);
const removingKeys = ref<string[]>([]);
const removedKeys = ref<string[]>([]);
const reportedFailures = new Set<string>();
const reportedSuccesses = new Set<string>();
const autoRemoveAttempts = new Map<string, number>();
const keys = ref(['']);
const open = ref(false);
const loading = ref(false);
@@ -98,6 +104,7 @@ const em = defineEmits(['close']);
const handleClose = () => {
initProcessToken++;
closeSocket();
loading.value = false;
open.value = false;
em('close', open.value);
};
@@ -114,14 +121,21 @@ const clearSendTimer = () => {
const closeSocket = () => {
clearSendTimer();
if (processSocket) {
processSocket.onopen = null;
processSocket.onmessage = null;
processSocket.onerror = null;
processSocket.onclose = null;
processSocket.close();
}
processSocket = null;
};
const onOpenProcess = () => {};
const onMessage = (message: any) => {
const onOpenProcess = () => {
sendProgressRequest();
sendMsg();
};
const onMessage = async (message: any) => {
const token = initProcessToken;
let processes: DownloadProcess[];
try {
processes = JSON.parse(message.data) || [];
@@ -129,19 +143,94 @@ const onMessage = (message: any) => {
return;
}
if (!Array.isArray(processes)) return;
res.value = processes.map((value, index) => ({
...value,
key: value.key || (processes.length === keys.value.length ? keys.value[index] : `legacy:${value.name}`),
}));
if (res.value.every((value) => !isActive(value))) {
closeSocket();
loading.value = false;
res.value = processes
.map((value, index) => ({
...value,
key: value.key || (processes.length === keys.value.length ? keys.value[index] : `legacy:${value.name}`),
}))
.filter((value) => !removedKeys.value.includes(value.key));
if (open.value) {
const failures = res.value.filter((value) => getStatus(value) === 'Failed' && !reportedFailures.has(value.key));
if (failures.length > 0) {
failures.forEach((value) => reportedFailures.add(value.key));
MsgError(failures.map((value) => `${value.name}: ${value.error || getStatusText(value)}`).join('\n'));
}
const successes = res.value.filter(
(value) => getStatus(value) === 'Success' && !reportedSuccesses.has(value.key),
);
if (successes.length > 0) {
successes.forEach((value) => reportedSuccesses.add(value.key));
MsgSuccess(successes.map((value) => `${value.name}: ${getStatusText(value)}`).join('\n'));
}
await onRemove(getAutoRemoveKeys());
}
if (token !== initProcessToken) return;
closeIdleSocket();
};
const onerror = () => {
if (open.value && loading.value) {
MsgError(i18n.global.t('commons.msg.operationFailed'));
handleClose();
}
};
const onerror = () => {};
const onClose = () => {};
const onClose = () => {
clearSendTimer();
onerror();
};
const getStatus = (value: DownloadProcess) => value.status || (value.percent === 100 ? 'Success' : 'Downloading');
const isActive = (value: DownloadProcess) => ['Downloading', 'Retrying'].includes(getStatus(value));
const isRemovable = (value: DownloadProcess) =>
keys.value.includes(value.key) && ['Success', 'Failed', 'Canceled'].includes(getStatus(value));
const getFinishedKeys = () => res.value.filter(isRemovable).map((value) => value.key);
const getAutoRemoveKeys = () =>
res.value
.filter((value) => isRemovable(value) && (autoRemoveAttempts.get(value.key) || 0) < 3)
.map((value) => value.key);
const closeIdleSocket = () => {
if (open.value && res.value.length === 0 && removingKeys.value.length === 0) {
keys.value = [];
handleClose();
return;
}
if (res.value.every((value) => !isActive(value)) && getAutoRemoveKeys().length === 0) closeSocket();
};
const onRemove = async (requestedKeys: string[]) => {
if (removingKeys.value.length > 0) return;
const removable = getFinishedKeys();
const selected = [...new Set(requestedKeys.filter((key) => removable.includes(key)))];
if (selected.length === 0) return;
const node = globalCurrentNode.value;
const token = initProcessToken;
removingKeys.value = selected;
try {
for (let offset = 0; offset < selected.length; offset += 1000) {
if (node !== globalCurrentNode.value || token !== initProcessToken || !open.value) return;
const batch = selected.slice(offset, offset + 1000);
batch.forEach((key) => autoRemoveAttempts.set(key, (autoRemoveAttempts.get(key) || 0) + 1));
const response = await removeWgetRecords(batch, node);
if (node !== globalCurrentNode.value || token !== initProcessToken || !open.value) return;
const removed = (response.data?.keys || []).filter((key) => batch.includes(key));
removedKeys.value.push(...removed);
res.value = res.value.filter((value) => !removed.includes(value.key));
keys.value = keys.value.filter((key) => !removed.includes(key));
closeIdleSocket();
if (removed.length !== batch.length) {
if (batch.some((key) => (autoRemoveAttempts.get(key) || 0) >= 3)) {
MsgError(i18n.global.t('file.downloadRecordsNotRemoved'));
}
return;
}
}
} catch (error) {
} finally {
if (token === initProcessToken) {
removingKeys.value = [];
closeIdleSocket();
}
}
};
const getProgressPercent = (value: DownloadProcess) => {
if (getStatus(value) === 'Success') return 100;
if (!Number.isFinite(value.percent)) return 0;
@@ -171,7 +260,7 @@ const getProgressStatus = (value: DownloadProcess) => {
};
const initProcess = async () => {
const token = ++initProcessToken;
const token = initProcessToken;
let href = window.location.href;
let protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss';
let ipLocal = href.split('//')[1].split('/')[0];
@@ -183,6 +272,7 @@ const initProcess = async () => {
}
if (authError) {
MsgError(authError);
handleClose();
return;
}
closeSocket();
@@ -191,39 +281,40 @@ const initProcess = async () => {
processSocket.onmessage = onMessage;
processSocket.onerror = onerror;
processSocket.onclose = onClose;
sendMsg();
};
const getKeys = async () => {
const token = ++initProcessToken;
keys.value = [];
res.value = [];
removingKeys.value = [];
removedKeys.value = [];
reportedFailures.clear();
reportedSuccesses.clear();
autoRemoveAttempts.clear();
loading.value = true;
try {
const res = await fileWgetKeys();
if (token !== initProcessToken || !open.value) return;
if (res.data?.keys?.length > 0) {
keys.value = res.data.keys;
initProcess();
await initProcess();
} else {
handleClose();
}
} catch (error) {
} finally {
loading.value = false;
if (token === initProcessToken && open.value) handleClose();
}
};
const sendProgressRequest = () => {
if (isWsOpen()) {
processSocket?.send(JSON.stringify({ type: 'wget', keys: keys.value }));
}
};
const sendMsg = () => {
clearSendTimer();
sendTimer = setInterval(() => {
if (isWsOpen()) {
processSocket?.send(
JSON.stringify({
type: 'wget',
keys: keys.value,
}),
);
}
}, 1000);
sendTimer = setInterval(sendProgressRequest, 1000);
};
const getFileSize = (size: number) => {
@@ -260,6 +351,11 @@ watch(globalCurrentNode, () => {
handleClose();
keys.value = [];
res.value = [];
removingKeys.value = [];
removedKeys.value = [];
reportedFailures.clear();
reportedSuccesses.clear();
autoRemoveAttempts.clear();
});
onBeforeUnmount(() => {