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:
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
1634
web/src/views/file/ListView.vue
Normal file
1634
web/src/views/file/ListView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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=">">
|
||||
|
||||
@@ -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] })
|
||||
|
||||
66
web/src/views/file/PropertyModal.vue
Normal file
66
web/src/views/file/PropertyModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user