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>
317 lines
7.0 KiB
Vue
317 lines
7.0 KiB
Vue
<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>
|