Files
thebet365/apps/admin/src/components/ContentImageField.vue
Mars f9343b00af 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>
2026-06-17 17:51:10 +08:00

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"
>
&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>