feat: File management supports file format conversion (#10541)

#10028
This commit is contained in:
2025-09-29 17:26:21 +08:00
committed by GitHub
parent 56936aa6cd
commit 5d83f21a26
39 changed files with 1030 additions and 5 deletions

View File

@@ -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,
})
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -44,6 +44,7 @@ type SystemDir struct {
RecycleBinDir string
SSLLogDir string
McpDir string
ConvertLogDir string
}
type LogConfig struct {

View File

@@ -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 }}"

View File

@@ -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 }}'

View File

@@ -368,6 +368,7 @@ TaskIsExecuting: 'タスクは実行中です'
CustomAppstore: 'カスタム アプリケーション ウェアハウス'
TaskExec: '実行'
TaskBatch: "一括操作"
FileConvert: 'ファイル形式の変換'
# task - clam
Clamscan: "{{ .name }} をスキャン"

View File

@@ -369,6 +369,7 @@ TaskIsExecuting: '작업이 실행 중입니다'
CustomAppstore: '사용자 정의 애플리케이션 웨어하우스'
TaskExec: '실행'
TaskBatch: "일괄 작업"
FileConvert: '파일 형식 변환'
# task - clam
Clamscan: "{{ .name }} 스캔"

View File

@@ -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 }}"

View File

@@ -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 }}"

View File

@@ -369,6 +369,7 @@ TaskIsExecuting: 'Задача выполняется'
CustomAppstore: 'Хранилище пользовательских приложений'
TaskExec: 'Выполнить'
TaskBatch: "Пакетная операция"
FileConvert: 'Преобразование формата файла'
# task - clam
Clamscan: "Сканировать {{ .name }}"

View File

@@ -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"

View File

@@ -368,6 +368,7 @@ TaskIsExecuting: '任務正在運作'
CustomAppstore: '自訂應用程式倉庫'
TaskExec: '執行'
TaskBatch: "批量操作"
FileConvert: '文件格式轉換'
# task - clam
Clamscan: "掃描 {{ .name }}"

View File

@@ -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 }}"

View File

@@ -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"))
}

View File

@@ -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)
}
}

View File

@@ -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 ""
}

View File

@@ -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;
});

View File

@@ -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;
}
}

View File

@@ -158,3 +158,11 @@ export const searchHostMount = () => {
export const searchUserGroup = () => {
return http.post<File.UserGroupResponse>(`/files/user/group`);
};
export const convertFiles = (params: File.ConvertFileRequest) => {
return http.post<File.ConvertFile>('files/convert', params, TimeoutEnum.T_5M);
};
export const convertLogs = (params: ReqPage) => {
return http.post<ResPage<File.ConvertLogResponse>>('files/convert/log', params, TimeoutEnum.T_5M);
};

View File

@@ -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')
) {

View File

@@ -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;
}
</style>

View File

@@ -1549,6 +1549,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',

View File

@@ -1545,6 +1545,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',

View File

