feat: File management optimization (#8452)

Refs: #3545 #3387
This commit is contained in:
2025-04-22 17:11:06 +08:00 committed by GitHub
parent 3370bac67a
commit 3b6bb9bd22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 678 additions and 173 deletions

View file

@ -842,3 +842,31 @@ func (b *BaseApi) GetPathByType(c *gin.Context) {
resPath := fileService.GetPathByType(pathType)
helper.SuccessWithData(c, resPath)
}
// @Tags File
// @Summary system mount
// @Accept json
// @Success 200 {object} dto.DiskInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /mount [post]
func (b *BaseApi) GetHostMount(c *gin.Context) {
disks := fileService.GetHostMount()
helper.SuccessWithData(c, disks)
}
// @Tags File
// @Summary system user and group
// @Accept json
// @Success 200 {object} response.UserGroupResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /user/group [post]
func (b *BaseApi) GetUsersAndGroups(c *gin.Context) {
res, err := fileService.GetUsersAndGroups()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}

View file

@ -46,7 +46,8 @@ func (b *BaseApi) WsSSH(c *gin.Context) {
return
}
defer client.Close()
sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, "")
command := c.DefaultQuery("command", "")
sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, command)
if wshandleError(wsConn, err) {
return
}

View file

@ -54,3 +54,13 @@ type ExistFileInfo struct {
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
}
type UserInfo struct {
Username string `json:"username"`
Group string `json:"group"`
}
type UserGroupResponse struct {
Users []UserInfo `json:"users"`
Groups []string `json:"groups"`
}

View file

@ -1,13 +1,18 @@
package service
import (
"bufio"
"context"
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"io"
"io/fs"
"os"
"os/user"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
@ -55,6 +60,8 @@ type IFileService interface {
GetPathByType(pathType string) string
BatchCheckFiles(req request.FilePathsCheck) []response.ExistFileInfo
GetHostMount() []dto.DiskInfo
GetUsersAndGroups() (*response.UserGroupResponse, error)
}
var filteredPaths = []string{
@ -544,3 +551,101 @@ func (f *FileService) BatchCheckFiles(req request.FilePathsCheck) []response.Exi
}
return fileList
}
func (f *FileService) GetHostMount() []dto.DiskInfo {
return loadDiskInfo()
}
func (f *FileService) GetUsersAndGroups() (*response.UserGroupResponse, error) {
groupMap, err := getValidGroups()
if err != nil {
return nil, err
}
users, groupSet, err := getValidUsers(groupMap)
if err != nil {
return nil, err
}
var groups []string
for group := range groupSet {
groups = append(groups, group)
}
sort.Strings(groups)
return &response.UserGroupResponse{
Users: users,
Groups: groups,
}, nil
}
func getValidGroups() (map[string]bool, error) {
groupFile, err := os.Open("/etc/group")
if err != nil {
return nil, fmt.Errorf("failed to open /etc/group: %w", err)
}
defer groupFile.Close()
groupMap := make(map[string]bool)
scanner := bufio.NewScanner(groupFile)
for scanner.Scan() {
parts := strings.Split(scanner.Text(), ":")
if len(parts) < 3 {
continue
}
groupName := parts[0]
gid, _ := strconv.Atoi(parts[2])
if groupName == "root" || gid >= 1000 {
groupMap[groupName] = true
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to scan /etc/group: %w", err)
}
return groupMap, nil
}
func getValidUsers(validGroups map[string]bool) ([]response.UserInfo, map[string]struct{}, error) {
passwdFile, err := os.Open("/etc/passwd")
if err != nil {
return nil, nil, fmt.Errorf("failed to open /etc/passwd: %w", err)
}
defer passwdFile.Close()
var users []response.UserInfo
groupSet := make(map[string]struct{})
scanner := bufio.NewScanner(passwdFile)
for scanner.Scan() {
parts := strings.Split(scanner.Text(), ":")
if len(parts) < 4 {
continue
}
username := parts[0]
uid, _ := strconv.Atoi(parts[2])
gid := parts[3]
if username != "root" && uid < 1000 {
continue
}
groupName := gid
if g, err := user.LookupGroupId(gid); err == nil {
groupName = g.Name
}
if !validGroups[groupName] {
continue
}
users = append(users, response.UserInfo{
Username: username,
Group: groupName,
})
groupSet[groupName] = struct{}{}
}
if err := scanner.Err(); err != nil {
return nil, nil, fmt.Errorf("failed to scan /etc/passwd: %w", err)
}
return users, groupSet, nil
}

View file

@ -49,5 +49,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
fileRouter.POST("/favorite/del", baseApi.DeleteFavorite)
fileRouter.GET("/path/:type", baseApi.GetPathByType)
fileRouter.POST("/mount", baseApi.GetHostMount)
fileRouter.POST("/user/group", baseApi.GetUsersAndGroups)
}
}

