20260820223837
This commit is contained in:
1 parent
68ac5db038
commit
45854b0570
48 files changed
+7545
-650
No files matched your search
@@ -0,0 +1,295 @@
|
||||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { Trash2, Download, Eye, MoreHorizontal } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Checkbox from '@/components/ui/checkbox/checkbox.vue'
|
||||
import Tag from '@/components/ui/tag/tag.vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import { getPreviewUrl } from '@/services/fileService'
|
||||
import {
|
||||
extFromName,
|
||||
extColor,
|
||||
isImageType,
|
||||
formatSize,
|
||||
} from './fileVisual'
|
||||
|
||||
const props = defineProps({
|
||||
file: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'preview', 'download', 'delete'])
|
||||
|
||||
const isPending = computed(() => !!props.file?.pending)
|
||||
const isImage = computed(() => isImageType(props.file?.mx_filename || ''))
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (!isImage.value) return ''
|
||||
return props.file?.blobUrl || getPreviewUrl(props.file) || ''
|
||||
})
|
||||
|
||||
const extname = computed(() => extFromName(props.file?.mx_filename || ''))
|
||||
const extnameUpper = computed(() => extname.value.toUpperCase() || '?')
|
||||
const typeColor = computed(() => extColor(props.file?.mx_filename || ''))
|
||||
|
||||
const moreMenuItems = computed(() => [
|
||||
{ key: 'preview', label: '预览', icon: h(Eye, { size: 13 }) },
|
||||
])
|
||||
|
||||
function onCheck(checked) {
|
||||
emit('select', props.file, checked)
|
||||
}
|
||||
|
||||
function onCardClick() {
|
||||
if (!isPending.value) emit('preview', props.file)
|
||||
}
|
||||
|
||||
function onDownload() {
|
||||
if (!isPending.value) emit('download', props.file)
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
emit('delete', props.file)
|
||||
}
|
||||
|
||||
function onMoreClick(info) {
|
||||
if (info?.key === 'preview' && !isPending.value) emit('preview', props.file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fcard"
|
||||
:class="{ 'fcard--selected': selected, 'fcard--pending': isPending }"
|
||||
:title="file.mx_filename"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<!-- 缩略图区域 -->
|
||||
<div class="fcard__thumb">
|
||||
<img v-if="isImage && thumbnailUrl" :src="thumbnailUrl" alt="" />
|
||||
<div v-else class="fcard__type" :style="{ '--c': typeColor }">
|
||||
{{ extnameUpper }}
|
||||
</div>
|
||||
|
||||
<!-- 左上 checkbox(始终可见) -->
|
||||
<span class="fcard__check" @click.stop>
|
||||
<Checkbox :model-value="selected" @change="onCheck" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<div class="fcard__info">
|
||||
<div class="fcard__name">
|
||||
<span class="fcard__title" :title="file.mx_filename">{{
|
||||
file.mx_filename
|
||||
}}</span>
|
||||
<Tag v-if="isPending" variant="outline" class="fcard__tag">待上传</Tag>
|
||||
</div>
|
||||
<div class="fcard__meta">{{ formatSize(file.mx_filesize) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏:图标按钮(无文字,语言无关,避免多语言下文字长度撑破布局) -->
|
||||
<div class="fcard__footer" @click.stop>
|
||||
<Button type="ghost" :disabled="isPending" @click="onDownload">
|
||||
<Download />
|
||||
</Button>
|
||||
<span class="fcard__divider" />
|
||||
<Button type="ghost" class="fcard__btn-danger" @click="onDelete">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
<span class="fcard__divider" />
|
||||
<Dropdown :items="moreMenuItems" :disabled="isPending" @click="onMoreClick">
|
||||
<Button type="ghost" :disabled="isPending">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fcard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 6px;
|
||||
background: var(--fms-card);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
&--selected {
|
||||
border-color: var(--fms-primary);
|
||||
}
|
||||
|
||||
&--pending {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* 缩略图 */
|
||||
.fcard__thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 84px;
|
||||
background: var(--fms-secondary-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/* 彩色类型方块(签名元素) */
|
||||
.fcard__type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 32%, #fff);
|
||||
}
|
||||
|
||||
/* checkbox:直接浮在缩略图上,不加底块/阴影,保持轻量 */
|
||||
.fcard__check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 3;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 常驻操作栏:下载 / 删除 / 更多 */
|
||||
.fcard__footer {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
background: var(--fms-card);
|
||||
|
||||
:deep(.button) {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* 底部三个操作按钮必须是等宽 flex 子项:在 flex-basis: 0% 下浏览器按
|
||||
内容盒均分剩余空间,任何一方额外的 padding / border 都会让其 border-box
|
||||
更宽。故这里与 .popover-trigger 统一 padding:0 + border:none,
|
||||
让三个按钮 border-box 完全相等 */
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: var(--fms-text-secondary);
|
||||
/* 仅过渡颜色,避免 hover 时因 all 过渡引发的闪烁/位移 */
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--fms-primary);
|
||||
background: var(--fms-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.fcard__btn-danger:hover:not(:disabled) {
|
||||
color: var(--fms-danger);
|
||||
background: color-mix(in srgb, var(--fms-danger) 10%, #fff);
|
||||
}
|
||||
|
||||
/* 明确的禁用态:覆盖按钮库默认透明度,
|
||||
让 pending 时的下载/更多按钮图标明显变淡灰、鼠标显示禁止手势,
|
||||
lucide 图标用 currentColor 自动跟随。 */
|
||||
&:disabled {
|
||||
color: var(--fms-disabled-text);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「更多」按钮被 Dropdown 包裹:Popover 的 .popover-trigger 才是 flex 子项,
|
||||
必须让它与两个直接按钮一样 flex:1 且 padding/border 保持一致,
|
||||
三个操作按钮宽度才会一致 */
|
||||
:deep(.popover-trigger) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-self: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 操作栏按钮间的竖向分隔线:独立元素,不依赖按钮类名 / 禁用态,
|
||||
避免「更多」按钮被 dropdown 包裹且禁用时 border 被抹掉导致分隔线消失。 */
|
||||
.fcard__divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: var(--fms-border);
|
||||
}
|
||||
|
||||
/* 文件信息 */
|
||||
.fcard__info {
|
||||
padding: 7px 10px 9px;
|
||||
}
|
||||
|
||||
.fcard__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fcard__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fms-text);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fcard__tag {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
height: 18px;
|
||||
color: var(--fms-warning);
|
||||
background: color-mix(in srgb, var(--fms-warning) 12%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--fms-warning) 35%, #fff);
|
||||
}
|
||||
|
||||
.fcard__meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--fms-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Tree from '@/components/ui/tree/tree.vue'
|
||||
import Spin from '@/components/ui/spin/spin.vue'
|
||||
|
||||
const props = defineProps({
|
||||
treeData: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
selectedKey: { type: String, default: '' },
|
||||
expandedKeys: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'update:expandedKeys'])
|
||||
|
||||
const selectedKeys = computed(() =>
|
||||
props.selectedKey ? [props.selectedKey] : [],
|
||||
)
|
||||
|
||||
function onSelect(keys, info) {
|
||||
if (info?.node?.isFmsRoot) {
|
||||
emit('select', '')
|
||||
return
|
||||
}
|
||||
emit('select', keys.length ? String(keys[0]) : '')
|
||||
}
|
||||
|
||||
function onExpand(keys) {
|
||||
emit('update:expandedKeys', keys)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="fc-panel">
|
||||
<div class="fc-panel__tree">
|
||||
<Tree
|
||||
:tree-data="treeData"
|
||||
:selected-keys="selectedKeys"
|
||||
:expanded-keys="expandedKeys"
|
||||
:block-node="true"
|
||||
@select="onSelect"
|
||||
@update:expanded-keys="onExpand"
|
||||
/>
|
||||
<div v-if="loading" class="fc-panel__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fc-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--fms-card, #fff);
|
||||
}
|
||||
|
||||
.fc-panel__tree {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.fc-panel__tree :deep(.tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.fc-panel__tree :deep(.tree-node-content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fc-panel__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,951 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Message } from '@/components/ui/message/message-manager'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import ButtonGroup from '@/components/ui/button-group/button-group.vue'
|
||||
import Checkbox from '@/components/ui/checkbox/checkbox.vue'
|
||||
import Spin from '@/components/ui/spin/spin.vue'
|
||||
import Tag from '@/components/ui/tag/tag.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/tooltip.vue'
|
||||
import Pagination from '@/components/ui/pagination/pagination.vue'
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
Upload,
|
||||
LayoutGrid,
|
||||
List,
|
||||
FileText,
|
||||
} from '@lucide/vue'
|
||||
import { pageDataApi } from '@/services/api'
|
||||
import {
|
||||
upload as fileUpload,
|
||||
removeFile,
|
||||
getPreviewUrl,
|
||||
} from '@/services/fileService'
|
||||
import FileCard from './FileCard.vue'
|
||||
import {
|
||||
extFromName,
|
||||
extColor,
|
||||
isImageType,
|
||||
formatSize,
|
||||
} from './fileVisual'
|
||||
|
||||
const props = defineProps({
|
||||
moduleCode: { type: [String, Number], default: '' },
|
||||
moduleId: { type: String, default: '' },
|
||||
father: { type: [String, Number], required: true },
|
||||
cateId: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['preview', 'uploaded', 'deleted'])
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const files = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const uploading = ref(false)
|
||||
const selectedFileIds = ref(new Set())
|
||||
|
||||
// 视图模式
|
||||
const viewMode = ref('grid') // 'grid' | 'list'
|
||||
|
||||
const pendingFiles = ref([])
|
||||
const pendingBlobUrls = ref([])
|
||||
const uploadInputRef = ref(null)
|
||||
const mainRef = ref(null)
|
||||
const isDragOver = ref(false)
|
||||
let dragCounter = 0
|
||||
|
||||
const displayFiles = computed(() => {
|
||||
const pending = pendingFiles.value.map((f, i) => ({
|
||||
pending: true,
|
||||
subid: `_pending_${i}`,
|
||||
mx_filename: f.name,
|
||||
mx_filesize: f.size,
|
||||
mx_fileext: extFromName(f.name),
|
||||
blobUrl: pendingBlobUrls.value[i] || '',
|
||||
rawFile: f,
|
||||
}))
|
||||
return [...pending, ...files.value]
|
||||
})
|
||||
|
||||
const hasFiles = computed(() => displayFiles.value.length > 0)
|
||||
const hasPending = computed(() => pendingFiles.value.length > 0)
|
||||
const uploadedFiles = computed(() => files.value)
|
||||
const uploadedCount = computed(() => files.value.length)
|
||||
|
||||
const selectedCount = computed(() => selectedFileIds.value.size)
|
||||
const allChecked = computed(
|
||||
() => hasFiles.value && selectedFileIds.value.size === displayFiles.value.length,
|
||||
)
|
||||
const indeterminate = computed(
|
||||
() => selectedCount.value > 0 && !allChecked.value,
|
||||
)
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
function buildCondition() {
|
||||
const f = props.father
|
||||
let cond = `[father] = N'${String(f).replaceAll("'", "''")}'`
|
||||
if (props.moduleId) {
|
||||
cond += ` AND [mx_moduleid] = N'${String(props.moduleId).replaceAll("'", "''")}'`
|
||||
}
|
||||
if (props.cateId) {
|
||||
cond += ` AND [mx_cate_id] = N'${String(props.cateId).replaceAll("'", "''")}'`
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
function normalizeFile(row) {
|
||||
return {
|
||||
...row,
|
||||
subid: String(row.subid),
|
||||
father: String(row.father),
|
||||
mx_moduleid: row.mx_moduleid ? String(row.mx_moduleid) : '',
|
||||
mx_cate_id: row.mx_cate_id ? String(row.mx_cate_id) : '',
|
||||
mx_filesize: Number(row.mx_filesize || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles(page = 1) {
|
||||
currentPage.value = page
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageDataApi({
|
||||
viewName: 'bf_files',
|
||||
orderBy: 'mx_xh ASC, subid DESC',
|
||||
searchCondition: buildCondition(),
|
||||
pageNo: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
files.value = (res.data?.rows || []).map(normalizeFile)
|
||||
total.value = res.data?.total || 0
|
||||
} catch {
|
||||
files.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 选择逻辑 ----------
|
||||
function toggleAll() {
|
||||
if (allChecked.value) {
|
||||
selectedFileIds.value = new Set()
|
||||
} else {
|
||||
selectedFileIds.value = new Set(displayFiles.value.map((f) => f.subid))
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelect(file, checked) {
|
||||
const sid = file.subid
|
||||
const next = new Set(selectedFileIds.value)
|
||||
if (checked) {
|
||||
next.add(sid)
|
||||
} else {
|
||||
next.delete(sid)
|
||||
}
|
||||
selectedFileIds.value = next
|
||||
}
|
||||
|
||||
// ---------- 上传 ----------
|
||||
function triggerSelect() {
|
||||
uploadInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onSelectChange(e) {
|
||||
const selected = e.target?.files
|
||||
if (!selected?.length) return
|
||||
addFiles(selected)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function addFiles(fileList) {
|
||||
// 允许重复上传同一文件:不再与已加入队列的文件做去重比较。
|
||||
// 拖拽重复入栈的问题已由 dragenter/dragleave 的 currentTarget 判断解决。
|
||||
for (const f of fileList) {
|
||||
pendingFiles.value.push(f)
|
||||
if (isImageType(f.name)) {
|
||||
pendingBlobUrls.value.push(URL.createObjectURL(f))
|
||||
} else {
|
||||
pendingBlobUrls.value.push('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 拖拽上传 ----------
|
||||
function handleDragEnter(e) {
|
||||
e.preventDefault()
|
||||
// 只在进入 fl-main 自身时计数,忽略子元素冒泡
|
||||
if (e.currentTarget !== e.target) return
|
||||
dragCounter++
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
// 只在离开 fl-main 自身时计数
|
||||
if (e.currentTarget !== e.target) return
|
||||
dragCounter--
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0
|
||||
isDragOver.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault()
|
||||
dragCounter = 0
|
||||
isDragOver.value = false
|
||||
const dt = e.dataTransfer
|
||||
if (dt?.files?.length) addFiles(dt.files)
|
||||
}
|
||||
|
||||
// ---------- 粘贴上传 ----------
|
||||
function handlePaste(e) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const fileList = []
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const f = item.getAsFile()
|
||||
if (f) fileList.push(f)
|
||||
}
|
||||
}
|
||||
if (fileList.length) addFiles(fileList)
|
||||
}
|
||||
|
||||
function revokeBlobUrls() {
|
||||
for (const url of pendingBlobUrls.value) {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
}
|
||||
pendingBlobUrls.value = []
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
if (!pendingFiles.value.length) return
|
||||
if (!props.moduleId) {
|
||||
Message.warning('未解析到模块 ID,无法上传')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
await fileUpload({
|
||||
files: [...pendingFiles.value],
|
||||
father: String(props.father),
|
||||
moduleId: props.moduleId,
|
||||
cateId: props.cateId || undefined,
|
||||
})
|
||||
Message.success(`成功上传 ${pendingFiles.value.length} 个文件`)
|
||||
revokeBlobUrls()
|
||||
pendingFiles.value = []
|
||||
emit('uploaded')
|
||||
await loadFiles(1)
|
||||
} catch (e) {
|
||||
Message.error('上传失败:' + (e?.message || e))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 删除(状态化确认弹窗,替代 antdv 的 Modal.confirm) ----------
|
||||
const confirm = ref(null) // { title, content, okText, onOk }
|
||||
const confirmLoading = ref(false)
|
||||
|
||||
function askConfirm({ title, content = '', okText = '删除', onOk }) {
|
||||
confirm.value = { title, content, okText, onOk }
|
||||
}
|
||||
|
||||
async function onConfirmOk() {
|
||||
const current = confirm.value
|
||||
if (!current) return
|
||||
confirmLoading.value = true
|
||||
try {
|
||||
await current.onOk()
|
||||
confirm.value = null
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!selectedFileIds.value.size) return
|
||||
const ids = [...selectedFileIds.value]
|
||||
askConfirm({
|
||||
title: `确定删除选中的 ${ids.length} 个文件?`,
|
||||
onOk: async () => {
|
||||
// 待上传文件:直接从队列移除
|
||||
const pendingIdxs = ids
|
||||
.filter((id) => String(id).startsWith('_pending_'))
|
||||
.map((id) => Number(String(id).replace('_pending_', '')))
|
||||
.toSorted((a, b) => b - a)
|
||||
const realIds = ids.filter((id) => !String(id).startsWith('_pending_'))
|
||||
try {
|
||||
for (const idx of pendingIdxs) {
|
||||
if (pendingFiles.value[idx]) {
|
||||
if (pendingBlobUrls.value[idx]) URL.revokeObjectURL(pendingBlobUrls.value[idx])
|
||||
pendingFiles.value.splice(idx, 1)
|
||||
pendingBlobUrls.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
// 已上传文件:调接口删除
|
||||
for (const id of realIds) {
|
||||
await removeFile(id)
|
||||
}
|
||||
Message.success(`已删除 ${ids.length} 个文件`)
|
||||
selectedFileIds.value = new Set()
|
||||
emit('deleted')
|
||||
await loadFiles(currentPage.value)
|
||||
} catch (e) {
|
||||
Message.error('删除失败:' + (e?.message || e))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteSingle(file) {
|
||||
if (file.pending) {
|
||||
const idx = pendingFiles.value.findIndex((f) => f === file.rawFile)
|
||||
if (idx >= 0) {
|
||||
if (pendingBlobUrls.value[idx]) URL.revokeObjectURL(pendingBlobUrls.value[idx])
|
||||
pendingFiles.value.splice(idx, 1)
|
||||
pendingBlobUrls.value.splice(idx, 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
askConfirm({
|
||||
title: `确定删除「${file.mx_filename}」?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeFile(file.subid)
|
||||
selectedFileIds.value.delete(file.subid)
|
||||
emit('deleted')
|
||||
await loadFiles(currentPage.value)
|
||||
} catch (e) {
|
||||
Message.error('删除失败:' + (e?.message || e))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 下载 ----------
|
||||
function downloadSelected() {
|
||||
const selected = files.value.filter((f) => selectedFileIds.value.has(f.subid))
|
||||
for (const f of selected) {
|
||||
const url = getPreviewUrl(f)
|
||||
if (url) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = f.mx_filename
|
||||
a.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSingle(file) {
|
||||
const url = getPreviewUrl(file)
|
||||
if (url) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.mx_filename
|
||||
a.click()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 预览 ----------
|
||||
function onPreview(file) {
|
||||
emit('preview', file)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFiles()
|
||||
document.addEventListener('paste', handlePaste)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handlePaste)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.father, props.moduleId, props.cateId],
|
||||
() => {
|
||||
selectedFileIds.value = new Set()
|
||||
revokeBlobUrls()
|
||||
pendingFiles.value = []
|
||||
loadFiles(1)
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({ files, triggerSelect })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-panel">
|
||||
<!-- Header -->
|
||||
<div class="fl-header">
|
||||
<Checkbox
|
||||
:model-value="allChecked"
|
||||
:indeterminate="indeterminate"
|
||||
:disabled="!hasFiles"
|
||||
@change="toggleAll"
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<Button type="outline" :disabled="!selectedCount" @click="downloadSelected">
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
type="outline"
|
||||
class="fl-header__danger"
|
||||
:disabled="!selectedCount"
|
||||
@click="deleteSelected"
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
<div class="fl-header__spacer" />
|
||||
<ButtonGroup class="fl-header__modes">
|
||||
<Button
|
||||
type="ghost"
|
||||
:class="{ 'is-active': viewMode === 'grid' }"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<LayoutGrid />
|
||||
</Button>
|
||||
<Button
|
||||
type="ghost"
|
||||
:class="{ 'is-active': viewMode === 'list' }"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<List />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<!-- Main:文件网格 / 列表 -->
|
||||
<div
|
||||
ref="mainRef"
|
||||
class="fl-main"
|
||||
:class="{ 'fl-main--dragover': isDragOver }"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<Spin :spinning="loading" class="fl-spin">
|
||||
<div v-if="hasFiles">
|
||||
<!-- 卡片模式 -->
|
||||
<div v-if="viewMode === 'grid'" class="fl-grid">
|
||||
<FileCard
|
||||
v-for="f in displayFiles"
|
||||
:key="f.subid"
|
||||
:file="f"
|
||||
:selected="selectedFileIds.has(f.subid)"
|
||||
@select="onFileSelect"
|
||||
@preview="onPreview"
|
||||
@download="downloadSingle"
|
||||
@delete="deleteSingle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表模式:手写表格(表头吸顶),避免引入重型表格库 -->
|
||||
<div v-else class="fl-list">
|
||||
<table class="fl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="fl-table__check">
|
||||
<Checkbox
|
||||
:model-value="allChecked"
|
||||
:indeterminate="indeterminate"
|
||||
:disabled="!hasFiles"
|
||||
@change="toggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th>文件名</th>
|
||||
<th class="is-right">大小</th>
|
||||
<th class="is-center">类型</th>
|
||||
<th class="is-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in displayFiles" :key="f.subid">
|
||||
<td class="fl-table__check">
|
||||
<Checkbox
|
||||
:model-value="selectedFileIds.has(f.subid)"
|
||||
@change="(v) => onFileSelect(f, v)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
class="fl-filename"
|
||||
:class="{ 'is-pending': f.pending }"
|
||||
:title="f.mx_filename"
|
||||
@click="!f.pending && onPreview(f)"
|
||||
>
|
||||
{{ f.mx_filename }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="is-right">
|
||||
<span class="fl-size">{{ formatSize(f.mx_filesize) }}</span>
|
||||
</td>
|
||||
<td class="is-center">
|
||||
<Tag
|
||||
v-if="f.pending"
|
||||
variant="outline"
|
||||
class="fl-ext-tag fl-ext-tag--pending"
|
||||
>待上传</Tag>
|
||||
<span
|
||||
v-else
|
||||
class="fl-ext-chip"
|
||||
:style="{ '--c': extColor(f.mx_filename) }"
|
||||
>{{ (f.mx_fileext || '?').toUpperCase() }}</span>
|
||||
</td>
|
||||
<td class="is-center">
|
||||
<div class="fl-actions">
|
||||
<Tooltip content="下载">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="fl-icon-btn"
|
||||
:disabled="!!f.pending"
|
||||
@click="downloadSingle(f)"
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="!readonly" content="删除">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="fl-icon-btn fl-icon-btn--danger"
|
||||
@click="deleteSingle(f)"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="fl-empty fl-empty--fill"
|
||||
:class="{ 'fl-empty--clickable': !readonly }"
|
||||
@click="!readonly && triggerSelect()"
|
||||
>
|
||||
<FileText :size="40" :stroke-width="1" />
|
||||
<span>暂无相关文件</span>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<!-- 拖拽悬浮遮罩 -->
|
||||
<div v-if="isDragOver" class="fl-drop-mask">
|
||||
<div class="fl-drop-hint">
|
||||
<Upload :size="28" />
|
||||
<span>释放文件到此处</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="!readonly" class="fl-footer">
|
||||
<div class="fl-footer__bar">
|
||||
<span class="fl-footer__count" :class="{ 'is-pending': hasPending }">
|
||||
<template v-if="hasPending">待上传 {{ pendingFiles.length }} 个</template>
|
||||
<template v-else>共 {{ uploadedCount }} 个文件</template>
|
||||
</span>
|
||||
<div class="fl-footer__btns">
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
type="file"
|
||||
class="fl-footer__input"
|
||||
multiple
|
||||
@change="onSelectChange"
|
||||
/>
|
||||
<Button type="outline" @click="triggerSelect">
|
||||
<FolderOpen />
|
||||
选择
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="!hasPending"
|
||||
:loading="uploading"
|
||||
@click="submitUpload"
|
||||
>
|
||||
<Upload />
|
||||
上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > pageSize" class="fl-pagination">
|
||||
<Pagination
|
||||
:current="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
@change="loadFiles"
|
||||
@show-size-change="(_, size) => { pageSize = size; loadFiles(1) }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认弹窗(状态化渲染) -->
|
||||
<Modal
|
||||
:open="!!confirm"
|
||||
:title="confirm?.title || '确认操作'"
|
||||
:width="416"
|
||||
centered
|
||||
:closable="false"
|
||||
:fullscreen="false"
|
||||
:resizable="false"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="confirmLoading"
|
||||
@cancel="confirm = null"
|
||||
>
|
||||
<div class="fl-confirm-content">
|
||||
{{ confirm?.content || '确定要执行此操作吗?' }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="confirmLoading" @click="confirm = null">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="danger" :loading="confirmLoading" @click="onConfirmOk">
|
||||
{{ confirm?.okText || '删除' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fl-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--fms-card);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.fl-header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: var(--fms-card);
|
||||
|
||||
&__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__modes {
|
||||
margin-left: 8px;
|
||||
|
||||
:deep(.button) {
|
||||
padding: 0 8px;
|
||||
|
||||
&.is-active {
|
||||
color: var(--fms-primary);
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 删除按钮:描边 + 红色语义(对齐 antdv danger 风格) */
|
||||
:deep(.fl-header__danger) {
|
||||
color: var(--fms-danger);
|
||||
border-color: color-mix(in srgb, var(--fms-danger) 40%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--fms-danger);
|
||||
border-color: var(--fms-danger);
|
||||
background: color-mix(in srgb, var(--fms-danger) 8%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--fms-disabled-text);
|
||||
border-color: var(--fms-border);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.fl-main {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: var(--fms-card);
|
||||
|
||||
&--dragover {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card));
|
||||
}
|
||||
}
|
||||
|
||||
.fl-spin {
|
||||
/* 不锁死高度:内容短时(min-height)仍撑满文件区,便于空态/加载态居中;
|
||||
内容长时自然溢出,由 .fl-main(overflow:auto) 统一产生 Y 轴滚动条 */
|
||||
min-height: 100%;
|
||||
|
||||
/* 加载态遮罩层让出定位上下文(.fl-main 已是 relative),
|
||||
使空态 .fl-empty--fill 正确铺满居中 */
|
||||
:deep(.spin-container) {
|
||||
height: auto;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.fl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* ── 列表模式(手写表格) ── */
|
||||
.fl-list {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.fl-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.is-center {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--fms-secondary-hover);
|
||||
color: var(--fms-text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: color-mix(in srgb, var(--fms-secondary-hover) 60%, transparent);
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.fl-filename {
|
||||
display: inline-block;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
color: var(--fms-primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fl-size {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.fl-ext-tag {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
|
||||
&--pending {
|
||||
color: var(--fms-warning);
|
||||
background: color-mix(in srgb, var(--fms-warning) 12%, #fff);
|
||||
border-color: color-mix(in srgb, var(--fms-warning) 35%, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
/* 彩色类型方块(与卡片一致) */
|
||||
.fl-ext-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 32%, #fff);
|
||||
}
|
||||
|
||||
.fl-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
:deep(.fl-icon-btn) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
&.fl-icon-btn--danger:hover:not(:disabled) {
|
||||
color: var(--fms-danger);
|
||||
background: color-mix(in srgb, var(--fms-danger) 10%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fl-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 160px;
|
||||
font-size: 13px;
|
||||
color: var(--fms-text-secondary);
|
||||
|
||||
&--fill {
|
||||
/* 相对 .fl-main(position:relative)铺满整个文件区,再做垂直水平居中,
|
||||
避免依赖 spin 容器的高度链导致仅贴顶居中 */
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* 非只读时空状态可点击选择文件 */
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 拖拽遮罩 ── */
|
||||
.fl-drop-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--fms-primary) 8%, transparent);
|
||||
border: 2px dashed var(--fms-primary);
|
||||
border-radius: 6px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fl-drop-hint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fms-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.fl-footer {
|
||||
flex: none;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
background: var(--fms-card);
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
|
||||
&.is-pending {
|
||||
color: var(--fms-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 分页 ── */
|
||||
.fl-pagination {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
/* ── 删除确认弹窗 ── */
|
||||
.fl-confirm-content {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--fms-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, FileText } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { getPreviewUrl } from '@/services/fileService'
|
||||
import { extColor, formatSize } from './fileVisual'
|
||||
|
||||
const props = defineProps({
|
||||
file: { type: Object, default: null },
|
||||
fileList: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['prev', 'next'])
|
||||
|
||||
const currentIndex = computed(() => {
|
||||
if (!props.file || !props.fileList.length) return -1
|
||||
return props.fileList.findIndex((f) => f.subid === props.file.subid)
|
||||
})
|
||||
|
||||
const totalCount = computed(() => props.fileList.length)
|
||||
|
||||
const canPrev = computed(() => currentIndex.value > 0)
|
||||
const canNext = computed(
|
||||
() => currentIndex.value >= 0 && currentIndex.value < totalCount.value - 1,
|
||||
)
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (!props.file) return ''
|
||||
return getPreviewUrl(props.file)
|
||||
})
|
||||
|
||||
const isImage = computed(() => {
|
||||
const ext = (props.file?.mx_fileext || '').toLowerCase()
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)
|
||||
})
|
||||
|
||||
const typeColor = computed(() => extColor(props.file?.mx_filename || ''))
|
||||
const extUpper = computed(() => (props.file?.mx_fileext || '?').toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="fp-panel">
|
||||
<div class="fp-toolbar">
|
||||
<div class="fp-toolbar__nav">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="fp-nav-btn"
|
||||
:disabled="!canPrev"
|
||||
title="上一个"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span class="fp-toolbar__counter" v-if="currentIndex >= 0">
|
||||
{{ currentIndex + 1 }} / {{ totalCount }}
|
||||
</span>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="fp-nav-btn"
|
||||
:disabled="!canNext"
|
||||
title="下一个"
|
||||
@click="emit('next')"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
<span class="fp-toolbar__name" :title="file?.mx_filename">
|
||||
{{ file?.mx_filename || '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="fp-content">
|
||||
<template v-if="file">
|
||||
<div v-if="isImage && previewUrl" class="fp-content__image">
|
||||
<img :src="previewUrl" :alt="file.mx_filename" />
|
||||
</div>
|
||||
<div v-else class="fp-content__generic">
|
||||
<div class="fp-content__chip" :style="{ '--c': typeColor }">
|
||||
{{ extUpper }}
|
||||
</div>
|
||||
<div class="fp-content__info">
|
||||
<div class="fp-content__name">{{ file.mx_filename }}</div>
|
||||
<div class="fp-content__meta">
|
||||
<span>{{ formatSize(file.mx_filesize) }}</span>
|
||||
<span v-if="file.mx_fileext">· {{ file.mx_fileext }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="fp-empty">
|
||||
<FileText :size="48" :stroke-width="1" />
|
||||
<div class="fp-empty__text">选择文件查看预览</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fp-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--fms-card);
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.fp-toolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: var(--fms-card);
|
||||
gap: 8px;
|
||||
|
||||
&__nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
|
||||
:deep(.fp-nav-btn) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--fms-text-secondary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--fms-primary);
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__counter {
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fms-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.fp-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--fms-background);
|
||||
|
||||
&__image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__generic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 彩色类型方块(与卡片一致) */
|
||||
&__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--c);
|
||||
background: color-mix(in srgb, var(--c) 14%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 32%, #fff);
|
||||
}
|
||||
|
||||
&__info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 空状态 ── */
|
||||
.fp-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
color: var(--fms-border);
|
||||
|
||||
&__text {
|
||||
font-size: 13px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import Splitter from '@/components/ui/splitter/splitter.vue'
|
||||
import SplitterPanel from '@/components/ui/splitter/panel.vue'
|
||||
import Spin from '@/components/ui/spin/spin.vue'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
import FileCategoryPanel from './FileCategoryPanel.vue'
|
||||
import FileListPanel from './FileListPanel.vue'
|
||||
import FilePreviewPanel from './FilePreviewPanel.vue'
|
||||
import { useFileCategory } from './composables/useFileCategory'
|
||||
|
||||
const props = defineProps({
|
||||
moduleCode: { type: [String, Number], required: true },
|
||||
father: { type: [String, Number], required: true },
|
||||
showTree: { type: Boolean, default: true },
|
||||
readonly: { type: Boolean, default: false },
|
||||
initialCateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['uploaded', 'deleted', 'selected'])
|
||||
|
||||
// ---------- 模块解析 ----------
|
||||
const moduleId = ref('')
|
||||
const moduleResolving = ref(false)
|
||||
const moduleError = ref('')
|
||||
|
||||
async function resolveModule() {
|
||||
const code = props.moduleCode
|
||||
if (!code) {
|
||||
moduleId.value = ''
|
||||
return
|
||||
}
|
||||
moduleResolving.value = true
|
||||
moduleError.value = ''
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
's_module',
|
||||
`b_code = N'${String(code).replaceAll("'", "''")}' AND b_canuse = 1`,
|
||||
'',
|
||||
['b_id', 'b_code', 'b_name'],
|
||||
)
|
||||
if (res.data?.length) {
|
||||
moduleId.value = String(res.data[0].b_id)
|
||||
} else {
|
||||
moduleId.value = ''
|
||||
moduleError.value = `未找到模块 "${code}"`
|
||||
}
|
||||
} catch (e) {
|
||||
moduleError.value = e?.message || '模块解析失败'
|
||||
moduleId.value = ''
|
||||
} finally {
|
||||
moduleResolving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.moduleCode,
|
||||
() => resolveModule(),
|
||||
)
|
||||
|
||||
// ---------- 分类 ----------
|
||||
const {
|
||||
treeData: categoryTreeData,
|
||||
loading: categoryLoading,
|
||||
selectedKey: categorySelectedKey,
|
||||
loadCategories,
|
||||
selectCategory,
|
||||
} = useFileCategory()
|
||||
|
||||
const activeCateId = computed(() => {
|
||||
const key = categorySelectedKey.value
|
||||
if (!key || key === '__fms_tree_root__') return ''
|
||||
return key
|
||||
})
|
||||
|
||||
const categoryExpandedKeys = ref(['__fms_tree_root__'])
|
||||
|
||||
function onCategorySelect(key) {
|
||||
selectCategory(key)
|
||||
}
|
||||
|
||||
// ---------- 预览选中 ----------
|
||||
const fileListRef = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
|
||||
const currentFileList = computed(() => fileListRef.value?.files || [])
|
||||
|
||||
function onPreviewFile(file) {
|
||||
selectedFile.value = file
|
||||
emit('selected', file)
|
||||
}
|
||||
|
||||
function onPreviewPrev() {
|
||||
const list = currentFileList.value
|
||||
const idx = list.findIndex((f) => f.subid === selectedFile.value?.subid)
|
||||
if (idx > 0) {
|
||||
selectedFile.value = list[idx - 1]
|
||||
emit('selected', selectedFile.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onPreviewNext() {
|
||||
const list = currentFileList.value
|
||||
const idx = list.findIndex((f) => f.subid === selectedFile.value?.subid)
|
||||
if (idx >= 0 && idx < list.length - 1) {
|
||||
selectedFile.value = list[idx + 1]
|
||||
emit('selected', selectedFile.value)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件代理 ----------
|
||||
function onUploaded() {
|
||||
emit('uploaded')
|
||||
}
|
||||
|
||||
function onDeleted() {
|
||||
selectedFile.value = null
|
||||
emit('deleted')
|
||||
}
|
||||
|
||||
// ---------- 挂载 ----------
|
||||
onMounted(() => {
|
||||
resolveModule()
|
||||
loadCategories()
|
||||
if (props.initialCateId) {
|
||||
selectCategory(props.initialCateId)
|
||||
categoryExpandedKeys.value = ['__fms_tree_root__', String(props.initialCateId)]
|
||||
}
|
||||
})
|
||||
|
||||
const ready = computed(() => !moduleResolving.value && !moduleError.value)
|
||||
|
||||
defineExpose({ moduleId, refreshCategories: loadCategories })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ff-container">
|
||||
<div v-if="moduleResolving" class="ff-status">
|
||||
<Spin />
|
||||
<span class="ff-status__text">正在解析模块...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="moduleError" class="ff-status ff-status--error">
|
||||
{{ moduleError }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Splitter class="ff-splitter">
|
||||
<!-- 左:分类树(固定 220px 侧栏) -->
|
||||
<SplitterPanel
|
||||
v-if="showTree"
|
||||
:default-size="220"
|
||||
:min="160"
|
||||
:max="320"
|
||||
>
|
||||
<FileCategoryPanel
|
||||
:tree-data="categoryTreeData"
|
||||
:loading="categoryLoading"
|
||||
:selected-key="categorySelectedKey"
|
||||
:expanded-keys="categoryExpandedKeys"
|
||||
@select="onCategorySelect"
|
||||
@update:expanded-keys="(keys) => (categoryExpandedKeys = keys)"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- 中:文件列表(固定 500px) -->
|
||||
<SplitterPanel :default-size="500" :min="280">
|
||||
<div class="ff-center">
|
||||
<FileListPanel
|
||||
ref="fileListRef"
|
||||
:module-code="moduleCode"
|
||||
:module-id="moduleId"
|
||||
:father="father"
|
||||
:cate-id="activeCateId"
|
||||
:readonly="readonly"
|
||||
@preview="onPreviewFile"
|
||||
@uploaded="onUploaded"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- 右:预览(不指定默认宽,自动占剩余空间) -->
|
||||
<SplitterPanel :min="440">
|
||||
<FilePreviewPanel
|
||||
:file="selectedFile"
|
||||
:file-list="currentFileList"
|
||||
@prev="onPreviewPrev"
|
||||
@next="onPreviewNext"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ff-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 6px;
|
||||
background: var(--fms-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ff-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ff-center {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ff-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--fms-text-secondary);
|
||||
|
||||
&--error {
|
||||
color: var(--fms-danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import { useFileModal } from './useFileModal'
|
||||
import FmsFile from './FmsFile.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
moduleCode: { type: [String, Number], required: true },
|
||||
father: { type: [String, Number], required: true },
|
||||
showTree: { type: Boolean, default: true },
|
||||
readonly: { type: Boolean, default: false },
|
||||
initialCateId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'uploaded', 'deleted', 'selected'])
|
||||
|
||||
function onClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
const { state } = useFileModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 声明式用法 -->
|
||||
<Modal
|
||||
:open="open"
|
||||
title="附件管理"
|
||||
:width="'80%'"
|
||||
:footer="false"
|
||||
@cancel="onClose"
|
||||
>
|
||||
<FmsFile
|
||||
:key="`${moduleCode}-${father}`"
|
||||
:module-code="moduleCode"
|
||||
:father="father"
|
||||
:show-tree="showTree"
|
||||
:readonly="readonly"
|
||||
:initial-cate-id="initialCateId"
|
||||
@uploaded="emit('uploaded')"
|
||||
@deleted="emit('deleted')"
|
||||
@selected="(f) => emit('selected', f)"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<!-- 命令式用法 -->
|
||||
<Modal
|
||||
:open="state.open"
|
||||
title="附件管理"
|
||||
:width="'80%'"
|
||||
:footer="false"
|
||||
@cancel="state.open = false"
|
||||
>
|
||||
<FmsFile
|
||||
:key="state.key"
|
||||
:module-code="state.moduleCode"
|
||||
:father="state.father"
|
||||
:show-tree="state.showTree"
|
||||
:readonly="state.readonly"
|
||||
:initial-cate-id="state.initialCateId"
|
||||
@uploaded="state.onUploaded?.()"
|
||||
@deleted="state.onDeleted?.()"
|
||||
@selected="(f) => state.onSelected?.(f)"
|
||||
/>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/* Modal 根节点是 Teleport,class 无法透传(Vue 会告警且不生效),
|
||||
因此通过内容锚点 .ff-container(FmsFile 根元素)用 :has() 定位附件弹窗。
|
||||
打开中的附件弹窗体内必然渲染 .ff-container,其他弹窗不受影响。 */
|
||||
.fms-modal:has(.ff-container) {
|
||||
max-width: 1440px; /* 超宽屏封顶,避免三栏被拉得过宽 */
|
||||
min-width: 960px; /* 极小屏保底,三栏不挤 */
|
||||
}
|
||||
|
||||
.fms-modal-body:has(.ff-container) {
|
||||
padding: 0;
|
||||
height: calc(70vh - 60px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 520px; /* 矮屏保底高度 */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
|
||||
/**
|
||||
* 文件分类逻辑 —— 加载 bf_files_cateid 扁平表并通过 Tree 展示。
|
||||
* 返回的 treeData 已包含「全部分类」根节点(key 固定为 __fms_tree_root__),
|
||||
* 供 FileCategoryPanel 直接渲染。
|
||||
*/
|
||||
export function useFileCategory() {
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const selectedKey = ref('')
|
||||
|
||||
/** 加载所有启用的分类 */
|
||||
async function loadCategories() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
'bf_files_cateid',
|
||||
'b_canuse = 1',
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
categories.value = (res.data || []).map((item) => ({
|
||||
...item,
|
||||
b_id: String(item.b_id),
|
||||
}))
|
||||
} catch (e) {
|
||||
error.value = e?.message || String(e)
|
||||
categories.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 扁平分类转 Tree 所需 treeData(b_id → key, b_name → title),
|
||||
并前置「全部分类」根节点(与旧版 FmsTree 行为一致) */
|
||||
const treeData = computed(() => {
|
||||
const children = categories.value.map((item) => ({
|
||||
key: String(item.b_id),
|
||||
title: item.b_name || item.b_code || '',
|
||||
b_id: String(item.b_id),
|
||||
isLeaf: true,
|
||||
}))
|
||||
return [
|
||||
{
|
||||
key: '__fms_tree_root__',
|
||||
title: '全部分类',
|
||||
isFmsRoot: true,
|
||||
children,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
/** 按 key 查找分类原始数据 */
|
||||
function findCategory(key) {
|
||||
const id = String(key || '')
|
||||
return categories.value.find((item) => String(item.b_id) === id) || null
|
||||
}
|
||||
|
||||
function selectCategory(key) {
|
||||
selectedKey.value = String(key || '')
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
loading,
|
||||
error,
|
||||
selectedKey,
|
||||
treeData,
|
||||
loadCategories,
|
||||
findCategory,
|
||||
selectCategory,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 文件视觉共享工具:扩展名语义配色、图片判断、大小格式化
|
||||
// 集中此处,避免 FileCard / FileListPanel / FilePreviewPanel 三处重复定义
|
||||
|
||||
// 扩展名 → 语义配色(ERP 附件管理可一眼辨类)
|
||||
const EXT_COLOR = {
|
||||
// 文档
|
||||
pdf: '#f5222d',
|
||||
doc: '#1677ff',
|
||||
docx: '#1677ff',
|
||||
txt: '#8c8c8c',
|
||||
md: '#8c8c8c',
|
||||
// 表格
|
||||
xls: '#52c41a',
|
||||
xlsx: '#52c41a',
|
||||
csv: '#52c41a',
|
||||
// 演示
|
||||
ppt: '#fa541c',
|
||||
pptx: '#fa541c',
|
||||
// 图片
|
||||
jpg: '#722ed1',
|
||||
jpeg: '#722ed1',
|
||||
png: '#722ed1',
|
||||
gif: '#722ed1',
|
||||
bmp: '#722ed1',
|
||||
webp: '#722ed1',
|
||||
svg: '#722ed1',
|
||||
// 压缩
|
||||
zip: '#fa8c16',
|
||||
rar: '#fa8c16',
|
||||
'7z': '#fa8c16',
|
||||
// 音视频
|
||||
mp3: '#13c2c2',
|
||||
wav: '#13c2c2',
|
||||
mp4: '#13c2c2',
|
||||
mov: '#13c2c2',
|
||||
}
|
||||
|
||||
const FALLBACK_COLOR = '#8c8c8c'
|
||||
|
||||
export function extFromName(name) {
|
||||
const idx = (name || '').lastIndexOf('.')
|
||||
return idx >= 0 ? name.substring(idx + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
export function extColor(name) {
|
||||
const ext = extFromName(name)
|
||||
return EXT_COLOR[ext] || FALLBACK_COLOR
|
||||
}
|
||||
|
||||
export function isImageType(name) {
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(
|
||||
extFromName(name),
|
||||
)
|
||||
}
|
||||
|
||||
export function formatSize(bytes) {
|
||||
const n = Number(bytes || 0)
|
||||
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(2) + ' GB'
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
open: false,
|
||||
moduleCode: '',
|
||||
father: '',
|
||||
showTree: true,
|
||||
readonly: false,
|
||||
initialCateId: '',
|
||||
key: 0,
|
||||
// 事件回调
|
||||
onUploaded: null,
|
||||
onDeleted: null,
|
||||
onSelected: null,
|
||||
})
|
||||
|
||||
let keyCounter = 0
|
||||
|
||||
/**
|
||||
* 文件 Modal 命令式调用 composable。
|
||||
*
|
||||
* 用法:
|
||||
* const fileModal = useFileModal();
|
||||
* fileModal.open({ moduleCode: 'b_port', father: '123' });
|
||||
*/
|
||||
export function useFileModal() {
|
||||
function open(opts) {
|
||||
keyCounter++
|
||||
state.key = keyCounter
|
||||
state.moduleCode = opts.moduleCode || ''
|
||||
state.father = String(opts.father ?? '')
|
||||
state.showTree = opts.showTree !== false
|
||||
state.readonly = opts.readonly || false
|
||||
state.initialCateId = opts.initialCateId || ''
|
||||
state.onUploaded = opts.onUploaded || null
|
||||
state.onDeleted = opts.onDeleted || null
|
||||
state.onSelected = opts.onSelected || null
|
||||
state.open = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.open = false
|
||||
}
|
||||
|
||||
return { open, close, state }
|
||||
}
|
||||
@@ -36,11 +36,22 @@ const isVertical = computed(() => props.direction === 'vertical')
|
||||
|
||||
// 收集默认插槽中的 SplitterPanel 节点,过滤掉注释/空节点;
|
||||
// 统一为 { node, props } 结构:Vue 中未传 props 的 VNode 其 props 为 null,
|
||||
// 规范化后后续访问 panel.props.xxx 安全
|
||||
// 规范化后后续访问 panel.props.xxx 安全。
|
||||
// 注意:父模板上写的 kebab 属性(如 default-size / bar-on-hover)在 VNode.props
|
||||
// 中保留为 kebab 键,此处统一转成 camelCase,保证 displaySize()/applyOffset() 等
|
||||
// 读取 panel.props.defaultSize / panel.props.min / panel.props.max 时能命中。
|
||||
function camelize(str) {
|
||||
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase())
|
||||
}
|
||||
const panelItems = computed(() =>
|
||||
(slots.default?.() ?? [])
|
||||
.filter((node) => node.type === SplitterPanel)
|
||||
.map((node) => ({ node, props: node.props ?? {} })),
|
||||
.map((node) => {
|
||||
const raw = node.props ?? {}
|
||||
const props = {}
|
||||
for (const key of Object.keys(raw)) props[camelize(key)] = raw[key]
|
||||
return { node, props }
|
||||
}),
|
||||
)
|
||||
|
||||
/* ---------- 尺寸工具 ---------- */
|
||||
|
||||
Reference in new issue
Block a user