astrbbbb / dashboard /src /components /shared /FileConfigItem.vue
qa1145's picture
Upload 1245 files
8ede856 verified
<template>
<div class="file-config-item">
<div class="d-flex align-center gap-2">
<v-btn size="small" color="primary" variant="tonal" @click="dialog = true">
{{ tm('fileUpload.button') }}
</v-btn>
<span class="text-caption text-medium-emphasis ml-2">
{{ fileCountText }}
</span>
</div>
<v-dialog v-model="dialog" max-width="700">
<v-card class="file-dialog-card" variant="flat">
<v-card-title class="d-flex align-center">
<span class="text-h3">{{ tm('fileUpload.dialogTitle') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="dialog = false" />
</v-card-title>
<v-card-text class="file-dialog-body">
<div v-if="mergedFileItems.length === 0" class="empty-text">
{{ tm('fileUpload.empty') }}
</div>
<v-list density="compact" lines="one">
<v-list-item v-for="item in mergedFileItems" :key="item.path">
<template #prepend>
<v-icon size="18">mdi-file</v-icon>
</template>
<v-list-item-title class="file-name">
{{ getDisplayName(item.path) }}
</v-list-item-title>
<template #append>
<div class="d-flex align-center gap-1">
<v-chip v-if="item.status !== 'ok'" size="x-small" :color="getStatusColor(item.status)"
variant="tonal">
{{ getStatusText(item.status) }}
</v-chip>
<v-btn v-if="item.status === 'unconfigured'" icon="mdi-plus" size="x-small" variant="text"
@click="addToConfig(item.path)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
@click="item.status === 'unconfigured' ? deletePhysicalFile(item.path) : deleteFile(item.path)" />
</div>
</template>
</v-list-item>
<v-divider v-if="mergedFileItems.length > 0" class="my-2" />
<v-list-item class="upload-item" :class="{ dragover: isDragging }" @drop.prevent="handleDrop"
@dragover.prevent="isDragging = true" @dragleave="isDragging = false" @click="openFilePicker">
<template #prepend>
<v-icon size="18" color="primary">mdi-plus</v-icon>
</template>
<v-list-item-title>{{ tm('fileUpload.dropzone') }}</v-list-item-title>
<v-list-item-subtitle v-if="allowedTypesText" class="upload-hint">
{{ tm('fileUpload.allowedTypes', { types: allowedTypesText }) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<input ref="fileInput" type="file" multiple hidden :accept="acceptAttr" @change="handleFileSelect" />
</v-card-text>
<v-card-actions class="file-dialog-actions">
<v-spacer />
<v-btn color="primary" variant="elevated" @click="dialog = false">
{{ tm('fileUpload.done') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import axios from 'axios'
import { useToast } from '@/utils/toast'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
itemMeta: {
type: Object,
default: null
},
pluginName: {
type: String,
default: ''
},
configKey: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('features/config')
const toast = useToast()
const dialog = ref(false)
const isDragging = ref(false)
const fileInput = ref(null)
const uploading = ref(false)
const loadingFiles = ref(false)
const MAX_FILE_BYTES = 500 * 1024 * 1024
const MAX_FILE_MB = 500
const directoryFiles = ref([])
const fileList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
set: (val) => emit('update:modelValue', val)
})
const mergedFileItems = computed(() => {
const configured = new Set(fileList.value)
const existing = new Set(directoryFiles.value)
const items = []
for (const path of fileList.value) {
items.push({
path,
status: existing.has(path) ? 'ok' : 'missing'
})
}
for (const path of directoryFiles.value) {
if (!configured.has(path)) {
items.push({
path,
status: 'unconfigured'
})
}
}
return items
})
const acceptAttr = computed(() => {
const types = props.itemMeta?.file_types
if (!Array.isArray(types) || types.length === 0) {
return undefined
}
return types
.map((ext) => `.${String(ext).replace(/^\\./, '')}`)
.join(',')
})
const allowedTypesText = computed(() => {
const types = props.itemMeta?.file_types
if (!Array.isArray(types) || types.length === 0) {
return ''
}
return types.map((ext) => String(ext).replace(/^\\./, '')).join(', ')
})
const fileCountText = computed(() => {
return tm('fileUpload.fileCount', { count: fileList.value.length })
})
const getStatusText = (status) => {
if (status === 'missing') {
return tm('fileUpload.statusMissing')
}
if (status === 'unconfigured') {
return tm('fileUpload.statusUnconfigured')
}
return ''
}
const getStatusColor = (status) => {
if (status === 'missing') {
return 'error'
}
if (status === 'unconfigured') {
return 'warning'
}
return 'primary'
}
const openFilePicker = () => {
fileInput.value?.click()
}
const loadDirectoryFiles = async () => {
if (!props.pluginName || !props.configKey || loadingFiles.value) {
return
}
loadingFiles.value = true
try {
const response = await axios.get(
`/api/config/file/get?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}&key=${encodeURIComponent(props.configKey)}`
)
if (response.data.status === 'ok') {
const files = response.data.data?.files || []
directoryFiles.value = Array.from(new Set(files))
} else {
toast.warning(response.data.message || tm('fileUpload.loadFailed'))
}
} catch (error) {
console.error('Load file list failed:', error)
toast.warning(tm('fileUpload.loadFailed'))
} finally {
loadingFiles.value = false
}
}
const handleFileSelect = (event) => {
const target = event.target
if (target?.files && target.files.length > 0) {
uploadFiles(Array.from(target.files))
}
if (target) {
target.value = ''
}
}
const handleDrop = (event) => {
isDragging.value = false
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
uploadFiles(Array.from(event.dataTransfer.files))
}
}
const uploadFiles = async (files) => {
if (!props.pluginName || !props.configKey) {
toast.warning('Missing plugin config info')
return
}
if (uploading.value) {
return
}
const oversized = files.filter((file) => file.size > MAX_FILE_BYTES)
if (oversized.length > 0) {
oversized.forEach((file) => {
toast.warning(
tm('fileUpload.fileTooLarge', { name: file.name, max: MAX_FILE_MB })
)
})
}
const validFiles = files.filter((file) => file.size <= MAX_FILE_BYTES)
if (validFiles.length === 0) {
return
}
uploading.value = true
try {
const formData = new FormData()
validFiles.forEach((file, index) => {
formData.append(`file${index}`, file)
})
const response = await axios.post(
`/api/config/file/upload?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}&key=${encodeURIComponent(props.configKey)}`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
if (response.data.status === 'ok') {
const uploaded = response.data.data?.uploaded || []
const errors = response.data.data?.errors || []
if (uploaded.length > 0) {
const merged = [...fileList.value]
for (const path of uploaded) {
if (!merged.includes(path)) {
merged.push(path)
}
}
fileList.value = merged
const updatedDirectory = new Set(directoryFiles.value)
uploaded.forEach((path) => updatedDirectory.add(path))
directoryFiles.value = Array.from(updatedDirectory)
toast.success(tm('fileUpload.uploadSuccess', { count: uploaded.length }))
}
if (errors.length > 0) {
toast.warning(errors.join('\\n'))
}
} else {
toast.error(response.data.message || tm('fileUpload.uploadFailed'))
}
} catch (error) {
console.error('File upload failed:', error)
toast.error(tm('fileUpload.uploadFailed'))
} finally {
uploading.value = false
}
}
const addToConfig = (filePath) => {
if (!fileList.value.includes(filePath)) {
fileList.value = [...fileList.value, filePath]
toast.success(tm('fileUpload.addToConfig'))
}
}
const deleteFile = (filePath) => {
fileList.value = fileList.value.filter((item) => item !== filePath)
directoryFiles.value = directoryFiles.value.filter((item) => item !== filePath)
if (props.pluginName) {
axios
.post(
`/api/config/file/delete?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}`,
{ path: filePath }
)
.catch((error) => {
console.warn('Staged file delete failed:', error)
toast.warning(tm('fileUpload.deleteFailed'))
})
}
toast.success(tm('fileUpload.deleteSuccess'))
}
const deletePhysicalFile = (filePath) => {
directoryFiles.value = directoryFiles.value.filter((item) => item !== filePath)
if (props.pluginName) {
axios
.post(
`/api/config/file/delete?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}`,
{ path: filePath }
)
.catch((error) => {
console.warn('File delete failed:', error)
toast.warning(tm('fileUpload.deleteFailed'))
})
}
toast.success(tm('fileUpload.deleteSuccess'))
}
const getDisplayName = (path) => {
if (!path) return ''
const parts = String(path).split('/')
return parts[parts.length - 1] || path
}
watch(
() => dialog.value,
(value) => {
if (value) {
loadDirectoryFiles()
}
}
)
</script>
<style scoped>
.file-config-item {
width: 100%;
}
.file-dialog-card {
height: 70vh;
box-shadow: none;
}
.file-dialog-body {
overflow-y: auto;
max-height: calc(70vh - 120px);
}
.file-dialog-actions {
padding: 16px 24px 20px;
}
.upload-hint {
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.5);
}
.empty-text {
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.5);
}
.file-name {
font-weight: 600;
word-break: break-word;
}
.upload-item {
cursor: pointer;
transition: background 0.2s ease;
}
.upload-item:hover,
.upload-item.dragover {
background: rgba(var(--v-theme-on-surface), 0.04);
}
</style>