feat: support file remark ability in file management (#11626)

* feat: Add file remark ability in file management

* refactor: improve mark get logic

* feat: Implement remark filtering for file management buttons
This commit is contained in:
KOMATA
2026-01-16 17:05:32 +08:00
committed by GitHub
parent e5b7e99cce
commit 3af07a42d6
18 changed files with 307 additions and 11 deletions
+37
View File
@@ -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)
}
+9
View File
@@ -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"`
}
+4
View File
@@ -81,3 +81,7 @@ type FileConvertLog struct {
Status string `json:"status"`
Message string `json:"message"`
}
type FileRemarksRes struct {
Remarks map[string]string `json:"remarks"`
}
+85
View File
@@ -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 {
+2
View File
@@ -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)
+10
View File
@@ -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<string, string>;
}
export interface UserGroupResponse {
users: UserInfo[];
groups: string[];
+8
View File
@@ -74,6 +74,14 @@ export const batchCheckFiles = (paths: string[]) => {
return http.post<File.ExistFileInfo[]>('files/batch/check', { paths: paths }, TimeoutEnum.T_5M);
};
export const batchGetFileRemarks = (paths: string[]) => {
return http.post<File.FileRemarksRes>('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<File.File>('files/chunkupload', params, config);
};
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -1505,6 +1505,12 @@ const message = {
pasteMsg: '対象ディレクトリの右上にある「貼り付け」ボタンをクリックしてください',
move: '動く',
calculate: '計算します',
remark: '備考',
setRemark: '備考を設定',
remarkPrompt: '備考を入力してください',
remarkPlaceholder: '備考',
remarkToggle: '備考',
remarkToggleTip: 'ファイルの備考を読み込む',
canNotDeCompress: 'このファイルを解凍できません',
uploadSuccess: '正常にアップロードします',
downloadProcess: '進捗状況をダウンロードします',
+6
View File
@@ -1485,6 +1485,12 @@ const message = {
pasteMsg: '대상 디렉토리의 오른쪽 상단에 있는 [붙여넣기] 버튼을 클릭하세요',
move: '이동',
calculate: '계산',
remark: '비고',
setRemark: '비고 설정',
remarkPrompt: '비고를 입력하세요',
remarkPlaceholder: '비고',
remarkToggle: '비고',
remarkToggleTip: '파일 비고 로드',
canNotDeCompress: '이 파일은 압축 해제할 수 없습니다',
uploadSuccess: '업로드 성공',
downloadProcess: '다운로드 진행률',
+6
View File
@@ -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',
+6
View File
@@ -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',
+6
View File
@@ -1537,6 +1537,12 @@ const message = {
pasteMsg: 'Нажмите кнопку «Вставить» в правом верхнем углу целевой директории',
move: 'Переместить',
calculate: 'Вычислить',
remark: 'Примечание',
setRemark: 'Задать примечание',
remarkPrompt: 'Введите примечание',
remarkPlaceholder: 'Примечание',
remarkToggle: 'Примечания',
remarkToggleTip: 'Загружать примечания файлов',
canNotDeCompress: 'Невозможно распаковать этот файл',
uploadSuccess: 'Успешно загружено',
downloadProcess: 'Прогресс загрузки',
+6
View File
@@ -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',
+6
View File
@@ -1470,6 +1470,12 @@ const message = {
pasteMsg: '請在目標目錄點擊右上角【貼上】按鈕',
move: '移動',
calculate: '計算',
remark: '備註',
setRemark: '設定備註',
remarkPrompt: '請輸入備註內容',
remarkPlaceholder: '備註內容',
remarkToggle: '備註',
remarkToggleTip: '載入檔案備註',
canNotDeCompress: '無法解壓此文件',
uploadSuccess: '上傳成功!',
downloadProcess: '下載進度',
+6
View File
@@ -1473,6 +1473,12 @@ const message = {
pasteMsg: '请在目标目录点击右上角【粘贴】按钮',
move: '移动',
calculate: '计算',
remark: '备注',
setRemark: '设置备注',
remarkPrompt: '请输入备注内容',
remarkPlaceholder: '备注内容',
remarkToggle: '备注',
remarkToggleTip: '加载文件备注',
canNotDeCompress: '无法解压此文件',
uploadSuccess: '上传成功!',
downloadProcess: '下载进度',
@@ -526,20 +526,14 @@
</template>
</el-table-column>
<el-table-column
:label="$t('commons.table.user')"
:label="`${$t('commons.table.user')} / ${$t('file.group')}`"
prop="user"
show-overflow-tooltip
min-width="90"
min-width="150"
>
<template #default="{ row }">
<el-link underline="never" @click="openChown(row)">
{{ row.user ? row.user : '-' }} ({{ row.uid }})
</el-link>
</template>
</el-table-column>
<el-table-column :label="$t('file.group')" prop="group" show-overflow-tooltip>
<template #default="{ row }">
<el-link underline="never" @click="openChown(row)">
{{ row.user ? row.user : '-' }} ({{ row.uid }}) /
{{ row.group ? row.group : '-' }} ({{ row.gid }})
</el-link>
</template>
@@ -573,6 +567,16 @@
show-overflow-tooltip
:sortable="'custom'"
></el-table-column>
<el-table-column
:label="$t('file.remark')"
prop="remark"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ row.remark ? row.remark : '-' }}</span>
</template>
</el-table-column>
<fu-table-operations
:max-height="dropdownMaxHeight"
:ellipsis="mobile ? 0 : 2"
@@ -641,11 +645,13 @@
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import {
addFavorite,
batchGetFileRemarks,
computeDepthDirSize,
computeDirSize,
fileWgetKeys,
getFileContent,
getFilesList,
setFileRemark,
removeFavorite,
renameRile,
searchFavorite,
@@ -715,6 +721,8 @@ const heightDiff = ref(365);
const fileTableRef = ref<HTMLElement | null>(null);
const dropdownMaxHeight = ref(450);
const baseDir = ref();
const remarkRequestId = ref(0);
const remarkLoadTimer = ref<number | null>(null);
const editableTabsKey = ref('');
const editableTabs = ref([
{ id: '1', name: getLastPath(baseDir.value), path: baseDir.value },
@@ -881,6 +889,7 @@ const handleSearchResult = (res: ResultData<File.File>) => {
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);
}
});
</script>