diff --git a/agent/app/api/v2/file.go b/agent/app/api/v2/file.go index 65159a447c44..277630cb1ce3 100644 --- a/agent/app/api/v2/file.go +++ b/agent/app/api/v2/file.go @@ -978,3 +978,40 @@ func (b *BaseApi) ConvertLog(c *gin.Context) { Total: total, }) } + +// @Tags File +// @Summary Batch get file remarks +// @Accept json +// @Param request body request.FileRemarkBatch true "request" +// @Success 200 {object} response.FileRemarksRes +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /files/remarks [post] +func (b *BaseApi) BatchGetFileRemarks(c *gin.Context) { + var req request.FileRemarkBatch + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + remarks := fileService.BatchGetRemarks(req) + helper.SuccessWithData(c, response.FileRemarksRes{Remarks: remarks}) +} + +// @Tags File +// @Summary Set file remark +// @Accept json +// @Param request body request.FileRemarkUpdate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /files/remark [post] +func (b *BaseApi) SetFileRemark(c *gin.Context) { + var req request.FileRemarkUpdate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := fileService.SetRemark(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} diff --git a/agent/app/dto/request/file.go b/agent/app/dto/request/file.go index 51b0fd55fa67..78f8a86b523a 100644 --- a/agent/app/dto/request/file.go +++ b/agent/app/dto/request/file.go @@ -163,3 +163,12 @@ type FileConvertRequest struct { DeleteSource bool `json:"deleteSource"` TaskID string `json:"taskID"` } + +type FileRemarkBatch struct { + Paths []string `json:"paths" validate:"required"` +} + +type FileRemarkUpdate struct { + Path string `json:"path" validate:"required"` + Remark string `json:"remark"` +} diff --git a/agent/app/dto/response/file.go b/agent/app/dto/response/file.go index 60ea92e4bb4e..14add3d6e29a 100644 --- a/agent/app/dto/response/file.go +++ b/agent/app/dto/response/file.go @@ -81,3 +81,7 @@ type FileConvertLog struct { Status string `json:"status"` Message string `json:"message"` } + +type FileRemarksRes struct { + Remarks map[string]string `json:"remarks"` +} diff --git a/agent/app/service/file.go b/agent/app/service/file.go index 50882ec16079..5b64500b1a91 100644 --- a/agent/app/service/file.go +++ b/agent/app/service/file.go @@ -3,6 +3,7 @@ package service import ( "bufio" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -75,12 +76,19 @@ type IFileService interface { GetUsersAndGroups() (*response.UserGroupResponse, error) Convert(req request.FileConvertRequest) ConvertLog(req dto.PageInfo) (int64, []response.FileConvertLog, error) + BatchGetRemarks(req request.FileRemarkBatch) map[string]string + SetRemark(req request.FileRemarkUpdate) error } var filteredPaths = []string{ "/.1panel_clash", } +const ( + fileRemarkXattr = "user.1panel.remark" + fileRemarkEncodedMaxLen = 256 +) + func NewIFileService() IFileService { return &FileService{} } @@ -760,6 +768,52 @@ func (f *FileService) GetUsersAndGroups() (*response.UserGroupResponse, error) { }, nil } +func (f *FileService) BatchGetRemarks(req request.FileRemarkBatch) map[string]string { + remarks := make(map[string]string) + for _, filePath := range req.Paths { + remark, err := getFileRemark(filePath) + if err != nil { + if isXattrNotSupported(err) { + return map[string]string{} + } + continue + } + if remark == "" { + continue + } + remarks[filePath] = remark + } + + return remarks +} + +func (f *FileService) SetRemark(req request.FileRemarkUpdate) error { + if req.Remark == "" { + if err := unix.Lremovexattr(req.Path, fileRemarkXattr); err != nil { + if isXattrNotFound(err) { + return nil + } + if isXattrNotSupported(err) { + return buserr.WithDetail("ErrInvalidParams", "xattr not supported", err) + } + return err + } + return nil + } + + encoded := base64.StdEncoding.EncodeToString([]byte(req.Remark)) + if len(encoded) >= fileRemarkEncodedMaxLen { + return buserr.WithDetail("ErrInvalidParams", "remark length must be less than 256", nil) + } + if err := unix.Lsetxattr(req.Path, fileRemarkXattr, []byte(encoded), 0); err != nil { + if isXattrNotSupported(err) { + return buserr.WithDetail("ErrInvalidParams", "xattr not supported", err) + } + return err + } + return nil +} + func getValidGroups() (map[string]bool, error) { groupFile, err := os.Open("/etc/group") if err != nil { @@ -786,6 +840,37 @@ func getValidGroups() (map[string]bool, error) { return groupMap, nil } +func getFileRemark(filePath string) (string, error) { + size, err := unix.Lgetxattr(filePath, fileRemarkXattr, nil) + if err != nil { + if isXattrNotFound(err) { + return "", nil + } + return "", err + } + if size == 0 { + return "", nil + } + buf := make([]byte, size) + n, err := unix.Lgetxattr(filePath, fileRemarkXattr, buf) + if err != nil { + return "", err + } + decoded, err := base64.StdEncoding.DecodeString(string(buf[:n])) + if err != nil { + return "", err + } + return string(decoded), nil +} + +func isXattrNotSupported(err error) bool { + return errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EOPNOTSUPP) +} + +func isXattrNotFound(err error) bool { + return errors.Is(err, unix.ENODATA) +} + func getValidUsers(validGroups map[string]bool) ([]response.UserInfo, map[string]struct{}, error) { passwdFile, err := os.Open("/etc/passwd") if err != nil { diff --git a/agent/router/ro_file.go b/agent/router/ro_file.go index d47c339e457a..6c682434687a 100644 --- a/agent/router/ro_file.go +++ b/agent/router/ro_file.go @@ -25,6 +25,8 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) { fileRouter.POST("/content", baseApi.GetContent) fileRouter.POST("/preview", baseApi.PreviewContent) fileRouter.POST("/save", baseApi.SaveContent) + fileRouter.POST("/remarks", baseApi.BatchGetFileRemarks) + fileRouter.POST("/remark", baseApi.SetFileRemark) fileRouter.POST("/check", baseApi.CheckFile) fileRouter.POST("/batch/check", baseApi.BatchCheckFiles) fileRouter.POST("/upload", baseApi.UploadFiles) diff --git a/frontend/src/api/interface/file.ts b/frontend/src/api/interface/file.ts index 98641c1afd64..1892a95a47a7 100644 --- a/frontend/src/api/interface/file.ts +++ b/frontend/src/api/interface/file.ts @@ -23,6 +23,7 @@ export namespace File { extension: string; itemTotal: number; favoriteID: number; + remark?: string; } export interface ReqFile extends ReqPage { @@ -219,6 +220,15 @@ export namespace File { sub: boolean; } + export interface FileRemarkUpdate { + path: string; + remark: string; + } + + export interface FileRemarksRes { + remarks: Record; + } + export interface UserGroupResponse { users: UserInfo[]; groups: string[]; diff --git a/frontend/src/api/modules/files.ts b/frontend/src/api/modules/files.ts index 4fda2fd48a65..9b372ba3a689 100644 --- a/frontend/src/api/modules/files.ts +++ b/frontend/src/api/modules/files.ts @@ -74,6 +74,14 @@ export const batchCheckFiles = (paths: string[]) => { return http.post('files/batch/check', { paths: paths }, TimeoutEnum.T_5M); }; +export const batchGetFileRemarks = (paths: string[]) => { + return http.post('files/remarks', { paths: paths }, TimeoutEnum.T_5M); +}; + +export const setFileRemark = (params: File.FileRemarkUpdate) => { + return http.post('files/remark', params); +}; + export const chunkUploadFileData = (params: FormData, config: AxiosRequestConfig) => { return http.upload('files/chunkupload', params, config); }; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index f226b27f327b..e46f696a27c7 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1551,6 +1551,12 @@ const message = { pasteMsg: 'Please click the [Paste] button at the top right of the target directory', move: 'Move', calculate: 'Calculate', + remark: 'Remark', + setRemark: 'Set remark', + remarkPrompt: 'Enter a remark', + remarkPlaceholder: 'Remark', + remarkToggle: 'Remarks', + remarkToggleTip: 'Load file remarks', canNotDeCompress: 'Cannot decompress this file', uploadSuccess: 'Successfully upload', downloadProcess: 'Download progress', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 29d24f6e1464..5508eb988e0e 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1559,6 +1559,12 @@ const message = { pasteMsg: 'Por favor haz clic en el botón [Pegar] en la parte superior derecha del directorio de destino', move: 'Mover', calculate: 'Calcular', + remark: 'Observación', + setRemark: 'Establecer observación', + remarkPrompt: 'Ingrese una observación', + remarkPlaceholder: 'Observación', + remarkToggle: 'Notas', + remarkToggleTip: 'Cargar notas del archivo', canNotDeCompress: 'No se puede descomprimir este archivo', uploadSuccess: 'Carga completada correctamente', downloadProcess: 'Progreso de descarga', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 25c80cbd710a..d7e28898290c 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1505,6 +1505,12 @@ const message = { pasteMsg: '対象ディレクトリの右上にある「貼り付け」ボタンをクリックしてください', move: '動く', calculate: '計算します', + remark: '備考', + setRemark: '備考を設定', + remarkPrompt: '備考を入力してください', + remarkPlaceholder: '備考', + remarkToggle: '備考', + remarkToggleTip: 'ファイルの備考を読み込む', canNotDeCompress: 'このファイルを解凍できません', uploadSuccess: '正常にアップロードします', downloadProcess: '進捗状況をダウンロードします', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 7fce36b996f7..608574574b20 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1485,6 +1485,12 @@ const message = { pasteMsg: '대상 디렉토리의 오른쪽 상단에 있는 [붙여넣기] 버튼을 클릭하세요', move: '이동', calculate: '계산', + remark: '비고', + setRemark: '비고 설정', + remarkPrompt: '비고를 입력하세요', + remarkPlaceholder: '비고', + remarkToggle: '비고', + remarkToggleTip: '파일 비고 로드', canNotDeCompress: '이 파일은 압축 해제할 수 없습니다', uploadSuccess: '업로드 성공', downloadProcess: '다운로드 진행률', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index fee75e75bc43..dadc8f24d40e 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1545,6 +1545,12 @@ const message = { pasteMsg: 'Sila klik butang "Tampal" di bahagian kanan atas direktori sasaran', move: 'Pindah', calculate: 'Kira', + remark: 'Catatan', + setRemark: 'Tetapkan catatan', + remarkPrompt: 'Masukkan catatan', + remarkPlaceholder: 'Catatan', + remarkToggle: 'Catatan', + remarkToggleTip: 'Muatkan catatan fail', canNotDeCompress: 'Tidak dapat nyahmampatkan fail ini', uploadSuccess: 'Berjaya dimuat naik', downloadProcess: 'Kemajuan muat turun', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 24052769999d..85955205428f 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1533,6 +1533,12 @@ const message = { pasteMsg: 'Clique no botão "Colar" no canto superior direito do diretório de destino', move: 'Mover', calculate: 'Calcular', + remark: 'Observação', + setRemark: 'Definir observação', + remarkPrompt: 'Digite uma observação', + remarkPlaceholder: 'Observação', + remarkToggle: 'Observações', + remarkToggleTip: 'Carregar observações do arquivo', canNotDeCompress: 'Não é possível descompactar este arquivo', uploadSuccess: 'Upload bem-sucedido', downloadProcess: 'Progresso do download', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index aeae60138145..fc64720866e8 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1537,6 +1537,12 @@ const message = { pasteMsg: 'Нажмите кнопку «Вставить» в правом верхнем углу целевой директории', move: 'Переместить', calculate: 'Вычислить', + remark: 'Примечание', + setRemark: 'Задать примечание', + remarkPrompt: 'Введите примечание', + remarkPlaceholder: 'Примечание', + remarkToggle: 'Примечания', + remarkToggleTip: 'Загружать примечания файлов', canNotDeCompress: 'Невозможно распаковать этот файл', uploadSuccess: 'Успешно загружено', downloadProcess: 'Прогресс загрузки', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index beaac3e8b292..45520b4a7b0e 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1570,6 +1570,12 @@ const message = { pasteMsg: 'Hedef dizinin sağ üst köşesindeki "Yapıştır" düğmesine tıklayın', move: 'Taşı', calculate: 'Hesapla', + remark: 'Not', + setRemark: 'Not ekle', + remarkPrompt: 'Bir not girin', + remarkPlaceholder: 'Not', + remarkToggle: 'Notlar', + remarkToggleTip: 'Dosya notlarını yükle', canNotDeCompress: 'Bu dosyanın sıkıştırması açılamaz', uploadSuccess: 'Başarıyla yüklendi', downloadProcess: 'İndirme ilerlemesi', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 471fff2ddb48..fc49630b42ea 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1470,6 +1470,12 @@ const message = { pasteMsg: '請在目標目錄點擊右上角【貼上】按鈕', move: '移動', calculate: '計算', + remark: '備註', + setRemark: '設定備註', + remarkPrompt: '請輸入備註內容', + remarkPlaceholder: '備註內容', + remarkToggle: '備註', + remarkToggleTip: '載入檔案備註', canNotDeCompress: '無法解壓此文件', uploadSuccess: '上傳成功!', downloadProcess: '下載進度', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 4adb7c47fac5..a4affa3a587e 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1473,6 +1473,12 @@ const message = { pasteMsg: '请在目标目录点击右上角【粘贴】按钮', move: '移动', calculate: '计算', + remark: '备注', + setRemark: '设置备注', + remarkPrompt: '请输入备注内容', + remarkPlaceholder: '备注内容', + remarkToggle: '备注', + remarkToggleTip: '加载文件备注', canNotDeCompress: '无法解压此文件', uploadSuccess: '上传成功!', downloadProcess: '下载进度', diff --git a/frontend/src/views/host/file-management/index.vue b/frontend/src/views/host/file-management/index.vue index 550b75025f56..94ff10634c75 100644 --- a/frontend/src/views/host/file-management/index.vue +++ b/frontend/src/views/host/file-management/index.vue @@ -526,20 +526,14 @@ - - - @@ -573,6 +567,16 @@ show-overflow-tooltip :sortable="'custom'" > + + + (null); const dropdownMaxHeight = ref(450); const baseDir = ref(); +const remarkRequestId = ref(0); +const remarkLoadTimer = ref(null); const editableTabsKey = ref(''); const editableTabs = ref([ { id: '1', name: getLastPath(baseDir.value), path: baseDir.value }, @@ -881,6 +889,7 @@ const handleSearchResult = (res: ResultData) => { dirNum.value = data.value.filter((item) => item.isDir).length; fileNum.value = data.value.filter((item) => !item.isDir).length; req.path = res.data.path; + scheduleRemarkLoad(); }; const viewHideFile = async () => { @@ -1560,6 +1569,23 @@ const openWithVSCode = (row: File.File) => { dialogVscodeOpenRef.value.acceptParams({ path: row.path + (row.isDir ? '' : ':1:1') }); }; +const openRemark = async (row: File.File) => { + try { + const res = await ElMessageBox.prompt(i18n.global.t('file.remarkPrompt'), i18n.global.t('file.setRemark'), { + confirmButtonText: i18n.global.t('commons.button.confirm'), + cancelButtonText: i18n.global.t('commons.button.cancel'), + inputValue: row.remark ?? '', + inputPlaceholder: i18n.global.t('file.remarkPlaceholder'), + }); + const remark = res.value ?? ''; + await setFileRemark({ path: row.path, remark: remark }); + row.remark = remark; + MsgSuccess(i18n.global.t('commons.msg.updateSuccess')); + } catch (error) { + return; + } +}; + const beforeButtons = [ { label: i18n.global.t('commons.button.open'), @@ -1621,6 +1647,13 @@ const beforeButtons = [ openBatchRole([row]); }, }, + { + label: i18n.global.t('file.setRemark'), + hideOnRemarkBlackList: true, + click: (row: File.File) => { + openRemark(row); + }, + }, ]; const afterButtons = [ { @@ -1682,8 +1715,15 @@ const moreBtnRename = [ }, ]; -const rightButtons = [...beforeButtons, ...rightBtnRename, ...afterButtons]; -const tableMoreButtons = [...beforeButtons, ...moreBtnRename, ...afterButtons]; +const filterRemarkButtons = (buttons: any[]) => { + if (!isInRemarkBlackList(req.path)) { + return buttons; + } + return buttons.filter((btn) => !btn.hideOnRemarkBlackList); +}; + +const rightButtons = computed(() => filterRemarkButtons([...beforeButtons, ...rightBtnRename, ...afterButtons])); +const tableMoreButtons = computed(() => filterRemarkButtons([...beforeButtons, ...moreBtnRename, ...afterButtons])); const openConvert = (item: File.File) => { if (!ffmpegExist.value) { ElMessageBox.confirm(i18n.global.t('cronjob.library.noSuchApp', ['FFmpeg']), i18n.global.t('file.convert'), { @@ -1768,6 +1808,44 @@ function initShowHidden() { } } +const remarkBlackList = ['/proc', '/sys', '/dev', '/run']; + +const scheduleRemarkLoad = () => { + if (remarkLoadTimer.value) { + window.clearTimeout(remarkLoadTimer.value); + } + if (isInRemarkBlackList(req.path)) { + return; + } + remarkLoadTimer.value = window.setTimeout(() => { + void loadRemarksForCurrentPage(); + }, 1000); +}; + +const isInRemarkBlackList = (path: string) => { + return remarkBlackList.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); +}; + +const loadRemarksForCurrentPage = async () => { + if (!Array.isArray(data.value) || data.value.length === 0) return; + const paths = data.value.map((item) => item.path).filter(Boolean); + if (paths.length === 0) return; + const currentId = ++remarkRequestId.value; + try { + const res = await batchGetFileRemarks(paths); + if (currentId !== remarkRequestId.value) return; + const remarks = res.data?.remarks || {}; + data.value.forEach((item) => { + const remark = remarks[item.path]; + if (remark !== undefined && remark !== '') { + item.remark = remark; + } + }); + } catch (error) { + if (currentId !== remarkRequestId.value) return; + } +}; + function initTabsAndPaths() { initTabs(); let path = getInitialPath(); @@ -1967,6 +2045,9 @@ onBeforeUnmount(() => { if (resizeObserver) resizeObserver.disconnect(); window.removeEventListener('resize', watchTitleHeight); window.removeEventListener('resize', updateHeight); + if (remarkLoadTimer.value) { + window.clearTimeout(remarkLoadTimer.value); + } });