feat(theme-3): sync inbox/announcements/presence/deposit/search from main
This commit is contained in:
531
apps/admin/src/components/ContentRichEditor.vue
Normal file
531
apps/admin/src/components/ContentRichEditor.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
uploadCategory?: string;
|
||||
disabled?: boolean;
|
||||
fill?: boolean;
|
||||
}>(),
|
||||
{ uploadCategory: 'contents', fill: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string] }>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
|
||||
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
|
||||
const EDITOR_IMAGE_MAX_HEIGHT = '200px';
|
||||
|
||||
const editorRef = ref<HTMLDivElement | null>(null);
|
||||
const mediaPickerVisible = ref(false);
|
||||
/** blob/object URL -> File,保存时由父组件调用 uploadPendingImages 上传 */
|
||||
const pendingFiles = new Map<string, File>();
|
||||
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
|
||||
const mediaLoading = ref(false);
|
||||
const syncing = ref(false);
|
||||
let savedRange: Range | null = null;
|
||||
|
||||
function isNodeInEditor(node: Node | null): boolean {
|
||||
const el = editorRef.value;
|
||||
if (!el || !node) return false;
|
||||
return el.contains(node.nodeType === Node.TEXT_NODE ? node.parentNode : node);
|
||||
}
|
||||
|
||||
function saveSelection() {
|
||||
const sel = window.getSelection();
|
||||
const el = editorRef.value;
|
||||
if (!sel || sel.rangeCount === 0 || !el) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (isNodeInEditor(range.commonAncestorContainer)) {
|
||||
savedRange = range.cloneRange();
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSelection(): boolean {
|
||||
const el = editorRef.value;
|
||||
if (!savedRange || !el) return false;
|
||||
try {
|
||||
if (!isNodeInEditor(savedRange.commonAncestorContainer)) return false;
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return false;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(savedRange);
|
||||
return true;
|
||||
} catch {
|
||||
savedRange = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentSelectionChange() {
|
||||
if (isNodeInEditor(window.getSelection()?.anchorNode ?? null)) {
|
||||
saveSelection();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtml(html: string) {
|
||||
const trimmed = html.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function syncFromModel() {
|
||||
const el = editorRef.value;
|
||||
if (!el) return;
|
||||
syncing.value = true;
|
||||
el.innerHTML = props.modelValue || '';
|
||||
if (el.innerHTML) normalizeEditorImages(el);
|
||||
syncing.value = false;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
if (syncing.value || !editorRef.value) return;
|
||||
const html = normalizeHtml(editorRef.value.innerHTML);
|
||||
cleanupOrphanedBlobs(html);
|
||||
emit('update:modelValue', html);
|
||||
}
|
||||
|
||||
function cleanupOrphanedBlobs(html: string) {
|
||||
const used = new Set<string>();
|
||||
for (const match of html.matchAll(/blob:[^\s"'<>]+/g)) {
|
||||
used.add(match[0]);
|
||||
}
|
||||
for (const blobUrl of pendingFiles.keys()) {
|
||||
if (!used.has(blobUrl)) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
pendingFiles.delete(blobUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function revokeAllPendingBlobs() {
|
||||
for (const blobUrl of pendingFiles.keys()) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
pendingFiles.clear();
|
||||
}
|
||||
|
||||
function exec(cmd: string, value?: string) {
|
||||
editorRef.value?.focus();
|
||||
document.execCommand(cmd, false, value);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function applyInlineImageStyles(img: HTMLImageElement) {
|
||||
img.removeAttribute('width');
|
||||
img.removeAttribute('height');
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.width = 'auto';
|
||||
img.style.height = 'auto';
|
||||
img.style.maxHeight = EDITOR_IMAGE_MAX_HEIGHT;
|
||||
img.style.objectFit = 'contain';
|
||||
img.style.display = 'block';
|
||||
img.style.margin = '10px 0';
|
||||
img.style.borderRadius = '6px';
|
||||
}
|
||||
|
||||
function createEditorImage(url: string, pending = false) {
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
if (pending || url.startsWith('blob:')) {
|
||||
img.setAttribute('data-local-blob', '1');
|
||||
}
|
||||
applyInlineImageStyles(img);
|
||||
return img;
|
||||
}
|
||||
|
||||
function normalizeEditorImages(root: HTMLElement) {
|
||||
root.querySelectorAll('img').forEach((node) => applyInlineImageStyles(node as HTMLImageElement));
|
||||
}
|
||||
|
||||
function insertImageUrl(url: string) {
|
||||
const el = editorRef.value;
|
||||
if (!el) return;
|
||||
|
||||
el.focus();
|
||||
restoreSelection();
|
||||
|
||||
const img = createEditorImage(url, url.startsWith('blob:'));
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
if (isNodeInEditor(range.commonAncestorContainer)) {
|
||||
range.deleteContents();
|
||||
range.insertNode(img);
|
||||
const after = document.createRange();
|
||||
after.setStartAfter(img);
|
||||
after.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(after);
|
||||
savedRange = after.cloneRange();
|
||||
normalizeEditorImages(el);
|
||||
emitChange();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
el.appendChild(img);
|
||||
normalizeEditorImages(el);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function insertPendingImage(file: File) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
ElMessage.error(t('content.upload.size_error'));
|
||||
return;
|
||||
}
|
||||
saveSelection();
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
pendingFiles.set(blobUrl, file);
|
||||
insertImageUrl(blobUrl);
|
||||
}
|
||||
|
||||
async function uploadSingleFile(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const { data } = await api.post(`/admin/uploads?category=${props.uploadCategory}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const url = data.data?.url as string;
|
||||
if (!url) {
|
||||
throw new Error(t('content.upload.failed'));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** 扫描 HTML 中的 blob: 图片,上传后替换为 /uploads/... URL */
|
||||
async function uploadPendingImages(html: string): Promise<string> {
|
||||
if (!html.includes('blob:')) return html;
|
||||
|
||||
let result = html;
|
||||
const blobUrls = [...new Set([...html.matchAll(/blob:[^\s"'<>]+/g)].map((m) => m[0]))];
|
||||
|
||||
for (const blobUrl of blobUrls) {
|
||||
const file = pendingFiles.get(blobUrl);
|
||||
if (!file) continue;
|
||||
|
||||
const serverUrl = await uploadSingleFile(file);
|
||||
result = result.replaceAll(blobUrl, serverUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
pendingFiles.delete(blobUrl);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getHtml(): string {
|
||||
if (!editorRef.value) return normalizeHtml(props.modelValue);
|
||||
return normalizeHtml(editorRef.value.innerHTML);
|
||||
}
|
||||
|
||||
defineExpose({ uploadPendingImages, getHtml });
|
||||
|
||||
function onImageFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.[0]) {
|
||||
insertPendingImage(input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function openMediaPicker() {
|
||||
saveSelection();
|
||||
mediaPickerVisible.value = true;
|
||||
mediaLoading.value = true;
|
||||
try {
|
||||
const res = await api.get('/admin/files', {
|
||||
params: { category: props.uploadCategory, pageSize: 200 },
|
||||
});
|
||||
mediaFiles.value = res.data.data.items ?? [];
|
||||
} catch {
|
||||
mediaFiles.value = [];
|
||||
} finally {
|
||||
mediaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickMediaFile(url: string) {
|
||||
insertImageUrl(url);
|
||||
mediaPickerVisible.value = false;
|
||||
}
|
||||
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
saveSelection();
|
||||
const file = item.getAsFile();
|
||||
if (file) insertPendingImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
void nextTick(() => {
|
||||
const el = editorRef.value;
|
||||
if (!el) return;
|
||||
normalizeEditorImages(el);
|
||||
emitChange();
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val, prev) => {
|
||||
if (val === prev) return;
|
||||
const el = editorRef.value;
|
||||
if (!el) return;
|
||||
if (normalizeHtml(el.innerHTML) !== normalizeHtml(val)) {
|
||||
syncFromModel();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
syncFromModel();
|
||||
document.addEventListener('selectionchange', onDocumentSelectionChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('selectionchange', onDocumentSelectionChange);
|
||||
revokeAllPendingBlobs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rich-editor" :class="{ 'is-disabled': disabled, 'rich-editor--fill': fill }">
|
||||
<div class="toolbar">
|
||||
<button type="button" title="Bold" :disabled="disabled" @mousedown.prevent @click="exec('bold')">
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button type="button" title="Italic" :disabled="disabled" @mousedown.prevent @click="exec('italic')">
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button type="button" :disabled="disabled" @mousedown.prevent @click="exec('insertUnorderedList')">
|
||||
•
|
||||
</button>
|
||||
<button type="button" :disabled="disabled" @mousedown.prevent @click="exec('insertOrderedList')">
|
||||
1.
|
||||
</button>
|
||||
<label class="toolbar-upload" @mousedown.prevent="saveSelection">
|
||||
<input
|
||||
type="file"
|
||||
:accept="IMAGE_ACCEPT"
|
||||
style="display: none"
|
||||
:disabled="disabled"
|
||||
@change="onImageFileChange"
|
||||
/>
|
||||
{{ t('content.editor.insert_image') }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@mousedown.prevent="saveSelection"
|
||||
@click="openMediaPicker"
|
||||
>
|
||||
{{ t('content.upload.pick_media') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="editor-body"
|
||||
:data-placeholder="placeholder || t('content.editor.placeholder')"
|
||||
:contenteditable="disabled ? 'false' : 'true'"
|
||||
@input="emitChange"
|
||||
@blur="emitChange"
|
||||
@paste="onPaste"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="mediaPickerVisible"
|
||||
:title="t('content.upload.pick_media_title')"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
|
||||
<div v-else class="media-grid">
|
||||
<div
|
||||
v-for="file in mediaFiles"
|
||||
:key="file.id"
|
||||
class="media-card"
|
||||
@click="pickMediaFile(file.url)"
|
||||
>
|
||||
<div class="media-thumb">
|
||||
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
|
||||
<div v-else class="media-svg">SVG</div>
|
||||
</div>
|
||||
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rich-editor {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor--fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rich-editor.is-disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 5px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.toolbar button,
|
||||
.toolbar-upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-upload {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
min-height: 140px;
|
||||
max-height: 280px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rich-editor--fill .editor-body {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.editor-body:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-body :deep(img) {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rich-editor--fill .editor-body :deep(img) {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.editor-body :deep(ul),
|
||||
.editor-body :deep(ol) {
|
||||
margin: 8px 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.media-state {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
border-color: #d5cfc3;
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
height: 80px;
|
||||
background: #f4f0e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-thumb img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.media-svg {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.media-name {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user