feat(admin+api+player): 站内信手动发送、媒体库选择与发送记录详情

新增管理员手动发送站内信(三语富文本),支持发送记录查看与删除;修复全站「从媒体库选择」因分类过滤导致列表为空的问题;玩家端支持渲染管理员自定义消息;并优化后台列表页双层卡片布局。
This commit is contained in:
2026-06-22 14:01:31 +08:00
parent 648c314e23
commit 1210142a33
21 changed files with 1355 additions and 40 deletions

View File

@@ -3,6 +3,8 @@ import { ref } 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<{
@@ -23,7 +25,7 @@ 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 mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string; category?: string }>>([]);
const mediaLoading = ref(false);
async function uploadImage(file: File) {
@@ -67,12 +69,10 @@ 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 = await fetchMediaLibraryImages({ pageSize: 200 });
} catch (err: unknown) {
mediaFiles.value = [];
ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed'));
} finally {
mediaLoading.value = false;
}
@@ -133,6 +133,7 @@ function pickMediaFile(url: string) {
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>
@@ -148,6 +149,7 @@ function pickMediaFile(url: string) {
<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>
@@ -306,11 +308,18 @@ function pickMediaFile(url: string) {
}
.media-name {
padding: 6px 8px;
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>

View File

@@ -3,6 +3,8 @@ 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<{
@@ -27,7 +29,7 @@ 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 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;
@@ -241,12 +243,10 @@ async function openMediaPicker() {
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 = await fetchMediaLibraryImages({ pageSize: 200 });
} catch (err: unknown) {
mediaFiles.value = [];
ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed'));
} finally {
mediaLoading.value = false;
}
@@ -352,6 +352,7 @@ onBeforeUnmount(() => {
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>
@@ -367,6 +368,7 @@ onBeforeUnmount(() => {
<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>
@@ -521,11 +523,18 @@ onBeforeUnmount(() => {
}
.media-name {
padding: 6px 8px;
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>