fix: m4mini deployment issues - duplicate script setup, auth redirect, Tauri bypass

This commit is contained in:
2026-06-26 20:33:35 +08:00
parent f915aaf794
commit 691b38fe96
14 changed files with 5185 additions and 219 deletions

690
src/views/ClientView.vue Normal file
View File

@@ -0,0 +1,690 @@
<template>
<div id="ms-view-client">
<div class="client-header">
<h1>File Manager</h1>
<div class="client-toolbar">
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
<button @click="showCreateFolderDialog">New Folder</button>
<button @click="showUploadDialog">Upload</button>
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
<button @click="refreshFiles">Refresh</button>
<span class="current-path">Path: {{ currentPath }}</span>
</div>
</div>
<div class="client-content">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<table v-else class="file-table">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr v-if="files.length === 0">
<td colspan="4" class="empty-folder">Empty folder</td>
</tr>
<tr
v-for="f in files"
:key="f.name"
@click="selectFile(f)"
@dblclick="openFile(f)"
:class="{ selected: selectedFile?.name === f.name }"
>
<td>
<span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span>
{{ f.name }}
</td>
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
<td>{{ f.modified || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Folder Dialog -->
<div v-if="showCreateFolder" class="dialog-overlay" @click="hideCreateFolderDialog">
<div class="dialog" @click.stop>
<h3>Create New Folder</h3>
<input v-model="newFolderName" type="text" placeholder="Folder name" @keyup.enter="createFolder" />
<div class="dialog-buttons">
<button @click="createFolder">Create</button>
<button @click="hideCreateFolderDialog">Cancel</button>
</div>
</div>
</div>
<!-- File Preview Dialog -->
<div v-if="showPreview" class="dialog-overlay" @click="hidePreview">
<div class="dialog preview-dialog" @click.stop>
<h3>File Preview: {{ selectedFile?.name }}</h3>
<div v-if="previewLoading" class="loading">Loading preview...</div>
<div v-else-if="previewContent" class="preview-content">
<img v-if="isImage" :src="previewContent" alt="preview" />
<pre v-else>{{ previewContent }}</pre>
</div>
<div class="dialog-buttons">
<button @click="downloadFile">Download</button>
<button @click="hidePreview">Close</button>
</div>
</div>
</div>
<!-- Upload Dialog -->
<div v-if="showUpload" class="dialog-overlay" @click="hideUploadDialog">
<div class="dialog upload-dialog" @click.stop>
<h3>Upload File</h3>
<input ref="fileInput" type="file" @change="selectUploadFile" />
<div v-if="uploadFile" class="upload-info">
<p>File: {{ uploadFile.name }}</p>
<p>Size: {{ formatSize(uploadFile.size) }}</p>
</div>
<div v-if="uploadProgress > 0" class="upload-progress">
<progress :value="uploadProgress" max="100"></progress>
<span>{{ uploadProgress }}%</span>
</div>
<div class="dialog-buttons">
<button @click="uploadFileToServer" :disabled="!uploadFile || uploading">
{{ uploading ? 'Uploading...' : 'Upload' }}
</button>
<button @click="hideUploadDialog">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const currentPath = ref('/')
const files = ref<any[]>([])
const selectedFile = ref<any>(null)
const loading = ref(false)
const error = ref('')
const showCreateFolder = ref(false)
const newFolderName = ref('')
const showPreview = ref(false)
const previewContent = ref('')
const previewLoading = ref(false)
const isImage = ref(false)
const showUpload = ref(false)
const uploadFile = ref<File | null>(null)
const uploadProgress = ref(0)
const uploading = ref(false)
// API base URL (MarkBase server)
const API_BASE = 'http://localhost:11438/api/v2'
// Load files from current directory
async function loadFiles() {
loading.value = true
error.value = ''
selectedFile.value = null
try {
// Use MarkBase myfiles API
const username = 'demo'
const folderParam = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
let apiUrl = `${API_BASE}/myfiles/${username}/files`
if (folderParam) {
apiUrl += `?folder=${encodeURIComponent(folderParam)}`
}
const response = await fetch(apiUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
// Transform files to file list (data is array, not { files: [] })
if (Array.isArray(data)) {
files.value = data.map((file: any) => ({
name: file.name || file.filename,
isDir: false, // All files in myfiles are files, not folders
size: file.size || 0,
modified: '-',
uuid: null,
tags: file.tags || [],
})).sort((a: any, b: any) => a.name.localeCompare(b.name))
} else {
files.value = []
}
} catch (e: any) {
error.value = 'Failed to load files: ' + e.message
files.value = []
} finally {
loading.value = false
}
}
// Navigate to parent directory
function goUp() {
if (currentPath.value === '/') return
const parts = currentPath.value.split('/').filter(p => p)
parts.pop()
currentPath.value = '/' + parts.join('/')
loadFiles()
}
// Select file
function selectFile(f: any) {
selectedFile.value = f
}
// Open file (double-click)
function openFile(f: any) {
if (f.isDir) {
// Navigate into directory
currentPath.value = currentPath.value === '/'
? '/' + f.name
: currentPath.value + '/' + f.name
loadFiles()
} else {
// Preview file
showFilePreview(f)
}
}
// Show create folder dialog
function showCreateFolderDialog() {
newFolderName.value = ''
showCreateFolder.value = true
}
// Hide create folder dialog
function hideCreateFolderDialog() {
showCreateFolder.value = false
newFolderName.value = ''
}
// Create new folder
async function createFolder() {
if (!newFolderName.value.trim()) {
alert('Please enter folder name')
return
}
const folderPath = currentPath.value === '/'
? '/' + newFolderName.value
: currentPath.value + '/' + newFolderName.value
try {
const response = await fetch(`${API_BASE}/tree/demo/node`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parent_id: currentPath.value === '/' ? null : currentPath.value,
node_type: 'folder',
label: newFolderName.value,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
hideCreateFolderDialog()
loadFiles()
} catch (e: any) {
alert('Failed to create folder: ' + e.message)
}
}
// Delete selected file/folder
async function deleteSelected() {
if (!selectedFile.value) return
const itemName = selectedFile.value.name
if (!confirm(`Delete "${itemName}"?`)) return
const itemPath = currentPath.value === '/'
? '/' + itemName
: currentPath.value + '/' + itemName
try {
// Find node_id from files list
const node = files.value.find(f => f.name === itemName)
if (!node || !node.uuid) {
throw new Error('Node UUID not found')
}
const response = await fetch(`${API_BASE}/tree/demo/node/${node.uuid}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
selectedFile.value = null
loadFiles()
} catch (e: any) {
alert('Failed to delete: ' + e.message)
}
}
// Refresh file list
function refreshFiles() {
loadFiles()
}
// Show file preview
async function showFilePreview(f: any) {
showPreview.value = true
previewLoading.value = true
previewContent.value = ''
isImage.value = false
try {
// Check file type by extension
const ext = f.name.split('.').pop()?.toLowerCase() || ''
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
const videoExtensions = ['mp4', 'avi', 'mov', 'mkv', 'webm']
const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'm4a']
const textExtensions = ['txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'vue', 'py', 'rs', 'go', 'java']
if (imageExtensions.includes(ext)) {
// Image preview - use WebDAV with auth
isImage.value = true
const token = localStorage.getItem('token') || ''
const username = localStorage.getItem('username') || 'demo'
// Use WebDAV endpoint with Basic auth
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (response.ok) {
const blob = await response.blob()
previewContent.value = URL.createObjectURL(blob)
} else {
// Fallback: try without auth
previewContent.value = `http://localhost:11438/webdav/${username}/${f.name}`
}
} else if (videoExtensions.includes(ext)) {
// Video preview - show video element
isImage.value = false
const username = localStorage.getItem('username') || 'demo'
previewContent.value = `<video controls style="max-width:100%; max-height:60vh">
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="video/${ext}">
Your browser does not support video preview.
</video>`
} else if (audioExtensions.includes(ext)) {
// Audio preview - show audio element
isImage.value = false
const username = localStorage.getItem('username') || 'demo'
previewContent.value = `<audio controls style="width:100%">
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="audio/${ext}">
Your browser does not support audio preview.
</audio>`
} else if (textExtensions.includes(ext)) {
// Text preview - fetch and display content
const username = localStorage.getItem('username') || 'demo'
const token = localStorage.getItem('token') || ''
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (response.ok) {
const text = await response.text()
// Limit preview size
if (text.length > 10000) {
previewContent.value = text.substring(0, 10000) + '\n\n... (truncated, file too large)'
} else {
previewContent.value = text
}
} else {
previewContent.value = 'Failed to load file content'
}
} else {
// Unknown file type - show download link
previewContent.value = `File type: ${ext}\n\nPreview not available for this file type.\nClick Download button to download the file.`
}
} catch (e: any) {
previewContent.value = 'Failed to load preview: ' + e.message
} finally {
previewLoading.value = false
}
}
// Hide preview dialog
function hidePreview() {
showPreview.value = false
previewContent.value = ''
}
// Show upload dialog
function showUploadDialog() {
uploadFile.value = null
uploadProgress.value = 0
uploading.value = false
showUpload.value = true
}
// Hide upload dialog
function hideUploadDialog() {
showUpload.value = false
uploadFile.value = null
uploadProgress.value = 0
uploading.value = false
}
// Select file for upload
function selectUploadFile(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
uploadFile.value = target.files[0]
}
}
// Upload file to server
async function uploadFileToServer() {
if (!uploadFile.value) {
alert('No file selected')
return
}
uploading.value = true
uploadProgress.value = 0
try {
const formData = new FormData()
formData.append('file', uploadFile.value)
const xhr = new XMLHttpRequest()
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
uploadProgress.value = Math.round((e.loaded / e.total) * 100)
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200 || xhr.status === 201) {
alert('Upload successful!')
hideUploadDialog()
loadFiles()
} else {
alert('Upload failed: ' + xhr.statusText)
}
uploading.value = false
})
xhr.addEventListener('error', () => {
alert('Upload failed: Network error')
uploading.value = false
})
// Upload to upload-unlimited endpoint
xhr.open('POST', `${API_BASE}/upload-unlimited/demo`)
xhr.send(formData)
} catch (e: any) {
alert('Upload failed: ' + e.message)
uploading.value = false
}
}
// Download file
function downloadFile() {
if (!selectedFile.value) {
alert('No file selected')
return
}
if (selectedFile.value.downloadUrl) {
window.open(selectedFile.value.downloadUrl, '_blank')
} else if (selectedFile.value.uuid) {
const downloadUrl = `${API_BASE}/files/demo/${selectedFile.value.uuid}/stream`
window.open(downloadUrl, '_blank')
} else {
alert('Download URL not available')
}
}
// Format file size
function formatSize(n: number): string {
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
return (n / 1024 / 1024 / 1024).toFixed(1) + ' GB'
}
// Initialize on mount
onMounted(() => {
loadFiles()
})
</script>
<style scoped>
#ms-view-client {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.client-header {
margin-bottom: 20px;
}
.client-header h1 {
margin: 0;
font-size: 24px;
color: #333;
}
.client-toolbar {
display: flex;
gap: 10px;
align-items: center;
margin-top: 15px;
padding: 10px;
background: #f5f5f5;
border-radius: 5px;
}
.client-toolbar button {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.client-toolbar button:hover:not(:disabled) {
background: #e3f2fd;
}
.client-toolbar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current-path {
margin-left: 20px;
font-weight: bold;
color: #667eea;
}
.client-content {
background: white;
border-radius: 5px;
overflow: hidden;
}
.loading, .error {
padding: 40px;
text-align: center;
color: #666;
}
.error {
color: #e74c3c;
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th, .file-table td {
padding: 12px;
border-bottom: 1px solid #eee;
text-align: left;
}
.file-table th {
background: #f5f5f5;
font-weight: bold;
color: #333;
}
.file-table tr.selected {
background: #e3f2fd;
}
.file-table tr:hover {
background: #f5f5f5;
cursor: pointer;
}
.empty-folder {
text-align: center;
color: #999;
padding: 40px;
}
.folder-icon, .file-icon {
margin-right: 8px;
}
/* Dialog styles */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog {
background: white;
padding: 20px;
border-radius: 10px;
width: 90%;
max-width: 400px;
}
.dialog h3 {
margin: 0 0 15px;
color: #333;
}
.dialog input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
.dialog-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.dialog-buttons button {
padding: 8px 20px;
border: 1px solid #ddd;
border-radius: 5px;
cursor: pointer;
}
.dialog-buttons button:first-child {
background: #667eea;
color: white;
border-color: #667eea;
}
.preview-dialog {
max-width: 800px;
max-height: 80vh;
}
.preview-content {
max-height: 60vh;
overflow: auto;
margin-top: 15px;
}
.preview-content img {
max-width: 100%;
max-height: 60vh;
}
.preview-content pre {
white-space: pre-wrap;
font-size: 14px;
background: #f5f5f5;
padding: 10px;
border-radius: 5px;
}
.upload-dialog {
max-width: 500px;
}
.upload-info {
margin-top: 15px;
padding: 10px;
background: #f5f5f5;
border-radius: 5px;
}
.upload-info p {
margin: 5px 0;
font-size: 14px;
}
.upload-progress {
margin-top: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.upload-progress progress {
width: 100%;
height: 20px;
}
.upload-progress span {
font-size: 14px;
color: #667eea;
}
</style>