feat(projects): 1、执行、任务、工作日志开发调试;2、增加富文本、附件等支撑
This commit is contained in:
718
src/components/custom/business-attachment-uploader.vue
Normal file
718
src/components/custom/business-attachment-uploader.vue
Normal file
@@ -0,0 +1,718 @@
|
||||
<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>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
import { ElImageViewer } from 'element-plus';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import { uploadFile } from '@/service/api/file';
|
||||
import { deleteFile, uploadFile } from '@/service/api/file';
|
||||
|
||||
defineOptions({ name: 'BusinessRichTextEditor' });
|
||||
|
||||
@@ -28,6 +29,140 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const model = defineModel<string | null | undefined>({ default: '' });
|
||||
|
||||
const editorRef = shallowRef<IDomEditor>();
|
||||
const containerRef = ref<HTMLElement>();
|
||||
|
||||
/**
|
||||
* 图片预览:
|
||||
* - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
|
||||
* - 点按钮 → ElImageViewer 多图模式,url-list = 当前 HTML 里所有 img src(按出现顺序去重)
|
||||
* - 编辑态与 disabled 只读态共用
|
||||
*/
|
||||
const zoomBtnVisible = ref(false);
|
||||
const zoomBtnStyle = ref<Record<string, string>>({});
|
||||
const hoveredImageSrc = ref('');
|
||||
|
||||
const viewerVisible = ref(false);
|
||||
const viewerUrlList = ref<string[]>([]);
|
||||
const viewerIndex = ref(0);
|
||||
|
||||
let hideZoomBtnTimer: number | undefined;
|
||||
|
||||
function cancelHideZoomBtn() {
|
||||
if (hideZoomBtnTimer !== undefined) {
|
||||
window.clearTimeout(hideZoomBtnTimer);
|
||||
hideZoomBtnTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHideZoomBtn() {
|
||||
cancelHideZoomBtn();
|
||||
hideZoomBtnTimer = window.setTimeout(() => {
|
||||
zoomBtnVisible.value = false;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function positionZoomBtn(img: HTMLImageElement) {
|
||||
const container = containerRef.value;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
const btnSize = 28;
|
||||
const gap = 8;
|
||||
zoomBtnStyle.value = {
|
||||
top: `${imgRect.top - containerRect.top + gap}px`,
|
||||
left: `${imgRect.right - containerRect.left - btnSize - gap}px`
|
||||
};
|
||||
hoveredImageSrc.value = img.getAttribute('src') ?? '';
|
||||
zoomBtnVisible.value = true;
|
||||
}
|
||||
|
||||
function isZoomBtn(el: EventTarget | null): boolean {
|
||||
return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
|
||||
}
|
||||
|
||||
function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
|
||||
const container = containerRef.value;
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
// 1) target 本身或祖先链上是 img
|
||||
const direct =
|
||||
target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
|
||||
if (direct && container.contains(direct)) {
|
||||
return direct;
|
||||
}
|
||||
// 2) 兜底:wangeditor 可能在图片上层叠了 resize/selection 遮罩,target 不是 img;用坐标穿透找
|
||||
if (typeof document.elementsFromPoint === 'function') {
|
||||
const stack = document.elementsFromPoint(e.clientX, e.clientY);
|
||||
for (const el of stack) {
|
||||
if (el.tagName === 'IMG' && container.contains(el)) {
|
||||
return el as HTMLImageElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onContainerMouseOver(e: MouseEvent) {
|
||||
if (isZoomBtn(e.target)) {
|
||||
cancelHideZoomBtn();
|
||||
return;
|
||||
}
|
||||
const img = findImageAtPoint(e);
|
||||
if (img) {
|
||||
cancelHideZoomBtn();
|
||||
positionZoomBtn(img);
|
||||
} else {
|
||||
scheduleHideZoomBtn();
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerMouseLeave() {
|
||||
scheduleHideZoomBtn();
|
||||
}
|
||||
|
||||
function onTextScroll() {
|
||||
// wangeditor 内部滚动后按钮坐标会和图片错位,直接隐藏由下次 hover 重算
|
||||
zoomBtnVisible.value = false;
|
||||
}
|
||||
|
||||
function openImageViewer() {
|
||||
if (!hoveredImageSrc.value) {
|
||||
return;
|
||||
}
|
||||
const urls = listImageSrcs(model.value);
|
||||
const idx = urls.indexOf(hoveredImageSrc.value);
|
||||
viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
|
||||
viewerIndex.value = idx >= 0 ? idx : 0;
|
||||
viewerVisible.value = true;
|
||||
}
|
||||
|
||||
function closeImageViewer() {
|
||||
viewerVisible.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话级清理账本(富文本图片治标):
|
||||
* - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
|
||||
* - committed: commit() 调用后置 true,阻止后续 rollback / 卸载兜底重复删
|
||||
*
|
||||
* 真删时机:
|
||||
* - commit(): 扫当前 model HTML,删 uploadedMap 里"url 已不在 HTML"的项(被用户删掉的图)
|
||||
* - rollback(): 删 uploadedMap 里所有项(整个会话不要了)
|
||||
* - onBeforeUnmount: 兜底走 rollback 等价逻辑
|
||||
*/
|
||||
interface RichTextSession {
|
||||
uploadedMap: Map<string, string>;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
const session = reactive<RichTextSession>({
|
||||
uploadedMap: new Map(),
|
||||
committed: false
|
||||
});
|
||||
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
excludeKeys: [
|
||||
@@ -63,7 +198,9 @@ const editorConfig: Partial<IEditorConfig> = {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = result.data;
|
||||
const { id, url } = result.data;
|
||||
// 记录 url -> fileId,后续 commit/rollback 才知道删哪个
|
||||
session.uploadedMap.set(url, id);
|
||||
insertFn(url, file.name, url);
|
||||
}
|
||||
}
|
||||
@@ -88,9 +225,116 @@ watch(
|
||||
|
||||
function handleCreated(editor: IDomEditor) {
|
||||
editorRef.value = editor;
|
||||
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
||||
textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 字符串里抓所有 <img src="...">,返回 url 集合。
|
||||
* 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
|
||||
*/
|
||||
function extractImageUrls(html: string | null | undefined): Set<string> {
|
||||
const urls = new Set<string>();
|
||||
if (!html) {
|
||||
return urls;
|
||||
}
|
||||
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null = re.exec(html);
|
||||
while (match !== null) {
|
||||
urls.add(match[1]);
|
||||
match = re.exec(html);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** 按出现顺序去重列出当前 HTML 内所有 img src,给 ElImageViewer 用。 */
|
||||
function listImageSrcs(html: string | null | undefined): string[] {
|
||||
const list: string[] = [];
|
||||
if (!html) {
|
||||
return list;
|
||||
}
|
||||
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null = re.exec(html);
|
||||
while (match !== null) {
|
||||
if (!list.includes(match[1])) {
|
||||
list.push(match[1]);
|
||||
}
|
||||
match = re.exec(html);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 删除一批 fileId。fire-and-forget;单项失败仅 console.warn。 */
|
||||
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('[BusinessRichTextEditor] 删除失败(已忽略)', id, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* 父组件在【打开弹层并填充 model 之后】调用。
|
||||
* 清空 uploadedMap 并重置 committed;HTML 里已有的图(编辑模式回显的)不进 uploadedMap,
|
||||
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
|
||||
*/
|
||||
initSession() {
|
||||
session.uploadedMap.clear();
|
||||
session.committed = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 父组件在【业务保存成功后】调用。
|
||||
* 扫当前 model HTML:uploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
|
||||
*/
|
||||
async commit() {
|
||||
const currentUrls = extractImageUrls(model.value);
|
||||
const toDelete: string[] = [];
|
||||
session.uploadedMap.forEach((fileId, url) => {
|
||||
if (!currentUrls.has(url)) {
|
||||
toDelete.push(fileId);
|
||||
}
|
||||
});
|
||||
session.uploadedMap.clear();
|
||||
session.committed = true;
|
||||
await deleteMany(toDelete);
|
||||
},
|
||||
|
||||
/**
|
||||
* 父组件取消/关闭时调用;onBeforeUnmount 也会兜底调一次。
|
||||
* 删 uploadedMap 里所有项(整个会话回滚)。
|
||||
*/
|
||||
async rollback() {
|
||||
if (session.committed) {
|
||||
return;
|
||||
}
|
||||
const toDelete = Array.from(session.uploadedMap.values());
|
||||
session.uploadedMap.clear();
|
||||
session.committed = true;
|
||||
await deleteMany(toDelete);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelHideZoomBtn();
|
||||
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
||||
textContainer?.removeEventListener('scroll', onTextScroll);
|
||||
|
||||
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
|
||||
if (!session.committed) {
|
||||
const toDelete = Array.from(session.uploadedMap.values());
|
||||
session.uploadedMap.clear();
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
deleteMany(toDelete);
|
||||
}
|
||||
editorRef.value?.destroy();
|
||||
editorRef.value = undefined;
|
||||
});
|
||||
@@ -116,7 +360,7 @@ const editorStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="containerClass">
|
||||
<div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
|
||||
<Toolbar
|
||||
class="business-rich-text-editor__toolbar"
|
||||
:editor="editorRef"
|
||||
@@ -131,11 +375,36 @@ const editorStyle = computed(() => {
|
||||
mode="default"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
<button
|
||||
v-show="zoomBtnVisible"
|
||||
type="button"
|
||||
class="business-rich-text-editor__zoom-btn"
|
||||
:style="zoomBtnStyle"
|
||||
title="预览图片"
|
||||
aria-label="预览图片"
|
||||
@click.stop="openImageViewer"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ElImageViewer
|
||||
v-if="viewerVisible"
|
||||
:url-list="viewerUrlList"
|
||||
:initial-index="viewerIndex"
|
||||
:z-index="3100"
|
||||
teleported
|
||||
hide-on-click-modal
|
||||
@close="closeImageViewer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.business-rich-text-editor {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
@@ -157,6 +426,27 @@ const editorStyle = computed(() => {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__zoom-btn {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */
|
||||
|
||||
Reference in New Issue
Block a user