Files
Nexus/frontend/src/pages/FilesPage.vue
T
Your Name e084d90c61 fix: add Bing wallpaper proxy endpoint, make it public (no JWT required)
- Added GET /api/settings/bing-wallpaper — proxies cn.bing.com HPImageArchive
  to avoid CORS issues, returns {url} for the daily wallpaper
- Added /api/settings/bing-wallpaper to PUBLIC_PREFIXES in auth_jwt.py
  so the login page can fetch it without authentication
- Login page now fetches wallpaper via backend proxy instead of direct CORS-blocked fetch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:12:09 +08:00

548 lines
18 KiB
Vue
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.
<template>
<v-container fluid class="pa-6">
<!-- Toolbar -->
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
<v-row align="center" dense>
<v-col cols="12" sm="4">
<v-select
v-model="selectedServer"
:items="serverList"
item-title="name"
item-value="id"
label="选择服务器"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-server"
@update:model-value="onServerChange"
/>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model="currentPath"
label="路径"
variant="outlined"
density="compact"
prepend-inner-icon="mdi-folder"
append-inner-icon="mdi-arrow-right"
@click:append-inner="browse"
@keydown.enter="browse"
/>
</v-col>
<v-col cols="12" sm="4" class="d-flex ga-2 justify-end">
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="showUpload = true">上传</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-folder-plus" @click="showMkdir = true">新建目录</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="browse">刷新</v-btn>
</v-col>
</v-row>
</v-card>
<!-- Breadcrumb -->
<v-breadcrumbs :items="breadcrumbs" class="pa-0 mb-2">
<template #divider>/</template>
</v-breadcrumbs>
<!-- Batch action bar -->
<v-card v-if="selectedFileCount > 0" class="mb-4" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ selectedFileCount }} 个文件</span>
<v-spacer />
<v-btn size="small" variant="text" color="error" @click="batchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="selectedFiles = []">取消选择</v-btn>
</v-card-text>
</v-card>
<!-- File list -->
<v-card elevation="0" border rounded="lg">
<v-data-table
v-model="selectedFiles"
:items="files"
:headers="fileHeaders"
:loading="loading"
show-select
hover
density="comfortable"
item-value="name"
@click:row="onRowClick"
@contextmenu:row="onRowContext"
>
<template #item.name="{ item }">
<div class="d-flex align-center ga-2">
<v-icon :color="item.type === 'directory' ? 'amber' : 'blue'" size="20">
{{ item.type === 'directory' ? 'mdi-folder' : 'mdi-file-outline' }}
</v-icon>
<span :class="{ 'font-weight-medium': item.type === 'directory' }">{{ item.name }}</span>
</div>
</template>
<template #item.size="{ item }">
<span class="text-medium-emphasis">{{ item.type === 'directory' ? '—' : formatSize(item.size) }}</span>
</template>
<template #item.modified="{ item }">
<span class="text-medium-emphasis">{{ item.modified || '—' }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn v-if="item.type !== 'directory'" variant="text" size="x-small" density="compact" color="primary" @click.stop="editFile(item)">编辑</v-btn>
<v-btn v-if="item.type !== 'directory'" variant="text" size="x-small" density="compact" @click.stop="previewFile(item)">查看</v-btn>
<v-btn v-if="item.type !== 'directory'" variant="text" size="x-small" density="compact" @click.stop="downloadFile(item)">下载</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click.stop="confirmDeleteFile(item)">删除</v-btn>
</div>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无文件</div>
</template>
</v-data-table>
</v-card>
<!-- Upload dialog -->
<v-dialog v-model="showUpload" max-width="500">
<v-card border>
<v-card-title>上传文件</v-card-title>
<v-card-text>
<v-file-input v-model="uploadFiles" label="选择文件" variant="outlined" multiple show-size />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showUpload = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doUpload" :loading="uploading">上传</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Mkdir dialog -->
<v-dialog v-model="showMkdir" max-width="400">
<v-card border>
<v-card-title>新建目录</v-card-title>
<v-card-text>
<v-text-field v-model="mkdirName" label="目录名" variant="outlined" density="compact" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showMkdir = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doMkdir">创建</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete confirm -->
<v-dialog v-model="showFileDelete" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除 <strong>{{ deletingFile?.name }}</strong> </v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showFileDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDeleteFile">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Rename dialog -->
<v-dialog v-model="showRename" max-width="400">
<v-card border>
<v-card-title>重命名</v-card-title>
<v-card-text>
<v-text-field v-model="newName" label="新名称" variant="outlined" density="compact" :rules="[required('名称')]" autofocus @keydown.enter="doRename" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRename = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doRename">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Chmod dialog -->
<v-dialog v-model="showChmod" max-width="400">
<v-card border>
<v-card-title>修改权限</v-card-title>
<v-card-text>
<v-text-field v-model="chmodMode" label="权限 (如 755, 644)" variant="outlined" density="compact" :rules="[required('权限'), (v: string) => /^[0-7]{3,4}$/.test(v) || '格式: 3-4位八进制数']" autofocus @keydown.enter="doChmod" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showChmod = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doChmod">确定</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Context Menu -->
<v-menu v-model="showContextMenu" :target="[contextX, contextY]" location="bottom start">
<v-list density="compact" nav>
<v-list-item v-if="contextFile?.type !== 'directory'" prepend-icon="mdi-pencil" @click="contextAction('edit')">
<v-list-item-title>编辑</v-list-item-title>
</v-list-item>
<v-list-item v-if="contextFile?.type !== 'directory'" prepend-icon="mdi-eye" @click="contextAction('preview')">
<v-list-item-title>查看</v-list-item-title>
</v-list-item>
<v-list-item v-if="contextFile?.type !== 'directory'" prepend-icon="mdi-download" @click="contextAction('download')">
<v-list-item-title>下载</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-file-rename-box" @click="contextAction('rename')">
<v-list-item-title>重命名</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-lock" @click="contextAction('chmod')">
<v-list-item-title>修改权限</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item prepend-icon="mdi-delete" @click="contextAction('delete')">
<v-list-item-title class="text-error">删除</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!-- Monaco Editor -->
<MonacoEditor
v-model="showEditor"
:file-path="editorFilePath"
:file-name="editorFileName"
:file-content="editorContent"
:server-id="selectedServer ?? 0"
@saved="browse"
/>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, defineAsyncComponent } from 'vue'
import { useRoute } from 'vue-router'
import { http } from '@/api'
import { useServerList } from '@/composables/useServerList'
import { useSnackbar } from '@/composables/useSnackbar'
import { required } from '@/utils/validation'
const MonacoEditor = defineAsyncComponent(() => import('@/components/MonacoEditor.vue'))
import type { BrowseResponse, FileEntry } from '@/types/api'
/** Safe path join — avoids double-slash when at root */
function joinPath(base: string, name: string): string {
return base === '/' ? `/${name}` : `${base}/${name}`
}
const snackbar = useSnackbar()
const { servers: serverList, loadServers } = useServerList()
const route = useRoute()
// ── State ──
const selectedServer = ref<number | null>(null)
const currentPath = ref('/')
const files = ref<FileEntry[]>([])
const loading = ref(false)
// Dialogs
const showUpload = ref(false)
const showMkdir = ref(false)
const showFileDelete = ref(false)
const uploadFiles = ref<File[]>([])
const uploading = ref(false)
const mkdirName = ref('')
const actionLoading = ref(false)
const deletingFile = ref<FileEntry | null>(null)
// Rename
const showRename = ref(false)
const newName = ref('')
const renamingFile = ref<FileEntry | null>(null)
// Chmod
const showChmod = ref(false)
const chmodMode = ref('')
const chmodFile = ref<FileEntry | null>(null)
// Context menu
const showContextMenu = ref(false)
const contextX = ref(0)
const contextY = ref(0)
const contextFile = ref<FileEntry | null>(null)
// Batch select
const selectedFiles = ref<FileEntry[]>([])
const selectedFileCount = computed(() => selectedFiles.value.length)
// Editor
const showEditor = ref(false)
const editorFilePath = ref('')
const editorFileName = ref('')
const editorContent = ref('')
const fileHeaders = [
{ title: '名称', key: 'name' },
{ title: '大小', key: 'size', width: 120 },
{ title: '修改时间', key: 'modified', width: 180 },
{ title: '权限', key: 'permissions', width: 100 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
]
// ── Breadcrumbs ──
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
const items = [{ title: '/', props: { onClick: () => { currentPath.value = '/'; browse() } } }]
let path = ''
for (const p of parts) {
path += '/' + p
const finalPath = path
items.push({ title: p, props: { onClick: () => { currentPath.value = finalPath; browse() } } })
}
return items
})
// ── Data loading ──
async function browse() {
if (!selectedServer.value) return
loading.value = true
try {
const res = await http.post<BrowseResponse | FileEntry[]>('/sync/browse', {
server_id: selectedServer.value,
path: currentPath.value,
})
// Sort: directories first
files.value = Array.isArray(res) ? res : (res as BrowseResponse).items || []
files.value.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
} catch {
files.value = []
snackbar('浏览目录失败', 'error')
} finally {
loading.value = false
}
}
function onServerChange() {
currentPath.value = '/'
browse()
}
function onRowClick(_event: Event, { item }: { item: FileEntry }) {
if (item.type === 'directory') {
currentPath.value = currentPath.value === '/' ? `/${item.name}` : `${currentPath.value}/${item.name}`
browse()
}
}
function onRowContext(e: MouseEvent, { item }: { item: FileEntry }) {
e.preventDefault()
contextFile.value = item
contextX.value = e.clientX
contextY.value = e.clientY
showContextMenu.value = true
}
function contextAction(action: string) {
showContextMenu.value = false
if (!contextFile.value) return
switch (action) {
case 'edit': editFile(contextFile.value); break
case 'preview': previewFile(contextFile.value); break
case 'download': downloadFile(contextFile.value); break
case 'rename': startRename(contextFile.value); break
case 'chmod': startChmod(contextFile.value); break
case 'delete': confirmDeleteFile(contextFile.value); break
}
}
function startRename(item: FileEntry) {
renamingFile.value = item
newName.value = item.name
showRename.value = true
}
async function doRename() {
if (!selectedServer.value || !renamingFile.value || !newName.value) return
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'rename',
path: joinPath(currentPath.value, renamingFile.value.name),
new_path: joinPath(currentPath.value, newName.value),
})
showRename.value = false
browse()
snackbar('重命名成功')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '重命名失败'
snackbar(msg, 'error')
}
}
function startChmod(item: FileEntry) {
chmodFile.value = item
chmodMode.value = ''
showChmod.value = true
}
async function doChmod() {
if (!selectedServer.value || !chmodFile.value || !chmodMode.value) return
try {
await http.post('/sync/chmod', {
server_id: selectedServer.value,
path: joinPath(currentPath.value, chmodFile.value.name),
mode: chmodMode.value,
})
showChmod.value = false
browse()
snackbar('权限已修改')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '修改权限失败'
snackbar(msg, 'error')
}
}
async function batchDelete() {
if (!selectedServer.value || !selectedFiles.value.length) return
if (!confirm(`确定删除 ${selectedFiles.value.length} 个文件?`)) return
for (const f of selectedFiles.value) {
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'delete',
path: joinPath(currentPath.value, f.name),
})
} catch { /* skip individual failures */ }
}
selectedFiles.value = []
browse()
snackbar('批量删除完成')
}
async function previewFile(item: FileEntry) {
if (!selectedServer.value) return
actionLoading.value = true
try {
const res = await http.post('/sync/read-file', {
server_id: selectedServer.value,
path: joinPath(currentPath.value, item.name),
})
snackbar(typeof res === 'string' ? res : JSON.stringify(res, null, 2), 'info')
} catch (e: any) {
snackbar(e.message || '读取失败', 'error')
} finally {
actionLoading.value = false
}
}
async function editFile(item: FileEntry) {
if (!selectedServer.value) return
actionLoading.value = true
try {
const res = await http.post<{ content?: string } | string>('/sync/read-file', {
server_id: selectedServer.value,
path: joinPath(currentPath.value, item.name),
})
const content = typeof res === 'string' ? res : (res.content ?? JSON.stringify(res, null, 2))
editorFilePath.value = joinPath(currentPath.value, item.name)
editorFileName.value = item.name
editorContent.value = content
showEditor.value = true
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '读取失败'
snackbar(msg, 'error')
} finally {
actionLoading.value = false
}
}
async function downloadFile(item: FileEntry) {
if (!selectedServer.value) return
try {
await http.post('/sync/download', {
server_id: selectedServer.value,
path: joinPath(currentPath.value, item.name),
})
snackbar('下载已开始')
} catch (e: any) {
snackbar(e.message || '下载失败', 'error')
}
}
async function confirmDeleteFile(item: FileEntry) {
deletingFile.value = item
showFileDelete.value = true
}
async function doDeleteFile() {
if (!selectedServer.value || !deletingFile.value) return
actionLoading.value = true
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'delete',
path: joinPath(currentPath.value, deletingFile.value.name),
})
showFileDelete.value = false
browse()
snackbar('已删除')
} catch (e: any) {
snackbar(e.message || '删除失败', 'error')
} finally {
actionLoading.value = false
}
}
async function doMkdir() {
if (!selectedServer.value || !mkdirName.value) return
actionLoading.value = true
try {
await http.post('/sync/file-ops', {
server_id: selectedServer.value,
operation: 'mkdir',
path: joinPath(currentPath.value, mkdirName.value),
})
showMkdir.value = false
mkdirName.value = ''
browse()
snackbar('目录已创建')
} catch (e: any) {
snackbar(e.message || '创建失败', 'error')
} finally {
actionLoading.value = false
}
}
async function doUpload() {
if (!selectedServer.value || !uploadFiles.value.length) return
uploading.value = true
try {
const form = new FormData()
for (const f of uploadFiles.value) form.append('files', f)
form.append('server_id', String(selectedServer.value))
form.append('remote_path', currentPath.value)
await http.upload('/sync/upload', form)
showUpload.value = false
uploadFiles.value = []
browse()
snackbar('上传成功')
} catch (e: any) {
snackbar(e.message || '上传失败', 'error')
} finally {
uploading.value = false
}
}
function formatSize(bytes: number | undefined) {
if (!bytes) return '—'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
// ── Init ──
onMounted(() => {
loadServers()
// Auto-select server from query param
const qServer = route.query.server_id
if (qServer) {
selectedServer.value = Number(qServer)
browse()
}
})
</script>