2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 07:57:21 +08:00

feat: 文件搜索

This commit is contained in:
耗子
2024-10-20 18:51:05 +08:00
parent a2ebc070ae
commit f9233bd36b
10 changed files with 397 additions and 63 deletions

View File

@@ -1,5 +1,11 @@
package request
import (
"net/http"
"github.com/spf13/cast"
)
type FileList struct {
Path string `json:"path" form:"path" validate:"required"`
Sort string `json:"sort" form:"sort"`
@@ -50,5 +56,11 @@ type FileUnCompress struct {
type FileSearch struct {
Path string `form:"path" json:"path" validate:"required"`
KeyWord string `form:"keyword" json:"keyword" validate:"required"`
Keyword string `form:"keyword" json:"keyword" validate:"required"`
Sub bool `form:"sub" json:"sub"`
}
func (r *FileSearch) Prepare(req *http.Request) error {
r.Sub = cast.ToBool(req.FormValue("sub"))
return nil
}

View File

@@ -232,7 +232,7 @@ func Http(r chi.Router) {
r.Post("/permission", file.Permission)
r.Post("/compress", file.Compress)
r.Post("/unCompress", file.UnCompress)
r.Post("/search", file.Search)
r.Get("/search", file.Search)
r.Get("/list", file.List)
})

View File

@@ -15,6 +15,7 @@ import (
"time"
"github.com/go-rat/chix"
"github.com/spf13/cast"
"github.com/TheTNB/panel/internal/http/request"
"github.com/TheTNB/panel/pkg/io"
@@ -327,22 +328,18 @@ func (s *FileService) Search(w http.ResponseWriter, r *http.Request) {
return
}
paths := make(map[string]stdos.FileInfo)
err = filepath.Walk(req.Path, func(path string, info stdos.FileInfo, err error) error {
if err != nil {
return err
}
if strings.Contains(info.Name(), req.KeyWord) {
paths[path] = info
}
return nil
})
results, err := io.SearchX(req.Path, req.Keyword, req.Sub)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, paths)
paged, total := Paginate(r, s.formatInfo(results))
Success(w, chix.M{
"total": total,
"items": paged,
})
}
func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
@@ -378,14 +375,27 @@ func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
})
}
var paths []any
for _, file := range list {
info, _ := file.Info()
stat := info.Sys().(*syscall.Stat_t)
paged, total := Paginate(r, s.formatDir(req.Path, list))
Success(w, chix.M{
"total": total,
"items": paged,
})
}
// formatDir 格式化目录信息
func (s *FileService) formatDir(base string, entries []stdos.DirEntry) []any {
var paths []any
for _, file := range entries {
info, err := file.Info()
if err != nil {
continue
}
stat := info.Sys().(*syscall.Stat_t)
paths = append(paths, map[string]any{
"name": info.Name(),
"full": filepath.Join(req.Path, info.Name()),
"full": filepath.Join(base, info.Name()),
"size": str.FormatBytes(float64(info.Size())),
"mode_str": info.Mode().String(),
"mode": fmt.Sprintf("%04o", info.Mode().Perm()),
@@ -395,21 +405,52 @@ func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
"gid": stat.Gid,
"hidden": io.IsHidden(info.Name()),
"symlink": io.IsSymlink(info.Mode()),
"link": io.GetSymlink(filepath.Join(req.Path, info.Name())),
"link": io.GetSymlink(filepath.Join(base, info.Name())),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
})
}
paged, total := Paginate(r, paths)
Success(w, chix.M{
"total": total,
"items": paged,
})
return paths
}
// setPermission
// formatInfo 格式化文件信息
func (s *FileService) formatInfo(infos map[string]stdos.FileInfo) []map[string]any {
var paths []map[string]any
for path, info := range infos {
stat := info.Sys().(*syscall.Stat_t)
paths = append(paths, map[string]any{
"name": info.Name(),
"full": path,
"size": str.FormatBytes(float64(info.Size())),
"mode_str": info.Mode().String(),
"mode": fmt.Sprintf("%04o", info.Mode().Perm()),
"owner": os.GetUser(stat.Uid),
"group": os.GetGroup(stat.Gid),
"uid": stat.Uid,
"gid": stat.Gid,
"hidden": io.IsHidden(info.Name()),
"symlink": io.IsSymlink(info.Mode()),
"link": io.GetSymlink(path),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
})
}
slices.SortFunc(paths, func(a, b map[string]any) int {
if cast.ToBool(a["dir"]) && !cast.ToBool(b["dir"]) {
return -1
}
if !cast.ToBool(a["dir"]) && cast.ToBool(b["dir"]) {
return 1
}
return strings.Compare(strings.ToLower(cast.ToString(a["name"])), strings.ToLower(cast.ToString(b["name"])))
})
return paths
}
// setPermission 设置权限
func (s *FileService) setPermission(path string, mode stdos.FileMode, owner, group string) {
_ = io.Chmod(path, mode)
_ = io.Chown(path, owner, group)

View File

@@ -16,6 +16,7 @@ import (
"time"
"github.com/go-rat/chix"
"github.com/spf13/cast"
"github.com/TheTNB/panel/internal/http/request"
"github.com/TheTNB/panel/pkg/io"
@@ -314,22 +315,18 @@ func (s *FileService) Search(w http.ResponseWriter, r *http.Request) {
return
}
paths := make(map[string]stdos.FileInfo)
err = filepath.Walk(req.Path, func(path string, info stdos.FileInfo, err error) error {
if err != nil {
return err
}
if strings.Contains(info.Name(), req.KeyWord) {
paths[path] = info
}
return nil
})
results, err := io.SearchX(req.Path, req.Keyword, req.Sub)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, paths)
paged, total := Paginate(r, s.formatInfo(results))
Success(w, chix.M{
"total": total,
"items": paged,
})
}
func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
@@ -365,13 +362,23 @@ func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
})
}
paged, total := Paginate(r, s.formatDir(req.Path, list))
Success(w, chix.M{
"total": total,
"items": paged,
})
}
// formatDir 格式化目录信息
func (s *FileService) formatDir(base string, entries []stdos.DirEntry) []any {
var paths []any
for _, file := range list {
for _, file := range entries {
info, _ := file.Info()
paths = append(paths, map[string]any{
"name": info.Name(),
"full": filepath.Join(req.Path, info.Name()),
"full": filepath.Join(base, info.Name()),
"size": str.FormatBytes(float64(info.Size())),
"mode_str": info.Mode().String(),
"mode": fmt.Sprintf("%04o", info.Mode().Perm()),
@@ -381,18 +388,48 @@ func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
"gid": 0,
"hidden": io.IsHidden(info.Name()),
"symlink": io.IsSymlink(info.Mode()),
"link": io.GetSymlink(filepath.Join(req.Path, info.Name())),
"link": io.GetSymlink(filepath.Join(base, info.Name())),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
})
}
paged, total := Paginate(r, paths)
return paths
}
Success(w, chix.M{
"total": total,
"items": paged,
// formatInfo 格式化文件信息
func (s *FileService) formatInfo(infos map[string]stdos.FileInfo) []map[string]any {
var paths []map[string]any
for path, info := range infos {
paths = append(paths, map[string]any{
"name": info.Name(),
"full": path,
"size": str.FormatBytes(float64(info.Size())),
"mode_str": info.Mode().String(),
"mode": fmt.Sprintf("%04o", info.Mode().Perm()),
"owner": "",
"group": "",
"uid": 0,
"gid": 0,
"hidden": io.IsHidden(info.Name()),
"symlink": io.IsSymlink(info.Mode()),
"link": io.GetSymlink(path),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
})
}
slices.SortFunc(paths, func(a, b map[string]any) int {
if cast.ToBool(a["dir"]) && !cast.ToBool(b["dir"]) {
return -1
}
if !cast.ToBool(a["dir"]) && cast.ToBool(b["dir"]) {
return 1
}
return strings.Compare(strings.ToLower(cast.ToString(a["name"])), strings.ToLower(cast.ToString(b["name"])))
})
return paths
}
// setPermission

View File

@@ -211,6 +211,11 @@ func GetSymlink(path string) string {
return linkPath
}
// TempFile 创建临时文件
func TempFile(dir, prefix string) (*os.File, error) {
return os.CreateTemp(dir, prefix)
}
func getFormat(f FormatArchive) archiver.CompressedArchive {
format := archiver.CompressedArchive{}
switch f {

View File

@@ -1,6 +1,7 @@
package io
import (
"bytes"
"fmt"
"io"
"os"
@@ -146,11 +147,6 @@ func TempDir(prefix string) (string, error) {
return os.MkdirTemp("", prefix)
}
// TempFile 创建临时文件
func TempFile(dir, prefix string) (*os.File, error) {
return os.CreateTemp(dir, prefix)
}
// ReadDir 读取目录
func ReadDir(path string) ([]os.DirEntry, error) {
return os.ReadDir(path)
@@ -190,3 +186,57 @@ func CountX(path string) (int64, error) {
count := len(string(out))
return int64(count), nil
}
// Search 查找文件/文件夹
func Search(path, keyword string, sub bool) (map[string]os.FileInfo, error) {
paths := make(map[string]os.FileInfo)
baseDepth := strings.Count(filepath.Clean(path), string(os.PathSeparator))
err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !sub && strings.Count(p, string(os.PathSeparator)) > baseDepth+1 {
return filepath.SkipDir
}
if strings.Contains(info.Name(), keyword) {
paths[p] = info
}
return nil
})
return paths, err
}
// SearchX 查找文件/文件夹find命令
func SearchX(path, keyword string, sub bool) (map[string]os.FileInfo, error) {
paths := make(map[string]os.FileInfo)
var cmd *exec.Cmd
if sub {
cmd = exec.Command("find", path, "-name", "*"+keyword+"*")
} else {
cmd = exec.Command("find", path, "-maxdepth", "1", "-name", "*"+keyword+"*")
}
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
lines := strings.Split(out.String(), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
info, err := os.Stat(line)
if err != nil {
return nil, err
}
paths[line] = info
}
return paths, nil
}

View File

@@ -50,8 +50,14 @@ export default {
unCompress: (file: string, path: string): Promise<AxiosResponse<any>> =>
request.post('/file/unCompress', { file, path }),
// 搜索文件
search: (keyword: string): Promise<AxiosResponse<any>> =>
request.post('/file/search', { keyword }),
search: (
path: string,
keyword: string,
sub: boolean,
page: number,
limit: number
): Promise<AxiosResponse<any>> =>
request.get('/file/search', { params: { path, keyword, sub, page, limit } }),
// 获取文件列表
list: (path: string, page: number, limit: number, sort: string): Promise<AxiosResponse<any>> =>
request.get('/file/list', { params: { path, page, limit, sort } })

View File

@@ -71,13 +71,8 @@ const columns: DataTableColumns<RowData> = [
title: '名称',
key: 'name',
minWidth: 180,
ellipsis: {
tooltip: true
},
defaultSortOrder: false,
sorter(row1, row2) {
return row1.name - row2.name
},
sorter: 'default',
render(row) {
let icon = 'bi:file-earmark'
if (row.dir) {
@@ -388,7 +383,6 @@ const handleRename = () => {
const target = path.value + '/' + renameModel.value.target
if (!checkName(renameModel.value.source) || !checkName(renameModel.value.target)) {
window.$message.error('名称不合法')
console.log(source, target)
return
}
@@ -500,8 +494,6 @@ const handleSorterChange = (sorter: {
}) => {
if (!sorter || sorter.columnKey === 'name') {
if (!loading.value) {
console.log(sorter)
console.log(sorter.order)
switch (sorter.order) {
case 'ascend':
sort.value = 'asc'

View File

@@ -4,6 +4,7 @@ import { onUnmounted } from 'vue'
import EventBus from '@/utils/event'
import { checkPath } from '@/utils/file'
import SearchModal from '@/views/file/SearchModal.vue'
const path = defineModel<string>('path', { type: String, required: true })
const isInput = ref(false)
@@ -13,6 +14,12 @@ const input = ref('www')
const history: string[] = []
let current = -1
const searchModal = ref(false)
const search = ref({
keyword: '',
sub: false
})
const handleInput = () => {
isInput.value = true
nextTick(() => {
@@ -84,7 +91,7 @@ const handlePushHistory = (path: string) => {
}
const handleSearch = () => {
window.$message.info('搜索功能暂未实现')
searchModal.value = true
}
watch(
@@ -141,9 +148,9 @@ onUnmounted(() => {
/>
</n-input-group>
<n-input-group w-400>
<n-input placeholder="请输入搜索内容">
<n-input v-model:value="search.keyword" placeholder="请输入搜索内容">
<template #suffix>
<n-checkbox> 包含子目录 </n-checkbox>
<n-checkbox v-model:checked="search.sub"> 包含子目录 </n-checkbox>
</template>
</n-input>
<n-button type="primary" @click="handleSearch">
@@ -151,6 +158,12 @@ onUnmounted(() => {
</n-button>
</n-input-group>
</n-flex>
<search-modal
v-model:show="searchModal"
v-model:path="path"
v-model:keyword="search.keyword"
v-model:sub="search.sub"
/>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import file from '@/api/panel/file'
import EventBus from '@/utils/event'
import { NButton, NPopconfirm, NSpace, NTag } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import type { RowData } from 'naive-ui/es/data-table/src/interface'
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const path = defineModel<string>('path', { type: String, required: true })
const keyword = defineModel<string>('keyword', { type: String, required: true })
const sub = defineModel<boolean>('sub', { type: Boolean, required: true })
const loading = ref(false)
const columns: DataTableColumns<RowData> = [
{
title: '名称',
key: 'full',
minWidth: 300,
ellipsis: {
tooltip: true
}
},
{
title: '大小',
key: 'size',
width: 80,
render(row: any): any {
return h(NTag, { type: 'info', size: 'small', bordered: false }, { default: () => row.size })
}
},
{
title: '修改时间',
key: 'modify',
width: 200,
render(row: any): any {
return h(
NTag,
{ type: 'warning', size: 'small', bordered: false },
{ default: () => row.modify }
)
}
},
{
title: '操作',
key: 'action',
width: 200,
render(row) {
return h(
NSpace,
{},
{
default: () => [
h(
NButton,
{
size: 'small',
type: 'success',
tertiary: true,
onClick: () => {
navigator.clipboard.writeText(row.full)
window.$message.success('复制成功')
}
},
{
default: () => {
return '复制路径'
}
}
),
h(
NPopconfirm,
{
onPositiveClick: () => {
file.delete(row.full).then(() => {
window.$message.success('删除成功')
EventBus.emit('file:refresh')
})
},
onNegativeClick: () => {}
},
{
default: () => {
return '确定删除吗?'
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error',
tertiary: true
},
{ default: () => '删除' }
)
}
}
)
]
}
)
}
}
]
const data = ref<RowData[]>([])
const pagination = reactive({
page: 1,
pageCount: 1,
pageSize: 100,
itemCount: 0,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [100, 200, 500, 1000, 1500, 2000, 5000]
})
const handlePageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
handlePageChange(1)
}
const handlePageChange = (page: number) => {
search(page)
}
const search = async (page: number) => {
loading.value = true
await file
.search(path.value, keyword.value, sub.value, page, pagination.pageSize!)
.then((res) => {
data.value = res.data.items
pagination.itemCount = res.data.total
pagination.pageCount = res.data.total / pagination.pageSize! + 1
})
.catch(() => {
window.$message.error('搜索失败')
})
loading.value = false
}
watch(show, (value) => {
if (value) {
search(1)
}
})
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="keyword + ' - 搜索结果'"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-data-table
remote
striped
virtual-scroll
size="small"
:scroll-x="800"
:columns="columns"
:data="data"
:loading="loading"
:pagination="pagination"
:row-key="(row: any) => row.full"
max-height="60vh"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</n-modal>
</template>
<style scoped lang="scss"></style>