View file

@ -206,4 +206,13 @@ export namespace File {
group: string;
sub: boolean;
}
export interface UserGroupResponse {
users: UserInfo[];
groups: string[];
}
export interface UserInfo {
username: string;
group: string;
}
}

View file

@ -4,6 +4,7 @@ import { AxiosRequestConfig } from 'axios';
import { ResPage } from '../interface';
import { TimeoutEnum } from '@/enums/http-enum';
import { ReqPage } from '@/api/interface';
import { Dashboard } from '@/api/interface/dashboard';
export const getFilesList = (params: File.ReqFile) => {
return http.post<File.File>('files/search', params, TimeoutEnum.T_5M);
@ -144,3 +145,11 @@ export const getRecycleStatusByNode = (node: string) => {
export const getPathByType = (pathType: string) => {
return http.get<string>(`files/path/${pathType}`);
};
export const searchHostMount = () => {
return http.post<Dashboard.DiskInfo[]>(`/files/mount`);
};
export const searchUserGroup = () => {
return http.post<File.UserGroupResponse>(`/files/user/group`);
};

View file

@ -30,7 +30,7 @@
:page-sizes="[5, 10, 20, 50, 100]"
@size-change="sizeChange"
@current-change="currentChange"
:small="mobile || paginationConfig.small"
:size="mobile || paginationConfig.small ? 'small' : 'default'"
:layout="mobile ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
/>
</slot>

View file

@ -109,6 +109,12 @@ const showBack = computed(() => {
.el-button + .el-button {
margin: 0 !important;
}
.el-button-group > .el-button + .el-button {
margin-left: 0 !important;
}
.el-button-group > .el-button:not(:last-child) {
margin-right: -1px !important;
}
}
.content-container_form {

View file

@ -1,7 +1,7 @@
<template>
<div>
<div class="flex w-full flex-col gap-2 md:flex-row items-center">
<div class="flex flex-wrap items-center" v-if="props.footer">
<div class="flex flex-wrap gap-y-2 items-center" v-if="props.footer">
<el-link type="primary" :underline="false" @click="toForum">
<span class="font-normal">{{ $t('setting.forum') }}</span>
</el-link>
@ -14,31 +14,22 @@
<span class="font-normal">{{ $t('setting.project') }}</span>
</el-link>
<el-divider direction="vertical" />
</div>
<div class="flex flex-wrap items-center">
<el-link :underline="false" class="-ml-2" type="primary" @click="toLxware">
{{ $t(!isMasterProductPro ? 'license.community' : 'license.pro') }}
</el-link>
<el-link :underline="false" class="version" type="primary" @click="copyText(version)">
{{ version }}
</el-link>
<el-badge is-dot class="-mt-0.5" v-if="version !== 'Waiting' && globalStore.hasNewVersion">
<el-link class="ml-2" :underline="false" type="primary" @click="onLoadUpgradeInfo">
{{ $t('commons.button.update') }}
<div class="flex flex-wrap items-center">
<el-link :underline="false" type="primary" @click="toLxware">
{{ $t(!isMasterProductPro ? 'license.community' : 'license.pro') }}
</el-link>
</el-badge>
<el-link
v-if="version !== 'Waiting' && !globalStore.hasNewVersion"
type="primary"
:underline="false"
class="ml-2"
@click="onLoadUpgradeInfo"
>
{{ $t('commons.button.update') }}
</el-link>
<el-tag v-if="version === 'Waiting'" round class="ml-2.5">
{{ $t('setting.upgrading') }}
</el-tag>
<el-link :underline="false" class="version" type="primary" @click="copyText(version)">
{{ version }}
</el-link>
<el-badge is-dot class="-mt-0.5" :hidden="version !== 'Waiting' && globalStore.hasNewVersion">
<el-link class="ml-2" :underline="false" type="primary" @click="onLoadUpgradeInfo">
{{ $t('commons.button.update') }}
</el-link>
</el-badge>
<el-tag v-if="version === 'Waiting'" round class="ml-2.5">
{{ $t('setting.upgrading') }}
</el-tag>
</div>
</div>
</div>

View file

@ -4,10 +4,20 @@
<FileRole :mode="mode" @get-mode="getMode" :key="open.toString()"></FileRole>
<el-form ref="fileForm" label-position="left" :model="addForm" label-width="100px" :rules="rules">
<el-form-item :label="$t('commons.table.user')" prop="user">
<el-input v-model.trim="addForm.user" />
<el-select v-model="addForm.user" @change="handleUserChange" filterable allow-create>
<el-option
v-for="item in users"
:key="item.username"
:label="item.username"
:value="item.username"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t('file.group')" prop="group">
<el-input v-model.trim="addForm.group" />
<el-select v-model="addForm.group" filterable allow-create>
<el-option v-for="group in groups" :key="group" :label="group" :value="group" />
</el-select>
</el-form-item>
<el-form-item>
<el-checkbox v-model="addForm.sub">{{ $t('file.containSub') }}</el-checkbox>
@ -26,7 +36,7 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { File } from '@/api/interface/file';
import { batchChangeRole } from '@/api/modules/files';
import { batchChangeRole, searchUserGroup } from '@/api/modules/files';
import i18n from '@/lang';
import FileRole from '@/components/file-role/index.vue';
import { MsgSuccess } from '@/utils/message';
@ -41,6 +51,8 @@ const open = ref(false);
const loading = ref(false);
const mode = ref('0755');
const files = ref<File.File[]>([]);
const users = ref<File.UserInfo[]>([]);
const groups = ref<string[]>([]);
const rules = reactive<FormRules>({
user: [Rules.requiredInput],
@ -76,6 +88,23 @@ const acceptParams = (props: BatchRoleProps) => {
open.value = true;
};
const getUserAndGroup = async () => {
try {
const res = await searchUserGroup();
users.value = res.data.users;
groups.value = res.data.groups;
} catch (error) {
console.error('Failed to fetch user and group:', error);
}
};
const handleUserChange = (val: string) => {
const found = users.value.find((u) => u.username === val);
if (found) {
addForm.group = found.group;
}
};
const getMode = (val: number) => {
addForm.mode = val;
};
@ -96,6 +125,9 @@ const submit = async () => {
loading.value = false;
});
};
onMounted(() => {
getUserAndGroup();
});
defineExpose({ acceptParams });
</script>

View file

@ -1,7 +1,7 @@
<template>
<div>
<div class="flex items-center">
<div class="flex-shrink-0 flex items-center mr-4">
<div class="flex gap-y-2 items-center gap-x-4">
<div class="flex-shrink-0 flex items-center justify-between">
<el-tooltip :content="$t('file.back')" placement="top">
<el-button icon="Back" @click="back" circle />
</el-tooltip>
@ -15,39 +15,139 @@
<el-button icon="Refresh" circle @click="search" />
</el-tooltip>
</div>
<div
v-show="!searchableStatus"
tabindex="0"
@click="searchableStatus = true"
class="address-bar shadow-md rounded-md px-4 py-2 flex items-center flex-grow"
>
<div ref="pathRef" class="w-full">
<span ref="breadCrumbRef" class="w-full flex items-center">
<div class="flex-1 hidden sm:block" ref="pathRef">
<div
v-show="!searchableStatus"
@click="searchableStatus = true"
class="address-bar shadow-md rounded-md px-4 py-2 flex items-center flex-grow"
>
<div ref="breadCrumbRef" class="flex items-center address-url">
<span class="root mr-2">
<el-link @click.stop="jump('/')">
<el-icon :size="20"><HomeFilled /></el-icon>
</el-link>
</span>
<span v-for="path in paths" :key="path.url" class="inline-flex items-center">
<span v-for="(path, index) in paths" :key="path.url" class="inline-flex items-center">
<span class="mr-2 arrow">></span>
<el-link class="path-segment cursor-pointer mr-2 pathname" @click.stop="jump(path.url)">
{{ path.name }}
<template v-if="index === 0 && hidePaths.length > 0">
<el-dropdown>
<span
class="path-segment cursor-pointer mr-2 pathname focus:outline-none focus-visible:outline-none"
>
..
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="hidePath in hidePaths"
:key="hidePath.url"
@click.stop="jump(hidePath.url)"
>
<el-tooltip
class="box-item"
effect="dark"
:content="hidePath.name"
placement="bottom"
>
{{
hidePath.name.length > 25
? hidePath.name.substring(0, 22) + '...'
: hidePath.name
}}
</el-tooltip>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="mr-2 arrow">></span>
<el-tooltip class="box-item" effect="dark" :content="path.name" placement="bottom">
<el-link
class="path-segment cursor-pointer mr-2 pathname"
@click.stop="jump(path.url)"
>
{{ path.name.length > 25 ? path.name.substring(0, 22) + '...' : path.name }}
</el-link>
</el-tooltip>
</template>
<template v-else>
<el-tooltip class="box-item" effect="dark" :content="path.name" placement="bottom">
<el-link
class="path-segment cursor-pointer mr-2 pathname"
@click.stop="jump(path.url)"
>
{{ path.name.length > 25 ? path.name.substring(0, 22) + '...' : path.name }}
</el-link>
</el-tooltip>
</template>
</span>
</div>
</div>
<el-input
ref="searchableInputRef"
v-show="searchableStatus"
v-model="searchablePath"
@blur="searchableInputBlur"
class="px-4 py-2 border rounded-md shadow-md"
@keyup.enter="
jump(searchablePath);
searchableStatus = false;
"
/>
</div>
<div class="flex-1 sm:hidden block">
<div class="address-bar shadow-md rounded-md px-4 py-2 flex items-center flex-grow">
<div class="flex items-center address-url">
<span class="root mr-2">
<el-link @click.stop="jump('/')">
<el-icon :size="20"><HomeFilled /></el-icon>
</el-link>
</span>
</span>
<span v-for="(path, index) in paths" :key="path.url" class="inline-flex items-center">
<span class="mr-2 arrow">></span>
<template v-if="index === 0 && hidePaths.length > 0">
<el-dropdown>
<span
class="path-segment cursor-pointer mr-2 pathname focus:outline-none focus-visible:outline-none"
>
..
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="hidePath in hidePaths"
:key="hidePath.url"
@click.stop="jump(hidePath.url)"
>
<el-tooltip
class="box-item"
effect="dark"
:content="hidePath.name"
placement="bottom"
>
{{
hidePath.name.length > 25
? hidePath.name.substring(0, 22) + '...'
: hidePath.name
}}
</el-tooltip>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="mr-2 arrow">></span>
<el-tooltip class="box-item" effect="dark" :content="path.name" placement="bottom">
<el-link
class="path-segment cursor-pointer mr-2 pathname"
@click.stop="jump(path.url)"
>
{{ path.name.length > 25 ? path.name.substring(0, 22) + '...' : path.name }}
</el-link>
</el-tooltip>
</template>
</span>
</div>
</div>
</div>
<el-input
ref="searchableInputRef"
v-show="searchableStatus"
v-model="searchablePath"
@blur="searchableInputBlur"
class="px-4 py-2 border rounded-md shadow-md"
@keyup.enter="
jump(searchablePath);
searchableStatus = false;
"
/>
</div>
<LayoutContent :title="$t('menu.files')" v-loading="loading">
<template #prompt>
@ -60,115 +160,187 @@
</el-alert>
</template>
<template #leftToolBar>
<el-dropdown @command="handleCreate" class="mr-2.5">
<el-button type="primary">
{{ $t('commons.button.create') }}
<el-icon><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="dir">
<svg-icon iconName="p-file-folder"></svg-icon>
{{ $t('file.dir') }}
</el-dropdown-item>
<el-dropdown-item command="file">
<svg-icon iconName="p-file-normal"></svg-icon>
{{ $t('menu.files') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button-group>
<el-button plain @click="openUpload">{{ $t('commons.button.upload') }}</el-button>
<el-button plain @click="openWget">{{ $t('file.remoteFile') }}</el-button>
<el-button plain @click="openMove('copy')" :disabled="selects.length === 0">
{{ $t('commons.button.copy') }}
</el-button>
<el-button plain @click="openMove('cut')" :disabled="selects.length === 0">
{{ $t('file.move') }}
</el-button>
<el-button plain @click="openCompress(selects)" :disabled="selects.length === 0">
{{ $t('file.compress') }}
</el-button>
<el-button plain @click="openBatchRole(selects)" :disabled="selects.length === 0">
{{ $t('file.role') }}
</el-button>
<el-button plain @click="batchDelFiles" :disabled="selects.length === 0">
{{ $t('commons.button.delete') }}
</el-button>
</el-button-group>
<div ref="leftWrapper">
<el-dropdown @command="handleCreate" class="mr-2.5">
<el-button type="primary">
{{ $t('commons.button.create') }}
<el-icon><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="dir">
<svg-icon iconName="p-file-folder"></svg-icon>
{{ $t('file.dir') }}
</el-dropdown-item>
<el-dropdown-item command="file">
<svg-icon iconName="p-file-normal"></svg-icon>
{{ $t('menu.files') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button-group>
<el-button plain @click="openUpload">{{ $t('commons.button.upload') }}</el-button>
<el-button plain @click="openWget">{{ $t('file.remoteFile') }}</el-button>
<el-button class="btn mr-2.5" @click="openRecycleBin">
{{ $t('file.recycleBin') }}
</el-button>
<el-button class="btn" @click="toTerminal">
{{ $t('menu.terminal') }}
</el-button>
<el-popover placement="bottom" :width="200" trigger="hover" @before-enter="getFavoriates">
<template #reference>
<el-button @click="openFavorite">
{{ $t('file.favorite') }}
</el-button>
</template>
<div class="favorite-item">
<el-table :data="favorites">
<el-table-column prop="name">
<template #default="{ row }">
<el-tooltip
class="box-item"
effect="dark"
:content="row.path"
placement="top"
>
<span
class="table-link text-ellipsis"
@click="toFavorite(row)"
type="primary"
>
<svg-icon
v-if="row.isDir"
className="table-icon"
iconName="p-file-folder"
></svg-icon>
<svg-icon
v-else
className="table-icon"
iconName="p-file-normal"
></svg-icon>
{{ row.name }}
</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</el-popover>
<template v-if="hostMount.length == 1">
<el-button class="btn" @click.stop="jump(hostMount[0]?.path)">
{{ hostMount[0]?.path }} ({{ $t('file.root') }}) {{ getFileSize(hostMount[0]?.free) }}
</el-button>
</template>
<template v-else>
<el-dropdown class="mr-2.5" style="border-left: 1px solid #ccc">
<el-button class="btn">
{{ hostMount[0]?.path }} ({{ $t('file.root') }})
{{ getFileSize(hostMount[0]?.free) }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<template v-for="(mount, index) in hostMount" :key="mount.path">
<el-dropdown-item v-if="index != 0" @click.stop="jump(mount.path)">
{{ mount.path }} ({{ $t('home.mount') }}) {{ getFileSize(mount.free) }}
</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-button-group>
<el-button class="btn" @click="toTerminal">
{{ $t('menu.terminal') }}
</el-button>
<el-badge :value="processCount" class="btn" v-if="processCount > 0">
<el-button class="btn" @click="openProcess">
{{ $t('file.wgetTask') }}
</el-button>
</el-badge>
<el-button-group class="copy-button" v-if="moveOpen">
<el-tooltip class="box-item" effect="dark" :content="$t('file.paste')" placement="bottom">
<el-button plain @click="openPaste">{{ $t('file.paste') }}({{ fileMove.count }})</el-button>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
:content="$t('commons.button.cancel')"
placement="bottom"
>
<el-button plain class="close" icon="Close" @click="closeMove"></el-button>
</el-tooltip>
</el-button-group>
<el-badge :value="processCount" class="btn" v-if="processCount > 0">
<el-button class="btn" @click="openProcess">
{{ $t('file.wgetTask') }}
</el-button>
</el-badge>
</div>
</template>
<template #rightToolBar>
<el-popover placement="bottom" :width="200" trigger="hover" @before-enter="getFavoriates">
<template #reference>
<el-button @click="openFavorite">
{{ $t('file.favorite') }}
</el-button>
</template>
<div class="favorite-item">
<el-table :data="favorites">
<el-table-column prop="name">
<template #default="{ row }">
<el-tooltip class="box-item" effect="dark" :content="row.path" placement="top">
<span class="table-link text-ellipsis" @click="toFavorite(row)" type="primary">
<svg-icon
v-if="row.isDir"
className="table-icon"
iconName="p-file-folder"
></svg-icon>
<svg-icon v-else className="table-icon" iconName="p-file-normal"></svg-icon>
{{ row.name }}
</span>
</el-tooltip>
<div ref="btnWrapper" class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-2 flex-wrap">
<template v-if="visibleButtons.length == 0">
<el-dropdown v-if="moreButtons.length">
<el-button>
{{ $t('tabs.more') }}
<i class="el-icon-arrow-down el-icon--right" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="btn in moreButtons"
:key="btn.label"
@click="btn.action"
:disabled="selects.length === 0"
>
{{ $t(btn.label) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-table-column>
</el-table>
</el-dropdown>
</template>
<template v-if="visibleButtons.length > 0">
<el-button-group class="flex items-center">
<template v-for="btn in visibleButtons" :key="btn.label">
<el-button plain @click="btn.action" :disabled="selects.length === 0">
{{ $t(btn.label) }}
</el-button>
</template>
<el-dropdown v-if="moreButtons.length" style="border-left: 1px solid #ccc">
<el-button>
{{ $t('tabs.more') }}
<i class="el-icon-arrow-down el-icon--right" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="btn in moreButtons"
:key="btn.label"
@click="btn.action"
:disabled="selects.length === 0"
>
{{ $t(btn.label) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-button-group>
</template>
</div>
<el-button-group class="copy-button" v-if="moveOpen">
<el-tooltip class="box-item" effect="dark" :content="$t('file.paste')" placement="bottom">
<el-button plain @click="openPaste">{{ $t('file.paste') }}({{ fileMove.count }})</el-button>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
:content="$t('commons.button.cancel')"
placement="bottom"
>
<el-button plain class="close" icon="Close" @click="closeMove"></el-button>
</el-tooltip>
</el-button-group>
<div class="w-80">
<el-input
v-model="req.search"
clearable
@clear="search()"
@keydown.enter="search()"
:placeholder="$t('file.search')"
>
<template #prepend>
<el-checkbox v-model="req.containSub">
{{ $t('file.sub') }}
</el-checkbox>
</template>
<template #append>
<el-button icon="Search" @click="search" round />
</template>
</el-input>
</div>
</el-popover>
<el-button class="btn mr-2.5" @click="openRecycleBin">
{{ $t('file.recycleBin') }}
</el-button>
<div class="w-76">
<el-input
v-model="req.search"
clearable
@clear="search()"
@keydown.enter="search()"
:placeholder="$t('file.search')"
>
<template #prepend>
<el-checkbox v-model="req.containSub">
{{ $t('file.sub') }}
</el-checkbox>
</template>
<template #append>
<el-button icon="Search" @click="search" round />
</template>
</el-input>
</div>
</template>
<template #main>
@ -306,6 +478,7 @@
<BatchRole ref="batchRoleRef" @close="search" />
<VscodeOpenDialog ref="dialogVscodeOpenRef" />
<Preview ref="previewRef" />
<TerminalDialog ref="dialogTerminalRef" />
</LayoutContent>
</div>
</template>
@ -320,6 +493,7 @@ import {
removeFavorite,
searchFavorite,
fileWgetKeys,
searchHostMount,
} from '@/api/modules/files';
import { computeSize, copyText, dateFormat, getFileType, getIcon, getRandomStr, downloadFile } from '@/utils/util';
import { File } from '@/api/interface/file';
@ -350,6 +524,9 @@ 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 { debounce } from 'lodash-es';
import TerminalDialog from './terminal/index.vue';
import { Dashboard } from '@/api/interface/dashboard';
const globalStore = GlobalStore();
@ -378,6 +555,7 @@ const initData = () => ({
let req = reactive(initData());
let loading = ref(false);
const paths = ref<FilePaths[]>([]);
const hidePaths = ref<FilePaths[]>([]);
let pathWidth = ref(0);
const history: string[] = [];
let pointer = -1;
@ -417,6 +595,8 @@ const batchRoleRef = ref();
const dialogVscodeOpenRef = ref();
const previewRef = ref();
const processRef = ref();
const hostMount = ref<Dashboard.DiskInfo[]>([]);
let resizeObserver: ResizeObserver;
const { searchableStatus, searchablePath, searchableInputRef, searchableInputBlur } = useSearchable(paths);
@ -494,23 +674,91 @@ const copyDir = (row: File.File) => {
}
};
const handlePath = () => {
const breadcrumbElement = breadCrumbRef.value as any;
const pathNodes = breadcrumbElement.querySelectorAll('.pathname');
const btnWrapper = ref<HTMLElement | null>(null);
const leftWrapper = ref<HTMLElement | null>(null);
const toolButtons = ref([
{
label: 'commons.button.copy',
action: () => openMove('copy'),
},
{
label: 'file.move',
action: () => openMove('cut'),
},
{
label: 'file.compress',
action: () => openCompress(selects.value),
},
{
label: 'file.role',
action: () => openBatchRole(selects.value),
},
{
label: 'commons.button.delete',
action: () => batchDelFiles(),
},
]);
let totalpathWidth = 0;
pathNodes.forEach((node) => {
totalpathWidth += node.offsetWidth;
});
if (totalpathWidth > pathWidth.value) {
paths.value.splice(0, 1);
paths.value[0].name = '..';
nextTick(function () {
handlePath();
});
const visibleButtons = ref([...toolButtons.value]);
const moreButtons = ref([]);
const updateButtons = async () => {
await nextTick();
if (!btnWrapper.value) return;
const pathWidth = pathRef.value.offsetWidth;
const leftWidth = leftWrapper.value.offsetWidth;
let num = Math.floor((pathWidth - leftWidth - 420) / 70);
if (num < 0) {
visibleButtons.value = toolButtons.value;
moreButtons.value = [];
} else {
visibleButtons.value = toolButtons.value.slice(0, num);
moreButtons.value = toolButtons.value.slice(num);
}
};
const handlePath = () => {
nextTick(function () {
let breadCrumbWidth = breadCrumbRef.value.offsetWidth;
let pathWidth = pathRef.value.offsetWidth;
if (pathWidth - breadCrumbWidth < 50 && paths.value.length > 1) {
const removed = paths.value.shift();
if (removed) hidePaths.value.push(removed);
handlePath();
}
});
};
const resizeHandler = debounce(() => {
resetPaths();
handlePath();
}, 100);
const btnResizeHandler = debounce(() => {
updateButtons();
}, 100);
const observeResize = () => {
const el = pathRef.value as any;
if (!el) return;
resizeObserver = new ResizeObserver(() => {
resizeHandler();
});
const ele = btnWrapper.value as any;
if (!ele) return;
resizeObserver = new ResizeObserver(() => {
btnResizeHandler();
});
resizeObserver.observe(el);
resizeObserver.observe(ele);
};
const resetPaths = () => {
paths.value = [...hidePaths.value, ...paths.value];
hidePaths.value = [];
};
const right = () => {
if (pointer < history.length - 1) {
pointer++;
@ -578,6 +826,7 @@ const backForwardJump = async (url: string) => {
const getPaths = (reqPath: string) => {
const pathArray = reqPath.split('/');
paths.value = [];
hidePaths.value = [];
let base = '/';
for (const p of pathArray) {
if (p != '') {
@ -895,8 +1144,9 @@ const toFavorite = (row: File.Favorite) => {
}
};
const dialogTerminalRef = ref();
const toTerminal = () => {
router.push({ path: '/terminal', query: { path: req.path } });
dialogTerminalRef.value!.acceptParams({ cwd: req.path, command: '/bin/sh' });
};
const openWithVSCode = (row: File.File) => {
@ -975,7 +1225,17 @@ const isDecompressFile = (row: File.File) => {
}
};
const getHostMount = async () => {
try {
const res = await searchHostMount();
hostMount.value = res.data;
} catch (error) {
console.error('Error fetching host mount:', error);
}
};
onMounted(() => {
getHostMount();
if (router.currentRoute.value.query.path) {
req.path = String(router.currentRoute.value.query.path);
getPaths(req.path);
@ -992,8 +1252,13 @@ onMounted(() => {
pointer = history.length - 1;
nextTick(function () {
handlePath();
observeResize();
});
});
onBeforeUnmount(() => {
if (resizeObserver) resizeObserver.disconnect();
});
</script>
<style scoped lang="scss">

View file

@ -0,0 +1,47 @@
<template>
<DrawerPro v-model="terminalVisible" :header="$t('menu.terminal')" @close="handleClose" size="large">
<template #content>
<Terminal style="height: calc(100vh - 100px)" ref="terminalRef"></Terminal>
</template>
</DrawerPro>
</template>
<script lang="ts" setup>
import { ref, nextTick } from 'vue';
import Terminal from '@/components/terminal/index.vue';
const terminalVisible = ref(false);
const terminalRef = ref<InstanceType<typeof Terminal> | null>(null);
interface DialogProps {
cwd: string;
command: string;
}
const acceptParams = async (params: DialogProps): Promise<void> => {
terminalVisible.value = true;
await initTerm(params.cwd);
};
const initTerm = async (cwd: string) => {
await nextTick();
terminalRef.value!.acceptParams({
endpoint: '/api/v2/hosts/terminal',
args: `command=${encodeURIComponent(`clear && cd ${cwd}`)}`,
error: '',
initCmd: '',
});
};
const onClose = () => {
terminalRef.value?.onClose();
};
function handleClose() {
onClose();
terminalVisible.value = false;
}
defineExpose({
acceptParams,
});
</script>