mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-10 08:00:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb70b9daa1 |
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -325,18 +324,16 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
|
||||
}
|
||||
|
||||
go func() {
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
taskItem.Log("Failed to create Docker client: " + err.Error())
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
for _, itemPath := range req.Paths {
|
||||
currentPath := itemPath
|
||||
itemName := path.Base(currentPath)
|
||||
taskItem.AddSubTask(i18n.GetWithName("TaskImport", itemName), func(t *task.Task) error {
|
||||
taskItem.Logf("----------------- %s -----------------", itemName)
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
file, err := os.Open(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -347,14 +344,7 @@ func (u *ImageService) ImageLoad(req dto.ImageLoad) error {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(string(content), "Error") {
|
||||
return errors.New(string(content))
|
||||
}
|
||||
return nil
|
||||
return consumeImageLoadResponse(res.Body, taskItem.Log)
|
||||
}, nil)
|
||||
}
|
||||
_ = taskItem.Execute()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/pkg/jsonmessage"
|
||||
)
|
||||
|
||||
// Docker may report load failures in a successful HTTP response's JSON stream.
|
||||
func consumeImageLoadResponse(reader io.Reader, log func(string)) error {
|
||||
decoder := json.NewDecoder(reader)
|
||||
for {
|
||||
var message jsonmessage.JSONMessage
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if message.Error != nil && message.Error.Message != "" {
|
||||
return message.Error
|
||||
}
|
||||
if message.ErrorMessage != "" {
|
||||
return errors.New(message.ErrorMessage)
|
||||
}
|
||||
if text := strings.TrimSpace(message.Stream); text != "" {
|
||||
log(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,8 +144,13 @@ export const imagePull = (params: Container.ImagePull) => {
|
||||
export const imagePush = (params: Container.ImagePush) => {
|
||||
return http.post<string>(`/containers/image/push`, params);
|
||||
};
|
||||
export const imageLoad = (params: Container.ImageLoad) => {
|
||||
return http.post(`/containers/image/load`, params, TimeoutEnum.T_10M);
|
||||
export const imageLoad = (params: Container.ImageLoad, currentNode?: string) => {
|
||||
return http.post(
|
||||
`/containers/image/load`,
|
||||
params,
|
||||
TimeoutEnum.T_10M,
|
||||
currentNode ? { CurrentNode: currentNode } : undefined,
|
||||
);
|
||||
};
|
||||
export const imageSave = (params: Container.ImageSave) => {
|
||||
return http.post(`/containers/image/save`, params, TimeoutEnum.T_10M);
|
||||
|
||||
@@ -570,8 +570,6 @@ const message = {
|
||||
cancelKeepAlive: 'Stop keeping page session',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'User Center',
|
||||
exitSystem: 'Log Out',
|
||||
logout: 'Logout',
|
||||
},
|
||||
database: {
|
||||
@@ -1871,6 +1869,11 @@ const message = {
|
||||
imageName: 'Image name',
|
||||
pull: 'Pull',
|
||||
path: 'Path',
|
||||
imageUploadDrop: 'Drop an image file here, or click to upload',
|
||||
imageUploadHelper:
|
||||
'Any file format is allowed. Docker validates the contents. Uploaded files are retained for retrying imports or manual deletion.',
|
||||
imageUploadEmpty: 'The file is empty. Select a valid image archive.',
|
||||
imageImportSubmitted: 'Image import task submitted. Check the task log for the result.',
|
||||
importImage: 'Import',
|
||||
buildArgs: 'Build Arguments',
|
||||
imageBuild: 'Build',
|
||||
|
||||
@@ -570,8 +570,6 @@ const message = {
|
||||
cancelKeepAlive: 'Dejar de mantener la sesión de la página',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'Centro de usuario',
|
||||
exitSystem: 'Cerrar sesión',
|
||||
logout: 'Cerrar sesión',
|
||||
},
|
||||
database: {
|
||||
@@ -1895,6 +1893,12 @@ const message = {
|
||||
imageName: 'Nombre de la imagen',
|
||||
pull: 'Descargar',
|
||||
path: 'Ruta',
|
||||
imageUploadDrop: 'Arrastre un archivo de imagen aquí o haga clic para subirlo',
|
||||
imageUploadHelper:
|
||||
'Se permite cualquier formato de archivo. Docker valida el contenido. Los archivos subidos se conservan para reintentar la importación o eliminarlos manualmente.',
|
||||
imageUploadEmpty: 'El archivo está vacío. Seleccione un archivo de imagen válido.',
|
||||
imageImportSubmitted:
|
||||
'Tarea de importación de imagen enviada. Consulte el registro de la tarea para ver el resultado.',
|
||||
importImage: 'Importar',
|
||||
buildArgs: 'Argumentos de Construcción',
|
||||
imageBuild: 'Construir',
|
||||
|
||||
@@ -565,8 +565,6 @@ const message = {
|
||||
cancelKeepAlive: 'لغو حفظ نشست صفحه',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'مرکز کاربری',
|
||||
exitSystem: 'خروج از سیستم',
|
||||
logout: 'خروج',
|
||||
},
|
||||
database: {
|
||||
@@ -1855,6 +1853,11 @@ const message = {
|
||||
imageName: 'نام تصویر',
|
||||
pull: 'دریافت',
|
||||
path: 'مسیر',
|
||||
imageUploadDrop: 'فایل ایمیج را اینجا بکشید یا برای بارگذاری کلیک کنید',
|
||||
imageUploadHelper:
|
||||
'همه قالبهای فایل مجاز هستند. Docker محتوای فایل را اعتبارسنجی میکند. فایلهای بارگذاریشده برای تلاش مجدد جهت وارد کردن یا حذف دستی نگه داشته میشوند.',
|
||||
imageUploadEmpty: 'فایل خالی است. یک فایل ایمیج معتبر انتخاب کنید.',
|
||||
imageImportSubmitted: 'وظیفه وارد کردن ایمیج ثبت شد. برای مشاهده نتیجه، گزارش وظیفه را بررسی کنید.',
|
||||
importImage: 'واردات',
|
||||
buildArgs: 'آرگومانهای ساخت',
|
||||
imageBuild: 'ساخت',
|
||||
|
||||
@@ -568,8 +568,6 @@ const message = {
|
||||
cancelKeepAlive: 'ページセッションの保持を解除',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'ユーザーセンター',
|
||||
exitSystem: 'ログアウト',
|
||||
logout: 'ログアウト',
|
||||
},
|
||||
database: {
|
||||
@@ -1852,6 +1850,11 @@ const message = {
|
||||
imageName: '画像名',
|
||||
pull: '引く',
|
||||
path: 'パス',
|
||||
imageUploadDrop: 'イメージファイルをここにドラッグするか、クリックしてアップロードしてください',
|
||||
imageUploadHelper:
|
||||
'ファイル形式に制限はありません。Docker が内容を検証します。アップロードしたファイルは保持されるため、インポートを再試行したり、手動で削除したりできます。',
|
||||
imageUploadEmpty: 'ファイルが空です。有効なイメージファイルを選択してください。',
|
||||
imageImportSubmitted: 'イメージのインポートタスクを送信しました。結果はタスクログで確認してください。',
|
||||
importImage: '輸入',
|
||||
buildArgs: 'ビルド引数',
|
||||
imageBuild: '建てる',
|
||||
|
||||
@@ -562,8 +562,6 @@ const message = {
|
||||
cancelKeepAlive: '페이지 세션 유지 해제',
|
||||
},
|
||||
header: {
|
||||
userCenter: '사용자 센터',
|
||||
exitSystem: '로그아웃',
|
||||
logout: '로그아웃',
|
||||
},
|
||||
database: {
|
||||
@@ -1835,6 +1833,11 @@ const message = {
|
||||
imageName: '이미지 이름',
|
||||
pull: '풀',
|
||||
path: '경로',
|
||||
imageUploadDrop: '이미지 파일을 여기로 끌어 놓거나 클릭하여 업로드하세요',
|
||||
imageUploadHelper:
|
||||
'파일 형식에는 제한이 없습니다. Docker가 내용을 검증합니다. 업로드한 파일은 보관되므로 가져오기를 다시 시도하거나 수동으로 삭제할 수 있습니다.',
|
||||
imageUploadEmpty: '빈 파일입니다. 유효한 이미지 파일을 선택하세요.',
|
||||
imageImportSubmitted: '이미지 가져오기 작업이 제출되었습니다. 작업 로그에서 결과를 확인하세요.',
|
||||
importImage: '가져오기',
|
||||
buildArgs: '빌드 인수',
|
||||
imageBuild: '이미지 빌드',
|
||||
|
||||
@@ -563,8 +563,6 @@ const message = {
|
||||
cancelKeepAlive: 'ຍົກເລີກການຮັກສາເຊັດຊັນໜ້າ',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'ສູນຜູ້ໃຊ້',
|
||||
exitSystem: 'ອອກຈາກລະບົບ',
|
||||
logout: 'ອອກຈາກລະບົບ',
|
||||
},
|
||||
database: {
|
||||
@@ -1824,6 +1822,11 @@ const message = {
|
||||
imageName: 'ຊື່ Image',
|
||||
pull: 'ດຶງຂໍ້ມູນ (Pull)',
|
||||
path: 'ເສັ້ນທາງ',
|
||||
imageUploadDrop: 'ລາກໄຟລ໌ອິມເມຈມາວາງບ່ອນນີ້ ຫຼື ຄລິກເພື່ອອັບໂຫຼດ',
|
||||
imageUploadHelper:
|
||||
'ບໍ່ຈຳກັດຮູບແບບໄຟລ໌. Docker ຈະກວດສອບເນື້ອຫາ. ໄຟລ໌ທີ່ອັບໂຫຼດຈະຖືກເກັບໄວ້ ເພື່ອໃຫ້ສາມາດລອງນຳເຂົ້າອີກຄັ້ງ ຫຼື ລຶບດ້ວຍຕົນເອງ.',
|
||||
imageUploadEmpty: 'ໄຟລ໌ຫວ່າງເປົ່າ. ກະລຸນາເລືອກໄຟລ໌ອິມເມຈທີ່ຖືກຕ້ອງ.',
|
||||
imageImportSubmitted: 'ສົ່ງວຽກນຳເຂົ້າອິມເມຈແລ້ວ. ກະລຸນາເບິ່ງບັນທຶກວຽກເພື່ອກວດສອບຜົນ.',
|
||||
importImage: 'ນຳເຂົ້າ',
|
||||
buildArgs: 'Build Arguments',
|
||||
imageBuild: 'ສ້າງ (Build)',
|
||||
|
||||
@@ -575,8 +575,6 @@ const message = {
|
||||
cancelKeepAlive: 'Hentikan pengekalan sesi halaman',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'Pusat Pengguna',
|
||||
exitSystem: 'Log keluar',
|
||||
logout: 'Log keluar',
|
||||
},
|
||||
database: {
|
||||
@@ -1884,6 +1882,11 @@ const message = {
|
||||
imageName: 'Nama imej',
|
||||
pull: 'Tarik',
|
||||
path: 'Laluan',
|
||||
imageUploadDrop: 'Seret fail imej ke sini atau klik untuk memuat naik',
|
||||
imageUploadHelper:
|
||||
'Semua format fail dibenarkan. Docker mengesahkan kandungannya. Fail yang dimuat naik disimpan untuk mencuba semula import atau dipadam secara manual.',
|
||||
imageUploadEmpty: 'Fail kosong. Pilih arkib imej yang sah.',
|
||||
imageImportSubmitted: 'Tugas import imej telah dihantar. Semak log tugas untuk melihat hasilnya.',
|
||||
importImage: 'Import',
|
||||
buildArgs: 'Argumen Binaan',
|
||||
imageBuild: 'Bina',
|
||||
|
||||
@@ -568,8 +568,6 @@ const message = {
|
||||
cancelKeepAlive: 'Parar de manter a sessão da página',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'Central do usuário',
|
||||
exitSystem: 'Sair',
|
||||
logout: 'Logout',
|
||||
},
|
||||
database: {
|
||||
@@ -1886,6 +1884,11 @@ const message = {
|
||||
imageName: 'Nome da imagem',
|
||||
pull: 'Puxar',
|
||||
path: 'Caminho',
|
||||
imageUploadDrop: 'Arraste um arquivo de imagem para cá ou clique para enviar',
|
||||
imageUploadHelper:
|
||||
'Qualquer formato de arquivo é permitido. O Docker valida o conteúdo. Os arquivos enviados são mantidos para novas tentativas de importação ou exclusão manual.',
|
||||
imageUploadEmpty: 'O arquivo está vazio. Selecione um arquivo de imagem válido.',
|
||||
imageImportSubmitted: 'Tarefa de importação de imagem enviada. Consulte o log da tarefa para ver o resultado.',
|
||||
importImage: 'Importar',
|
||||
buildArgs: 'Argumentos de Build',
|
||||
imageBuild: 'Construção de imagem',
|
||||
|
||||
@@ -566,8 +566,6 @@ const message = {
|
||||
cancelKeepAlive: 'Не сохранять сеанс страницы',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'Центр пользователя',
|
||||
exitSystem: 'Выйти из системы',
|
||||
logout: 'Выход',
|
||||
},
|
||||
database: {
|
||||
@@ -1876,6 +1874,11 @@ const message = {
|
||||
imageName: 'Имя образа',
|
||||
pull: 'Загрузить',
|
||||
path: 'Путь',
|
||||
imageUploadDrop: 'Перетащите файл образа сюда или нажмите для загрузки',
|
||||
imageUploadHelper:
|
||||
'Допускаются файлы любого формата. Docker проверяет содержимое. Загруженные файлы сохраняются для повторного импорта или удаления вручную.',
|
||||
imageUploadEmpty: 'Файл пуст. Выберите корректный архив образа.',
|
||||
imageImportSubmitted: 'Задача импорта образа отправлена. Результат можно посмотреть в журнале задачи.',
|
||||
importImage: 'Импорт',
|
||||
buildArgs: 'Аргументы Сборки',
|
||||
imageBuild: 'Сборка',
|
||||
|
||||
@@ -570,8 +570,6 @@ const message = {
|
||||
cancelKeepAlive: 'Sayfa oturumunu korumayı bırak',
|
||||
},
|
||||
header: {
|
||||
userCenter: 'Kullanıcı Merkezi',
|
||||
exitSystem: 'Çıkış yap',
|
||||
logout: 'Çıkış',
|
||||
},
|
||||
database: {
|
||||
@@ -1885,6 +1883,11 @@ const message = {
|
||||
imageName: 'İmaj adı',
|
||||
pull: 'Çek',
|
||||
path: 'Yol',
|
||||
imageUploadDrop: 'Bir imaj dosyasını buraya sürükleyin veya yüklemek için tıklayın',
|
||||
imageUploadHelper:
|
||||
'Tüm dosya biçimlerine izin verilir. Docker içeriği doğrular. Yüklenen dosyalar, içe aktarmayı yeniden denemek veya elle silmek için saklanır.',
|
||||
imageUploadEmpty: 'Dosya boş. Geçerli bir imaj arşivi seçin.',
|
||||
imageImportSubmitted: 'İmajı içe aktarma görevi gönderildi. Sonuç için görev günlüğünü kontrol edin.',
|
||||
importImage: 'İçe aktar',
|
||||
buildArgs: 'Derleme Argümanları',
|
||||
imageBuild: 'Oluştur',
|
||||
|
||||
@@ -548,8 +548,6 @@ const message = {
|
||||
cancelKeepAlive: '取消保持頁面工作階段',
|
||||
},
|
||||
header: {
|
||||
userCenter: '使用者中心',
|
||||
exitSystem: '登出系統',
|
||||
logout: '登出',
|
||||
},
|
||||
database: {
|
||||
@@ -1775,6 +1773,10 @@ const message = {
|
||||
delInsecureHelper: '刪除授信需要重新啟動 docker 服務,是否刪除?',
|
||||
pull: '拉取',
|
||||
path: '路徑',
|
||||
imageUploadDrop: '拖曳映像檔案至此處,或點擊上傳',
|
||||
imageUploadHelper: '不限制檔案格式,由 Docker 驗證內容。上傳的檔案會保留,可重試匯入或手動刪除。',
|
||||
imageUploadEmpty: '不能上傳空檔案,請選擇有效的映像檔案。',
|
||||
imageImportSubmitted: '映像匯入任務已提交,請查看任務日誌',
|
||||
importImage: '匯入映像',
|
||||
imageBuild: '構建映像',
|
||||
buildArgs: '構建參數',
|
||||
|
||||
@@ -552,8 +552,6 @@ const message = {
|
||||
cancelKeepAlive: '取消保持页面会话',
|
||||
},
|
||||
header: {
|
||||
userCenter: '用户中心',
|
||||
exitSystem: '退出系统',
|
||||
logout: '退出登录',
|
||||
},
|
||||
database: {
|
||||
@@ -1795,6 +1793,10 @@ const message = {
|
||||
delInsecureHelper: '删除授信需要重启 docker 服务,是否删除?',
|
||||
pull: '拉取',
|
||||
path: '路径',
|
||||
imageUploadDrop: '拖拽镜像文件到此处,或点击上传',
|
||||
imageUploadHelper: '不限制文件格式,由 Docker 校验内容。上传的文件会保留,可重试导入或手动删除。',
|
||||
imageUploadEmpty: '不能上传空文件,请选择有效的镜像文件。',
|
||||
imageImportSubmitted: '镜像导入任务已提交,请查看任务日志',
|
||||
importImage: '导入镜像',
|
||||
imageBuild: '构建镜像',
|
||||
buildArgs: '构建参数',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<el-popover
|
||||
v-model:visible="popoverVisible"
|
||||
placement="right-start"
|
||||
placement="right-end"
|
||||
:show-arrow="false"
|
||||
:offset="0"
|
||||
:width="200"
|
||||
@@ -26,6 +26,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="dropdown-menu" v-loading="loading || switchingNode">
|
||||
<div class="dropdown-item" v-if="currentUser" @click="changeUserInfo">
|
||||
<SvgIcon class="icon" iconName="p-gerenzhongxin1" />
|
||||
{{ currentUser.name }}
|
||||
</div>
|
||||
<el-divider class="divider" />
|
||||
|
||||
<div class="dropdown-item" @click="openTask">
|
||||
<SvgIcon class="icon" iconName="p-renwuzhongxin1" />
|
||||
{{ $t('menu.msgCenter') }}
|
||||
@@ -68,6 +74,11 @@
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider class="divider" />
|
||||
<div class="dropdown-item" @click="logout">
|
||||
<SvgIcon class="icon" iconName="p-tuichudenglu3" />
|
||||
{{ $t('commons.login.logout') }}
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
<NodeDrawer
|
||||
@@ -118,7 +129,7 @@ const props = defineProps({
|
||||
|
||||
const defaultNodeLimit = 8;
|
||||
|
||||
const emit = defineEmits(['openTask', 'refresh', 'userChange']);
|
||||
const emit = defineEmits(['openTask', 'refresh']);
|
||||
bus.on('refreshTask', () => {
|
||||
checkTask();
|
||||
});
|
||||
@@ -261,7 +272,6 @@ const openNodeDashboard = () => {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
popoverVisible.value = false;
|
||||
ElMessageBox.confirm(i18n.global.t('commons.msg.sureLogOut'), i18n.global.t('commons.msg.infoTitle'), {
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
@@ -287,17 +297,13 @@ const loadCurrentUser = async (currentNode?: string) => {
|
||||
const authInfo = await syncAuthInfo(currentNode);
|
||||
if (authInfo) {
|
||||
currentUser.value = authInfo;
|
||||
emit('userChange', authInfo);
|
||||
}
|
||||
};
|
||||
const changeUserInfo = () => {
|
||||
popoverVisible.value = false;
|
||||
loadCurrentUser();
|
||||
userInfoRef.value?.openDrawer();
|
||||
};
|
||||
|
||||
defineExpose({ openUserInfo: changeUserInfo, logout });
|
||||
|
||||
onMounted(() => {
|
||||
loadNodes();
|
||||
checkTask();
|
||||
@@ -312,7 +318,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--panel-footer-border);
|
||||
border-top: 1px solid var(--panel-footer-border);
|
||||
height: 48px;
|
||||
.icon {
|
||||
margin-left: 25px;
|
||||
|
||||
@@ -10,14 +10,6 @@
|
||||
<PrimaryMenu />
|
||||
</div>
|
||||
<Logo :isCollapse="isCollapse" />
|
||||
<Collapse
|
||||
ref="nodeSelectorRef"
|
||||
class="node-selector"
|
||||
:version="version"
|
||||
@open-task="openTask"
|
||||
@refresh="search"
|
||||
@user-change="currentUser = $event"
|
||||
/>
|
||||
<el-scrollbar>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
@@ -31,40 +23,7 @@
|
||||
<SubItem :menuList="routerMenus" :level="0" />
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<el-dropdown
|
||||
v-if="currentUser"
|
||||
class="user-dropdown"
|
||||
trigger="click"
|
||||
placement="right-end"
|
||||
:popper-options="{ modifiers: [{ name: 'flip', enabled: false }] }"
|
||||
:show-arrow="false"
|
||||
:hide-on-click="true"
|
||||
@visible-change="userMenuVisible = $event"
|
||||
@command="handleUserCommand"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="user-entry"
|
||||
:class="{ 'is-active': userMenuVisible }"
|
||||
:aria-label="currentUser.name"
|
||||
:title="currentUser.name"
|
||||
>
|
||||
<SvgIcon class="icon" iconName="p-gerenzhongxin1" />
|
||||
<span v-if="!isCollapse" class="user-name">{{ currentUser.name }}</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="user-menu">
|
||||
<el-dropdown-item command="userCenter">
|
||||
<SvgIcon class="user-menu-icon" iconName="p-gerenzhongxin1" />
|
||||
<span>{{ $t('header.userCenter') }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout">
|
||||
<SvgIcon class="user-menu-icon" iconName="p-tuichudenglu3" />
|
||||
<span>{{ $t('header.exitSystem') }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<Collapse :version="version" @open-task="openTask" @refresh="search" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,24 +40,12 @@ import { getSettingBaseInfo } from '@/api/modules/setting';
|
||||
import PrimaryMenu from '@/assets/images/menu-bg.svg?component';
|
||||
import { hasPermissionMetaAccess, hasRouteRoleAccess } from '@/utils/rbac';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import type { Login } from '@/api/interface/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const menuStore = MenuStore();
|
||||
const { currentNode, isAdmin, menuAccordion, permissions } = useGlobalStore();
|
||||
const version = ref();
|
||||
const nodeSelectorRef = ref<InstanceType<typeof Collapse>>();
|
||||
const currentUser = ref<Login.AuthInfo>();
|
||||
const userMenuVisible = ref(false);
|
||||
|
||||
const handleUserCommand = (command: string) => {
|
||||
if (command === 'userCenter') {
|
||||
nodeSelectorRef.value?.openUserInfo();
|
||||
} else if (command === 'logout') {
|
||||
nodeSelectorRef.value?.logout();
|
||||
}
|
||||
};
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route;
|
||||
@@ -377,14 +324,8 @@ watch(
|
||||
height: 100%;
|
||||
background: var(--panel-menu-bg-color) no-repeat top;
|
||||
|
||||
.logo,
|
||||
.node-selector {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.el-scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.el-menu {
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -396,62 +337,4 @@ watch(
|
||||
.ico {
|
||||
height: 20px !important;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
min-width: 160px;
|
||||
|
||||
:deep(.el-dropdown-menu__item) {
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.user-menu-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 25px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--panel-footer-border);
|
||||
background: transparent;
|
||||
color: var(--panel-main-bg-color-1);
|
||||
font-family: inherit;
|
||||
font-size: var(--el-font-size-base);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&.is-active {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-menu-item-bg-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { chunkUploadFileData, type FileUploadRequestConfig } from '@/api/modules/files';
|
||||
|
||||
export const CHUNK_SIZE = 5 * 1024 * 1024;
|
||||
const MAX_CHUNK_RETRIES = 3;
|
||||
|
||||
const shouldRetryChunkUpload = (error: unknown) => {
|
||||
const item = error as {
|
||||
code?: string | number;
|
||||
data?: { retryable?: boolean };
|
||||
response?: { status?: number };
|
||||
};
|
||||
if (item?.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
if (typeof item?.data?.retryable === 'boolean') {
|
||||
return item.data.retryable;
|
||||
}
|
||||
const status = item?.response?.status ?? (typeof item?.code === 'number' ? item.code : undefined);
|
||||
return !status || status === 408 || status === 429 || status >= 500;
|
||||
};
|
||||
|
||||
const waitForChunkRetry = (attempt: number, signal: AbortSignal) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(done, 500 * 2 ** attempt);
|
||||
function done() {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadChunkWithRetry = async (
|
||||
formData: FormData,
|
||||
config: FileUploadRequestConfig,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
let retryCount = 0;
|
||||
while (true) {
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
await chunkUploadFileData(formData, config);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (signal.aborted || retryCount >= MAX_CHUNK_RETRIES || !shouldRetryChunkUpload(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForChunkRetry(retryCount, signal);
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,105 +1,254 @@
|
||||
<template>
|
||||
<DrawerPro v-model="loadVisible" :header="$t('container.importImage')" @close="handleClose" size="small">
|
||||
<el-form @submit.prevent v-loading="loading" ref="formRef" :model="form" label-position="top">
|
||||
<el-form-item :label="$t('container.path')" :rules="Rules.requiredInput" prop="path">
|
||||
<el-input v-model="form.path" @input="handlePathChange">
|
||||
<template #prepend>
|
||||
<el-button icon="Folder" @click="fileRef.acceptParams({ dir: false, multiple: true })" />
|
||||
</template>
|
||||
</el-input>
|
||||
<DrawerPro
|
||||
v-model="loadVisible"
|
||||
:header="$t('container.importImage')"
|
||||
:auto-close="!busy"
|
||||
:confirm-before-close="true"
|
||||
@before-close="beforeClose"
|
||||
size="small"
|
||||
>
|
||||
<el-form @submit.prevent label-position="top">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<el-button :disabled="busy" @click="source = 'local'">
|
||||
{{ $t('database.localUpload') }}
|
||||
</el-button>
|
||||
<el-button :disabled="busy" @click="onServerSelect">
|
||||
{{ $t('database.hostSelect') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form-item v-if="source === 'server'" :label="$t('container.path')">
|
||||
<el-input v-model="form.path" :disabled="busy" @input="handlePathChange" />
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:limit="1"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:disabled="busy"
|
||||
:on-change="onFileChange"
|
||||
:on-exceed="onFileExceed"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">{{ $t('container.imageUploadDrop') }}</div>
|
||||
</el-upload>
|
||||
<p class="input-help">{{ $t('container.imageUploadHelper') }}</p>
|
||||
<template v-if="localFile">
|
||||
<p class="break-all">{{ localFile.name }} ({{ computeSize(localFile.size) }})</p>
|
||||
<el-progress
|
||||
:percentage="uploadPercent"
|
||||
:status="uploadFailed ? 'exception' : uploadedPath ? 'success' : undefined"
|
||||
/>
|
||||
<p v-if="uploadedPath" class="input-help break-all">{{ uploadedPath }}</p>
|
||||
<el-button v-if="uploadFailed" :disabled="busy" @click="uploadLocalFile">
|
||||
{{ $t('commons.button.retry') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<el-button v-if="submittedTaskID" link type="primary" @click="openTaskLog">
|
||||
{{ $t('commons.button.log') }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button :disabled="loading" @click="loadVisible = false">
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:disabled="loading || !form.path.trim() || form.paths.length === 0"
|
||||
type="primary"
|
||||
@click="onSubmit(formRef)"
|
||||
>
|
||||
{{ $t('commons.button.import') }}
|
||||
</el-button>
|
||||
</span>
|
||||
<el-button :disabled="busy" @click="loadVisible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button
|
||||
v-if="source === 'server'"
|
||||
:disabled="busy || !!submittedTaskID || !form.paths.length"
|
||||
:loading="loading"
|
||||
type="primary"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ $t('commons.button.import') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<FileList ref="fileRef" @choose="loadLoadDir" />
|
||||
<TaskLog ref="taskLogRef" width="70%" />
|
||||
<FileList v-if="loadVisible" ref="fileRef" @choose="loadLoadDir" />
|
||||
<TaskLog ref="taskLogRef" width="70%" @close="emit('search')" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FileList from '@/components/file-list/index.vue';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||
import { genFileId, type UploadFile, type UploadInstance, type UploadProps, type UploadRawFile } from 'element-plus';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import i18n from '@/lang';
|
||||
import { ElForm } from 'element-plus';
|
||||
import { imageLoad } from '@/api/modules/container';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { stopChunkUpload } from '@/api/modules/files';
|
||||
import { loadBaseDir } from '@/api/modules/setting';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import { computeSize } from '@/utils/size';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { CHUNK_SIZE, uploadChunkWithRetry } from '@/utils/chunk-upload';
|
||||
|
||||
const { currentNode } = useGlobalStore();
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const busy = computed(() => loading.value || uploading.value);
|
||||
const fileRef = ref();
|
||||
const taskLogRef = ref();
|
||||
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
const loadVisible = ref(false);
|
||||
const form = reactive({
|
||||
path: '',
|
||||
paths: [] as string[],
|
||||
taskID: '',
|
||||
});
|
||||
|
||||
const acceptParams = () => {
|
||||
loadVisible.value = true;
|
||||
form.path = '';
|
||||
form.paths = [];
|
||||
};
|
||||
const handleClose = () => {
|
||||
loadVisible.value = false;
|
||||
};
|
||||
|
||||
const source = ref<'server' | 'local'>('local');
|
||||
const operateNode = ref('');
|
||||
const form = reactive({ path: '', paths: [] as string[] });
|
||||
const localFile = ref<UploadRawFile>();
|
||||
const uploadPercent = ref(0);
|
||||
const uploadFailed = ref(false);
|
||||
const uploadedPath = ref('');
|
||||
const submittedTaskID = ref('');
|
||||
let uploadID = '';
|
||||
let uploadController: AbortController | undefined;
|
||||
let disposed = false;
|
||||
const emit = defineEmits<{ (e: 'search'): void }>();
|
||||
|
||||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const onSubmit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl || !form.path.trim() || form.paths.length === 0) return;
|
||||
formEl.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
form.taskID = newUUID();
|
||||
await imageLoad({ paths: form.paths, taskID: form.taskID })
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
loadVisible.value = false;
|
||||
openTaskLog(form.taskID);
|
||||
emit('search');
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
})
|
||||
.catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
const acceptParams = () => {
|
||||
if (busy.value) return;
|
||||
operateNode.value = currentNode.value;
|
||||
source.value = 'local';
|
||||
form.path = '';
|
||||
form.paths = [];
|
||||
localFile.value = undefined;
|
||||
uploadedPath.value = '';
|
||||
uploadPercent.value = 0;
|
||||
uploadFailed.value = false;
|
||||
submittedTaskID.value = '';
|
||||
uploadRef.value?.clearFiles();
|
||||
loadVisible.value = true;
|
||||
};
|
||||
const beforeClose = (done: () => void) => {
|
||||
if (!busy.value) done();
|
||||
};
|
||||
const onServerSelect = () => {
|
||||
if (busy.value) return;
|
||||
source.value = 'server';
|
||||
fileRef.value.acceptParams({ dir: false, multiple: true });
|
||||
};
|
||||
const onFileChange = (file: UploadFile) => {
|
||||
if (!file.raw || busy.value) return;
|
||||
if (file.raw.size === 0) {
|
||||
MsgError(i18n.global.t('container.imageUploadEmpty'));
|
||||
uploadRef.value?.clearFiles();
|
||||
return;
|
||||
}
|
||||
localFile.value = file.raw;
|
||||
uploadedPath.value = '';
|
||||
submittedTaskID.value = '';
|
||||
void uploadLocalFile();
|
||||
};
|
||||
const onFileExceed: UploadProps['onExceed'] = (files) => {
|
||||
if (busy.value || !files.length) return;
|
||||
uploadRef.value?.clearFiles();
|
||||
const file = files[0] as UploadRawFile;
|
||||
file.uid = genFileId();
|
||||
uploadRef.value?.handleStart(file);
|
||||
};
|
||||
|
||||
const openTaskLog = (taskID: string) => {
|
||||
taskLogRef.value.openWithTaskID(taskID);
|
||||
const uploadLocalFile = async () => {
|
||||
const file = localFile.value;
|
||||
if (!file || busy.value) return;
|
||||
uploading.value = true;
|
||||
uploadFailed.value = false;
|
||||
uploadPercent.value = 0;
|
||||
const node = operateNode.value;
|
||||
const id = newUUID();
|
||||
uploadID = id;
|
||||
const controller = new AbortController();
|
||||
uploadController = controller;
|
||||
try {
|
||||
const { data: baseDir } = await loadBaseDir(node);
|
||||
controller.signal.throwIfAborted();
|
||||
const directory = `${baseDir}/uploads/image/${id}`;
|
||||
const count = Math.ceil(file.size / CHUNK_SIZE);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const data = new FormData();
|
||||
data.append('filename', file.name);
|
||||
data.append('path', directory);
|
||||
data.append('uploadID', id);
|
||||
data.append('chunk', file.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE));
|
||||
data.append('chunkIndex', String(index));
|
||||
data.append('chunkCount', String(count));
|
||||
data.append('offset', String(index * CHUNK_SIZE));
|
||||
data.append('fileSize', String(file.size));
|
||||
await uploadChunkWithRetry(
|
||||
data,
|
||||
{
|
||||
headers: { CurrentNode: node },
|
||||
signal: controller.signal,
|
||||
skipErrorMessage: true,
|
||||
timeout: 0,
|
||||
onUploadProgress: (event: { total?: number; loaded: number }) => {
|
||||
const fraction = event.total ? Math.min(1, event.loaded / event.total) : 0;
|
||||
uploadPercent.value = Math.min(99, Math.floor(((index + fraction) * 100) / count));
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
uploadedPath.value = `${directory}/${file.name}`;
|
||||
uploadPercent.value = 100;
|
||||
} catch {
|
||||
if (!controller.signal.aborted) uploadFailed.value = true;
|
||||
await stopChunkUpload(id, node).catch(() => {});
|
||||
return;
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
uploadController = undefined;
|
||||
uploadID = '';
|
||||
}
|
||||
if (!disposed && currentNode.value === node) await onSubmit();
|
||||
};
|
||||
|
||||
const handlePathChange = () => {
|
||||
if (!form.path.trim()) {
|
||||
form.paths = [];
|
||||
const openTaskLog = () => {
|
||||
taskLogRef.value.openWithTaskID(submittedTaskID.value, true, operateNode.value);
|
||||
};
|
||||
const onSubmit = async () => {
|
||||
const paths = source.value === 'local' ? [uploadedPath.value].filter(Boolean) : [...form.paths];
|
||||
if (busy.value || submittedTaskID.value || !paths.length) return;
|
||||
loading.value = true;
|
||||
const taskID = newUUID();
|
||||
const node = operateNode.value;
|
||||
submittedTaskID.value = taskID;
|
||||
try {
|
||||
await imageLoad({ paths, taskID }, node);
|
||||
if (disposed || currentNode.value !== node) return;
|
||||
loadVisible.value = false;
|
||||
openTaskLog();
|
||||
MsgSuccess(i18n.global.t('container.imageImportSubmitted'));
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadLoadDir = async (paths: string | string[]) => {
|
||||
const newPaths = Array.isArray(paths) ? paths : [paths];
|
||||
form.paths = [...new Set(newPaths)];
|
||||
const handlePathChange = () => {
|
||||
submittedTaskID.value = '';
|
||||
form.paths = [
|
||||
...new Set(
|
||||
form.path
|
||||
.split(';')
|
||||
.map((path) => path.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
};
|
||||
const loadLoadDir = (paths: string | string[]) => {
|
||||
if (currentNode.value !== operateNode.value) return;
|
||||
source.value = 'server';
|
||||
submittedTaskID.value = '';
|
||||
form.paths = [...new Set(Array.isArray(paths) ? paths : [paths])];
|
||||
form.path = form.paths.join('; ');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
watch(currentNode, () => {
|
||||
loadVisible.value = false;
|
||||
uploadController?.abort();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
uploadController?.abort();
|
||||
if (uploadID) void stopChunkUpload(uploadID, operateNode.value).catch(() => {});
|
||||
});
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
|
||||
@@ -121,13 +121,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { UploadFile, UploadFiles, UploadInstance, UploadProps, UploadRawFile } from 'element-plus';
|
||||
import {
|
||||
batchCheckFiles,
|
||||
chunkUploadFileData,
|
||||
stopChunkUpload,
|
||||
uploadFileData,
|
||||
type FileUploadRequestConfig,
|
||||
} from '@/api/modules/files';
|
||||
import { batchCheckFiles, stopChunkUpload, uploadFileData } from '@/api/modules/files';
|
||||
import { CHUNK_SIZE, uploadChunkWithRetry } from '@/utils/chunk-upload';
|
||||
import i18n from '@/lang';
|
||||
import { MsgError, MsgSuccess, MsgWarning } from '@/utils/message';
|
||||
import { Close, Document, UploadFilled } from '@element-plus/icons-vue';
|
||||
@@ -225,8 +220,6 @@ const uploaderFiles = ref<UploadFiles>([]);
|
||||
const hoverIndex = ref<number | null>(null);
|
||||
const tmpFiles = ref<UploadFiles>([]);
|
||||
const breakFlag = ref(false);
|
||||
const CHUNK_SIZE = 1024 * 1024 * 5;
|
||||
const MAX_CHUNK_RETRIES = 3;
|
||||
|
||||
const upload = (command: string) => {
|
||||
if (uploadLocked.value) {
|
||||
@@ -504,54 +497,6 @@ const cleanupActiveChunkUpload = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const shouldRetryChunkUpload = (error: unknown) => {
|
||||
const item = error as {
|
||||
code?: string | number;
|
||||
data?: { retryable?: boolean };
|
||||
response?: { status?: number };
|
||||
};
|
||||
if (item?.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
if (typeof item?.data?.retryable === 'boolean') {
|
||||
return item.data.retryable;
|
||||
}
|
||||
const status = item?.response?.status ?? (typeof item?.code === 'number' ? item.code : undefined);
|
||||
return !status || status === 408 || status === 429 || status >= 500;
|
||||
};
|
||||
|
||||
const waitForChunkRetry = (attempt: number, signal: AbortSignal) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(done, 500 * 2 ** attempt);
|
||||
function done() {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const uploadChunkWithRetry = async (formData: FormData, config: FileUploadRequestConfig, signal: AbortSignal) => {
|
||||
let retryCount = 0;
|
||||
while (true) {
|
||||
try {
|
||||
await chunkUploadFileData(formData, config);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (signal.aborted || retryCount >= MAX_CHUNK_RETRIES || !shouldRetryChunkUpload(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForChunkRetry(retryCount, signal);
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadChunkedFile = async (
|
||||
file: { size: number; raw: Blob; name: string },
|
||||
controller: AbortController,
|
||||
|
||||
Reference in New Issue
Block a user