Files
thebet365/apps/admin/src/components/ContentRichEditor.vue
Mars d8e08aaed0 sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
2026-06-22 14:11:47 +08:00

541 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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';
import { fetchMediaLibraryImages } from '../utils/media-library';
import { resolveApiError } from '../i18n/form-validation';
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; category?: 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 {
mediaFiles.value = await fetchMediaLibraryImages({ pageSize: 200 });
} catch (err: unknown) {
mediaFiles.value = [];
ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed'));
} 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
:z-index="4000"
>
<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 v-if="file.category" class="media-category">{{ file.category }}</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 2px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-category {
padding: 0 8px 6px;
font-size: 10px;
color: var(--text-muted);
opacity: 0.85;
}
</style>