a4fc92e803
Co-authored-by: Soulter <905617992@qq.com>
408 lines
11 KiB
Vue
408 lines
11 KiB
Vue
<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>
|