e084d90c61
- 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>
548 lines
18 KiB
Vue
548 lines
18 KiB
Vue
<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>
|