diff --git a/agent/app/api/v2/file.go b/agent/app/api/v2/file.go index 9afb2b1817df..15a7df0c171c 100644 --- a/agent/app/api/v2/file.go +++ b/agent/app/api/v2/file.go @@ -901,3 +901,44 @@ func (b *BaseApi) GetUsersAndGroups(c *gin.Context) { } helper.SuccessWithData(c, res) } + +// @Tags File +// @Summary Convert file +// @Accept json +// @Param request body request.FileConvert true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /files/convert [post] +func (b *BaseApi) ConvertFile(c *gin.Context) { + var req request.FileConvertRequest + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + fileService.Convert(req) + helper.SuccessWithData(c, nil) +} + +// @Tags File +// @Summary Convert file +// @Accept json +// @Param request body dto.PageInfo true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /files/convert/log [post] +func (b *BaseApi) ConvertLog(c *gin.Context) { + var req dto.PageInfo + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + total, logs, err := fileService.ConvertLog(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, dto.PageResult{ + Items: logs, + Total: total, + }) +} diff --git a/agent/app/dto/request/file.go b/agent/app/dto/request/file.go index 78a982b3c8be..6b8100b1c05b 100644 --- a/agent/app/dto/request/file.go +++ b/agent/app/dto/request/file.go @@ -148,3 +148,19 @@ type FileExistReq struct { Name string `json:"name" validate:"required"` Dir string `json:"dir" validate:"required"` } + +type FileConvert struct { + Path string `json:"path" validate:"required"` + Type string `json:"type" validate:"required"` + InputFile string `json:"inputFile" validate:"required"` + Extension string `json:"extension" validate:"required"` + OutputFormat string `json:"outputFormat" validate:"required"` + Status string `json:"status"` +} + +type FileConvertRequest struct { + Files []FileConvert `json:"files" validate:"required"` + OutputPath string `json:"outputPath" validate:"required"` + DeleteSource bool `json:"deleteSource"` + TaskID string `json:"taskID"` +} diff --git a/agent/app/dto/response/file.go b/agent/app/dto/response/file.go index 77d4f11f655f..60ea92e4bb4e 100644 --- a/agent/app/dto/response/file.go +++ b/agent/app/dto/response/file.go @@ -73,3 +73,11 @@ type DepthDirSizeRes struct { Path string `json:"path"` Size int64 `json:"size"` } + +type FileConvertLog struct { + Date string `json:"date"` + Type string `json:"type"` + Log string `json:"log"` + Status string `json:"status"` + Message string `json:"message"` +} diff --git a/agent/app/service/file.go b/agent/app/service/file.go index dd4c6cb36ca7..251ec5b2ba1a 100644 --- a/agent/app/service/file.go +++ b/agent/app/service/file.go @@ -3,7 +3,11 @@ package service import ( "bufio" "context" + "encoding/json" "fmt" + "github.com/1Panel-dev/1Panel/agent/app/task" + "github.com/1Panel-dev/1Panel/agent/i18n" + "github.com/1Panel-dev/1Panel/agent/utils/convert" "io" "io/fs" "os" @@ -66,6 +70,8 @@ type IFileService interface { BatchCheckFiles(req request.FilePathsCheck) []response.ExistFileInfo GetHostMount() []dto.DiskInfo GetUsersAndGroups() (*response.UserGroupResponse, error) + Convert(req request.FileConvertRequest) + ConvertLog(req dto.PageInfo) (int64, []response.FileConvertLog, error) } var filteredPaths = []string{ @@ -723,3 +729,102 @@ func getValidUsers(validGroups map[string]bool) ([]response.UserInfo, map[string } return users, groupSet, nil } + +func (f *FileService) Convert(req request.FileConvertRequest) { + convertTask, err := task.NewTaskWithOps(i18n.GetMsgByKey("FileConvert"), task.TaskExec, task.TaskScopeFileConvert, req.TaskID, 1) + if err != nil { + global.LOG.Errorf("Create convert task failed %v", err) + return + } + convertTask.AddSubTask(task.GetTaskName(i18n.GetMsgByKey("FileConvert"), task.TaskExec, task.TaskScopeFileConvert), func(t *task.Task) (err error) { + for _, file := range req.Files { + input := filepath.Join(file.Path, file.InputFile) + nameOnly := file.InputFile[0 : len(file.InputFile)-len(file.Extension)] + output := filepath.Join(req.OutputPath, nameOnly+"."+file.OutputFormat) + status, errMsg := convert.MediaFile(input, output, file.OutputFormat, req.DeleteSource) + if status == "FAILED" { + convertTask.Log(fmt.Sprintf("%s -> %s [%s]: %s\n", + input, output, status, errMsg)) + } else { + convertTask.Log(fmt.Sprintf("%s -> %s [%s]: %s\n", + input, output, status, "SUCCESS")) + } + } + return nil + }, nil) + go func() { + _ = convertTask.Execute() + }() +} + +func (f *FileService) ConvertLog(req dto.PageInfo) (total int64, data []response.FileConvertLog, err error) { + logFilePath := filepath.Join(global.Dir.ConvertLogDir, "convert.log") + file, err := os.Open(logFilePath) + if err != nil { + return 0, nil, err + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return 0, nil, err + } + size := stat.Size() + + buffer := make([]byte, 1) + var line strings.Builder + var lines []response.FileConvertLog + var lineCount int64 + + skipCount := int64((req.Page - 1) * req.PageSize) + + for offset := size - 1; offset >= 0; offset-- { + _, err := file.ReadAt(buffer, offset) + if err != nil { + break + } + + if buffer[0] == '\n' { + text := reverse(line.String()) + line.Reset() + if text == "" { + continue + } + + var entry response.FileConvertLog + if err := json.Unmarshal([]byte(text), &entry); err != nil { + continue + } + + if lineCount >= skipCount && len(lines) < req.PageSize { + lines = append(lines, entry) + } + lineCount++ + } else { + line.WriteByte(buffer[0]) + } + } + + if line.Len() > 0 { + text := reverse(line.String()) + var entry response.FileConvertLog + if err := json.Unmarshal([]byte(text), &entry); err == nil { + if lineCount >= skipCount && len(lines) < req.PageSize { + lines = append(lines, entry) + } + lineCount++ + } + } + + total = lineCount + data = lines + return total, data, nil +} + +func reverse(s string) string { + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} diff --git a/agent/app/task/task.go b/agent/app/task/task.go index 23b3e3087d9b..e1493e90c29e 100644 --- a/agent/app/task/task.go +++ b/agent/app/task/task.go @@ -73,6 +73,7 @@ const ( TaskExec = "TaskExec" TaskBatch = "TaskBatch" TaskProtect = "TaskProtect" + TaskConvert = "TaskConvert" ) const ( @@ -93,6 +94,7 @@ const ( TaskScopeRuntimeExtension = "RuntimeExtension" TaskScopeCustomAppstore = "CustomAppstore" TaskScopeTamper = "Tamper" + TaskScopeFileConvert = "Convert" ) func GetTaskName(resourceName, operate, scope string) string { diff --git a/agent/global/config.go b/agent/global/config.go index b2c62b138f39..34d6e2eedb4e 100644 --- a/agent/global/config.go +++ b/agent/global/config.go @@ -44,6 +44,7 @@ type SystemDir struct { RecycleBinDir string SSLLogDir string McpDir string + ConvertLogDir string } type LogConfig struct { diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 120f56ed51c0..15d5def9b802 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -369,6 +369,7 @@ TaskIsExecuting: 'Task is running' CustomAppstore: 'Custom application warehouse' TaskExec: 'Execute' TaskBatch: "Batch Operation" +FileConvert: 'File Conversion' # task - clam Clamscan: "Scan {{ .name }}" diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index 72a22d0c697a..bef8d63d8288 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -368,6 +368,7 @@ CustomAppstore: 'Almacén de aplicaciones personalizado' TaskClean: "Limpieza" TaskExec: "Ejecutar" TaskBatch: "Operación por Lotes" +FileConvert: 'Conversión de Formato de Archivo' # task - ai OllamaModelPull: 'Descargar modelo Ollama {{ .name }}' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index ce2d05386bcb..9d6d5143f57b 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -368,6 +368,7 @@ TaskIsExecuting: 'タスクは実行中です' CustomAppstore: 'カスタム アプリケーション ウェアハウス' TaskExec: '実行' TaskBatch: "一括操作" +FileConvert: 'ファイル形式の変換' # task - clam Clamscan: "{{ .name }} をスキャン" diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index 5b493ba83655..c3318e3cd6a2 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -369,6 +369,7 @@ TaskIsExecuting: '작업이 실행 중입니다' CustomAppstore: '사용자 정의 애플리케이션 웨어하우스' TaskExec: '실행' TaskBatch: "일괄 작업" +FileConvert: '파일 형식 변환' # task - clam Clamscan: "{{ .name }} 스캔" diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index 984b2403598e..c54316847072 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -369,6 +369,7 @@ TaskIsExecuting: 'Tugas sedang berjalan' CustomAppstore: 'Gudang aplikasi tersuai' TaskExec: 'Laksanakan' TaskBatch: "Operasi Batch" +FileConvert: 'Penukaran Format Fail' # task - clam Clamscan: "Imbas {{ .name }}" diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index 4d805f7d5e1e..ccdf9e93a9c6 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -369,6 +369,7 @@ TaskIsExecuting: 'A tarefa está em execução' CustomAppstore: 'Armazém de aplicativos personalizados' TaskExec: 'Executar' TaskBatch: "Operação em Lote" +FileConvert: 'Conversão de Formato de Arquivo' # task - clam Clamscan: "Escanear {{ .name }}" diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index 44ac9f421d3a..3bfc89cf5ae7 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -369,6 +369,7 @@ TaskIsExecuting: 'Задача выполняется' CustomAppstore: 'Хранилище пользовательских приложений' TaskExec: 'Выполнить' TaskBatch: "Пакетная операция" +FileConvert: 'Преобразование формата файла' # task - clam Clamscan: "Сканировать {{ .name }}" diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index 61c9a639c9d2..e4ce45adb64d 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -370,6 +370,7 @@ TaskIsExecuting: 'Görev çalışıyor' CustomAppstore: 'Özel uygulama deposu' TaskExec: 'Çalıştır' TaskBatch: "Toplu İşlem" +FileConvert: 'Dosya Formatı Dönüştürme' # task - clam Clamscan: "{{ .name }} Tara" diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index 7d0f8ff2ce9c..46373f19073d 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -368,6 +368,7 @@ TaskIsExecuting: '任務正在運作' CustomAppstore: '自訂應用程式倉庫' TaskExec: '執行' TaskBatch: "批量操作" +FileConvert: '文件格式轉換' # task - clam Clamscan: "掃描 {{ .name }}" diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 7a589109a675..c54c40b91a2f 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -95,7 +95,7 @@ ErrAppVersionDeprecated: " {{ .name }} 应用不适配当前 1Panel 版本,跳 ErrDockerFailed: "Docker 状态异常,请检查服务状态" ErrDockerComposeCmdNotFound: "Docker Compose 命令不存在,请先在宿主机安装此命令" -#ssh +#ssh ExportIP: "登录 IP" ExportArea: "归属地" ExportPort: "端口" @@ -369,6 +369,7 @@ TaskIsExecuting: "任务正在运行" CustomAppstore: "自定义应用仓库" TaskExec: "执行" TaskBatch: "批量操作" +FileConvert: "文件格式转换" # task - clam Clamscan: "扫描 {{ .name }}" diff --git a/agent/init/dir/dir.go b/agent/init/dir/dir.go index af40a8ba67d9..a31bc39da696 100644 --- a/agent/init/dir/dir.go +++ b/agent/init/dir/dir.go @@ -31,4 +31,5 @@ func Init() { global.Dir.RecycleBinDir, _ = fileOp.CreateDirWithPath(true, "/.1panel_clash") global.Dir.SSLLogDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/log/ssl")) global.Dir.McpDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/mcp")) + global.Dir.ConvertLogDir, _ = fileOp.CreateDirWithPath(true, path.Join(baseDir, "1panel/log/convert")) } diff --git a/agent/router/ro_file.go b/agent/router/ro_file.go index 0f6e8de02667..8c1ecda06a70 100644 --- a/agent/router/ro_file.go +++ b/agent/router/ro_file.go @@ -52,5 +52,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) { fileRouter.GET("/path/:type", baseApi.GetPathByType) fileRouter.POST("/mount", baseApi.GetHostMount) fileRouter.POST("/user/group", baseApi.GetUsersAndGroups) + fileRouter.POST("/convert", baseApi.ConvertFile) + fileRouter.POST("/convert/log", baseApi.ConvertLog) } } diff --git a/agent/utils/convert/convert.go b/agent/utils/convert/convert.go new file mode 100644 index 000000000000..61dc753e048c --- /dev/null +++ b/agent/utils/convert/convert.go @@ -0,0 +1,181 @@ +package convert + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/1Panel-dev/1Panel/agent/app/dto/response" + "github.com/1Panel-dev/1Panel/agent/global" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type FormatOption struct { + Type string + Codec string +} + +var FormatMap = map[string]FormatOption{ + // images + "png": {Type: "image", Codec: "png"}, + "jpg": {Type: "image", Codec: "mjpeg"}, + "jpeg": {Type: "image", Codec: "mjpeg"}, + "webp": {Type: "image", Codec: "libwebp"}, + "gif": {Type: "image", Codec: "gif"}, + "bmp": {Type: "image", Codec: "bmp"}, + "tiff": {Type: "image", Codec: "tiff"}, + + // videos + "mp4": {Type: "video", Codec: "libx264"}, + "avi": {Type: "video", Codec: "libx264"}, + "mov": {Type: "video", Codec: "libx264"}, + "mkv": {Type: "video", Codec: "libx264"}, + + // audios + "mp3": {Type: "audio", Codec: "libmp3lame"}, + "wav": {Type: "audio", Codec: "pcm_s16le"}, + "flac": {Type: "audio", Codec: "flac"}, + "aac": {Type: "audio", Codec: "aac"}, +} + +func hasFfmpeg() (string, bool) { + ffmpegPath, err := exec.LookPath("ffmpeg") + return ffmpegPath, err == nil +} + +func MediaFile(inputFile, outputFile, outputFormat string, deleteSource bool) (state string, err error) { + status := "FAILED" + msg := "" + ffmpegPath, flag := hasFfmpeg() + if !flag { + return status, fmt.Errorf("ffmpeg not found, cannot convert file") + } + logFile, logErr := os.OpenFile(filepath.Join(global.Dir.ConvertLogDir, "convert.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + allLogFile, allErr := os.OpenFile(filepath.Join(global.Dir.ConvertLogDir, "convert-all.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + if logErr != nil || allErr != nil { + return status, fmt.Errorf("cannot open log file: %w", err) + } + defer logFile.Close() + args, fileType, err := buildFFmpegArgs(inputFile, outputFile, outputFormat) + if err != nil { + return status, fmt.Errorf("FFmpeg args failed: %w", err) + } + ctx := context.Background() + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + cmdr := exec.CommandContext(ctx, ffmpegPath, args...) + cmdr.Env = append(os.Environ(), + "PATH="+filepath.Dir(ffmpegPath)+":"+os.Getenv("PATH"), + "LD_LIBRARY_PATH=/usr/local/lib:"+os.Getenv("LD_LIBRARY_PATH"), + ) + var buf bytes.Buffer + cmdr.Stdout = &buf + cmdr.Stderr = &buf + err = cmdr.Run() + logStr := buf.String() + + stat, statErr := os.Stat(outputFile) + if err != nil || statErr != nil || stat.Size() == 0 { + status = "FAILED" + msg = extractFFmpegError(logStr) + _ = os.Remove(outputFile) + } else { + status = "SUCCESS" + msg = "SUCCESS" + } + + entry := response.FileConvertLog{ + Date: time.Now().Format("2006-01-02 15:04:05"), + Type: fileType, + Log: fmt.Sprintf("%s -> %s", inputFile, outputFile), + Status: status, + Message: msg, + } + _ = appendJSONLog(logFile, entry) + + allLogEntry := fmt.Sprintf("[%s] %s %s -> %s [%s]: %s\n", + time.Now().Format("2006-01-02 15:04:05"), + fileType, inputFile, outputFile, status, logStr) + _ = appendLog(allLogFile, allLogEntry) + if err == nil && deleteSource { + _ = os.Remove(inputFile) + } + return status, nil +} + +func buildFFmpegArgs(inputFile, outputFile, outputFormat string) ([]string, string, error) { + args := []string{"-y", "-i", inputFile} + opt, ok := FormatMap[outputFormat] + if !ok { + return nil, "", fmt.Errorf("unsupported format: %s", outputFormat) + } + + switch opt.Type { + case "image": + switch outputFormat { + case "webp": + args = append(args, "-c:v", "libwebp", "-lossless", "0", "-q:v", "75") + case "png", "gif", "jpg", "jpeg", "bmp", "tiff": + args = append(args, "-c:v", opt.Codec) + } + + case "video": + args = append(args, "-c:v", opt.Codec, "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "192k") + + case "audio": + args = append(args, "-c:a", opt.Codec, "-b:a", "192k") + + default: + return nil, opt.Type, fmt.Errorf("unsupported media type: %s", opt.Type) + } + + args = append(args, outputFile) + return args, opt.Type, nil +} + +func appendLog(f *os.File, content string) error { + _, err := f.WriteString(content) + return err +} + +func appendJSONLog(f *os.File, entry response.FileConvertLog) error { + data, err := json.Marshal(entry) + if err != nil { + return err + } + if _, err := f.WriteString(string(data) + "\n"); err != nil { + return err + } + return nil +} + +func extractFFmpegError(logStr string) string { + priority := []string{"Error", "Invalid", "failed", "No "} + matches := make(map[string]string) + lines := strings.Split(strings.TrimSpace(logStr), "\n") + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + for _, kw := range priority { + if _, ok := matches[kw]; !ok && strings.Contains(line, kw) { + matches[kw] = line + } + } + } + + for _, kw := range priority { + if line, ok := matches[kw]; ok { + return line + } + } + + if len(lines) > 0 { + return lines[len(lines)-1] + } + return "" +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.cjs similarity index 100% rename from frontend/postcss.config.js rename to frontend/postcss.config.cjs diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1914753f3867..6876dc8a4b3c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -35,7 +35,7 @@ const i18nLocale = computed(() => { if (globalStore.language === 'pt-br') return ptBR; if (globalStore.language === 'ko') return ko; if (globalStore.language === 'tr') return tr; - if (globalStore.language === 'es-es') return esES; + if (globalStore.language === 'es-ES') return esES; return zhCn; }); diff --git a/frontend/src/api/interface/file.ts b/frontend/src/api/interface/file.ts index cec78b235a2f..72392da49909 100644 --- a/frontend/src/api/interface/file.ts +++ b/frontend/src/api/interface/file.ts @@ -222,4 +222,27 @@ export namespace File { username: string; group: string; } + + export interface ConvertFile { + type: string; + path: string; + extension: string; + inputFile: string; + outputFormat: string; + } + + export interface ConvertFileRequest { + files: ConvertFile[]; + outputPath: string; + deleteSource: boolean; + taskID: string; + } + + export interface ConvertLogResponse { + date: string; + type: string; + log: string; + status: string; + message: string; + } } diff --git a/frontend/src/api/modules/files.ts b/frontend/src/api/modules/files.ts index 0767e6eabcf4..c516faf142ba 100644 --- a/frontend/src/api/modules/files.ts +++ b/frontend/src/api/modules/files.ts @@ -158,3 +158,11 @@ export const searchHostMount = () => { export const searchUserGroup = () => { return http.post(`/files/user/group`); }; + +export const convertFiles = (params: File.ConvertFileRequest) => { + return http.post('files/convert', params, TimeoutEnum.T_5M); +}; + +export const convertLogs = (params: ReqPage) => { + return http.post>('files/convert/log', params, TimeoutEnum.T_5M); +}; diff --git a/frontend/src/components/complex-table/index.vue b/frontend/src/components/complex-table/index.vue index 11c2b95e7bcc..518c5dedd93b 100644 --- a/frontend/src/components/complex-table/index.vue +++ b/frontend/src/components/complex-table/index.vue @@ -223,6 +223,7 @@ function handleRowClick(row: any, column: any, event: any) { target.closest('button') || target.closest('a') || target.closest('.el-switch') || + target.closest('.el-select') || target.closest('.table-link') || target.closest('.cursor-pointer') ) { diff --git a/frontend/src/components/terminal/index.vue b/frontend/src/components/terminal/index.vue index 8adca8bb9cc2..a1ce96f2c3b7 100644 --- a/frontend/src/components/terminal/index.vue +++ b/frontend/src/components/terminal/index.vue @@ -268,5 +268,10 @@ onBeforeUnmount(() => { } :deep(.xterm) { padding: 5px !important; + background-color: var(--panel-logs-bg-color) !important; +} + +:deep(.xterm .xterm-viewport) { + background-color: var(--panel-logs-bg-color) !important; } diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 3164780c50fd..f1e54a7b1bfc 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1550,6 +1550,19 @@ const message = { cancelUploadHelper: 'Whether to cancel the upload, after cancellation the upload list will be cleared.', keepOneTab: 'Keep at least one tab', notCanTab: 'Cannot add more tabs', + convert: 'Convert Format', + converting: 'Convert To', + fileCanNotConvert: 'This file does not support format conversion', + formatType: 'Format Type', + sourceFormat: 'Source Format', + sourceFile: 'Source File', + saveDir: 'Save Directory', + deleteSourceFile: 'Delete Source File', + convertHelper: 'Convert the selected files to another format', + convertHelper1: 'Please select the files to be converted', + execConvert: 'Start conversion. You can view the conversion logs in the Task Center', + convertLogs: 'Conversion Logs', + formatConvert: 'Format Conversion', }, ssh: { autoStart: 'Auto start', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 8a1b9379b9c3..4e472a09c405 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1546,6 +1546,19 @@ const message = { cancelUploadHelper: 'Indica si se cancela la carga; después de la cancelación, la lista de cargas se borrará.', keepOneTab: 'Mantener al menos una pestaña', notCanTab: 'No se pueden añadir más pestañas', + convert: 'Convertir Formato', + converting: 'Convirtiendo', + fileCanNotConvert: 'Este archivo no admite conversión de formato', + formatType: 'Tipo de Formato', + sourceFormat: 'Formato de Origen', + sourceFile: 'Archivo de Origen', + saveDir: 'Directorio de Guardado', + deleteSourceFile: 'Eliminar Archivo de Origen', + convertHelper: 'Convertir los archivos seleccionados a otro formato', + convertHelper1: 'Por favor, seleccione los archivos a convertir', + execConvert: 'Iniciar conversión. Puede ver los registros de conversión en el Centro de Tareas', + convertLogs: 'Registros de Conversión', + formatConvert: 'Conversión de Formato', }, ssh: { autoStart: 'Inicio automático', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 8e147b659e0a..cb159bf455bc 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1495,6 +1495,19 @@ const message = { cancelUploadHelper: 'アップロードをキャンセルするかどうか、キャンセル後、アップロードリストはクリアされます。', keepOneTab: '少なくとも1つのタブを保持してください', notCanTab: 'これ以上タブを追加できません', + convert: 'フォーマットを変換', + converting: 'に変換中', + fileCanNotConvert: 'このファイルはフォーマット変換に対応していません', + formatType: 'フォーマットタイプ', + sourceFormat: '元のフォーマット', + sourceFile: '元ファイル', + saveDir: '保存ディレクトリ', + deleteSourceFile: '元ファイルを削除するかどうか', + convertHelper: '選択したファイルを別のフォーマットに変換します', + convertHelper1: '変換するファイルを選択してください', + execConvert: '変換を開始します。タスクセンターで変換ログを確認できます', + convertLogs: '変換ログ', + formatConvert: 'フォーマット変換', }, ssh: { autoStart: 'オートスタート', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 469701a0a8f5..f3c65cb00c83 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1479,6 +1479,19 @@ const message = { cancelUploadHelper: '업로드를 취소할지 여부, 취소 후 업로드 목록이 비워집니다.', keepOneTab: '최소한 하나의 탭을 유지하세요', notCanTab: '더 이상 탭을 추가할 수 없습니다', + convert: '파일 형식 변환', + converting: '로 변환 중', + fileCanNotConvert: '이 파일은 형식 변환을 지원하지 않습니다', + formatType: '형식 종류', + sourceFormat: '원본 형식', + sourceFile: '원본 파일', + saveDir: '저장 디렉토리', + deleteSourceFile: '원본 파일 삭제 여부', + convertHelper: '선택한 파일을 다른 형식으로 변환합니다', + convertHelper1: '변환할 파일을 선택하세요', + execConvert: '변환을 시작합니다. 작업 센터에서 변환 로그를 확인할 수 있습니다', + convertLogs: '변환 로그', + formatConvert: '형식 변환', }, ssh: { autoStart: '자동 시작', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 76231cb5cadb..d91f0a5a4a0d 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1538,6 +1538,19 @@ const message = { 'Adakah hendak membatalkan muat naik, selepas pembatalan senarai muat naik akan dikosongkan.', keepOneTab: 'Pastikan sekurang-kurangnya satu tab dikekalkan', notCanTab: 'Tidak dapat menambah tab lagi', + convert: 'Tukar Format', + converting: 'Menukar Ke', + fileCanNotConvert: 'Fail ini tidak menyokong penukaran format', + formatType: 'Jenis Format', + sourceFormat: 'Format Asal', + sourceFile: 'Fail Asal', + saveDir: 'Direktori Simpanan', + deleteSourceFile: 'Padam Fail Asal', + convertHelper: 'Tukar fail yang dipilih ke format lain', + convertHelper1: 'Sila pilih fail yang hendak ditukar', + execConvert: 'Mulakan penukaran. Anda boleh melihat log penukaran di Pusat Tugas', + convertLogs: 'Log Penukaran', + formatConvert: 'Penukaran Format', }, ssh: { autoStart: 'Mula automatik', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 26db403e092d..7bf5df0b06b4 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1528,6 +1528,19 @@ const message = { cancelUploadHelper: 'Deseja cancelar o upload, após o cancelamento, a lista de upload será limpa.', keepOneTab: 'Mantenha pelo menos uma aba', notCanTab: 'Não é possível adicionar mais abas', + convert: 'Converter Formato', + converting: 'Convertendo Para', + fileCanNotConvert: 'Este arquivo não suporta conversão de formato', + formatType: 'Tipo de Formato', + sourceFormat: 'Formato de Origem', + sourceFile: 'Arquivo de Origem', + saveDir: 'Diretório de Salvamento', + deleteSourceFile: 'Excluir Arquivo de Origem', + convertHelper: 'Converter os arquivos selecionados para outro formato', + convertHelper1: 'Por favor, selecione os arquivos a serem convertidos', + execConvert: 'Iniciar conversão. Você pode visualizar os logs de conversão no Centro de Tarefas', + convertLogs: 'Logs de Conversão', + formatConvert: 'Conversão de Formato', }, ssh: { autoStart: 'Início automático', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 607f604081f0..0dfa297027b3 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1530,6 +1530,19 @@ const message = { cancelUploadHelper: 'Отменить загрузку или нет, после отмены список загрузок будет очищен.', keepOneTab: 'Необходимо оставить как минимум одну вкладку', notCanTab: 'Невозможно добавить больше вкладок', + convert: 'Конвертировать формат', + converting: 'Конвертация в', + fileCanNotConvert: 'Этот файл не поддерживает конвертацию формата', + formatType: 'Тип формата', + sourceFormat: 'Исходный формат', + sourceFile: 'Исходный файл', + saveDir: 'Каталог сохранения', + deleteSourceFile: 'Удалить исходный файл', + convertHelper: 'Конвертировать выбранные файлы в другой формат', + convertHelper1: 'Пожалуйста, выберите файлы для конвертации', + execConvert: 'Начать конвертацию. Вы можете просмотреть журналы конвертации в Центре задач', + convertLogs: 'Журналы конвертации', + formatConvert: 'Конвертация формата', }, ssh: { autoStart: 'Автозапуск', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index b8b118070f9c..e134d64c8ddc 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1567,6 +1567,19 @@ const message = { cancelUploadHelper: 'Yüklemeyi iptal etmek ister misiniz, iptal sonrası yükleme listesi temizlenecektir.', keepOneTab: 'En az bir sekme açık kalmalıdır', notCanTab: 'Daha fazla sekme eklenemez', + convert: 'Formatı Dönüştür', + converting: 'Dönüştürülüyor', + fileCanNotConvert: 'Bu dosya format dönüşümünü desteklemiyor', + formatType: 'Format Türü', + sourceFormat: 'Kaynak Format', + sourceFile: 'Kaynak Dosya', + saveDir: 'Kaydetme Dizini', + deleteSourceFile: 'Kaynak Dosyayı Sil', + convertHelper: 'Seçilen dosyaları başka bir formata dönüştür', + convertHelper1: 'Lütfen dönüştürülecek dosyaları seçin', + execConvert: 'Dönüştürmeyi başlatın. Dönüştürme günlüklerini Görev Merkezi’nde görüntüleyebilirsiniz', + convertLogs: 'Dönüştürme Günlükleri', + formatConvert: 'Format Dönüştürme', }, ssh: { autoStart: 'Otomatik başlat', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 7c8e793c59f8..1df71d75edcc 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1475,6 +1475,19 @@ const message = { cancelUploadHelper: '是否取消上傳,取消後將清空上傳列表', keepOneTab: '至少保留一個分頁', notCanTab: '無法新增更多分頁', + convert: '文件格式轉換', + converting: '轉換為', + fileCanNotConvert: '此文件不支援轉換格式', + formatType: '格式類型', + sourceFormat: '來源格式', + sourceFile: '來源檔案', + saveDir: '儲存目錄', + deleteSourceFile: '是否刪除來源檔案', + convertHelper: '將選中的檔案轉換為其他格式', + convertHelper1: '請選擇需要轉換格式的檔案', + execConvert: '開始轉換,可在任務中心查看轉換日誌', + convertLogs: '轉換日誌', + formatConvert: '格式轉換', }, ssh: { autoStart: '開機自啟', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 6b1e0532bbf5..f0d7af02cf31 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1471,6 +1471,19 @@ const message = { cancelUploadHelper: '是否取消上传,取消后将清空上传列表', keepOneTab: '至少保留一个标签页', notCanTab: '不可增加更多的标签页', + convert: '转换格式', + converting: '转换为', + fileCanNotConvert: '此文件不支持转换格式', + formatType: '格式类型', + sourceFormat: '源格式', + sourceFile: '源文件', + saveDir: '保存目录', + deleteSourceFile: '是否删除源文件', + convertHelper: '是否为选中的文件进行格式转换', + convertHelper1: '请选择需要转换格式的文件', + execConvert: '开始转换,可以在任务中心查看转换日志', + convertLogs: '转换日志', + formatConvert: '格式转换', }, ssh: { autoStart: '开机自启', diff --git a/frontend/src/styles/element.scss b/frontend/src/styles/element.scss index 84ff0b08331c..f87cbe085d49 100644 --- a/frontend/src/styles/element.scss +++ b/frontend/src/styles/element.scss @@ -48,8 +48,8 @@ html { --panel-terminal-tag-active-bg-color: #575758; --panel-terminal-tag-active-text-color: #ebeef5; --panel-terminal-tag-hover-text-color: #575758; - --panel-terminal-bg-color: #1e1e1e; - --panel-logs-bg-color: #1e1e1e; + --panel-terminal-bg-color: #1b1b1b; + --panel-logs-bg-color: #1b1b1b; --panel-alert-bg-color: rgba(0, 94, 235, 0.03); --panel-alert-bg: #e2e4ec; diff --git a/frontend/src/utils/util.ts b/frontend/src/utils/util.ts index 45aaa16447fb..852ed66fc549 100644 --- a/frontend/src/utils/util.ts +++ b/frontend/src/utils/util.ts @@ -861,3 +861,10 @@ export const isSensitiveLinuxPath = (path) => { ]; return sensitivePath.indexOf(path) !== -1; }; + +const convertTypes = ['image', 'video', 'audio'] as const; +type ConvertType = (typeof convertTypes)[number]; + +export function isConvertible(extension: string, mimeType: string): boolean { + return convertTypes.includes(getFileType(extension) as ConvertType) && /^(image|audio|video)\//.test(mimeType); +} diff --git a/frontend/src/views/host/file-management/convert/index.vue b/frontend/src/views/host/file-management/convert/index.vue new file mode 100644 index 000000000000..e3c8e0e27c9e --- /dev/null +++ b/frontend/src/views/host/file-management/convert/index.vue @@ -0,0 +1,408 @@ + + + diff --git a/frontend/src/views/host/file-management/index.vue b/frontend/src/views/host/file-management/index.vue index 13e0955c0b82..dea2f9542116 100644 --- a/frontend/src/views/host/file-management/index.vue +++ b/frontend/src/views/host/file-management/index.vue @@ -638,6 +638,7 @@ + @@ -655,7 +656,16 @@ import { searchFavorite, searchHostMount, } from '@/api/modules/files'; -import { computeSize, copyText, dateFormat, downloadFile, getFileType, getIcon, getRandomStr } from '@/utils/util'; +import { + computeSize, + copyText, + dateFormat, + downloadFile, + getFileType, + getIcon, + getRandomStr, + isConvertible, +} from '@/utils/util'; import { File } from '@/api/interface/file'; import { Languages, Mimetypes } from '@/global/mimetype'; import { useRouter } from 'vue-router'; @@ -685,11 +695,14 @@ import Favorite from './favorite/index.vue'; import BatchRole from './batch-role/index.vue'; import Preview from './preview/index.vue'; import VscodeOpenDialog from '@/components/vscode-open/index.vue'; +import Convert from './convert/index.vue'; import { debounce } from 'lodash-es'; import TerminalDialog from './terminal/index.vue'; import { Dashboard } from '@/api/interface/dashboard'; import { CompressExtension, CompressType } from '@/enums/files'; import type { TabPaneName } from 'element-plus'; +import { getComponentInfo } from '@/api/modules/host'; +import { routerToNameWithQuery } from '@/utils/router'; const globalStore = GlobalStore(); @@ -741,6 +754,22 @@ const fileUpload = reactive({ path: '' }); const fileRename = reactive({ path: '', oldName: '', newName: '' }); const fileWget = reactive({ path: '' }); const fileMove = reactive({ oldPaths: [''], allNames: [''], type: '', path: '', name: '', count: 0, isDir: false }); +const fileConvert = reactive<{ + outputPath: string; + files: File.ConvertFile[]; +}>({ + outputPath: '', + files: [ + { + type: '', + inputFile: '', + extension: '', + path: '', + outputFormat: '', + }, + ], +}); +const ffmpegExist = ref(false); const createRef = ref(); const roleRef = ref(); @@ -775,6 +804,7 @@ const dirNum = ref(0); const fileNum = ref(0); const imageFiles = ref([]); const isEdit = ref(false); +const convertRef = ref(); const renameRefs = ref>({}); @@ -1584,6 +1614,15 @@ const buttons = [ } }, }, + { + label: i18n.global.t('file.convert'), + click: (row: File.File) => { + openConvert(row); + }, + disabled: (row: File.File) => { + return row?.isDir || !isConvertible(row?.extension, row?.mimeType); + }, + }, { label: i18n.global.t('file.openWithVscode'), click: openWithVSCode, @@ -1595,6 +1634,36 @@ const buttons = [ }, ]; +const openConvert = (item: File.File) => { + if (!ffmpegExist.value) { + ElMessageBox.confirm(i18n.global.t('cronjob.library.noSuchApp', ['FFmpeg']), i18n.global.t('file.convert'), { + confirmButtonText: i18n.global.t('app.toInstall'), + cancelButtonText: i18n.global.t('commons.button.cancel'), + }).then(() => { + routerToNameWithQuery('Library', { t: Date.now(), uncached: 'true' }); + }); + return; + } else { + if (!isConvertible(item.extension, item.mimeType)) { + MsgWarning(i18n.global.t('file.fileCanNotConvert')); + return; + } + const fileType = getFileType(item.extension); + fileConvert.outputPath = req.path; + fileConvert.files = [ + { + type: fileType, + path: req.path, + extension: item.extension, + inputFile: item.name, + outputFormat: item.extension.slice(1), + }, + ]; + + convertRef.value.acceptParams(fileConvert); + } +}; + const isDecompressFile = (row: File.File) => { if (row.isDir) { return false; @@ -1644,6 +1713,7 @@ onMounted(() => { initTabsAndPaths(); getHostMount(); initHistory(); + checkFFmpeg(); nextTick(function () { handlePath(); observeResize(); @@ -1826,6 +1896,12 @@ const removeTab = (targetId: TabPaneName) => { changeTab(String(nextActive)); }; +const checkFFmpeg = () => { + getComponentInfo('ffmpeg', globalStore.currentNode).then((res) => { + ffmpegExist.value = res.data.exists ?? false; + }); +}; + onBeforeUnmount(() => { if (resizeObserver) resizeObserver.disconnect(); window.removeEventListener('resize', watchTitleHeight);