20260820223837

This commit is contained in:
oneao committed 2026-08-20 22:38:37 +08:00
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 }
}),
)
/* ---------- 尺寸工具 ---------- */