719 lines
19 KiB
Vue
719 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||
import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue';
|
||
import { deleteFile, downloadFile, uploadFile } from '@/service/api/file';
|
||
|
||
defineOptions({ name: 'BusinessAttachmentUploader' });
|
||
|
||
interface Props {
|
||
/** 上传目录,传给后端 directory 字段 */
|
||
directory?: string;
|
||
/** 数量上限,默认 20(与后端 AttachmentValidator 一致) */
|
||
max?: number;
|
||
/** 单文件大小上限 MB(前端兜底;最终由 /system/file/upload 拦截) */
|
||
maxFileSizeMB?: number;
|
||
disabled?: boolean;
|
||
/**
|
||
* 平铺模式:所有附件直接逐项渲染,不再做"首项 + 折叠浮层"。
|
||
* 用于本身已经在 popover / 详情卡片里展示,避免嵌套浮层。
|
||
*/
|
||
flat?: boolean;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
directory: undefined,
|
||
max: 20,
|
||
maxFileSizeMB: 50,
|
||
disabled: false,
|
||
flat: false
|
||
});
|
||
|
||
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
|
||
|
||
/** 给用户看的简短分类(hint 行展示) */
|
||
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4';
|
||
|
||
// 与后端 AttachmentValidator 白/黑名单保持一致(5.16)
|
||
const ALLOWED_EXTENSIONS = new Set([
|
||
'pdf',
|
||
'doc',
|
||
'docx',
|
||
'xls',
|
||
'xlsx',
|
||
'ppt',
|
||
'pptx',
|
||
'txt',
|
||
'md',
|
||
'csv',
|
||
'jpg',
|
||
'jpeg',
|
||
'png',
|
||
'gif',
|
||
'webp',
|
||
'bmp',
|
||
'zip',
|
||
'rar',
|
||
'7z',
|
||
'mp4',
|
||
'mp3'
|
||
]);
|
||
|
||
const FORBIDDEN_EXTENSIONS = new Set([
|
||
'exe',
|
||
'bat',
|
||
'cmd',
|
||
'sh',
|
||
'ps1',
|
||
'msi',
|
||
'dll',
|
||
'jar',
|
||
'war',
|
||
'php',
|
||
'jsp',
|
||
'asp',
|
||
'aspx',
|
||
'py',
|
||
'rb',
|
||
'pl',
|
||
'com',
|
||
'scr',
|
||
'vbs',
|
||
'js'
|
||
]);
|
||
|
||
interface PendingItem {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
const pending = ref<PendingItem[]>([]);
|
||
const inputRef = ref<HTMLInputElement>();
|
||
const isUnmounting = ref(false);
|
||
|
||
/**
|
||
* 会话级清理账本:
|
||
* - originalIds: 弹层打开时已存在的 fileId(编辑模式下来自 rowData.attachments)。
|
||
* 当前未在 commit/rollback 中直接读取(清理逻辑靠 addedIds 自己判定);
|
||
* 保留是为了让会话模型完整、便于后续扩展(如"撤销删除""仅删原有附件"等差异行为)。
|
||
* - addedIds: 本次会话内上传成功的 fileId
|
||
* - pendingDeleteIds: 用户在 UI 上点过"删除"的 fileId(含 original 和 added 两类)
|
||
* - committed: commit() 调用后置 true,阻止后续 rollback 误删
|
||
*
|
||
* UI 显示 = model(已减去 pendingDelete 项)
|
||
* 真删时机:commit() 删 pendingDelete;rollback() 删 addedIds(除非 committed)
|
||
*/
|
||
interface UploadSession {
|
||
originalIds: Set<string>;
|
||
addedIds: Set<string>;
|
||
pendingDeleteIds: Set<string>;
|
||
committed: boolean;
|
||
}
|
||
|
||
const session = reactive<UploadSession>({
|
||
originalIds: new Set<string>(),
|
||
addedIds: new Set<string>(),
|
||
pendingDeleteIds: new Set<string>(),
|
||
committed: false
|
||
});
|
||
|
||
const totalCount = computed(() => model.value.length + pending.value.length);
|
||
const isFull = computed(() => totalCount.value >= props.max);
|
||
const hasUploading = computed(() => pending.value.length > 0);
|
||
|
||
const acceptExtensionsList = computed(() => Array.from(ALLOWED_EXTENSIONS).join(', '));
|
||
|
||
/**
|
||
* 列表区拆成"直接展示"和"折叠浮层"两组:
|
||
* - flat:全部直接展示(适合本身已在 popover 里)
|
||
* - 默认:首项直接展示,>1 时其余进入悬浮浮层
|
||
*/
|
||
const displayedAttachments = computed(() => (props.flat ? model.value : model.value.slice(0, 1)));
|
||
const popoverAttachments = computed(() => (props.flat || model.value.length <= 1 ? [] : model.value.slice(1)));
|
||
|
||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']);
|
||
|
||
function isImage(item: Api.Project.AttachmentItem) {
|
||
if (item.contentType?.startsWith('image/')) {
|
||
return true;
|
||
}
|
||
return IMAGE_EXTENSIONS.has(getExtension(item.name));
|
||
}
|
||
|
||
interface ImagePreviewState {
|
||
visible: boolean;
|
||
urls: string[];
|
||
}
|
||
|
||
const imagePreview = reactive<ImagePreviewState>({
|
||
visible: false,
|
||
urls: []
|
||
});
|
||
|
||
function getExtension(name: string) {
|
||
const idx = name.lastIndexOf('.');
|
||
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
|
||
}
|
||
|
||
function validateFile(file: File): string | null {
|
||
if (!file.name) {
|
||
return '文件名为空';
|
||
}
|
||
if (file.name.length > 255) {
|
||
return '文件名超过 255 字符';
|
||
}
|
||
|
||
const ext = getExtension(file.name);
|
||
if (!ext) {
|
||
return '文件缺少扩展名';
|
||
}
|
||
if (FORBIDDEN_EXTENSIONS.has(ext)) {
|
||
return `不允许上传 .${ext} 文件`;
|
||
}
|
||
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||
return `暂不支持 .${ext} 文件`;
|
||
}
|
||
|
||
if (file.size > props.maxFileSizeMB * 1024 * 1024) {
|
||
return `单文件不能超过 ${props.maxFileSizeMB}MB`;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function triggerSelect() {
|
||
if (props.disabled || isFull.value) {
|
||
return;
|
||
}
|
||
inputRef.value?.click();
|
||
}
|
||
|
||
async function handleFileChange(event: Event) {
|
||
const input = event.target as HTMLInputElement;
|
||
const files = Array.from(input.files || []);
|
||
input.value = '';
|
||
|
||
if (files.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const remaining = props.max - totalCount.value;
|
||
if (files.length > remaining) {
|
||
window.$message?.warning(`最多还能上传 ${remaining} 个附件`);
|
||
return;
|
||
}
|
||
|
||
const validFiles: File[] = [];
|
||
files.forEach(file => {
|
||
const err = validateFile(file);
|
||
if (err) {
|
||
window.$message?.error(`${file.name}:${err}`);
|
||
return;
|
||
}
|
||
validFiles.push(file);
|
||
});
|
||
|
||
if (validFiles.length === 0) {
|
||
return;
|
||
}
|
||
|
||
await Promise.all(validFiles.map(uploadOne));
|
||
}
|
||
|
||
async function uploadOne(file: File) {
|
||
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
pending.value = [...pending.value, { id: tempId, name: file.name }];
|
||
|
||
try {
|
||
const result = await uploadFile(file, props.directory);
|
||
if (result.error || !result.data) {
|
||
window.$message?.error(`${file.name}:上传失败`);
|
||
return;
|
||
}
|
||
|
||
const { id, url } = result.data;
|
||
|
||
// 组件已卸载(用户上传过程中关弹层):onBeforeUnmount 已跑过且看不到这个 id,
|
||
// 这里立刻调删除,避免孤儿文件
|
||
if (isUnmounting.value) {
|
||
deleteFile(id).catch(() => {
|
||
// 已卸载场景下 console.warn 也访问不到 component scope,这里静默吞掉
|
||
});
|
||
return;
|
||
}
|
||
|
||
model.value = [
|
||
...model.value,
|
||
{
|
||
fileId: id,
|
||
url,
|
||
name: file.name,
|
||
size: file.size,
|
||
contentType: file.type || undefined
|
||
}
|
||
];
|
||
session.addedIds.add(id);
|
||
} finally {
|
||
pending.value = pending.value.filter(item => item.id !== tempId);
|
||
}
|
||
}
|
||
|
||
function handleRemove(item: Api.Project.AttachmentItem) {
|
||
removeAttachmentByFileId(item.fileId);
|
||
}
|
||
|
||
async function fetchAsBlobUrl(item: Api.Project.AttachmentItem) {
|
||
const { data, error } = await downloadFile(item.fileId);
|
||
if (error || !data) {
|
||
window.$message?.error(`${item.name}:加载失败`);
|
||
return null;
|
||
}
|
||
return URL.createObjectURL(data);
|
||
}
|
||
|
||
async function handleDownload(item: Api.Project.AttachmentItem) {
|
||
const blobUrl = await fetchAsBlobUrl(item);
|
||
if (!blobUrl) {
|
||
return;
|
||
}
|
||
|
||
const link = document.createElement('a');
|
||
link.href = blobUrl;
|
||
link.download = item.name;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(blobUrl);
|
||
}
|
||
|
||
async function handlePreviewImage(item: Api.Project.AttachmentItem) {
|
||
const blobUrl = await fetchAsBlobUrl(item);
|
||
if (!blobUrl) {
|
||
return;
|
||
}
|
||
imagePreview.urls = [blobUrl];
|
||
imagePreview.visible = true;
|
||
}
|
||
|
||
function handleClosePreview() {
|
||
imagePreview.urls.forEach(url => URL.revokeObjectURL(url));
|
||
imagePreview.urls = [];
|
||
imagePreview.visible = false;
|
||
}
|
||
|
||
/** 文件名点击的统一入口:图片走预览,其余走下载 */
|
||
function handleOpen(item: Api.Project.AttachmentItem) {
|
||
if (isImage(item)) {
|
||
handlePreviewImage(item);
|
||
} else {
|
||
handleDownload(item);
|
||
}
|
||
}
|
||
|
||
/** 把 model 里的某项移除(折叠浮层里也用,不依赖索引) */
|
||
function removeAttachmentByFileId(fileId: string) {
|
||
if (props.disabled) {
|
||
return;
|
||
}
|
||
const idx = model.value.findIndex(item => item.fileId === fileId);
|
||
if (idx === -1) {
|
||
return;
|
||
}
|
||
session.pendingDeleteIds.add(fileId);
|
||
model.value = model.value.filter((_, i) => i !== idx);
|
||
}
|
||
|
||
function formatSize(size?: number) {
|
||
if (!size && size !== 0) {
|
||
return '';
|
||
}
|
||
if (size < 1024) {
|
||
return `${size}B`;
|
||
}
|
||
if (size < 1024 * 1024) {
|
||
return `${(size / 1024).toFixed(1)}KB`;
|
||
}
|
||
if (size < 1024 * 1024 * 1024) {
|
||
return `${(size / 1024 / 1024).toFixed(1)}MB`;
|
||
}
|
||
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
|
||
}
|
||
|
||
/**
|
||
* 删除一批 fileId。fire-and-forget:
|
||
* - 不阻塞 UI;任何失败仅 console.warn
|
||
* - 后端返回 1001003001(文件不存在)视为成功
|
||
*/
|
||
async function deleteMany(ids: string[]) {
|
||
if (ids.length === 0) {
|
||
return;
|
||
}
|
||
await Promise.allSettled(
|
||
ids.map(async id => {
|
||
const { error } = await deleteFile(id);
|
||
if (error) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[BusinessAttachmentUploader] 删除失败(已忽略)', id, error);
|
||
}
|
||
})
|
||
);
|
||
}
|
||
|
||
/** 等关闭弹层时先等再清理。设上限 5s,避免极端网络下 commit/rollback 永久挂起。 */
|
||
async function waitForPending(maxWaitMs = 5000) {
|
||
const start = Date.now();
|
||
while (pending.value.length > 0) {
|
||
if (Date.now() - start >= maxWaitMs) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[BusinessAttachmentUploader] 等待 pending 上传超时,继续后续清理');
|
||
return;
|
||
}
|
||
// polling: 需要在循环里 await,suppress 即可
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await new Promise<void>(resolve => {
|
||
setTimeout(resolve, 50);
|
||
});
|
||
}
|
||
}
|
||
|
||
defineExpose({
|
||
/**
|
||
* 父组件在【打开弹层并填充 model 之后】调用。
|
||
* 把当前 model 视为 original,清空 added / pendingDelete,重置 committed。
|
||
*/
|
||
initSession() {
|
||
session.originalIds = new Set(model.value.map(item => item.fileId));
|
||
session.addedIds.clear();
|
||
session.pendingDeleteIds.clear();
|
||
session.committed = false;
|
||
},
|
||
|
||
/**
|
||
* 父组件在【业务保存成功后】调用。
|
||
* 真删 pendingDelete(含 original 和 added 两类);置 committed 阻止后续 rollback。
|
||
*/
|
||
async commit() {
|
||
await waitForPending();
|
||
const ids = Array.from(session.pendingDeleteIds);
|
||
session.pendingDeleteIds.clear();
|
||
session.addedIds.clear();
|
||
session.committed = true;
|
||
await deleteMany(ids);
|
||
},
|
||
|
||
/**
|
||
* 父组件取消/关闭时调用;onBeforeUnmount 也会兜底调一次。
|
||
* 真删 addedIds(保留 original);committed=true 时跳过。
|
||
*/
|
||
async rollback() {
|
||
if (session.committed) {
|
||
return;
|
||
}
|
||
await waitForPending();
|
||
const ids = Array.from(session.addedIds);
|
||
session.addedIds.clear();
|
||
session.pendingDeleteIds.clear();
|
||
session.committed = true;
|
||
await deleteMany(ids);
|
||
},
|
||
|
||
/** 父组件在提交前可读此值判断是否还有 pending 上传 */
|
||
get hasUploading() {
|
||
return hasUploading.value;
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
// 标记卸载中:让正在 flight 的 uploadOne 完成时知道要立刻删除自己
|
||
isUnmounting.value = true;
|
||
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
|
||
// deleteMany 内部已 swallow 单项失败,这里不再 await,fire-and-forget
|
||
if (!session.committed) {
|
||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||
deleteMany(Array.from(session.addedIds));
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="business-attachment-uploader">
|
||
<div v-if="!disabled" class="business-attachment-uploader__trigger">
|
||
<ElButton :icon="Upload" :disabled="isFull" :loading="hasUploading" @click="triggerSelect">点击上传</ElButton>
|
||
<span class="business-attachment-uploader__hint">
|
||
最多 {{ max }} 个,已选 {{ totalCount }} 个;单文件 ≤ {{ maxFileSizeMB }}MB
|
||
<ElTooltip placement="top">
|
||
<template #content>
|
||
<div class="business-attachment-uploader__hint-tooltip">
|
||
<div>{{ ALLOWED_EXTENSIONS_HINT }}</div>
|
||
<div class="business-attachment-uploader__hint-tooltip-ext">允许扩展名:{{ acceptExtensionsList }}</div>
|
||
</div>
|
||
</template>
|
||
<ElIcon class="business-attachment-uploader__hint-icon"><QuestionFilled /></ElIcon>
|
||
</ElTooltip>
|
||
</span>
|
||
<input
|
||
ref="inputRef"
|
||
type="file"
|
||
multiple
|
||
class="business-attachment-uploader__input"
|
||
@change="handleFileChange"
|
||
/>
|
||
</div>
|
||
|
||
<div v-else-if="totalCount === 0" class="business-attachment-uploader__empty">暂无附件</div>
|
||
|
||
<ul v-if="totalCount > 0" class="business-attachment-uploader__list">
|
||
<!-- 直接展示:默认仅首项;flat 模式全部 -->
|
||
<li v-for="item in displayedAttachments" :key="`done-${item.fileId}`" class="business-attachment-uploader__item">
|
||
<ElIcon class="business-attachment-uploader__icon">
|
||
<Picture v-if="isImage(item)" />
|
||
<Document v-else />
|
||
</ElIcon>
|
||
<ElLink
|
||
type="primary"
|
||
:underline="false"
|
||
class="business-attachment-uploader__name"
|
||
:title="item.name"
|
||
@click="handleOpen(item)"
|
||
>
|
||
{{ item.name }}
|
||
</ElLink>
|
||
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
|
||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||
</li>
|
||
|
||
<!-- 折叠提示:>1 个时显示,hover 弹完整列表(flat 模式下永不出现) -->
|
||
<li v-if="popoverAttachments.length > 0" class="business-attachment-uploader__more-row">
|
||
<ElPopover
|
||
trigger="hover"
|
||
placement="bottom-start"
|
||
:width="380"
|
||
:show-after="200"
|
||
popper-class="business-attachment-uploader__popover"
|
||
>
|
||
<template #reference>
|
||
<span class="business-attachment-uploader__more">
|
||
还有 {{ popoverAttachments.length }} 个附件
|
||
<ElIcon><ArrowDown /></ElIcon>
|
||
</span>
|
||
</template>
|
||
<ul class="business-attachment-uploader__popover-list">
|
||
<li
|
||
v-for="item in popoverAttachments"
|
||
:key="`popover-${item.fileId}`"
|
||
class="business-attachment-uploader__item"
|
||
>
|
||
<ElIcon class="business-attachment-uploader__icon">
|
||
<Picture v-if="isImage(item)" />
|
||
<Document v-else />
|
||
</ElIcon>
|
||
<ElLink
|
||
type="primary"
|
||
:underline="false"
|
||
class="business-attachment-uploader__name"
|
||
:title="item.name"
|
||
@click="handleOpen(item)"
|
||
>
|
||
{{ item.name }}
|
||
</ElLink>
|
||
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
|
||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||
</li>
|
||
</ul>
|
||
</ElPopover>
|
||
</li>
|
||
|
||
<!-- pending 项不折叠:让用户能持续看到上传进度 -->
|
||
<li
|
||
v-for="item in pending"
|
||
:key="`pending-${item.id}`"
|
||
class="business-attachment-uploader__item business-attachment-uploader__item--pending"
|
||
>
|
||
<ElIcon class="business-attachment-uploader__icon is-loading"><Loading /></ElIcon>
|
||
<span class="business-attachment-uploader__name" :title="item.name">{{ item.name }}</span>
|
||
<span class="business-attachment-uploader__status">上传中…</span>
|
||
</li>
|
||
</ul>
|
||
|
||
<ElImageViewer
|
||
v-if="imagePreview.visible"
|
||
:url-list="imagePreview.urls"
|
||
hide-on-click-modal
|
||
@close="handleClosePreview"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.business-attachment-uploader {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.business-attachment-uploader__trigger {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.business-attachment-uploader__hint {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: rgb(100 116 139 / 88%);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.business-attachment-uploader__hint-icon {
|
||
color: rgb(100 116 139 / 88%);
|
||
cursor: help;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.business-attachment-uploader__hint-tooltip {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-width: 320px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.business-attachment-uploader__hint-tooltip-ext {
|
||
word-break: break-all;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.business-attachment-uploader__empty {
|
||
color: rgb(100 116 139 / 88%);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.business-attachment-uploader__input {
|
||
display: none;
|
||
}
|
||
|
||
.business-attachment-uploader__list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
}
|
||
|
||
.business-attachment-uploader__item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 4px;
|
||
background: var(--el-fill-color-blank);
|
||
font-size: 13px;
|
||
|
||
&--pending {
|
||
background: var(--el-fill-color-light);
|
||
color: rgb(100 116 139 / 88%);
|
||
}
|
||
}
|
||
|
||
.business-attachment-uploader__icon {
|
||
flex: 0 0 auto;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.business-attachment-uploader__name {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
justify-content: flex-start;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.business-attachment-uploader__size {
|
||
flex: 0 0 auto;
|
||
color: rgb(100 116 139 / 88%);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.business-attachment-uploader__status {
|
||
flex: 0 0 auto;
|
||
color: rgb(100 116 139 / 88%);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.business-attachment-uploader__more-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 2px 0;
|
||
}
|
||
|
||
.business-attachment-uploader__more {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
color: var(--el-color-primary);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style lang="scss">
|
||
// 浮层非 scoped:popper 渲染到 body
|
||
.business-attachment-uploader__popover {
|
||
padding: 8px 4px !important;
|
||
|
||
.business-attachment-uploader__popover-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-height: 280px;
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.business-attachment-uploader__item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 8px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
|
||
&:hover {
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
}
|
||
|
||
.business-attachment-uploader__icon {
|
||
flex: 0 0 auto;
|
||
color: var(--el-color-primary);
|
||
}
|
||
|
||
.business-attachment-uploader__name {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.business-attachment-uploader__size {
|
||
flex: 0 0 auto;
|
||
color: rgb(100 116 139 / 88%);
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
</style>
|