@@ -1494,6 +1494,19 @@ const message = {
cancelUploadHelper: 'アップロードをキャンセルするかどうか、キャンセル後、アップロードリストはクリアされます。',
keepOneTab: '少なくとも1つのタブを保持してください',
notCanTab: 'これ以上タブを追加できません',
convert: 'フォーマットを変換',
converting: 'に変換中',
fileCanNotConvert: 'このファイルはフォーマット変換に対応していません',
formatType: 'フォーマットタイプ',
sourceFormat: '元のフォーマット',
sourceFile: '元ファイル',
saveDir: '保存ディレクトリ',
deleteSourceFile: '元ファイルを削除するかどうか',
convertHelper: '選択したファイルを別のフォーマットに変換します',
convertHelper1: '変換するファイルを選択してください',
execConvert: '変換を開始します。タスクセンターで変換ログを確認できます',
convertLogs: '変換ログ',
formatConvert: 'フォーマット変換',
},
ssh: {
autoStart: 'オートスタート',

View File

@@ -1478,6 +1478,19 @@ const message = {
cancelUploadHelper: '업로드를 취소할지 여부, 취소 후 업로드 목록이 비워집니다.',
keepOneTab: '최소한 하나의 탭을 유지하세요',
notCanTab: '더 이상 탭을 추가할 수 없습니다',
convert: '파일 형식 변환',
converting: '로 변환 중',
fileCanNotConvert: '이 파일은 형식 변환을 지원하지 않습니다',
formatType: '형식 종류',
sourceFormat: '원본 형식',
sourceFile: '원본 파일',
saveDir: '저장 디렉토리',
deleteSourceFile: '원본 파일 삭제 여부',
convertHelper: '선택한 파일을 다른 형식으로 변환합니다',
convertHelper1: '변환할 파일을 선택하세요',
execConvert: '변환을 시작합니다. 작업 센터에서 변환 로그를 확인할 수 있습니다',
convertLogs: '변환 로그',
formatConvert: '형식 변환',
},
ssh: {
autoStart: '자동 시작',

View File

@@ -1537,6 +1537,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',

View File

@@ -1527,6 +1527,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',

View File

@@ -1530,6 +1530,19 @@ const message = {
cancelUploadHelper: 'Отменить загрузку или нет, после отмены список загрузок будет очищен.',
keepOneTab: 'Необходимо оставить как минимум одну вкладку',
notCanTab: 'Невозможно добавить больше вкладок',
convert: 'Конвертировать формат',
converting: 'Конвертация в',
fileCanNotConvert: 'Этот файл не поддерживает конвертацию формата',
formatType: 'Тип формата',
sourceFormat: 'Исходный формат',
sourceFile: 'Исходный файл',
saveDir: 'Каталог сохранения',
deleteSourceFile: 'Удалить исходный файл',
convertHelper: 'Конвертировать выбранные файлы в другой формат',
convertHelper1: 'Пожалуйста, выберите файлы для конвертации',
execConvert: 'Начать конвертацию. Вы можете просмотреть журналы конвертации в Центре задач',
convertLogs: 'Журналы конвертации',
formatConvert: 'Конвертация формата',
},
ssh: {
autoStart: 'Автозапуск',

View File

@@ -1566,6 +1566,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 Merkezinde görüntüleyebilirsiniz',
convertLogs: 'Dönüştürme Günlükleri',
formatConvert: 'Format Dönüştürme',
},
ssh: {
autoStart: 'Otomatik başlat',

View File

@@ -1474,6 +1474,19 @@ const message = {
cancelUploadHelper: '是否取消上傳取消後將清空上傳列表',
keepOneTab: '至少保留一個分頁',
notCanTab: '無法新增更多分頁',
convert: '文件格式轉換',
converting: '轉換為',
fileCanNotConvert: '此文件不支援轉換格式',
formatType: '格式類型',
sourceFormat: '來源格式',
sourceFile: '來源檔案',
saveDir: '儲存目錄',
deleteSourceFile: '是否刪除來源檔案',
convertHelper: '將選中的檔案轉換為其他格式',
convertHelper1: '請選擇需要轉換格式的檔案',
execConvert: '開始轉換可在任務中心查看轉換日誌',
convertLogs: '轉換日誌',
formatConvert: '格式轉換',
},
ssh: {
autoStart: '開機自啟',

View File

@@ -1470,6 +1470,19 @@ const message = {
cancelUploadHelper: '是否取消上传取消后将清空上传列表',
keepOneTab: '至少保留一个标签页',
notCanTab: '不可增加更多的标签页',
convert: '转换格式',
converting: '转换为',
fileCanNotConvert: '此文件不支持转换格式',
formatType: '格式类型',
sourceFormat: '源格式',
sourceFile: '源文件',
saveDir: '保存目录',
deleteSourceFile: '是否删除源文件',
convertHelper: '是否为选中的文件进行格式转换',
convertHelper1: '请选择需要转换格式的文件',
execConvert: '开始转换可以在任务中心查看转换日志',
convertLogs: '转换日志',
formatConvert: '格式转换',
},
ssh: {
autoStart: '开机自启',

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -0,0 +1,408 @@
<template>
<div>
<DrawerPro v-model="open" :header="$t('file.formatConvert')" @close="handleClose" size="60%">
<template #content>
<el-tabs v-model="activeName" type="card" @tab-click="handleClick">
<el-tab-pane :label="$t('file.formatConvert')" name="convert">
<el-form v-loading="loading">
<div class="flex-row justify-start items-center">
<el-button type="primary" plain :icon="Plus" @click="addFile" />
</div>
<ComplexTable v-model:selects="selects" :data="data">
<el-table-column fixed type="selection" width="30" />
<el-table-column
:label="$t('file.sourceFile')"
show-overflow-tooltip
prop="inputFile"
min-width="150"
></el-table-column>
<el-table-column
:label="$t('file.sourceFormat')"
show-overflow-tooltip
prop="extension"
width="90"
></el-table-column>
<el-table-column prop="outputFile" width="160">
<template #header>
<div class="flex items-center justify-start gap-x-1">
<span>{{ $t('file.converting') }}</span>
<el-select
v-model="handleOutputFile"
placeholder="Select"
size="small"
style="width: 90px"
class="font-normal"
@change="handleHeaderSelectChange"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</el-select>
</div>
</template>
<template #default="{ row }">
<el-select
v-model="row.outputFormat"
style="width: 135px"
:value="row.outputFormat || getOptionsByType(row.type)[0]?.value"
>
<el-option
v-for="item in getOptionsByType(row.type)"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</el-select>
</template>
</el-table-column>
<fu-table-operations
:buttons="buttons"
:label="$t('commons.table.operate')"
fix
width="100"
/>
</ComplexTable>
<div class="flex-row justify-start items-center pt-6">
<div class="flex justify-start items-center">
<el-breadcrumb-item>{{ $t('file.saveDir') + ':' }}</el-breadcrumb-item>
<el-breadcrumb separator="/">
<el-breadcrumb-item>{{ outputPath }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<el-checkbox v-model="deleteSource" :label="$t('file.deleteSourceFile')" size="large" />
</div>
</el-form>
</el-tab-pane>
<el-tab-pane :label="$t('file.convertLogs')" name="log">
<ComplexTable :pagination-config="paginationConfig" @search="search()" :data="logs">
<el-table-column
:label="$t('commons.table.date')"
show-overflow-tooltip
prop="date"
width="160"
></el-table-column>
<el-table-column
:label="$t('commons.table.type')"
show-overflow-tooltip
prop="type"
width="60"
></el-table-column>
<el-table-column :label="$t('commons.button.log')" prop="log" min-width="160">
<template #default="{ row }">
<el-tooltip :content="row.log" placement="top">
{{ row.log }}
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.message')" prop="status" width="100">
<template #default="{ row }">
<el-tooltip :content="row.message" placement="top">
<el-button
:type="
row.status === 'FAILED'
? 'danger'
: row.status === 'SUCCESS'
? 'success'
: 'default'
"
link
>
{{ row.status }}
</el-button>
</el-tooltip>
</template>
</el-table-column>
</ComplexTable>
</el-tab-pane>
</el-tabs>
</template>
<template #footer v-if="activeName === 'convert'">
<span class="dialog-footer">
<el-button @click="removeSelected" :disabled="selects.length === 0">
{{ $t('commons.button.delete') }}
</el-button>
<el-button type="primary" @click="convert" :disabled="selects.length === 0">
{{ $t('commons.button.handle') }}
</el-button>
</span>
</template>
</DrawerPro>
<FileList ref="chooseFileRef" @choose="chooseFile" />
<TaskLog ref="taskLogRef" />
</div>
</template>
<script setup lang="ts">
import i18n from '@/lang';
import { reactive, ref } from 'vue';
import { File } from '@/api/interface/file';
import FileList from '@/components/file-list/index.vue';
import { Plus } from '@element-plus/icons-vue';
import { MsgWarning, MsgSuccess } from '@/utils/message';
import { convertFiles, convertLogs, getFileContent } from '@/api/modules/files';
import { getFileType, isConvertible } from '@/utils/util';
import { v4 as uuidv4 } from 'uuid';
import TaskLog from '@/components/log/task/index.vue';
import { TabsPaneContext } from 'element-plus';
import { GlobalStore } from '@/store';
const globalStore = GlobalStore();
const activeName = ref('convert');
const open = ref(false);
const loading = ref(false);
const deleteSource = ref(false);
const handleOutputFile = ref('png');
const chooseFileRef = ref();
const outputPath = ref('');
const data = ref<File.ConvertFile[]>([]);
const em = defineEmits(['close']);
let selects = ref<any>([]);
const taskLogRef = ref();
const logs = ref([]);
const CONVERT_KEY = 'convert-files:' + globalStore.currentNode;
const handleClose = () => {
open.value = false;
handleOutputFile.value = 'png';
activeName.value = 'convert';
em('close', false);
};
interface DialogProps {
files: File.ConvertFile[];
outputPath: string;
}
const options = [
{ value: 'png', label: 'png', type: 'image' },
{ value: 'jpg', label: 'jpg', type: 'image' },
{ value: 'webp', label: 'webp', type: 'image' },
{ value: 'gif', label: 'gif', type: 'image' },
{ value: 'jpeg', label: 'jpeg', type: 'image' },
{ value: 'bmp', label: 'bmp', type: 'image' },
{ value: 'tiff', label: 'tiff', type: 'image' },
{ value: 'mp4', label: 'mp4', type: 'video' },
{ value: 'avi', label: 'avi', type: 'video' },
{ value: 'mov', label: 'mov', type: 'video' },
{ value: 'mkv', label: 'mkv', type: 'video' },
{ value: 'mp3', label: 'mp3', type: 'audio' },
{ value: 'wav', label: 'wav', type: 'audio' },
{ value: 'flac', label: 'flac', type: 'audio' },
{ value: 'aac', label: 'aac', type: 'audio' },
];
let req = reactive({
name: '',
page: 1,
pageSize: 10,
orderBy: 'favorite',
order: 'descending',
});
const getOptionsByType = (type: string) => {
return options.filter((opt) => opt.type === type);
};
const initTableData = () => {
data.value.map((row) => {
const matchedOption = getOptionsByType(row.type).find((item) => row.extension.concat(item.value));
const defaultOption = getOptionsByType(row.type)[0]?.value;
row.outputFormat = matchedOption?.value || defaultOption;
});
};
const handleHeaderSelectChange = (value: string) => {
if (value && data.value.length) {
data.value.forEach((row) => {
const isValid = getOptionsByType(row.type).some((item) => item.value === value);
row.outputFormat = isValid ? value : row.outputFormat;
});
}
};
const addFile = () => {
chooseFileRef.value?.acceptParams({ dir: false });
};
const paginationConfig = reactive({
cacheSizeKey: 'file-convert-log-page-size',
currentPage: 1,
pageSize: Number(localStorage.getItem('file-convert-log-page-size')) || 20,
total: 0,
});
const search = async () => {
req.page = paginationConfig.currentPage;
req.pageSize = paginationConfig.pageSize;
loading.value = true;
data.value = [];
await convertLogs(req)
.then((res) => {
logs.value = res.data.items;
paginationConfig.total = res.data.total;
})
.finally(() => {
loading.value = false;
});
};
const chooseFile = async (path: string) => {
await getFileContent({ path: path, expand: false, page: 1, pageSize: 1, isDetail: true }).then((res) => {
if (!isConvertible(res.data.extension, res.data.mimeType)) {
MsgWarning(i18n.global.t('file.fileCanNotConvert'));
return;
}
updateFiles([
{
type: getFileType(res.data.extension),
inputFile: res.data.name,
extension: res.data.extension,
path: getDirPath(res.data.path, res.data.name),
outputFormat: res.data.extension.slice(1),
},
]);
});
};
const getDirPath = (fullPath: string, fileName: string): string => {
if (!fullPath || !fileName) return fullPath;
if (fullPath.endsWith(fileName)) {
const dir = fullPath.slice(0, fullPath.length - fileName.length);
return dir.replace(/\/$/, '');
}
return fullPath;
};
const delFile = async (path: string, name: string) => {
ElMessageBox.confirm(i18n.global.t('file.deleteHelper2'), i18n.global.t('commons.msg.remove'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
}).then(async () => {
try {
loading.value = true;
removeByPathAndName(path, name);
} catch (error) {
} finally {
loading.value = false;
}
});
};
const removeByPathAndName = (path: string, name: string) => {
const fullPath = `${path}/${name}`;
data.value = data.value.filter((item) => `${item.path}/${item.inputFile}` !== fullPath);
saveFiles();
};
const convert = async () => {
if (selects.value.length > 0) {
ElMessageBox.confirm(i18n.global.t('file.convertHelper'), i18n.global.t('file.convert'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
}).then(async () => {
loading.value = true;
const reqFiles = selects.value;
const output = outputPath.value;
const deleteSrc = deleteSource.value;
const taskID = uuidv4();
const singleReq = {
files: reqFiles,
outputPath: output,
deleteSource: deleteSrc,
taskID: taskID,
};
await convertFiles(singleReq)
.then(() => {
removeSelected();
deleteSource.value = false;
MsgSuccess(i18n.global.t('file.execConvert'));
openTaskLog(taskID);
})
.finally(() => {
loading.value = false;
});
});
} else {
MsgWarning(i18n.global.t('file.convertHelper1'));
}
};
const openTaskLog = (taskID: string) => {
taskLogRef.value.openWithTaskID(taskID);
};
const buttons = [
{
label: i18n.global.t('commons.button.delete'),
click: (row: any) => {
delFile(row.path, row.inputFile);
},
},
];
const loadFiles = () => {
const stored = localStorage.getItem(CONVERT_KEY);
if (stored) {
try {
data.value = JSON.parse(stored);
} catch {
data.value = [];
}
} else {
data.value = [];
}
};
const saveFiles = () => {
localStorage.setItem(CONVERT_KEY, JSON.stringify(data.value));
};
const updateFiles = (newFiles: File.ConvertFile[]) => {
const stored = localStorage.getItem(CONVERT_KEY);
if (stored) {
try {
const oldFiles: File.ConvertFile[] = JSON.parse(stored);
const merged = [...oldFiles];
for (const file of newFiles) {
if (!merged.some((item) => item.path === file.path && item.inputFile === file.inputFile)) {
merged.push(file);
}
}
data.value = merged;
} catch {
data.value = [...newFiles];
}
} else {
data.value = [...newFiles];
}
saveFiles();
};
const handleClick = (tab: TabsPaneContext) => {
if (tab.paneName == 'log') {
search();
} else {
loadFiles();
}
};
const removeSelected = () => {
const selectedKeys = new Set(selects.value.map((s) => `${s.path}/${s.inputFile}`));
data.value = data.value.filter((d) => !selectedKeys.has(`${d.path}/${d.inputFile}`));
saveFiles();
};
const acceptParams = (param: DialogProps): void => {
open.value = true;
outputPath.value = param.outputPath;
loadFiles();
updateFiles(param.files);
initTableData();
};
defineExpose({ acceptParams });
</script>

View File

@@ -638,6 +638,7 @@
<VscodeOpenDialog ref="dialogVscodeOpenRef" />
<Preview ref="previewRef" />
<TerminalDialog ref="dialogTerminalRef" />
<Convert ref="convertRef" @close="search" />
</div>
</template>
@@ -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<Record<string, any>>({});
@@ -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);