从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
541 lines
13 KiB
Vue
541 lines
13 KiB
Vue
<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>
|