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:
2026-06-17 17:51:10 +08:00
parent e9a23de935
commit f9343b00af
105 changed files with 6960 additions and 7523 deletions

View 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>

View File

@@ -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>

View 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"
>
&times;
</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>

View 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>