feat: Multi directory calculation size (#8762)

* feat: Multi directory calculation size

* feat: optimize code editor

* feat: directory calculation size
This commit is contained in:
2025-05-21 11:55:37 +08:00 committed by GitHub
parent 58d9c068e4
commit 057dcaf2e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 297 additions and 59 deletions

View file

@ -628,6 +628,28 @@ func (b *BaseApi) Size(c *gin.Context) {
helper.SuccessWithData(c, res)
}
// @Tags File
// @Summary Multi file size
// @Accept json
// @Param request body request.DirSizeReq true "request"
// @Success 200 {array} response.DepthDirSizeRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/depth/size [post]
// @x-panel-log {"bodyKeys":["path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"获取目录及其第一层子目录文件夹大小 [path]","formatEN":"Multi file size [path]"}
func (b *BaseApi) DepthDirSize(c *gin.Context) {
var req request.DirSizeReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := fileService.DepthDirSize(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
func mergeChunks(fileName string, fileDir string, dstDir string, chunkCount int, overwrite bool) error {
defer func() {
_ = os.RemoveAll(fileDir)

View file

@ -64,3 +64,8 @@ type UserGroupResponse struct {
Users []UserInfo `json:"users"`
Groups []string `json:"groups"`
}
type DepthDirSizeRes struct {
Path string `json:"path"`
Size int64 `json:"size"`
}

View file

@ -4,6 +4,7 @@ import (
"bufio"
"context"
"fmt"
"github.com/jinzhu/copier"
"io"
"io/fs"
"os"
@ -51,6 +52,7 @@ type IFileService interface {
SaveContent(edit request.FileEdit) error
FileDownload(d request.FileDownload) (string, error)
DirSize(req request.DirSizeReq) (response.DirSizeRes, error)
DepthDirSize(req request.DirSizeReq) ([]response.DepthDirSizeRes, error)
ChangeName(req request.FileRename) error
Wget(w request.FileWget) (string, error)
MvFile(m request.FileMove) error
@ -443,6 +445,22 @@ func (f *FileService) DirSize(req request.DirSizeReq) (response.DirSizeRes, erro
return res, nil
}
func (f *FileService) DepthDirSize(req request.DirSizeReq) ([]response.DepthDirSizeRes, error) {
var (
res []response.DepthDirSizeRes
)
if req.Path == "/proc" {
return res, nil
}
fo := files.NewFileOp()
dirSizes, err := fo.GetDepthDirSize(req.Path)
_ = copier.Copy(&res, &dirSizes)
if err != nil {
return res, err
}
return res, nil
}
func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.FileLineContent, error) {
logFilePath := ""
switch req.Type {

View file

@ -34,6 +34,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
fileRouter.GET("/download", baseApi.Download)
fileRouter.POST("/chunkdownload", baseApi.DownloadChunkFiles)
fileRouter.POST("/size", baseApi.Size)
fileRouter.POST("/depth/size", baseApi.DepthDirSize)
fileRouter.GET("/wget/process", baseApi.WgetProcess)
fileRouter.GET("/wget/process/keys", baseApi.ProcessKeys)
fileRouter.POST("/read", baseApi.ReadFileByLine)

View file

@ -512,6 +512,71 @@ func (f FileOp) GetDirSize(path string) (int64, error) {
return size, nil
}
type DirSize struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
func (f FileOp) GetDepthDirSize(path string) ([]DirSize, error) {
var result []DirSize
sizeMap := make(map[string]int64)
duCmd := exec.Command("du", "-k", "--max-depth=1", "--exclude=proc", path)
output, err := duCmd.Output()
if err == nil {
parseDUOutput(output, sizeMap)
} else {
calculateDirSizeFallback(path, sizeMap)
}
for dir, size := range sizeMap {
result = append(result, DirSize{
Path: dir,
Size: size,
})
}
return result, nil
}
func parseDUOutput(output []byte, sizeMap map[string]int64) {
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
fields := strings.Fields(line)
if len(fields) == 2 {
if sizeKB, err := strconv.ParseInt(fields[0], 10, 64); err == nil {
dir := fields[1]
sizeMap[dir] = sizeKB * 1024
}
}
}
}
func calculateDirSizeFallback(path string, sizeMap map[string]int64) {
_ = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
rel, err := filepath.Rel(path, p)
if err != nil {
return nil
}
parts := strings.Split(rel, string(os.PathSeparator))
var topLevel string
if len(parts) == 0 || parts[0] == "." {
topLevel = path
} else {
topLevel = filepath.Join(path, parts[0])
}
sizeMap[topLevel] += info.Size()
}
return nil
})
}
func getFormat(cType CompressType) archiver.CompressedArchive {
format := archiver.CompressedArchive{}
switch cType {

View file

@ -152,6 +152,11 @@ export namespace File {
size: number;
}
export interface DepthDirSizeRes {
size: number;
path: string;
}
export interface FilePath {
path: string;
}

View file

@ -98,6 +98,10 @@ export const computeDirSize = (params: File.DirSizeReq) => {
return http.post<File.DirSizeRes>('files/size', params, TimeoutEnum.T_5M);
};
export const computeDepthDirSize = (params: File.DirSizeReq) => {
return http.post<File.DepthDirSizeRes[]>('files/depth/size', params, TimeoutEnum.T_5M);
};
export const fileWgetKeys = () => {
return http.get<File.FileKeys>('files/wget/process/keys');
};

View file

@ -21,7 +21,12 @@
</fu-table>
</div>
<div class="complex-table__pagination" v-if="props.paginationConfig">
<div
class="complex-table__pagination flex items-center w-full"
v-if="props.paginationConfig"
:class="{ '!justify-between': slots.paginationLeft, '!justify-end': !slots.paginationLeft }"
>
<slot name="paginationLeft"></slot>
<slot name="pagination">
<el-pagination
v-model:current-page="paginationConfig.currentPage"
@ -100,21 +105,25 @@ defineExpose({
onMounted(() => {
let heightDiff = 320;
let tabHeight = 0;
if (props.heightDiff) {
heightDiff = props.heightDiff;
}
if (globalStore.openMenuTabs) {
tabHeight = 48;
}
if (props.height) {
tableHeight.value = props.height;
tableHeight.value = props.height - tabHeight;
} else {
tableHeight.value = window.innerHeight - heightDiff;
tableHeight.value = window.innerHeight - heightDiff - tabHeight;
}
window.onresize = () => {
return (() => {
if (props.height) {
tableHeight.value = props.height;
tableHeight.value = props.height - tabHeight;
} else {
tableHeight.value = window.innerHeight - heightDiff;
tableHeight.value = window.innerHeight - heightDiff - tabHeight;
}
})();
};

View file

@ -80,6 +80,7 @@ const message = {
fix: 'Fix',
down: 'Stop',
up: 'Start',
sure: 'Confirm',
},
operate: {
start: 'Start',
@ -1342,6 +1343,7 @@ const message = {
taskRunning: 'Running',
},
file: {
currentDir: 'Directory',
dir: 'Folder',
fileName: 'File name',
search: 'Find',

View file

@ -77,6 +77,7 @@ const message = {
fix: '修正',
down: '停止',
up: '起動',
sure: '確認',
},
operate: {
start: '開始',
@ -1278,6 +1279,7 @@ const message = {
errLog: 'エラーログ',
},
file: {
currentDir: '現在のディレクトリ',
dir: 'フォルダ',
upload: 'アップロード',
uploadFile: '@:file.upload@.lower:file.file',

View file

@ -77,6 +77,7 @@ const message = {
fix: '수정',
down: '중지',
up: '시작',
sure: '확인',
},
operate: {
start: '시작',
@ -1265,6 +1266,7 @@ const message = {
errLog: '에러 로그',
},
file: {
currentDir: '현재 디렉터리',
dir: '폴더',
upload: '업로드',
uploadFile: '@:file.upload @.lower:file.file',

View file

@ -77,6 +77,7 @@ const message = {
fix: 'Betulkan',
down: 'Hentikan',
up: 'Mulakan',
sure: 'Sahkan',
},
operate: {
start: 'Mula',
@ -1320,6 +1321,7 @@ const message = {
errLog: 'Log Ralat',
},
file: {
currentDir: 'Direktori Semasa',
dir: 'Folder',
upload: 'Muat naik',
uploadFile: 'Muat naik fail',

View file

@ -77,6 +77,7 @@ const message = {
fix: 'Corrigir',
down: 'Parar',
up: 'Iniciar',
sure: 'Confirmar',
},
operate: {
start: 'Iniciar',
@ -1304,6 +1305,7 @@ const message = {
errLog: 'Logs de erro',
},
file: {
currentDir: 'Diretório atual',
dir: 'Pasta',
upload: 'Carregar',
uploadFile: '@:file.upload @.lower:file.file',

View file

@ -77,6 +77,7 @@ const message = {
fix: 'Исправить',
down: 'Остановить',
up: 'Запустить',
sure: 'Подтвердить',
},
operate: {
start: 'Запустить',
@ -1308,6 +1309,7 @@ const message = {
errLog: 'Логи ошибок',
},
file: {
currentDir: 'Текущий каталог',
dir: 'Папка',
upload: 'Загрузить',
uploadFile: '@:file.upload @.lower:file.file',

View file

@ -80,6 +80,7 @@ const message = {
fix: '修復',
down: '停止',
up: '啟動',
sure: '確定',
},
operate: {
start: '啟動',
@ -1270,6 +1271,7 @@ const message = {
taskRunning: '運行中',
},
file: {
currentDir: '當前目錄',
dir: '文件夾',
upload: '上傳',
download: '下載',

View file

@ -80,6 +80,7 @@ const message = {
fix: '修复',
down: '停止',
up: '启动',
sure: '确定',
},
operate: {
start: '启动',
@ -1268,6 +1269,7 @@ const message = {
taskRunning: '执行中',
},
file: {
currentDir: '当前目录',
dir: '文件夹',
fileName: '文件名',
search: '在当前目录下查找',

View file

@ -30,10 +30,12 @@
<template #content>
<div ref="dialogForm" class="px-4 py-2">
<div class="flex justify-start items-center gap-x-4 card-action">
<el-text @click="handleReset">{{ $t('commons.button.reset') }}</el-text>
<el-text @click="saveContent()" class="ml-0">{{ $t('commons.button.save') }}</el-text>
<el-text class="cursor-pointer" @click="handleReset">{{ $t('commons.button.reset') }}</el-text>
<el-text class="cursor-pointer ml-0" @click="saveContent()">
{{ $t('commons.button.save') }}
</el-text>
<el-dropdown trigger="click" max-height="300" placement="bottom-start" @command="changeTheme">
<span class="el-dropdown-link">{{ $t('file.theme') }}</span>
<span class="el-dropdown-link cursor-pointer">{{ $t('file.theme') }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in themes" :key="item.label" :command="item.value">
@ -46,7 +48,7 @@
</template>
</el-dropdown>
<el-dropdown trigger="click" max-height="300" placement="bottom-start" @command="changeLanguage">
<span class="el-dropdown-link">{{ $t('file.language') }}</span>
<span class="el-dropdown-link cursor-pointer">{{ $t('file.language') }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in Languages" :key="item.label" :command="item.label">
@ -59,7 +61,7 @@
</template>
</el-dropdown>
<el-dropdown trigger="click" max-height="300" placement="bottom-start" @command="changeEOL">
<span class="el-dropdown-link">{{ $t('file.eol') }}</span>
<span class="el-dropdown-link cursor-pointer">{{ $t('file.eol') }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in eols" :key="item.label" :command="item.value">
@ -72,7 +74,7 @@
</template>
</el-dropdown>
<el-dropdown trigger="click" max-height="300" placement="bottom-start">
<span class="el-dropdown-link">{{ $t('file.setting') }}</span>
<span class="el-dropdown-link cursor-pointer">{{ $t('file.setting') }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="changeMinimap(!config.minimap)">
@ -92,7 +94,7 @@
</el-dropdown>
</div>
</div>
<div v-loading="loading" class="">
<div v-loading="loading">
<div class="flex">
<div
class="monaco-editor sm:w-48 w-1/3 monaco-editor-background border-0 tree-container"
@ -182,10 +184,13 @@
>
<DArrowRight />
</el-icon>
<div class="flex justify-center items-center h-full" v-if="fileTabs.length === 0">
<el-empty :image="noUpdateImage" />
</div>
</div>
</div>
</div>
<div class="code-footer pl-4 h-6 flex justify-end items-center gap-4 rounded-b">
<div class="code-footer pl-4 h-6 flex justify-end items-center gap-4 rounded-b" ref="dialogFooter">
<el-divider direction="vertical" class="!h-6" v-if="config.theme" />
<el-dropdown trigger="click" max-height="300" placement="top" @command="changeTheme">
<span class="el-dropdown-link">
@ -303,7 +308,7 @@
<script lang="ts" setup>
import { getFileContent, getFilesTree, saveFileContent } from '@/api/modules/files';
import i18n from '@/lang';
import { MsgError, MsgInfo, MsgSuccess } from '@/utils/message';
import { MsgError, MsgSuccess, MsgWarning } from '@/utils/message';
import * as monaco from 'monaco-editor';
import { nextTick, onBeforeUnmount, reactive, ref, onMounted, computed } from 'vue';
import { Languages } from '@/global/mimetype';
@ -323,6 +328,7 @@ import { loadBaseDir } from '@/api/modules/setting';
import { GlobalStore } from '@/store';
import CodeTabs from './tabs/index.vue';
import type { TabPaneName } from 'element-plus';
import noUpdateImage from '@/assets/images/no_update_app.svg';
const codeTabsRef = ref();
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
@ -496,6 +502,9 @@ const removeAllTab = (targetPath: string, type: string) => {
const targetIndex = arr.findIndex((t) => t.path === targetPath);
return index <= targetIndex;
});
} else if (type === 'all') {
fileTabs.value = [];
selectTab.value = '';
}
saveContent();
})
@ -524,9 +533,16 @@ const removeAllTab = (targetPath: string, type: string) => {
const targetIndex = arr.findIndex((t) => t.path === targetPath);
return index <= targetIndex;
});
} else if (type === 'all') {
fileTabs.value = [];
selectTab.value = '';
}
}
getContent(selectTab.value, '');
if (type === 'all') {
editor.dispose();
} else {
getContent(selectTab.value, '');
}
};
const removeOtherTab = (targetPath: string) => {
@ -648,7 +664,7 @@ const handleReset = () => {
MsgSuccess(i18n.global.t('commons.msg.resetSuccess'));
loading.value = false;
} else {
MsgInfo(i18n.global.t('file.noEdit'));
MsgWarning(i18n.global.t('file.noEdit'));
}
};
@ -665,7 +681,7 @@ onMounted(() => {
const updateHeights = () => {
const vh = window.innerHeight / 100;
if (isFullscreen.value) {
let paddingHeight = 75;
let paddingHeight = 30;
const headerHeight = dialogHeader.value.offsetHeight;
const formHeight = dialogForm.value.offsetHeight;
const footerHeight = dialogFooter.value.offsetHeight;
@ -789,7 +805,7 @@ const saveContent = () => {
loading.value = false;
});
} else {
MsgInfo(i18n.global.t('file.noEdit'));
MsgWarning(i18n.global.t('file.noEdit'));
}
};
@ -920,7 +936,7 @@ const getContent = (path: string, extension: string) => {
if (isEdit.value) {
ElMessageBox.confirm(i18n.global.t('file.saveAndOpenNewFile'), {
confirmButtonText: i18n.global.t('commons.button.open'),
confirmButtonText: i18n.global.t('commons.button.sure'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
})
@ -962,7 +978,7 @@ const search = async (path: string) => {
const getUpData = async () => {
if ('/' === directoryPath.value) {
MsgInfo(i18n.global.t('commons.msg.rootInfoErr'));
MsgWarning(i18n.global.t('commons.msg.rootInfoErr'));
return;
}
let pathParts = directoryPath.value.split('/');
@ -1085,7 +1101,6 @@ defineExpose({ acceptParams });
:deep(.el-tabs) {
--el-tabs-header-height: 28px;
--el-text-color-primary: var(--el-text-color-regular);
.el-tabs__header {
height: 28px;
margin: 0;
@ -1094,11 +1109,25 @@ defineExpose({ acceptParams });
height: 27px;
line-height: 27px;
}
.el-tabs__nav {
border-right: 1px solid var(--el-border-color-light);
border-top: none;
border-left: none;
border-bottom: none;
border-radius: 0;
box-sizing: border-box;
}
.el-tabs__nav,
.el-tabs__nav-next,
.el-tabs__nav-prev {
height: 28px;
line-height: 28px;
}
.el-tabs__item.is-active {
color: var(--el-color-primary);
.el-dropdown {
color: var(--el-color-primary);
}
}
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<el-tabs
v-model="props.selectTab"
v-model="currentTab"
type="card"
:closable="props.fileTabs.length > 1"
class="monaco-editor monaco-editor-background"
@ -9,40 +9,49 @@
>
<el-tab-pane v-for="item in props.fileTabs" :key="item.path" :name="item.path">
<template #label>
<el-dropdown
size="small"
:id="item.path"
:ref="(el) => setDropdownRef(item.path, el)"
trigger="contextmenu"
placement="bottom"
@visible-change="(visible) => onDropdownVisibleChange(visible, item.path)"
>
<span class="el-dropdown-link">
<el-tooltip :content="item.path" placement="bottom-start">
{{ item.name }}
</el-tooltip>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="props.onRemoveTab(item.path)">
<el-icon><Close /></el-icon>
{{ $t('commons.button.close') }}
</el-dropdown-item>
<el-dropdown-item @click="props.onRemoveAllTab(item.path, 'left')">
<el-icon><DArrowLeft /></el-icon>
{{ $t('tabs.closeLeft') }}
</el-dropdown-item>
<el-dropdown-item @click="props.onRemoveAllTab(item.path, 'right')">
<el-icon><DArrowRight /></el-icon>
{{ $t('tabs.closeRight') }}
</el-dropdown-item>
<el-dropdown-item @click="props.onRemoveOtherTab(item.path)">
<el-icon><More /></el-icon>
{{ $t('tabs.closeOther') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-tooltip v-if="props.fileTabs.length == 1" :content="item.path" placement="bottom-start">
<span>{{ item.name }}</span>
</el-tooltip>
<template v-if="props.fileTabs.length > 1">
<el-dropdown
size="small"
:id="item.path"
:ref="(el) => setDropdownRef(item.path, el)"
trigger="contextmenu"
placement="bottom"
@visible-change="(visible) => onDropdownVisibleChange(visible, item.path)"
>
<span class="el-dropdown-link">
<el-tooltip :content="item.path" placement="bottom-start">
{{ item.name }}
</el-tooltip>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="props.onRemoveTab(item.path)">
<el-icon><Close /></el-icon>
{{ $t('commons.button.close') }}
</el-dropdown-item>
<el-dropdown-item @click="props.onRemoveAllTab(item.path, 'left')">
<el-icon><DArrowLeft /></el-icon>
{{ $t('tabs.closeLeft') }}
</el-dropdown-item>
<el-dropdown-item @click="props.onRemoveAllTab(item.path, 'right')">
<el-icon><DArrowRight /></el-icon>
{{ $t('tabs.closeRight') }}
</el-dropdown-item>
<el-dropdown-item @click="props.onRemoveOtherTab(item.path)">
<el-icon><More /></el-icon>
{{ $t('tabs.closeOther') }}
</el-dropdown-item>
<el-dropdown-item @click="props.onRemoveAllTab(item.path, 'all')">
<el-icon><Operation /></el-icon>
{{ $t('tabs.closeAll') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</template>
</el-tab-pane>
</el-tabs>
@ -72,6 +81,15 @@ const setDropdownRef = (path: string, el: any) => {
}
};
const currentTab = ref(props.selectTab);
watch(
() => props.selectTab,
(val) => {
currentTab.value = val;
},
);
const onDropdownVisibleChange = (visible: boolean, currentPath: string) => {
if (visible) {
for (const path in dropdownRefs.value) {

View file

@ -252,6 +252,9 @@
</template>
</el-dropdown>
</template>
<el-button class="btn" @click="calculateSize(req.path)" :disabled="disableBtn">
{{ $t('file.calculate') }}
</el-button>
</el-button-group>
<el-badge :value="processCount" class="btn" v-if="processCount > 0">
@ -432,7 +435,7 @@
type="primary"
link
small
@click="getDirSize(row, $index)"
@click="getDirSize(row.path, $index)"
:loading="row.btnLoading"
>
<span v-if="row.dirSize == undefined">
@ -461,6 +464,16 @@
width="270"
fix
/>
<template #paginationLeft>
<el-button type="primary" link small @click="getDirTotalSize(req.path)">
<span v-if="dirTotalSize == -1">
{{ $t('file.calculate') }}
</span>
<span v-else>
{{ $t('file.currentDir') + $t('file.size') + ': ' + getFileSize(dirTotalSize) }}
</span>
</el-button>
</template>
</ComplexTable>
</template>
@ -492,6 +505,7 @@
import { computed, nextTick, onMounted, reactive, ref } from '@vue/runtime-core';
import {
addFavorite,
computeDepthDirSize,
computeDirSize,
fileWgetKeys,
getFileContent,
@ -602,6 +616,8 @@ const previewRef = ref();
const processRef = ref();
const hostMount = ref<Dashboard.DiskInfo[]>([]);
let resizeObserver: ResizeObserver;
const dirTotalSize = ref(-1);
const disableBtn = ref(false);
const { searchableStatus, searchablePath, searchableInputRef, searchableInputBlur } = useSearchable(paths);
@ -617,6 +633,7 @@ const mobile = computed(() => {
});
const search = async () => {
dirTotalSize.value = -1;
getWgetProcess();
loading.value = true;
if (req.search != '') {
@ -638,6 +655,7 @@ const search = async () => {
const searchFile = async () => {
loading.value = true;
dirTotalSize.value = -1;
try {
return await getFilesList(req);
} finally {
@ -866,9 +884,9 @@ const getFileSize = (size: number) => {
return computeSize(size);
};
const getDirSize = async (row: any, index: number) => {
const getDirSize = async (path: string, index: number) => {
const req = {
path: row.path,
path: path,
};
data.value[index].btnLoading = true;
await computeDirSize(req)
@ -882,6 +900,34 @@ const getDirSize = async (row: any, index: number) => {
});
};
const getDirTotalSize = async (path: string) => {
const req = {
path: path,
};
const res = await computeDirSize(req);
dirTotalSize.value = res.data.size;
};
const calculateSize = (path: string) => {
const req = { path };
disableBtn.value = true;
setTimeout(async () => {
try {
const res = await computeDepthDirSize(req);
const sizeMap = new Map(res.data.map((dir) => [dir.path, dir.size]));
data.value.forEach((item) => {
if (sizeMap.has(item.path)) {
item.dirSize = sizeMap.get(item.path)!;
}
});
} catch (err) {
console.error('Error computing dir size:', err);
} finally {
disableBtn.value = false;
}
}, 0);
};
const getIconName = (extension: string) => {
return getIcon(extension);
};