2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 06:47:20 +08:00
Files
panel/internal/service/file.go
Copilot 2f63523540 feat(file): 文件管理多项改进 - 权限显示、双击交互、终端集成、图标视图 (#1220)
* Initial plan

* feat(file): 实现文件管理多项改进功能

1. 修复权限弹窗中所有者和组固定显示 www 的问题
2. 目录单击进入,文件双击打开(编辑/预览)
3. 点击权限/所有组列直接打开编辑弹窗
4. 压缩包双击自动弹出解压窗口
5. 预留视图切换按钮(列表/图标视图)
6. 工具栏新增终端按钮
7. 右键菜单新增终端选项(仅目录)
8. 右键菜单新增属性选项
9. 新增显示/隐藏隐藏文件按钮(刷新按钮旁边)

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 完善后端 Info 接口返回完整文件信息并修复代码审查问题

1. 后端 /file/info 接口现在返回 owner、group、uid、gid 等完整信息
2. 修复权限弹窗 mode 处理逻辑,确保正确处理边界情况
3. 优化视图切换逻辑,避免不必要的状态变更
4. 提取 openFile 函数复用代码,减少重复逻辑

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 添加类型断言检查和提取 normalizeMode 辅助函数

1. 后端 Info 方法添加 syscall.Stat_t 类型断言安全检查
2. 前端 PermissionModal 提取 normalizeMode 函数消除代码重复

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat(file): 实现图标视图和鼠标框选功能

1. 新增 GridView.vue 组件实现图标视图
2. 支持鼠标拖动框选批量选择文件/目录
3. 支持 Ctrl/Cmd+点击多选和 Shift+点击范围选择
4. 更新 store 添加 viewType 状态持久化
5. 更新 ToolBar 和 IndexView 支持视图切换

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* feat: 完善切换逻辑

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-01-11 23:36:44 +08:00

610 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build !windows
package service
import (
"encoding/base64"
"fmt"
stdio "io"
"net/http"
stdos "os"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/utils/file"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/chattr"
"github.com/acepanel/panel/pkg/io"
"github.com/acepanel/panel/pkg/os"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/tools"
)
type FileService struct {
t *gotext.Locale
taskRepo biz.TaskRepo
}
func NewFileService(t *gotext.Locale, task biz.TaskRepo) *FileService {
return &FileService{
t: t,
taskRepo: task,
}
}
func (s *FileService) Create(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileCreate](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !req.Dir {
if _, err = shell.Execf("touch %s", req.Path); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
} else {
if err = stdos.MkdirAll(req.Path, 0755); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
s.setPermission(req.Path, 0755, "www", "www")
Success(w, nil)
}
func (s *FileService) Content(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
fileInfo, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if fileInfo.IsDir() {
Error(w, http.StatusInternalServerError, s.t.Get("target is a directory"))
return
}
if fileInfo.Size() > 10*1024*1024 {
Error(w, http.StatusInternalServerError, s.t.Get("file is too large, please download it to view"))
return
}
content, err := stdos.ReadFile(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
mime, err := file.MimeType(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, chix.M{
"mime": mime,
"content": base64.StdEncoding.EncodeToString(content),
})
}
func (s *FileService) Save(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileSave](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
fileInfo, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(req.Path, req.Content, fileInfo.Mode()); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Delete(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
banned := []string{"/", app.Root, filepath.Join(app.Root, "server"), filepath.Join(app.Root, "panel")}
if slices.Contains(banned, req.Path) {
Error(w, http.StatusForbidden, s.t.Get("please don't do this"))
return
}
if err = io.Remove(req.Path); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Upload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(2 << 30); err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
path := r.FormValue("path")
_, handler, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("upload file error: %v", err))
return
}
if io.Exists(path) {
Error(w, http.StatusForbidden, s.t.Get("target path %s already exists", path))
return
}
if !io.Exists(filepath.Dir(path)) {
if err = stdos.MkdirAll(filepath.Dir(path), 0755); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("create directory error: %v", err))
return
}
}
src, _ := handler.Open()
out, err := stdos.OpenFile(path, stdos.O_CREATE|stdos.O_RDWR|stdos.O_TRUNC, 0644)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("open file error: %v", err))
return
}
if _, err = stdio.Copy(out, src); err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("write file error: %v", err))
return
}
_ = src.Close()
s.setPermission(path, 0755, "www", "www")
Success(w, nil)
}
func (s *FileService) Exist(w http.ResponseWriter, r *http.Request) {
binder := chix.NewBind(r)
defer binder.Release()
var paths []string
if err := binder.Body(&paths); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
var results []bool
for item := range slices.Values(paths) {
results = append(results, io.Exists(item))
}
Success(w, results)
}
func (s *FileService) Move(w http.ResponseWriter, r *http.Request) {
binder := chix.NewBind(r)
defer binder.Release()
var req []request.FileControl
if err := binder.Body(&req); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
for item := range slices.Values(req) {
if io.Exists(item.Target) && !item.Force {
continue
}
if io.IsDir(item.Source) && strings.HasPrefix(item.Target, item.Source+"/") {
Error(w, http.StatusForbidden, s.t.Get("please don't do this"))
return
}
if err := io.Mv(item.Source, item.Target); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
Success(w, nil)
}
func (s *FileService) Copy(w http.ResponseWriter, r *http.Request) {
binder := chix.NewBind(r)
defer binder.Release()
var req []request.FileControl
if err := binder.Body(&req); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
for item := range slices.Values(req) {
if io.Exists(item.Target) && !item.Force {
continue
}
if io.IsDir(item.Source) && strings.HasPrefix(item.Target, item.Source+"/") {
Error(w, http.StatusForbidden, s.t.Get("please don't do this"))
return
}
if err := io.Cp(item.Source, item.Target); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
Success(w, nil)
}
func (s *FileService) Download(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
info, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if info.IsDir() {
Error(w, http.StatusInternalServerError, s.t.Get("can't download a directory"))
return
}
render := chix.NewRender(w, r)
defer render.Release()
render.Download(req.Path, info.Name())
}
func (s *FileService) RemoteDownload(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileRemoteDownload](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
timestamp := time.Now().Format("20060102150405")
task := new(biz.Task)
task.Name = s.t.Get("Download remote file %v", filepath.Base(req.Path))
task.Status = biz.TaskStatusWaiting
task.Shell = fmt.Sprintf(`wget -o /tmp/remote-download-%s.log -O '%s' '%s' && chmod 0755 '%s' && chown www:www '%s'`, timestamp, req.Path, req.URL, req.Path, req.Path)
task.Log = fmt.Sprintf("/tmp/remote-download-%s.log", timestamp)
if err = s.taskRepo.Push(task); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Info(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
info, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get file system info"))
return
}
// 检查是否有 immutable 属性
immutable := false
if f, err := stdos.OpenFile(req.Path, stdos.O_RDONLY, 0); err == nil {
immutable, _ = chattr.IsAttr(f, chattr.FS_IMMUTABLE_FL)
_ = f.Close()
}
Success(w, chix.M{
"name": info.Name(),
"full": req.Path,
"size": tools.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(req.Path),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
"immutable": immutable,
})
}
// Size 计算大小
func (s *FileService) Size(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePath](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
info, err := stdos.Stat(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if !info.IsDir() {
// 如果不是目录,直接返回文件大小
Success(w, chix.M{
"size": tools.FormatBytes(float64(info.Size())),
})
return
}
// 计算目录大小
output, err := shell.Execf("du -sb '%s' | awk '{print $1}'", req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, tools.FormatBytes(cast.ToFloat64(output)))
}
func (s *FileService) Permission(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FilePermission](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 解析成8进制
mode, err := strconv.ParseUint(req.Mode, 8, 64)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Chmod(req.Path, stdos.FileMode(mode)); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Chown(req.Path, req.Owner, req.Group); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *FileService) Compress(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileCompress](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Compress(req.Dir, req.Paths, req.File); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
s.setPermission(req.File, 0755, "www", "www")
Success(w, nil)
}
func (s *FileService) UnCompress(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileUnCompress](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.UnCompress(req.File, req.Path); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
list, err := io.ListCompress(req.File)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
for item := range slices.Values(list) {
s.setPermission(filepath.Join(req.Path, item), 0755, "www", "www")
}
Success(w, nil)
}
func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileList](r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
var list []stdos.DirEntry
if req.Keyword != "" {
list, err = io.SearchX(req.Path, req.Keyword, req.Sub)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
} else {
list, err = stdos.ReadDir(req.Path)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
}
// 前缀 - 表示降序
sortKey := req.Sort
sortDesc := false
if strings.HasPrefix(sortKey, "-") {
sortDesc = true
sortKey = strings.TrimPrefix(sortKey, "-")
}
// 获取文件信息用于排序
type entryWithInfo struct {
entry stdos.DirEntry
info stdos.FileInfo
}
entriesWithInfo := make([]entryWithInfo, 0, len(list))
for _, entry := range list {
info, err := entry.Info()
if err != nil {
continue
}
entriesWithInfo = append(entriesWithInfo, entryWithInfo{entry: entry, info: info})
}
// 排序
slices.SortFunc(entriesWithInfo, func(a, b entryWithInfo) int {
// 文件夹始终排在前面(除非按特定字段排序)
if sortKey == "" {
if a.info.IsDir() && !b.info.IsDir() {
return -1
}
if !a.info.IsDir() && b.info.IsDir() {
return 1
}
}
var cmp int
switch sortKey {
case "size":
// 按大小排序
if a.info.Size() < b.info.Size() {
cmp = -1
} else if a.info.Size() > b.info.Size() {
cmp = 1
} else {
cmp = 0
}
case "modify":
// 按修改时间排序
if a.info.ModTime().Before(b.info.ModTime()) {
cmp = -1
} else if a.info.ModTime().After(b.info.ModTime()) {
cmp = 1
} else {
cmp = 0
}
case "name":
// 按名称排序
cmp = strings.Compare(strings.ToLower(a.info.Name()), strings.ToLower(b.info.Name()))
default:
// 默认按名称排序
cmp = strings.Compare(strings.ToLower(a.info.Name()), strings.ToLower(b.info.Name()))
}
if sortDesc {
cmp = -cmp
}
return cmp
})
// 转换回 DirEntry 列表
sortedList := make([]stdos.DirEntry, len(entriesWithInfo))
for i, e := range entriesWithInfo {
sortedList[i] = e.entry
}
paged, total := Paginate(r, s.formatDir(req.Path, sortedList))
Success(w, chix.M{
"total": total,
"items": paged,
})
}
// formatDir 格式化目录信息
func (s *FileService) formatDir(base string, entries []stdos.DirEntry) []any {
var paths []any
for item := range slices.Values(entries) {
info, err := item.Info()
if err != nil {
continue // 直接跳过,不返回错误,不然很烦人的
}
if de, ok := item.(*io.SearchEntry); ok {
base = filepath.Dir(de.Path())
}
stat := info.Sys().(*syscall.Stat_t)
// 对于目录size 返回空字符串,需要用户手动计算
size := ""
if !info.IsDir() {
size = tools.FormatBytes(float64(info.Size()))
}
// 检查是否有 immutable 属性
fullPath := filepath.Join(base, info.Name())
immutable := false
if f, err := stdos.OpenFile(fullPath, stdos.O_RDONLY, 0); err == nil {
immutable, _ = chattr.IsAttr(f, chattr.FS_IMMUTABLE_FL)
_ = f.Close()
}
paths = append(paths, map[string]any{
"name": info.Name(),
"full": fullPath,
"size": 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(fullPath),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
"immutable": immutable,
})
}
return paths
}
// setPermission 设置权限
func (s *FileService) setPermission(path string, mode stdos.FileMode, owner, group string) {
_ = io.Chmod(path, mode)
_ = io.Chown(path, owner, group)
}