Files
FileShare/web/src/components/FileDrop.vue
T
SKYMirror 9686fe887a FileCodeBox Go 重写版 v2.5.6(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
2026-09-05 04:22:41 +08:00

103 lines
3.3 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.
<script setup lang="ts">
/** 文件拖拽/点选组件(需求⑧:allowedFileTypes 过滤文件选择器 accept;大小提示对齐 max_file_size */
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatBytes } from '@/utils/format'
const props = defineProps<{
modelValue: File | null
/** 最大字节数,超限提示 */
maxSize?: number
disabled?: boolean
/** 需求⑧:类型白名单(星号表示不限制;含斜杠按 MIME 匹配,否则按扩展名) */
acceptTypes?: string[]
}>()
const emit = defineEmits<{
'update:modelValue': [f: File | null]
}>()
const { t } = useI18n()
const inputRef = ref<HTMLInputElement | null>(null)
const dragover = ref(false)
const error = ref('')
/** 需求⑧:把白名单映射为 <input accept> 值("*" → 空 = 不限制) */
const acceptAttr = computed(() => {
const rules = (props.acceptTypes ?? []).map((r) => r.trim()).filter(Boolean)
if (!rules.length || rules.some((r) => r === '*' || r === '*/*')) return ''
return rules
.map((r) => (r.includes('/') ? r : r.startsWith('.') ? r : `.${r.toLowerCase()}`))
.join(',')
})
const acceptHint = computed(() => {
const rules = (props.acceptTypes ?? []).filter((r) => r && r !== '*' && r !== '*/*')
return rules.length ? t('drop.typeHint', { types: rules.join(', ') }) : ''
})
function accept(f: File | null | undefined): void {
error.value = ''
if (!f) return
if (props.maxSize && f.size > props.maxSize) {
error.value = t('drop.tooLarge', { size: formatBytes(f.size), limit: formatBytes(props.maxSize) })
emit('update:modelValue', null)
return
}
emit('update:modelValue', f)
}
function onDrop(e: DragEvent): void {
dragover.value = false
if (props.disabled) return
accept(e.dataTransfer?.files?.[0])
}
function onPick(e: Event): void {
const input = e.target as HTMLInputElement
accept(input.files?.[0])
input.value = ''
}
</script>
<template>
<div>
<div
v-if="!modelValue"
class="dropzone"
:class="{ dragover, disabled }"
role="button"
tabindex="0"
:aria-label="t('drop.aria')"
@click="!disabled && inputRef?.click()"
@keydown.enter.prevent="!disabled && inputRef?.click()"
@dragover.prevent="dragover = true"
@dragleave="dragover = false"
@drop.prevent="onDrop"
>
<div class="dz-icon" aria-hidden="true">📦</div>
<div class="dz-main">{{ t('drop.zone') }}</div>
<div class="dz-sub">
<template v-if="maxSize">{{ t('drop.maxSize', { size: formatBytes(maxSize) }) }}</template>
<template v-else>{{ t('drop.noLimit') }}</template>
<template v-if="acceptHint"> · {{ acceptHint }}</template>
</div>
</div>
<div v-else class="file-chip">
<span aria-hidden="true">📄</span>
<span class="fc-name">{{ modelValue.name }}</span>
<span class="fc-size">{{ formatBytes(modelValue.size) }}</span>
<button class="fc-remove" type="button" :title="t('drop.remove')" @click="emit('update:modelValue', null)"></button>
</div>
<p v-if="error" class="hint" style="color: var(--c-danger)">{{ error }}</p>
<input ref="inputRef" type="file" hidden :accept="acceptAttr" @change="onPick" />
</div>
</template>
<style scoped>
.dropzone.disabled { opacity: 0.55; cursor: not-allowed; }
</style>