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

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>
This commit is contained in:
Copilot
2026-01-11 23:36:44 +08:00
committed by GitHub
parent 9cbef88984
commit 2f63523540
11 changed files with 1999 additions and 899 deletions

View File

@@ -319,13 +319,35 @@ func (s *FileService) Info(w http.ResponseWriter, r *http.Request) {
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(),
"size": tools.FormatBytes(float64(info.Size())),
"mode_str": info.Mode().String(),
"mode": fmt.Sprintf("%04o", info.Mode().Perm()),
"dir": info.IsDir(),
"modify": info.ModTime().Format(time.DateTime),
"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,
})
}
@@ -449,28 +471,81 @@ func (s *FileService) List(w http.ResponseWriter, r *http.Request) {
}
}
switch req.Sort {
case "asc":
slices.SortFunc(list, func(a, b stdos.DirEntry) int {
return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name()))
})
case "desc":
slices.SortFunc(list, func(a, b stdos.DirEntry) int {
return strings.Compare(strings.ToLower(b.Name()), strings.ToLower(a.Name()))
})
default:
slices.SortFunc(list, func(a, b stdos.DirEntry) int {
if a.IsDir() && !b.IsDir() {
return -1
}
if !a.IsDir() && b.IsDir() {
return 1
}
return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name()))
})
// 前缀 - 表示降序
sortKey := req.Sort
sortDesc := false
if strings.HasPrefix(sortKey, "-") {
sortDesc = true
sortKey = strings.TrimPrefix(sortKey, "-")
}
paged, total := Paginate(r, s.formatDir(req.Path, list))
// 获取文件信息用于排序
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,

View File

