From 0ddb731e9cda70d1bcdaa9f56318427630d54bb7 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:56:45 +0800 Subject: [PATCH 1/2] feat: Add file content preview functionality - Implemented a new API endpoint for previewing file content. - Added PreviewContent method in BaseApi to handle requests. - Introduced GetPreviewContent method in FileService to retrieve file previews. - Updated frontend to include a TextPreview component for displaying file previews. - Added localization support for preview-related messages in multiple languages. - Enhanced file management view to support previewing large files. --- agent/app/api/v2/file.go | 23 +++ agent/app/dto/request/file.go | 1 - agent/app/service/file.go | 77 ++++++++ agent/router/ro_file.go | 1 + agent/utils/files/fileinfo.go | 1 + frontend/src/api/modules/files.ts | 4 + frontend/src/lang/modules/en.ts | 3 + frontend/src/lang/modules/es-es.ts | 3 + frontend/src/lang/modules/ja.ts | 3 + frontend/src/lang/modules/ko.ts | 3 + frontend/src/lang/modules/ms.ts | 3 + frontend/src/lang/modules/pt-br.ts | 3 + frontend/src/lang/modules/ru.ts | 3 + frontend/src/lang/modules/tr.ts | 3 + frontend/src/lang/modules/zh-Hant.ts | 3 + frontend/src/lang/modules/zh.ts | 3 + .../src/views/host/file-management/index.vue | 28 ++- .../file-management/text-preview/index.vue | 174 ++++++++++++++++++ 18 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/host/file-management/text-preview/index.vue diff --git a/agent/app/api/v2/file.go b/agent/app/api/v2/file.go index ffa4972f55ec..65159a447c44 100644 --- a/agent/app/api/v2/file.go +++ b/agent/app/api/v2/file.go @@ -272,6 +272,29 @@ func (b *BaseApi) GetContent(c *gin.Context) { } } +// @Tags File +// @Summary Preview file content +// @Accept json +// @Param request body request.FileContentReq true "request" +// @Success 200 {object} response.FileInfo +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /files/preview [post] +// @x-panel-log {"bodyKeys":["path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"预览文件内容 [path]","formatEN":"Preview file content [path]"} +func (b *BaseApi) PreviewContent(c *gin.Context) { + var req request.FileContentReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + info, err := fileService.GetPreviewContent(req) + if err != nil { + helper.InternalServer(c, err) + return + } + + helper.SuccessWithData(c, info) +} + // @Tags File // @Summary Update file content // @Accept json diff --git a/agent/app/dto/request/file.go b/agent/app/dto/request/file.go index 6b8100b1c05b..51b0fd55fa67 100644 --- a/agent/app/dto/request/file.go +++ b/agent/app/dto/request/file.go @@ -13,7 +13,6 @@ type FileContentReq struct { Path string `json:"path" validate:"required"` IsDetail bool `json:"isDetail"` } - type SearchUploadWithPage struct { dto.PageInfo Path string `json:"path" validate:"required"` diff --git a/agent/app/service/file.go b/agent/app/service/file.go index d50943eaae93..95d7a18710b0 100644 --- a/agent/app/service/file.go +++ b/agent/app/service/file.go @@ -56,6 +56,7 @@ type IFileService interface { Compress(c request.FileCompress) error DeCompress(c request.FileDeCompress) error GetContent(op request.FileContentReq) (response.FileInfo, error) + GetPreviewContent(op request.FileContentReq) (response.FileInfo, error) SaveContent(edit request.FileEdit) error FileDownload(d request.FileDownload) (string, error) DirSize(req request.DirSizeReq) (response.DirSizeRes, error) @@ -374,6 +375,82 @@ func (f *FileService) GetContent(op request.FileContentReq) (response.FileInfo, return response.FileInfo{FileInfo: *info}, nil } +func (f *FileService) GetPreviewContent(op request.FileContentReq) (response.FileInfo, error) { + info, err := files.NewFileInfo(files.FileOption{ + Path: op.Path, + Expand: false, + IsDetail: op.IsDetail, + }) + if err != nil { + return response.FileInfo{}, err + } + + if files.IsBlockDevice(info.FileMode) { + return response.FileInfo{FileInfo: *info}, nil + } + + file, err := os.Open(op.Path) + if err != nil { + return response.FileInfo{}, err + } + defer file.Close() + + headBuf := make([]byte, 1024) + n, err := file.Read(headBuf) + if err != nil && err != io.EOF { + return response.FileInfo{}, err + } + headBuf = headBuf[:n] + + if len(headBuf) > 0 && files.DetectBinary(headBuf) { + return response.FileInfo{FileInfo: *info}, nil + } + + const maxSize = 10 * 1024 * 1024 + if info.Size <= maxSize { + if _, err := file.Seek(0, 0); err != nil { + return response.FileInfo{}, err + } + content, err := io.ReadAll(file) + if err != nil { + return response.FileInfo{}, err + } + info.Content = string(content) + } else { + lines, err := files.TailFromEnd(op.Path, 300) + if err != nil { + return response.FileInfo{}, err + } + info.Content = strings.Join(lines, "\n") + } + + content := []byte(info.Content) + if len(content) > 1024 { + content = content[:1024] + } + if !utf8.Valid(content) { + _, decodeName, _ := charset.DetermineEncoding(content, "") + decoder := files.GetDecoderByName(decodeName) + if decoder != nil { + reader := strings.NewReader(info.Content) + var dec *encoding.Decoder + if decodeName == "windows-1252" { + dec = simplifiedchinese.GBK.NewDecoder() + } else { + dec = decoder.NewDecoder() + } + decodedReader := transform.NewReader(reader, dec) + contents, err := io.ReadAll(decodedReader) + if err != nil { + return response.FileInfo{}, err + } + info.Content = string(contents) + } + } + + return response.FileInfo{FileInfo: *info}, nil +} + func (f *FileService) SaveContent(edit request.FileEdit) error { info, err := files.NewFileInfo(files.FileOption{ Path: edit.Path, diff --git a/agent/router/ro_file.go b/agent/router/ro_file.go index 8c1ecda06a70..d47c339e457a 100644 --- a/agent/router/ro_file.go +++ b/agent/router/ro_file.go @@ -23,6 +23,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) { fileRouter.POST("/compress", baseApi.CompressFile) fileRouter.POST("/decompress", baseApi.DeCompressFile) fileRouter.POST("/content", baseApi.GetContent) + fileRouter.POST("/preview", baseApi.PreviewContent) fileRouter.POST("/save", baseApi.SaveContent) fileRouter.POST("/check", baseApi.CheckFile) fileRouter.POST("/batch/check", baseApi.BatchCheckFiles) diff --git a/agent/utils/files/fileinfo.go b/agent/utils/files/fileinfo.go index 1c4a2786ce85..2a0816bc2130 100644 --- a/agent/utils/files/fileinfo.go +++ b/agent/utils/files/fileinfo.go @@ -46,6 +46,7 @@ type FileInfo struct { ItemTotal int `json:"itemTotal"` FavoriteID uint `json:"favoriteID"` IsDetail bool `json:"isDetail"` + } type FileOption struct { diff --git a/frontend/src/api/modules/files.ts b/frontend/src/api/modules/files.ts index c516faf142ba..fe41f65f46e0 100644 --- a/frontend/src/api/modules/files.ts +++ b/frontend/src/api/modules/files.ts @@ -54,6 +54,10 @@ export const getFileContent = (params: File.ReqFile) => { return http.post('files/content', params); }; +export const getPreviewContent = (params: File.ReqFile) => { + return http.post('files/preview', params, TimeoutEnum.T_5M); +}; + export const saveFileContent = (params: File.FileEdit) => { return http.post('files/save', params); }; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 7f5f663e6acc..88d6008b41b1 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1576,6 +1576,9 @@ const message = { noNameFile: 'Untitled file', minimap: 'Code mini map', fileCanNotRead: 'File can not read', + previewTruncated: 'File is too large, only showing the last part', + previewEmpty: 'File is empty or not a text file', + previewLargeFile: 'Preview', panelInstallDir: `1Panel installation directory can't be deleted`, wgetTask: 'Download Task', existFileTitle: 'Same name file prompt', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index efdb2a00fe13..cf20c1811fa4 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1578,6 +1578,9 @@ const message = { noNameFile: 'Archivo sin nombre', minimap: 'Mapa de código', fileCanNotRead: 'No se puede leer el archivo', + previewTruncated: 'El archivo es demasiado grande, solo se muestra la última parte', + previewEmpty: 'El archivo está vacío o no es un archivo de texto', + previewLargeFile: 'Vista previa', panelInstallDir: 'El directorio de instalación de 1Panel no puede eliminarse', wgetTask: 'Tarea de descarga', existFileTitle: 'Archivo con el mismo nombre', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index fa3d4566c7b2..9042e2f50fe5 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1527,6 +1527,9 @@ const message = { noNameFile: '無題のファイル', minimap: 'コードミニマップ', fileCanNotRead: 'ファイルは読み取れません', + previewTruncated: 'ファイルが大きすぎるため、末尾の内容のみ表示しています', + previewEmpty: 'ファイルが空であるか、テキストファイルではありません', + previewLargeFile: 'プレビュー', panelInstallDir: `1Panelインストールディレクトリは削除できません`, wgetTask: 'ダウンロードタスク', existFileTitle: '同名ファイルの警告', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 271707d08e91..24520ccb21a9 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1509,6 +1509,9 @@ const message = { noNameFile: '제목 없는 파일', minimap: '코드 미니맵', fileCanNotRead: '파일을 읽을 수 없습니다.', + previewTruncated: '파일이 너무 커서 마지막 부분만 표시됩니다', + previewEmpty: '파일이 비어 있거나 텍스트 파일이 아닙니다', + previewLargeFile: '미리보기', panelInstallDir: `1Panel 설치 디렉터리는 삭제할 수 없습니다.`, wgetTask: '다운로드 작업', existFileTitle: '동일한 이름의 파일 경고', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 198dd39db25f..0536fce1a869 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1568,6 +1568,9 @@ const message = { noNameFile: 'Fail tanpa nama', minimap: 'Peta mini kod', fileCanNotRead: 'Fail tidak dapat dibaca', + previewTruncated: 'Fail terlalu besar, hanya menunjukkan bahagian terakhir', + previewEmpty: 'Fail kosong atau bukan fail teks', + previewLargeFile: 'Pratonton', panelInstallDir: 'Direktori pemasangan 1Panel tidak boleh dipadamkan', wgetTask: 'Tugas Muat Turun', existFileTitle: 'Amaran fail dengan nama yang sama', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index a9e84d0599b5..93ceed1c2cc3 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1558,6 +1558,9 @@ const message = { noNameFile: 'Arquivo sem nome', minimap: 'Mini mapa de código', fileCanNotRead: 'O arquivo não pode ser lido', + previewTruncated: 'O arquivo é muito grande, mostrando apenas a última parte', + previewEmpty: 'O arquivo está vazio ou não é um arquivo de texto', + previewLargeFile: 'Visualizar', panelInstallDir: 'O diretório de instalação do 1Panel não pode ser excluído', wgetTask: 'Tarefa de Download', existFileTitle: 'Aviso de arquivo com o mesmo nome', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 4235e0a79a5e..b5e0f4bffaac 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1559,6 +1559,9 @@ const message = { noNameFile: 'Безымянный файл', minimap: 'Мини-карта кода', fileCanNotRead: 'Файл не может быть прочитан', + previewTruncated: 'Файл слишком большой, отображается только последняя часть', + previewEmpty: 'Файл пуст или не является текстовым файлом', + previewLargeFile: 'Предпросмотр', panelInstallDir: 'Директорию установки 1Panel нельзя удалить', wgetTask: 'Задача загрузки', existFileTitle: 'Предупреждение о файле с тем же именем', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index ce1a1bfd3825..ac73eea84d9f 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1593,6 +1593,9 @@ const message = { noNameFile: 'İsimsiz dosya', minimap: 'Kod mini haritası', fileCanNotRead: 'Dosya okunamıyor', + previewTruncated: 'Dosya çok büyük, yalnızca son kısım gösteriliyor', + previewEmpty: 'Dosya boş veya metin dosyası değil', + previewLargeFile: 'Önizleme', panelInstallDir: '1Panel kurulum dizini silinemez', wgetTask: 'İndirme Görevi', existFileTitle: 'Aynı ada sahip dosya uyarısı', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 533e407be5dc..59b990c82498 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1494,6 +1494,9 @@ const message = { noNameFile: '未命名檔案', minimap: '縮圖', fileCanNotRead: '此文件不支援預覽', + previewTruncated: '檔案過大,僅顯示末尾內容', + previewEmpty: '檔案內容為空或不是文字檔案', + previewLargeFile: '預覽', panelInstallDir: '1Panel 安裝目錄不能刪除', wgetTask: '下載任務', existFileTitle: '同名檔案提示', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index eff185c14766..3df15ccc0de6 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1496,6 +1496,9 @@ const message = { noNameFile: '未命名文件', minimap: '缩略图', fileCanNotRead: '此文件不支持预览', + previewTruncated: '文件过大,仅显示末尾内容', + previewEmpty: '文件内容为空或不是文本文件', + previewLargeFile: '预览', panelInstallDir: '1Panel 安装目录不能删除', wgetTask: '下载任务', existFileTitle: '同名文件提示', diff --git a/frontend/src/views/host/file-management/index.vue b/frontend/src/views/host/file-management/index.vue index 07a6693a0e5d..7795d8fcec3c 100644 --- a/frontend/src/views/host/file-management/index.vue +++ b/frontend/src/views/host/file-management/index.vue @@ -627,6 +627,7 @@ + @@ -684,6 +685,7 @@ import RecycleBin from './recycle-bin/index.vue'; import Favorite from './favorite/index.vue'; import BatchRole from './batch-role/index.vue'; import Preview from './preview/index.vue'; +import TextPreview from './text-preview/index.vue'; import VscodeOpenDialog from '@/components/vscode-open/index.vue'; import Convert from './convert/index.vue'; import { debounce } from 'lodash-es'; @@ -786,7 +788,10 @@ const favorites = ref([]); const batchRoleRef = ref(); const dialogVscodeOpenRef = ref(); const previewRef = ref(); +const textPreviewRef = ref(); const processRef = ref(); + +const MAX_OPEN_SIZE = 10 * 1024 * 1024; const hostMount = ref([]); let resizeObserver: ResizeObserver; const dirTotalSize = ref(-1); @@ -1247,12 +1252,16 @@ const openView = (item: File.File) => { return openPreview(item, fileType); } + const path = item.isSymlink ? item.linkPath : item.path; + if (item.size > MAX_OPEN_SIZE) { + return openTextPreview(path, item.name); + } + const actionMap = { compress: openDeCompress, text: () => openCodeEditor(item.path, item.extension), }; - const path = item.isSymlink ? item.linkPath : item.path; return actionMap[fileType] ? actionMap[fileType](item) : openCodeEditor(path, item.extension); }; @@ -1296,6 +1305,10 @@ const openCodeEditor = (path: string, extension: string) => { .catch(() => {}); }; +const openTextPreview = (path: string, name: string) => { + textPreviewRef.value.acceptParams({ path, name }); +}; + const openUpload = () => { fileUpload.path = req.path; uploadRef.value.acceptParams(fileUpload); @@ -1544,6 +1557,19 @@ const beforeButtons = [ { label: i18n.global.t('commons.button.open'), click: open, + show: (row: File.File) => { + return row?.isDir || row?.size <= MAX_OPEN_SIZE; + }, + }, + { + label: i18n.global.t('file.previewLargeFile'), + click: (row: File.File) => { + const path = row.isSymlink ? row.linkPath : row.path; + openTextPreview(path, row.name); + }, + show: (row: File.File) => { + return !row?.isDir && row?.size > MAX_OPEN_SIZE; + }, }, { label: i18n.global.t('commons.button.download'), diff --git a/frontend/src/views/host/file-management/text-preview/index.vue b/frontend/src/views/host/file-management/text-preview/index.vue new file mode 100644 index 000000000000..996d56310598 --- /dev/null +++ b/frontend/src/views/host/file-management/text-preview/index.vue @@ -0,0 +1,174 @@ + + + + + From e7d724a1db09d6040ed99da4812327aede520d7b Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:59:41 +0800 Subject: [PATCH 2/2] feat: Update file preview functionality and interface - Added PreviewContentReq interface to define request parameters for file preview. - Updated getPreviewContent function to use the new PreviewContentReq type. - Modified text-preview component to align with updated API, removing unnecessary parameters. --- agent/utils/files/fileinfo.go | 1 - frontend/src/api/interface/file.ts | 5 +++++ frontend/src/api/modules/files.ts | 2 +- .../src/views/host/file-management/text-preview/index.vue | 3 +-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/agent/utils/files/fileinfo.go b/agent/utils/files/fileinfo.go index 2a0816bc2130..1c4a2786ce85 100644 --- a/agent/utils/files/fileinfo.go +++ b/agent/utils/files/fileinfo.go @@ -46,7 +46,6 @@ type FileInfo struct { ItemTotal int `json:"itemTotal"` FavoriteID uint `json:"favoriteID"` IsDetail bool `json:"isDetail"` - } type FileOption struct { diff --git a/frontend/src/api/interface/file.ts b/frontend/src/api/interface/file.ts index 72392da49909..98641c1afd64 100644 --- a/frontend/src/api/interface/file.ts +++ b/frontend/src/api/interface/file.ts @@ -41,6 +41,11 @@ export namespace File { node: string; } + export interface PreviewContentReq { + path: string; + isDetail?: boolean; + } + export interface SearchUploadInfo extends ReqPage { path: string; } diff --git a/frontend/src/api/modules/files.ts b/frontend/src/api/modules/files.ts index fe41f65f46e0..4fda2fd48a65 100644 --- a/frontend/src/api/modules/files.ts +++ b/frontend/src/api/modules/files.ts @@ -54,7 +54,7 @@ export const getFileContent = (params: File.ReqFile) => { return http.post('files/content', params); }; -export const getPreviewContent = (params: File.ReqFile) => { +export const getPreviewContent = (params: File.PreviewContentReq) => { return http.post('files/preview', params, TimeoutEnum.T_5M); }; diff --git a/frontend/src/views/host/file-management/text-preview/index.vue b/frontend/src/views/host/file-management/text-preview/index.vue index 996d56310598..820908c4b0c6 100644 --- a/frontend/src/views/host/file-management/text-preview/index.vue +++ b/frontend/src/views/host/file-management/text-preview/index.vue @@ -104,11 +104,10 @@ const acceptParams = async (props: PreviewProps) => { loading.value = true; try { - const res = await getPreviewContent({ path: props.path, expand: false }); + const res = await getPreviewContent({ path: props.path }); if (res.data.content) { lines.value = res.data.content.split('\n'); hasContent.value = true; - // 如果文件大于 10MB,内容是截断的末尾部分 if (res.data.size > 10 * 1024 * 1024) { isTruncated.value = true; }