feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强
API: - 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板 - 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数 - User 表增加 visible_menus 字段;新增 player_messages 表及迁移 - 充值审核通过/拒绝时按系统配置自动写入玩家站内消息 - 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count - 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置 - 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管) - SystemConfig 增加 inbox 功能开关及各类通知开关 Admin: - 员工管理:按角色默认菜单 + 可勾选可见菜单项 - ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询 - Contents:富文本编辑器、图片字段组件重构 - DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列 - 多页面 i18n 与权限细节调整 Player: - 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除 - 公告列表与详情页;走马灯可跳转详情 - 客服 Modal 改为 Panel,与邮箱 Hub 整合 - 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认 - HomeView 今日赛事板块;FootballView 等体验优化 Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码 Docs: 玩家端缺失功能分析文档 Chore: 移除 .agents/skills 设计类 skill 文件 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
52
apps/admin/src/components/AdminPlayerStatusCell.vue
Normal file
52
apps/admin/src/components/AdminPlayerStatusCell.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
status: string;
|
||||
isOnline?: boolean;
|
||||
}>(),
|
||||
{ isOnline: false },
|
||||
);
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
function statusTagType(s: string) {
|
||||
return s === 'ACTIVE' ? 'success' : s === 'SUSPENDED' ? 'warning' : 'info';
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
const key = `user.status.${s}`;
|
||||
const label = t(key);
|
||||
return label !== key ? label : s;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="player-status-with-presence">
|
||||
<el-tag :type="statusTagType(status)" size="small">{{ statusLabel(status) }}</el-tag>
|
||||
<span :class="isOnline ? 'presence-online' : 'presence-offline'">
|
||||
({{ t(isOnline ? 'user.presence_online' : 'user.presence_offline') }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.player-status-with-presence {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.presence-online {
|
||||
font-size: 12px;
|
||||
color: #2d8a4e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.presence-offline {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -37,7 +37,7 @@ interface AuditRow {
|
||||
const logs = ref<AuditRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const pageSize = ref(10);
|
||||
const filterModule = ref('');
|
||||
|
||||
onMounted(load);
|
||||
@@ -129,6 +129,7 @@ function operatorDisplay(row: AuditRow): string {
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" fixed="left" />
|
||||
<el-table-column :label="t('audit.col.time')" min-width="168" fixed="left">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
316
apps/admin/src/components/ContentImageField.vue
Normal file
316
apps/admin/src/components/ContentImageField.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
category?: string;
|
||||
sizeHintKey?: string;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{ category: 'contents', sizeHintKey: 'content.upload.cover_size_hint' },
|
||||
);
|
||||
|
||||
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 uploading = ref(false);
|
||||
const mediaPickerVisible = ref(false);
|
||||
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
|
||||
const mediaLoading = ref(false);
|
||||
|
||||
async function uploadImage(file: File) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
ElMessage.error(t('content.upload.size_error'));
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const { data } = await api.post(`/admin/uploads?category=${props.category}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const url = data.data?.url as string;
|
||||
if (url) {
|
||||
emit('update:modelValue', url);
|
||||
ElMessage.success(t('content.upload.success'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(String(e.response?.data?.message || t('content.upload.failed')));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.[0]) {
|
||||
void uploadImage(input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
emit('update:modelValue', '');
|
||||
}
|
||||
|
||||
async function openMediaPicker() {
|
||||
mediaPickerVisible.value = true;
|
||||
mediaLoading.value = true;
|
||||
try {
|
||||
const res = await api.get('/admin/files', {
|
||||
params: { category: props.category, pageSize: 200 },
|
||||
});
|
||||
mediaFiles.value = res.data.data.items ?? [];
|
||||
} catch {
|
||||
mediaFiles.value = [];
|
||||
} finally {
|
||||
mediaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickMediaFile(url: string) {
|
||||
emit('update:modelValue', url);
|
||||
mediaPickerVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-image-field">
|
||||
<div class="field-main">
|
||||
<div v-if="modelValue" class="preview">
|
||||
<img :src="modelValue" alt="" class="preview-img" />
|
||||
<button
|
||||
type="button"
|
||||
class="preview-remove"
|
||||
:title="t('content.upload.remove')"
|
||||
:disabled="disabled || uploading"
|
||||
@click="removeImage"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-controls">
|
||||
<p v-if="sizeHintKey" class="size-hint">{{ t(sizeHintKey) }}</p>
|
||||
<div class="actions">
|
||||
<label class="upload-btn" :class="{ 'is-uploading': uploading, 'is-disabled': disabled }">
|
||||
<input
|
||||
type="file"
|
||||
:accept="IMAGE_ACCEPT"
|
||||
style="display: none"
|
||||
:disabled="disabled || uploading"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
{{ uploading ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
|
||||
</label>
|
||||
<button type="button" class="pick-btn" :disabled="disabled || uploading" @click="openMediaPicker">
|
||||
{{ t('content.upload.pick_media') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
:model-value="modelValue"
|
||||
:placeholder="t('content.upload.url_placeholder')"
|
||||
size="small"
|
||||
:disabled="disabled"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</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>
|
||||
.content-image-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-controls {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.size-hint {
|
||||
margin: 0 0 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: #f4f0e8;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
max-height: 68px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(31, 35, 32, 0.78);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.36);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-remove:hover {
|
||||
background: rgba(159, 47, 45, 0.92);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-btn,
|
||||
.pick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
border: 1px solid var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.upload-btn.is-uploading,
|
||||
.upload-btn.is-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pick-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pick-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: 0 8px 22px rgba(56, 49, 37, 0.08);
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
height: 80px;
|
||||
background: #f4f0e8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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>
|
||||
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