@@ -194,7 +194,7 @@ const handleBeforeClose = (): Promise<boolean> => {
window.$dialog.warning({
title: $gettext('Confirm'),
content: $gettext(
'Command is still running. Closing the window will terminate the command. Are you sure?'
'Command may still running. Closing the window will terminate the command. Are you sure?'
),
positiveText: $gettext('Confirm'),
negativeText: $gettext('Cancel'),

View File

@@ -2,6 +2,10 @@ export interface File {
path: string
keyword: string
sub: boolean
showHidden: boolean
viewType: 'list' | 'grid'
sortKey: string
sortOrder: 'asc' | 'desc'
}
export const useFileStore = defineStore('file', {
@@ -9,7 +13,17 @@ export const useFileStore = defineStore('file', {
return {
path: '/opt',
keyword: '',
sub: false
sub: false,
showHidden: false,
viewType: 'list',
sortKey: '',
sortOrder: 'asc'
}
},
getters: {
sort(): string {
if (!this.sortKey) return ''
return this.sortOrder === 'desc' ? `-${this.sortKey}` : this.sortKey
}
},
actions: {
@@ -17,6 +31,31 @@ export const useFileStore = defineStore('file', {
this.path = info.path
this.keyword = info.keyword
this.sub = info.sub
this.showHidden = info.showHidden
this.viewType = info.viewType
this.sortKey = info.sortKey
this.sortOrder = info.sortOrder
},
toggleShowHidden() {
this.showHidden = !this.showHidden
},
toggleViewType() {
this.viewType = this.viewType === 'list' ? 'grid' : 'list'
},
setSort(key: string) {
if (this.sortKey === key) {
// 同一列:切换排序方向,或取消排序
if (this.sortOrder === 'asc') {
this.sortOrder = 'desc'
} else {
this.sortKey = ''
this.sortOrder = 'asc'
}
} else {
// 不同列:设置新的排序列
this.sortKey = key
this.sortOrder = 'asc'
}
}
},
persist: true

View File

@@ -5,17 +5,19 @@ defineOptions({
import { useFileStore } from '@/store'
import CompressModal from '@/views/file/CompressModal.vue'
import ListTable from '@/views/file/ListTable.vue'
import ListView from '@/views/file/ListView.vue'
import PathInput from '@/views/file/PathInput.vue'
import PermissionModal from '@/views/file/PermissionModal.vue'
import ToolBar from '@/views/file/ToolBar.vue'
import type { Marked } from '@/views/file/types'
import type { FileInfo, Marked } from '@/views/file/types'
const fileStore = useFileStore()
const selected = ref<string[]>([])
const marked = ref<Marked[]>([])
const markedType = ref<string>('copy')
// 权限编辑时的文件信息列表
const permissionFileInfoList = ref<FileInfo[]>([])
const compress = ref(false)
const permission = ref(false)
@@ -37,7 +39,7 @@ const permission = ref(false)
v-model:compress="compress"
v-model:permission="permission"
/>
<list-table
<list-view
v-model:path="fileStore.path"
v-model:keyword="fileStore.keyword"
v-model:sub="fileStore.sub"
@@ -46,13 +48,18 @@ const permission = ref(false)
v-model:markedType="markedType"
v-model:compress="compress"
v-model:permission="permission"
v-model:permission-file-info-list="permissionFileInfoList"
/>
<compress-modal
v-model:show="compress"
v-model:path="fileStore.path"
v-model:selected="selected"
/>
<permission-modal v-model:show="permission" v-model:selected="selected" />
<permission-modal
v-model:show="permission"
v-model:selected="selected"
v-model:file-info-list="permissionFileInfoList"
/>
</n-flex>
</common-page>
</template>

View File

@@ -1,854 +0,0 @@
<script setup lang="ts">
import {
NButton,
NDataTable,
NEllipsis,
NFlex,
NInput,
NPopconfirm,
NPopselect,
NSpin,
NTag,
useThemeVars
} from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import type { DataTableColumns, DropdownOption } from 'naive-ui'
import type { RowData } from 'naive-ui/es/data-table/src/interface'
import file from '@/api/panel/file'
import TheIcon from '@/components/custom/TheIcon.vue'
import {
checkName,
checkPath,
getExt,
getFilename,
getIconByExt,
isCompress,
isImage
} from '@/utils/file'
import EditModal from '@/views/file/EditModal.vue'
import PreviewModal from '@/views/file/PreviewModal.vue'
import type { Marked } from '@/views/file/types'
const { $gettext } = useGettext()
const themeVars = useThemeVars()
const sort = ref<string>('')
const path = defineModel<string>('path', { type: String, required: true }) // 当前路径
const keyword = defineModel<string>('keyword', { type: String, default: '' }) // 搜索关键词
const sub = defineModel<boolean>('sub', { type: Boolean, default: false }) // 搜索是否包括子目录
const selected = defineModel<any[]>('selected', { type: Array, default: () => [] })
const marked = defineModel<Marked[]>('marked', { type: Array, default: () => [] })
const markedType = defineModel<string>('markedType', { type: String, required: true })
const compress = defineModel<boolean>('compress', { type: Boolean, required: true })
const permission = defineModel<boolean>('permission', { type: Boolean, required: true })
const editorModal = ref(false)
const previewModal = ref(false)
const currentFile = ref('')
const showDropdown = ref(false)
const selectedRow = ref<any>()
const dropdownX = ref(0)
const dropdownY = ref(0)
// 目录大小计算状态
const sizeLoading = ref<Map<string, boolean>>(new Map())
const sizeCache = ref<Map<string, string>>(new Map())
const renameModal = ref(false)
const renameModel = ref({
source: '',
target: ''
})
const unCompressModal = ref(false)
const unCompressModel = ref({
path: '',
file: ''
})
// 检查是否有 immutable 属性,如果有则弹出确认对话框
const confirmImmutableOperation = (row: any, operation: string, callback: () => void) => {
if (row.immutable) {
window.$dialog.warning({
title: $gettext('Warning'),
content: $gettext(
'%{ name } has immutable attribute. The panel will temporarily remove the immutable attribute, perform the operation, and then restore the immutable attribute. Do you want to continue?',
{ name: row.name }
),
positiveText: $gettext('Continue'),
negativeText: $gettext('Cancel'),
onPositiveClick: callback
})
} else {
callback()
}
}
const options = computed<DropdownOption[]>(() => {
if (selectedRow.value == null) return []
const options = [
{
label: selectedRow.value.dir
? $gettext('Open')
: isImage(selectedRow.value.name)
? $gettext('Preview')
: $gettext('Edit'),
key: selectedRow.value.dir ? 'open' : isImage(selectedRow.value.name) ? 'preview' : 'edit'
},
{ label: $gettext('Copy'), key: 'copy' },
{ label: $gettext('Move'), key: 'move' },
{ label: $gettext('Permission'), key: 'permission' },
{
label: selectedRow.value.dir ? $gettext('Compress') : $gettext('Download'),
key: selectedRow.value.dir ? 'compress' : 'download'
},
{
label: $gettext('Uncompress'),
key: 'uncompress',
show: isCompress(selectedRow.value.full),
disabled: !isCompress(selectedRow.value.full)
},
{ label: $gettext('Rename'), key: 'rename' },
{ label: () => h('span', { style: { color: 'red' } }, $gettext('Delete')), key: 'delete' }
]
if (marked.value.length) {
options.unshift({
label: $gettext('Paste'),
key: 'paste'
})
}
return options
})
const columns: DataTableColumns<RowData> = [
{
type: 'selection',
fixed: 'left'
},
{
title: $gettext('Name'),
key: 'name',
minWidth: 180,
defaultSortOrder: false,
sorter: 'default',
render(row) {
let icon = 'mdi:file-outline'
if (row.dir) {
icon = 'mdi:folder-outline'
} else {
icon = getIconByExt(getExt(row.name))
}
return h(
NFlex,
{
class: 'cursor-pointer hover:opacity-60',
onClick: () => {
if (row.dir) {
path.value = row.full
} else {
currentFile.value = row.full
editorModal.value = true
}
}
},
() => [
h(TheIcon, { icon, size: 24 }),
h(NEllipsis, null, {
default: () => {
if (row.symlink) {
return row.name + ' -> ' + row.link
} else {
return row.name
}
}
}),
// 如果文件有 immutable 属性,显示锁定图标
row.immutable
? h(TheIcon, {
icon: 'mdi:lock',
size: 16,
style: { color: '#f0a020', marginLeft: '4px' }
})
: null
]
)
}
},
{
title: $gettext('Permission'),
key: 'mode',
minWidth: 80,
render(row: any): any {
return h(
NTag,
{ type: 'success', size: 'small', bordered: false },
{ default: () => row.mode }
)
}
},
{
title: $gettext('Owner / Group'),
key: 'owner/group',
minWidth: 120,
render(row: any): any {
return h('div', null, [
h(NTag, { type: 'primary', size: 'small', bordered: false }, { default: () => row.owner }),
' / ',
h(NTag, { type: 'primary', size: 'small', bordered: false }, { default: () => row.group })
])
}
},
{
title: $gettext('Size'),
key: 'size',
minWidth: 100,
render(row: any): any {
// 文件
if (!row.dir) {
return h(
NTag,
{ type: 'info', size: 'small', bordered: false },
{ default: () => row.size }
)
}
// 目录
const cachedSize = sizeCache.value.get(row.full)
if (cachedSize) {
return h(
NTag,
{ type: 'info', size: 'small', bordered: false },
{ default: () => cachedSize }
)
}
const isLoading = sizeLoading.value.get(row.full)
if (isLoading) {
return h(NSpin, { size: 16, style: { paddingTop: '4px' } })
}
return h(
'span',
{
style: { cursor: 'pointer', fontSize: '14px', color: themeVars.value.primaryColor },
onClick: (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
calculateDirSize(row.full)
}
},
$gettext('Calculate')
)
}
},
{
title: $gettext('Modification Time'),
key: 'modify',
minWidth: 200,
render(row: any): any {
return h(
NTag,
{ type: 'warning', size: 'small', bordered: false },
{ default: () => row.modify }
)
}
},
{
title: $gettext('Actions'),
key: 'action',
width: 400,
render(row) {
return h(
NFlex,
{},
{
default: () => [
h(
NButton,
{
size: 'small',
type: row.dir ? 'success' : 'primary',
tertiary: true,
onClick: () => {
if (!row.dir && !row.symlink) {
currentFile.value = row.full
if (isImage(row.name)) {
previewModal.value = true
} else {
editorModal.value = true
}
} else {
path.value = row.full
}
}
},
{
default: () => {
if (!row.dir && !row.symlink) {
return isImage(row.name) ? $gettext('Preview') : $gettext('Edit')
} else {
return $gettext('Open')
}
}
}
),
h(
NButton,
{
size: 'small',
type: row.dir ? 'primary' : 'info',
tertiary: true,
onClick: () => {
if (row.dir) {
selected.value = [row.full]
compress.value = true
} else {
window.open('/api/file/download?path=' + encodeURIComponent(row.full))
}
}
},
{
default: () => {
if (row.dir) {
return $gettext('Compress')
} else {
return $gettext('Download')
}
}
}
),
h(
NButton,
{
type: 'warning',
size: 'small',
tertiary: true,
onClick: () => {
confirmImmutableOperation(row, 'rename', () => {
renameModel.value.source = getFilename(row.name)
renameModel.value.target = getFilename(row.name)
renameModal.value = true
})
}
},
{ default: () => $gettext('Rename') }
),
h(
NPopconfirm,
{
onPositiveClick: () => {
confirmImmutableOperation(row, 'delete', () => {
useRequest(file.delete(row.full)).onComplete(() => {
window.$bus.emit('file:refresh')
window.$message.success($gettext('Deleted successfully'))
})
})
},
onNegativeClick: () => {}
},
{
default: () => {
return $gettext('Are you sure you want to delete %{ name }?', { name: row.name })
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error',
tertiary: true
},
{ default: () => $gettext('Delete') }
)
}
}
),
h(
NPopselect,
{
options: [
{ label: $gettext('Copy'), value: 'copy' },
{ label: $gettext('Move'), value: 'move' },
{ label: $gettext('Permission'), value: 'permission' },
{ label: $gettext('Compress'), value: 'compress' },
{
label: $gettext('Uncompress'),
value: 'uncompress',
disabled: !isCompress(row.name)
}
],
onUpdateValue: (value) => {
switch (value) {
case 'copy':
markedType.value = 'copy'
marked.value = [
{
name: row.name,
source: row.full,
force: false
}
]
window.$message.success(
$gettext(
'Marked successfully, please navigate to the destination path to paste'
)
)
break
case 'move':
markedType.value = 'move'
marked.value = [
{
name: row.name,
source: row.full,
force: false
}
]
window.$message.success(
$gettext(
'Marked successfully, please navigate to the destination path to paste'
)
)
break
case 'permission':
selected.value = [row.full]
permission.value = true
break
case 'compress':
selected.value = [row.full]
compress.value = true
break
case 'uncompress':
unCompressModel.value.file = row.full
unCompressModel.value.path = path.value
unCompressModal.value = true
break
}
}
},
{
default: () => {
return h(
NButton,
{
tertiary: true,
size: 'small'
},
{ default: () => $gettext('More') }
)
}
}
)
]
}
)
}
}
]
const rowProps = (row: any) => {
return {
onContextmenu: (e: MouseEvent) => {
e.preventDefault()
showDropdown.value = false
nextTick().then(() => {
showDropdown.value = true
selectedRow.value = row
dropdownX.value = e.clientX
dropdownY.value = e.clientY
})
}
}
}
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
(page, pageSize) =>
file.list(encodeURIComponent(path.value), keyword.value, sub.value, sort.value, page, pageSize),
{
initialData: { total: 0, list: [] },
initialPageSize: 100,
total: (res: any) => res.total,
data: (res: any) => res.items
}
)
// 计算目录大小
const calculateDirSize = (dirPath: string) => {
sizeLoading.value.set(dirPath, true)
useRequest(file.size(dirPath))
.onSuccess(({ data }) => {
sizeCache.value.set(dirPath, data)
})
.onComplete(() => {
sizeLoading.value.set(dirPath, false)
})
}
const handleRename = () => {
const source = path.value + '/' + renameModel.value.source
const target = path.value + '/' + renameModel.value.target
if (!checkName(renameModel.value.source) || !checkName(renameModel.value.target)) {
window.$message.error($gettext('Invalid name'))
return
}
useRequest(file.exist([target])).onSuccess(({ data }) => {
if (data[0]) {
window.$dialog.warning({
title: $gettext('Warning'),
content: $gettext('There are items with the same name. Do you want to overwrite?'),
positiveText: $gettext('Overwrite'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
useRequest(file.move([{ source, target, force: true }]))
.onSuccess(() => {
window.$bus.emit('file:refresh')
window.$message.success(
$gettext('Renamed %{ source } to %{ target } successfully', {
source: renameModel.value.source,
target: renameModel.value.target
})
)
})
.onComplete(() => {
renameModal.value = false
})
}
})
} else {
useRequest(file.move([{ source, target, force: false }]))
.onSuccess(() => {
window.$bus.emit('file:refresh')
window.$message.success(
$gettext('Renamed %{ source } to %{ target } successfully', {
source: renameModel.value.source,
target: renameModel.value.target
})
)
})
.onComplete(() => {
renameModal.value = false
})
}
})
}
const handleUnCompress = () => {
// 移除首位的 / 去检测
if (
!unCompressModel.value.path.startsWith('/') ||
!checkPath(unCompressModel.value.path.slice(1))
) {
window.$message.error($gettext('Invalid path'))
return
}
const message = window.$message.loading($gettext('Uncompressing...'), {
duration: 0
})
useRequest(file.unCompress(unCompressModel.value.file, unCompressModel.value.path))
.onSuccess(() => {
unCompressModal.value = false
window.$bus.emit('file:refresh')
window.$message.success($gettext('Uncompressed successfully'))
})
.onComplete(() => {
message?.destroy()
})
}
const handlePaste = () => {
if (!marked.value.length) {
window.$message.error($gettext('Please mark the files/folders to copy or move first'))
return
}
// 查重
let flag = false
const paths = marked.value.map((item) => {
return {
name: item.name,
source: item.source,
target: path.value + '/' + item.name,
force: false
}
})
const sources = paths.map((item: any) => item.target)
useRequest(file.exist(sources)).onSuccess(({ data }) => {
for (let i = 0; i < data.length; i++) {
if (data[i]) {
flag = true
paths[i].force = true
}
}
if (flag) {
window.$dialog.warning({
title: $gettext('Warning'),
content: $gettext(
'There are items with the same name %{ items } Do you want to overwrite?',
{
items: `${paths
.filter((item) => item.force)
.map((item) => item.name)
.join(', ')}`
}
),
positiveText: $gettext('Overwrite'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
if (markedType.value == 'copy') {
useRequest(file.copy(paths)).onSuccess(() => {
marked.value = []
window.$bus.emit('file:refresh')
window.$message.success($gettext('Copied successfully'))
})
} else {
useRequest(file.move(paths)).onSuccess(() => {
marked.value = []
window.$bus.emit('file:refresh')
window.$message.success($gettext('Moved successfully'))
})
}
},
onNegativeClick: () => {
marked.value = []
window.$message.info($gettext('Canceled'))
}
})
} else {
if (markedType.value == 'copy') {
useRequest(file.copy(paths)).onSuccess(() => {
marked.value = []
window.$bus.emit('file:refresh')
window.$message.success($gettext('Copied successfully'))
})
} else {
useRequest(file.move(paths)).onSuccess(() => {
marked.value = []
window.$bus.emit('file:refresh')
window.$message.success($gettext('Moved successfully'))
})
}
}
})
}
const handleSelect = (key: string) => {
switch (key) {
case 'paste':
handlePaste()
break
case 'open':
path.value = selectedRow.value.full
break
case 'edit':
currentFile.value = selectedRow.value.full
editorModal.value = true
break
case 'preview':
currentFile.value = selectedRow.value.full
previewModal.value = true
break
case 'copy':
markedType.value = 'copy'
marked.value = [
{
name: selectedRow.value.name,
source: selectedRow.value.full,
force: false
}
]
window.$message.success(
$gettext('Marked successfully, please navigate to the destination path to paste')
)
break
case 'move':
markedType.value = 'move'
marked.value = [
{
name: selectedRow.value.name,
source: selectedRow.value.full,
force: false
}
]
window.$message.success(
$gettext('Marked successfully, please navigate to the destination path to paste')
)
break
case 'permission':
selected.value = [selectedRow.value.full]
permission.value = true
break
case 'compress':
selected.value = [selectedRow.value.full]
compress.value = true
break
case 'download':
window.open('/api/file/download?path=' + encodeURIComponent(selectedRow.value.full))
break
case 'uncompress':
unCompressModel.value.file = selectedRow.value.full
unCompressModel.value.path = path.value
unCompressModal.value = true
break
case 'rename':
confirmImmutableOperation(selectedRow.value, 'rename', () => {
renameModel.value.source = getFilename(selectedRow.value.name)
renameModel.value.target = getFilename(selectedRow.value.name)
renameModal.value = true
})
break
case 'delete':
confirmImmutableOperation(selectedRow.value, 'delete', () => {
useRequest(file.delete(selectedRow.value.full)).onSuccess(() => {
window.$bus.emit('file:refresh')
window.$message.success($gettext('Deleted successfully'))
})
})
break
}
onCloseDropdown()
}
const onCloseDropdown = () => {
selectedRow.value = null
showDropdown.value = false
}
const handleSorterChange = (sorter: {
columnKey: string | number | null
order: 'ascend' | 'descend' | false
}) => {
if (!sorter || sorter.columnKey === 'name') {
if (!loading.value) {
switch (sorter.order) {
case 'ascend':
sort.value = 'asc'
nextTick(() => {
refresh()
})
break
case 'descend':
sort.value = 'desc'
nextTick(() => {
refresh()
})
break
default:
sort.value = ''
nextTick(() => {
refresh()
})
break
}
}
}
}
onMounted(() => {
// 监听路径变化并刷新列表
watch(
path,
() => {
selected.value = []
keyword.value = ''
sub.value = false
sizeCache.value.clear()
sizeLoading.value.clear()
nextTick(() => {
refresh()
})
window.$bus.emit('file:push-history', path.value)
},
{ immediate: true }
)
// 监听搜索事件
window.$bus.on('file:search', () => {
selected.value = []
nextTick(() => {
refresh()
})
window.$bus.emit('file:push-history', path.value)
})
// 监听刷新事件
window.$bus.on('file:refresh', refresh)
})
onUnmounted(() => {
window.$bus.off('file:refresh')
})
</script>
<template>
<n-data-table
remote
striped
virtual-scroll
size="small"
:scroll-x="1200"
:columns="columns"
:data="data"
:row-props="rowProps"
:loading="loading"
:row-key="(row: any) => row.full"
max-height="60vh"
@update:sorter="handleSorterChange"
v-model:checked-row-keys="selected"
v-model:page="page"
v-model:pageSize="pageSize"
:pagination="{
page: page,
pageCount: pageCount,
pageSize: pageSize,
itemCount: total,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [100, 200, 500, 1000, 1500, 2000, 5000]
}"
/>
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="dropdownX"
:y="dropdownY"
:options="options"
:show="showDropdown"
:on-clickoutside="onCloseDropdown"
@select="handleSelect"
/>
<edit-modal v-model:show="editorModal" v-model:file="currentFile" />
<preview-modal v-model:show="previewModal" v-model:path="currentFile" />
<n-modal
v-model:show="renameModal"
preset="card"
:title="$gettext('Rename - %{ source }', { source: renameModel.source })"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-flex vertical>
<n-form>
<n-form-item :label="$gettext('New Name')">
<n-input v-model:value="renameModel.target" />
</n-form-item>
</n-form>
<n-button type="primary" @click="handleRename">{{ $gettext('Save') }}</n-button>
</n-flex>
</n-modal>
<n-modal
v-model:show="unCompressModal"
preset="card"
:title="$gettext('Uncompress - %{ file }', { file: unCompressModel.file })"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-flex vertical>
<n-form>
<n-form-item :label="$gettext('Uncompress to')">
<n-input v-model:value="unCompressModel.path" />
</n-form-item>
</n-form>
<n-button type="primary" @click="handleUnCompress">{{ $gettext('Uncompress') }}</n-button>
</n-flex>
</n-modal>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,11 @@
import type { InputInst } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import { useFileStore } from '@/store'
import { checkPath } from '@/utils/file'
const { $gettext } = useGettext()
const fileStore = useFileStore()
const path = defineModel<string>('path', { type: String, required: true }) // 当前路径
const keyword = defineModel<string>('keyword', { type: String, default: '' }) // 搜索关键词
const sub = defineModel<boolean>('sub', { type: Boolean, default: false }) // 搜索是否包括子目录
@@ -38,6 +40,11 @@ const handleRefresh = () => {
window.$bus.emit('file:refresh')
}
// 切换显示隐藏文件
const toggleHidden = () => {
fileStore.toggleShowHidden()
}
const handleUp = () => {
const count = splitPath(path.value, '/').length
setPath(count - 2)
@@ -109,18 +116,29 @@ onUnmounted(() => {
<template>
<n-flex>
<n-button @click="handleBack">
<i-mdi-arrow-left :size="16" />
</n-button>
<n-button @click="handleForward">
<i-mdi-arrow-right :size="16" />
</n-button>
<n-button @click="handleUp">
<i-mdi-arrow-up :size="16" />
</n-button>
<n-button @click="handleRefresh">
<i-mdi-refresh :size="16" />
</n-button>
<n-button-group>
<n-button @click="handleBack">
<i-mdi-arrow-left :size="16" />
</n-button>
<n-button @click="handleForward">
<i-mdi-arrow-right :size="16" />
</n-button>
<n-button @click="handleUp">
<i-mdi-arrow-up :size="16" />
</n-button>
<n-button @click="handleRefresh">
<i-mdi-refresh :size="16" />
</n-button>
<n-tooltip>
<template #trigger>
<n-button @click="toggleHidden" :type="fileStore.showHidden ? 'primary' : 'default'">
<i-mdi-eye v-if="fileStore.showHidden" :size="16" />
<i-mdi-eye-off v-else :size="16" />
</n-button>
</template>
{{ fileStore.showHidden ? $gettext('Hide hidden files') : $gettext('Show hidden files') }}
</n-tooltip>
</n-button-group>
<n-input-group flex-1>
<n-tag size="large" v-if="!isInput" flex-1 @click="handleInput">
<n-breadcrumb separator=">">

View File

@@ -3,10 +3,13 @@ import { NButton, NInput } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import file from '@/api/panel/file'
import type { FileInfo } from '@/views/file/types'
const { $gettext } = useGettext()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const selected = defineModel<string[]>('selected', { type: Array, required: true })
// 文件信息列表,用于获取当前所有者和组
const fileInfoList = defineModel<FileInfo[]>('fileInfoList', { type: Array, default: () => [] })
const mode = ref('755')
const owner = ref('www')
const group = ref('www')
@@ -17,6 +20,28 @@ const checkbox = ref({
other: ['read', 'execute']
})
// 规范化 mode 字符串确保为3位数字
const normalizeMode = (modeStr: string): string => {
// 去掉前导0但保留至少一位数字
const trimmed = modeStr.replace(/^0+(?=\d)/, '')
// 确保 mode 至少有3位不足则左补0
return trimmed.padStart(3, '0') || '755'
}
// 当打开弹窗时,从文件信息中获取当前权限/所有者/组
watch(
() => show.value,
(newVal) => {
if (newVal && fileInfoList.value.length > 0) {
const firstFile = fileInfoList.value[0]
mode.value = normalizeMode(firstFile.mode)
owner.value = firstFile.owner || 'www'
group.value = firstFile.group || 'www'
updateCheckboxes()
}
}
)
const handlePermission = async () => {
const promises = selected.value.map((path) =>
file.permission(path, `0${mode.value}`, owner.value, group.value)
@@ -25,6 +50,7 @@ const handlePermission = async () => {
show.value = false
selected.value = []
fileInfoList.value = []
window.$bus.emit('file:refresh')
window.$message.success($gettext('Modified successfully'))
}
@@ -46,7 +72,9 @@ const calculateMode = () => {
}
const updateCheckboxes = () => {
const permissions = mode.value.split('').map(Number)
const paddedMode = normalizeMode(mode.value)
const permissions = paddedMode.split('').map(Number)
checkbox.value.owner = permissions[0] & 4 ? ['read'] : []
if (permissions[0] & 2) checkbox.value.owner.push('write')
if (permissions[0] & 1) checkbox.value.owner.push('execute')
@@ -61,6 +89,9 @@ const updateCheckboxes = () => {
}
const title = computed(() => {
if (selected.value.length === 0) {
return $gettext('Modify permissions')
}
return selected.value.length > 1
? $gettext('Batch modify permissions')
: $gettext('Modify permissions - %{ path }', { path: selected.value[0] })

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useGettext } from 'vue3-gettext'
import type { FileInfo } from '@/views/file/types'
const { $gettext } = useGettext()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const fileInfo = defineModel<FileInfo | null>('fileInfo', { type: Object, default: null })
const title = computed(() => {
if (!fileInfo.value) return $gettext('Properties')
return $gettext('Properties - %{ name }', { name: fileInfo.value.name })
})
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="title"
style="width: 500px"
size="huge"
:bordered="false"
:segmented="false"
>
<n-descriptions bordered :column="1" label-placement="left" v-if="fileInfo">
<n-descriptions-item :label="$gettext('Name')">
{{ fileInfo.name }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Full Path')">
{{ fileInfo.full }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Type')">
{{ fileInfo.dir ? $gettext('Directory') : $gettext('File') }}
<template v-if="fileInfo.symlink">
({{ $gettext('Symlink') }} -> {{ fileInfo.link }})
</template>
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Size')" v-if="!fileInfo.dir">
{{ fileInfo.size }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Permission')">
{{ fileInfo.mode_str }} ({{ fileInfo.mode }})
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Owner')">
{{ fileInfo.owner }} (UID: {{ fileInfo.uid }})
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Group')">
{{ fileInfo.group }} (GID: {{ fileInfo.gid }})
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Modification Time')">
{{ fileInfo.modify }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Hidden')">
{{ fileInfo.hidden ? $gettext('Yes') : $gettext('No') }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Immutable')">
<n-tag :type="fileInfo.immutable ? 'warning' : 'default'" size="small">
{{ fileInfo.immutable ? $gettext('Yes') : $gettext('No') }}
</n-tag>
</n-descriptions-item>
</n-descriptions>
</n-modal>
</template>
<style scoped lang="scss"></style>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import file from '@/api/panel/file'
import PtyTerminalModal from '@/components/common/PtyTerminalModal.vue'
import { useFileStore } from '@/store'
import { checkName, lastDirectory } from '@/utils/file'
import UploadModal from '@/views/file/UploadModal.vue'
import type { Marked } from '@/views/file/types'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const fileStore = useFileStore()
const path = defineModel<string>('path', { type: String, required: true })
const selected = defineModel<string[]>('selected', { type: Array, default: () => [] })
@@ -14,6 +17,9 @@ const markedType = defineModel<string>('markedType', { type: String, required: t
const compress = defineModel<boolean>('compress', { type: Boolean, required: true })
const permission = defineModel<boolean>('permission', { type: Boolean, required: true })
// 终端弹窗
const terminalModal = ref(false)
const upload = ref(false)
const create = ref(false)
const createModel = ref({
@@ -200,6 +206,37 @@ watch(
}
}
)
// 打开终端
const openTerminal = () => {
terminalModal.value = true
}
// 切换视图类型
const toggleViewType = () => {
fileStore.toggleViewType()
}
// 排序选项
const sortOptions = computed(() => [
{ label: $gettext('Name'), value: 'name' },
{ label: $gettext('Size'), value: 'size' },
{ label: $gettext('Modification Time'), value: 'modify' }
])
// 当前排序显示文本
const currentSortLabel = computed(() => {
if (!fileStore.sortKey) return $gettext('Sort')
const option = sortOptions.value.find((o) => o.value === fileStore.sortKey)
const label = option?.label || fileStore.sortKey
const arrow = fileStore.sortOrder === 'asc' ? '↑' : '↓'
return `${label} ${arrow}`
})
// 处理排序选择
const handleSortSelect = (key: string) => {
fileStore.setSort(key)
}
</script>
<template>
@@ -215,6 +252,28 @@ watch(
</n-popselect>
<n-button @click="upload = true">{{ $gettext('Upload') }}</n-button>
<n-button @click="download = true">{{ $gettext('Remote Download') }}</n-button>
<n-button @click="openTerminal">{{ $gettext('Terminal') }}</n-button>
<n-popselect :options="sortOptions" :value="fileStore.sortKey" @update:value="handleSortSelect">
<n-button>
<template #icon>
<i-mdi-sort :size="16" />
</template>
{{ currentSortLabel }}
</n-button>
</n-popselect>
<n-tooltip>
<template #trigger>
<n-button @click="toggleViewType">
<i-mdi-view-list v-if="fileStore.viewType === 'list'" :size="16" />
<i-mdi-view-grid v-else :size="16" />
</n-button>
</template>
{{
fileStore.viewType === 'list'
? $gettext('Switch to grid view')
: $gettext('Switch to list view')
}}
</n-tooltip>
<div ml-auto>
<n-flex>
<n-button v-if="marked.length" secondary type="error" @click="handleCancel">
@@ -280,6 +339,12 @@ watch(
</n-space>
</n-modal>
<upload-modal v-model:show="upload" v-model:path="path" />
<!-- 终端弹窗 -->
<pty-terminal-modal
v-model:show="terminalModal"
:title="$gettext('Terminal - %{ path }', { path })"
:command="`cd '${path}' && exec bash`"
/>
</template>
<style scoped lang="scss"></style>

View File

@@ -3,3 +3,22 @@ export interface Marked {
source: string
force: boolean
}
// 文件信息接口,用于权限编辑和属性显示
export interface FileInfo {
name: string
full: string
size: string
mode_str: string
mode: string
owner: string
group: string
uid: number
gid: number
hidden: boolean
symlink: boolean
link: string
dir: boolean
modify: string
immutable: boolean
}