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:
@@ -1153,6 +1153,12 @@ a {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-date-editor {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover,
|
||||
.el-select__wrapper:hover,
|
||||
.el-textarea__inner:hover {
|
||||
@@ -1426,6 +1432,40 @@ input:-webkit-autofill:focus {
|
||||
color: var(--warning-text) !important;
|
||||
}
|
||||
|
||||
/* Link buttons hover in light theme - override dark legacy */
|
||||
.el-button.is-link:hover,
|
||||
.el-button.is-link:focus,
|
||||
.el-button.is-link.el-button--default:hover,
|
||||
.el-button.is-link.el-button--default:focus,
|
||||
.el-button.is-link.el-button--primary:hover,
|
||||
.el-button.is-link.el-button--primary:focus {
|
||||
color: #155b86 !important; /* Darker blue */
|
||||
background: transparent !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.el-button.is-link.el-button--success:hover,
|
||||
.el-button.is-link.el-button--success:focus {
|
||||
color: #224225 !important; /* Darker green */
|
||||
background: transparent !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.el-button.is-link.el-button--warning:hover,
|
||||
.el-button.is-link.el-button--warning:focus {
|
||||
color: #704b00 !important; /* Darker warning/gold */
|
||||
background: transparent !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.el-button.is-link.el-button--danger:hover,
|
||||
.el-button.is-link.el-button--danger:focus {
|
||||
color: #7d2321 !important; /* Darker danger/red */
|
||||
background: transparent !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
|
||||
.el-button.is-disabled,
|
||||
.el-button.is-disabled:hover {
|
||||
background: #f4f0e8 !important;
|
||||
|
||||
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>
|
||||
40
apps/admin/src/composables/useDepositPendingCount.ts
Normal file
40
apps/admin/src/composables/useDepositPendingCount.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
|
||||
const pendingCount = ref(0);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pollConsumers = 0;
|
||||
|
||||
export async function refreshDepositPendingCount() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/deposit-orders/pending-count');
|
||||
pendingCount.value = Number(data.data?.count ?? 0);
|
||||
} catch {
|
||||
pendingCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function useDepositPendingCount() {
|
||||
function startDepositPendingPolling() {
|
||||
pollConsumers += 1;
|
||||
if (pollConsumers > 1) return;
|
||||
void refreshDepositPendingCount();
|
||||
pollTimer = setInterval(() => void refreshDepositPendingCount(), 30_000);
|
||||
}
|
||||
|
||||
function stopDepositPendingPolling() {
|
||||
pollConsumers = Math.max(0, pollConsumers - 1);
|
||||
if (pollConsumers > 0) return;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pendingCount,
|
||||
refreshDepositPendingCount,
|
||||
startDepositPendingPolling,
|
||||
stopDepositPendingPolling,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const zh: Record<string, string> = {
|
||||
'staff.col.last_login': '最近登录',
|
||||
'staff.dialog.create': '创建后台账号',
|
||||
'staff.dialog.edit': '编辑后台账号',
|
||||
'staff.field.visible_menus': '菜单权限',
|
||||
'login.captcha_ph': '验证码',
|
||||
'login.captcha_refresh': '点击刷新',
|
||||
|
||||
@@ -69,6 +70,7 @@ const zh: Record<string, string> = {
|
||||
'deposit.status_approved': '已通过',
|
||||
'deposit.status_rejected': '已拒绝',
|
||||
'deposit.search_player_ph': '搜索玩家...',
|
||||
'deposit.pending_badge': '待审核 {n} 笔',
|
||||
'deposit.order_no': '订单号',
|
||||
'deposit.player': '玩家',
|
||||
'deposit.amount': '金额',
|
||||
@@ -114,7 +116,7 @@ const zh: Record<string, string> = {
|
||||
'deposit.add_method': '+ 添加',
|
||||
'deposit.display_name': '展示名称',
|
||||
'deposit.details': '详情',
|
||||
'deposit.sort': '排序',
|
||||
'deposit.sort': '排序值',
|
||||
'deposit.active': '启用',
|
||||
'deposit.show_player': '前台展示',
|
||||
'deposit.edit_method': '编辑收款方式',
|
||||
@@ -124,7 +126,7 @@ const zh: Record<string, string> = {
|
||||
'deposit.account_number': '银行账号',
|
||||
'deposit.usdt_address': 'USDT 地址',
|
||||
'deposit.qr_code': '二维码',
|
||||
'deposit.sort_order': '排序序号',
|
||||
'deposit.sort_order': '排序值',
|
||||
'deposit.show_on_player': '展示给玩家',
|
||||
'deposit.save': '保存',
|
||||
'deposit.confirm_deactivate': '确认停用此收款方式?',
|
||||
@@ -150,11 +152,12 @@ const zh: Record<string, string> = {
|
||||
'role.tier2_agent': '二级代理',
|
||||
'logout': '退出',
|
||||
'lang': '语言',
|
||||
'portal.admin': '平台后台',
|
||||
'portal.agent': '代理后台',
|
||||
'portal.admin': '平台后台管理',
|
||||
'portal.agent': '代理后台管理',
|
||||
|
||||
'common.all': '全部',
|
||||
'common.search': '查询',
|
||||
'common.refresh': '刷新',
|
||||
'common.reset': '重置',
|
||||
'common.edit': '编辑',
|
||||
'common.yes': '是',
|
||||
@@ -164,9 +167,13 @@ const zh: Record<string, string> = {
|
||||
'common.confirm': '确定',
|
||||
'common.retry': '重试',
|
||||
'common.status': '状态',
|
||||
'user.status.ACTIVE': '正常',
|
||||
'user.status.SUSPENDED': '已冻结',
|
||||
'user.status.DISABLED': '已停用',
|
||||
'common.type': '类型',
|
||||
'common.keyword': '关键词',
|
||||
'common.actions': '操作',
|
||||
'common.seq': '序号',
|
||||
'common.more': '更多',
|
||||
'common.loading': '加载中…',
|
||||
'common.no_data': '暂无数据',
|
||||
@@ -218,6 +225,9 @@ const zh: Record<string, string> = {
|
||||
'dash.match_pending_settle': '待结算',
|
||||
'dash.match_settled': '已结算',
|
||||
'dash.user_active': '正常玩家',
|
||||
'dash.user_online': '当前在线',
|
||||
'dash.players_online': '当前在线',
|
||||
'dash.players_online_hint': '约 2 分钟内活跃',
|
||||
'dash.user_suspended': '停用',
|
||||
'dash.user_direct': '直属',
|
||||
'dash.user_agents': '代理',
|
||||
@@ -334,6 +344,7 @@ const en: Record<string, string> = {
|
||||
'staff.col.last_login': 'Last login',
|
||||
'staff.dialog.create': 'Create staff account',
|
||||
'staff.dialog.edit': 'Edit staff account',
|
||||
'staff.field.visible_menus': 'Menu Permissions',
|
||||
'login.captcha_ph': 'Captcha',
|
||||
'login.captcha_refresh': 'Click to refresh',
|
||||
|
||||
@@ -370,6 +381,7 @@ const en: Record<string, string> = {
|
||||
'deposit.status_approved': 'Approved',
|
||||
'deposit.status_rejected': 'Rejected',
|
||||
'deposit.search_player_ph': 'Search player...',
|
||||
'deposit.pending_badge': '{n} pending',
|
||||
'deposit.order_no': 'Order No',
|
||||
'deposit.player': 'Player',
|
||||
'deposit.amount': 'Amount',
|
||||
@@ -449,11 +461,12 @@ const en: Record<string, string> = {
|
||||
'role.tier2_agent': 'Tier-2 Agent',
|
||||
'logout': 'Logout',
|
||||
'lang': 'Language',
|
||||
'portal.admin': 'Platform Admin',
|
||||
'portal.agent': 'Agent Portal',
|
||||
'portal.admin': 'Admin Console',
|
||||
'portal.agent': 'Agent Console',
|
||||
|
||||
'common.all': 'All',
|
||||
'common.search': 'Search',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.reset': 'Reset',
|
||||
'common.edit': 'Edit',
|
||||
'common.yes': 'Yes',
|
||||
@@ -463,9 +476,13 @@ const en: Record<string, string> = {
|
||||
'common.confirm': 'OK',
|
||||
'common.retry': 'Retry',
|
||||
'common.status': 'Status',
|
||||
'user.status.ACTIVE': 'Active',
|
||||
'user.status.SUSPENDED': 'Suspended',
|
||||
'user.status.DISABLED': 'Disabled',
|
||||
'common.type': 'Type',
|
||||
'common.keyword': 'Keyword',
|
||||
'common.actions': 'Actions',
|
||||
'common.seq': 'No.',
|
||||
'common.more': 'More',
|
||||
'common.loading': 'Loading…',
|
||||
'common.no_data': 'No data',
|
||||
@@ -517,6 +534,9 @@ const en: Record<string, string> = {
|
||||
'dash.match_pending_settle': 'Pending settlement',
|
||||
'dash.match_settled': 'Settled',
|
||||
'dash.user_active': 'Active players',
|
||||
'dash.user_online': 'Online now',
|
||||
'dash.players_online': 'Online now',
|
||||
'dash.players_online_hint': 'Active within ~2 min',
|
||||
'dash.user_suspended': 'Suspended',
|
||||
'dash.user_direct': 'Direct',
|
||||
'dash.user_agents': 'Agents',
|
||||
@@ -633,6 +653,7 @@ const ms: Record<string, string> = {
|
||||
'staff.col.last_login': 'Log masuk terakhir',
|
||||
'staff.dialog.create': 'Cipta akaun kakitangan',
|
||||
'staff.dialog.edit': 'Edit akaun kakitangan',
|
||||
'staff.field.visible_menus': 'Kebenaran Menu',
|
||||
'login.captcha_ph': 'Captcha',
|
||||
'login.captcha_refresh': 'Klik untuk muat semula',
|
||||
|
||||
@@ -669,6 +690,7 @@ const ms: Record<string, string> = {
|
||||
'deposit.status_approved': 'Diluluskan',
|
||||
'deposit.status_rejected': 'Ditolak',
|
||||
'deposit.search_player_ph': 'Cari pemain...',
|
||||
'deposit.pending_badge': '{n} menunggu',
|
||||
'deposit.order_no': 'No. Pesanan',
|
||||
'deposit.player': 'Pemain',
|
||||
'deposit.amount': 'Jumlah',
|
||||
@@ -748,11 +770,12 @@ const ms: Record<string, string> = {
|
||||
'role.tier2_agent': 'Ejen Peringkat 2',
|
||||
'logout': 'Log keluar',
|
||||
'lang': 'Bahasa',
|
||||
'portal.admin': 'Admin Platform',
|
||||
'portal.agent': 'Portal Ejen',
|
||||
'portal.admin': 'Pengurusan Admin',
|
||||
'portal.agent': 'Pengurusan Ejen',
|
||||
|
||||
'common.all': 'Semua',
|
||||
'common.search': 'Cari',
|
||||
'common.refresh': 'Muat semula',
|
||||
'common.reset': 'Set semula',
|
||||
'common.edit': 'Edit',
|
||||
'common.yes': 'Ya',
|
||||
@@ -762,9 +785,13 @@ const ms: Record<string, string> = {
|
||||
'common.confirm': 'OK',
|
||||
'common.retry': 'Cuba lagi',
|
||||
'common.status': 'Status',
|
||||
'user.status.ACTIVE': 'Aktif',
|
||||
'user.status.SUSPENDED': 'Digantung',
|
||||
'user.status.DISABLED': 'Dinyahaktifkan',
|
||||
'common.type': 'Jenis',
|
||||
'common.keyword': 'Kata kunci',
|
||||
'common.actions': 'Tindakan',
|
||||
'common.seq': 'No.',
|
||||
'common.more': 'Lagi',
|
||||
'common.loading': 'Memuatkan…',
|
||||
'common.no_data': 'Tiada data',
|
||||
@@ -816,6 +843,9 @@ const ms: Record<string, string> = {
|
||||
'dash.match_pending_settle': 'Menunggu penyelesaian',
|
||||
'dash.match_settled': 'Diselesaikan',
|
||||
'dash.user_active': 'Pemain aktif',
|
||||
'dash.user_online': 'Dalam talian',
|
||||
'dash.players_online': 'Dalam talian',
|
||||
'dash.players_online_hint': 'Aktif dalam ~2 min',
|
||||
'dash.user_suspended': 'Digantung',
|
||||
'dash.user_direct': 'Terus',
|
||||
'dash.user_agents': 'Ejen',
|
||||
|
||||
@@ -28,6 +28,10 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'user.filter.agent': 'Ejen',
|
||||
'user.filter.agent_ph': 'Semua',
|
||||
'user.col.username': 'Nama pengguna',
|
||||
'user.col.online': 'Dalam talian',
|
||||
'user.online_yes': 'Sedang dalam talian',
|
||||
'user.presence_online': 'Dalam talian',
|
||||
'user.presence_offline': 'Luar talian',
|
||||
'user.col.agent': 'Ejen',
|
||||
'user.col.invite_code': 'Kod jemputan',
|
||||
'user.col.balance': 'Tersedia / Dibekukan',
|
||||
@@ -765,11 +769,39 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'content.btn.enable': 'Aktifkan',
|
||||
'content.btn.disable': 'Nyahaktif',
|
||||
'content.dialog.create': 'Kandungan awam baharu',
|
||||
'content.dialog.edit': 'Edit kandungan awam',
|
||||
'content.dialog.create_banner': 'Promosi laman utama baharu',
|
||||
'content.dialog.create_notice': 'Terbitkan notifikasi',
|
||||
'content.dialog.edit': 'Edit kandungan',
|
||||
'content.dialog.edit_banner': 'Edit promosi laman utama',
|
||||
'content.dialog.edit_notice': 'Edit notifikasi',
|
||||
'content.confirm_delete': 'Padam "{title}"?',
|
||||
'content.type.BANNER': 'Banner laman utama',
|
||||
'content.type.ANNOUNCEMENT': 'Pengumuman',
|
||||
'content.hint.announcement': 'Dipaparkan di ticker atas pemain; isi tajuk atau kandungan',
|
||||
'content.type.BANNER': 'Promosi laman utama',
|
||||
'content.type.ANNOUNCEMENT': 'Notifikasi',
|
||||
'content.type.INBOX_NOTIFY': 'Notifikasi peti mesej',
|
||||
'content.inbox_notify.inbox_enabled': 'Peti mesej pemain',
|
||||
'content.inbox_notify.inbox_enabled_hint': 'Jika dimatikan, pemain hanya dibawa ke sokongan (tiada tab peti mesej)',
|
||||
'content.inbox_notify.deposit': 'Keputusan deposit',
|
||||
'content.inbox_notify.deposit_hint': 'Hantar mesej apabila diluluskan atau ditolak',
|
||||
'content.inbox_notify.manual_title': 'Tanda "Notifikasi peti mesej" semasa mencipta:',
|
||||
'content.inbox_notify.banner_note': 'Promosi laman utama',
|
||||
'content.inbox_notify.announcement_note': 'Notifikasi / ticker',
|
||||
'content.hint.banner': 'Dipaparkan dalam karusel laman utama; ketik untuk halaman butiran. Muat naik imej muka depan dan kandungan kaya seperti pengumuman laman rasmi.',
|
||||
'content.hint.announcement': 'Dipaparkan sebagai teks bergulir di bahagian atas aplikasi pemain. Isi tajuk dan teks bergulir setiap bahasa — teks biasa sahaja.',
|
||||
'content.section.publish': 'Tetapan terbitan',
|
||||
'content.section.content': 'Kandungan notifikasi',
|
||||
'content.field.publish_kind': 'Jenis terbitan',
|
||||
'content.publish_kind.notice': 'Notifikasi penuh (halaman butiran)',
|
||||
'content.publish_kind.ticker': 'Teks ticker sahaja',
|
||||
'content.publish_kind.hint': 'Notifikasi penuh muncul dalam senarai dan butiran; ticker hanya papar satu baris bergulir.',
|
||||
'content.field.cover_image': 'Imej muka depan',
|
||||
'content.field.ticker_title_ph': 'Tajuk ticker (pilihan)',
|
||||
'content.field.ticker_body_ph': 'Teks dipaparkan dalam ticker',
|
||||
'content.field.ticker_hint': 'Ticker hanya papar teks biasa — tiada imej atau format kaya.',
|
||||
'content.editor.placeholder': 'Tulis kandungan notifikasi; boleh sisip imej dan senarai…',
|
||||
'content.editor.insert_image': 'Sisip imej',
|
||||
'content.upload.cover_size_hint': 'Lebar disyorkan 860px+. Imej muka depan dan dalam kandungan akan menyesuaikan lebar pada pemain.',
|
||||
'content.upload.pick_media_title': 'Pilih imej',
|
||||
'content.upload.no_media': 'Tiada imej dalam pustaka — muat naik dahulu',
|
||||
'content.status.DRAFT': 'Draf',
|
||||
'content.status.ACTIVE': 'Aktif',
|
||||
'content.status.INACTIVE': 'Tidak aktif',
|
||||
@@ -783,6 +815,9 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'content.field.link_target': 'Sasaran pautan',
|
||||
'content.field.start_time': 'Masa mula',
|
||||
'content.field.end_time': 'Masa tamat',
|
||||
'content.field.notify_inbox': 'Notifikasi peti mesej',
|
||||
'content.field.notify_inbox_hint': 'Hantar mesej peti kepada semua pemain tentang promosi ini selepas simpan',
|
||||
'content.msg.notify_sent': 'Notifikasi dihantar kepada {count} pemain',
|
||||
'content.field.title': 'Tajuk',
|
||||
'content.field.title_ph': 'Pilihan',
|
||||
'content.field.body': 'Kandungan',
|
||||
@@ -795,8 +830,6 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'content.upload.size_error': 'Imej mestilah di bawah 5 MB',
|
||||
'content.upload.remove': 'Buang imej',
|
||||
'content.upload.pick_media': 'Pilih dari pustaka',
|
||||
'content.upload.pick_media_title': 'Pilih Imej Banner',
|
||||
'content.upload.no_media': 'Tiada imej banner dalam pustaka — muat naik dahulu',
|
||||
'content.upload.url_placeholder': 'Atau tampal URL imej',
|
||||
'content.upload.recommended_size': 'Saiz disyorkan: 860 x 360 px, atau imej nisbah 43:18. Karusel pemain memaparkan imej penuh dan mengisi ruang tambahan.',
|
||||
'content.link.none': 'Tiada pautan',
|
||||
|
||||
@@ -838,15 +838,33 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'content.btn.enable': '启用',
|
||||
'content.btn.disable': '停用',
|
||||
'content.dialog.create': '新建公共内容',
|
||||
'content.dialog.edit': '编辑公共内容',
|
||||
'content.dialog.create_banner': '新建首页推广',
|
||||
'content.dialog.create_notice': '发布通知公告',
|
||||
'content.dialog.edit': '编辑内容',
|
||||
'content.dialog.edit_banner': '编辑首页推广',
|
||||
'content.dialog.edit_notice': '编辑通知公告',
|
||||
'content.confirm_delete': '确定删除「{title}」?',
|
||||
'content.type.BANNER': '首页轮播',
|
||||
'content.type.ANNOUNCEMENT': '公告滚动',
|
||||
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
|
||||
'content.type.BANNER': '首页推广',
|
||||
'content.type.ANNOUNCEMENT': '通知公告',
|
||||
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
|
||||
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
|
||||
'content.section.publish': '发布设置',
|
||||
'content.section.content': '通知内容',
|
||||
'content.field.publish_kind': '发布类型',
|
||||
'content.publish_kind.notice': '完整通知(详情页)',
|
||||
'content.publish_kind.ticker': '仅跑马灯文字',
|
||||
'content.publish_kind.hint': '完整通知会出现在公告列表与详情页;跑马灯仅显示顶部滚动一行文字。',
|
||||
'content.field.cover_image': '封面图',
|
||||
'content.field.ticker_title_ph': '跑马灯标题(选填)',
|
||||
'content.field.ticker_body_ph': '跑马灯显示的文字',
|
||||
'content.field.ticker_hint': '跑马灯只显示纯文字,不支持图片与富文本格式。',
|
||||
'content.editor.placeholder': '输入通知正文,可插入图片、列表等…',
|
||||
'content.editor.insert_image': '插入图片',
|
||||
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
|
||||
'content.status.DRAFT': '草稿',
|
||||
'content.status.ACTIVE': '已启用',
|
||||
'content.status.INACTIVE': '已停用',
|
||||
'content.col.sort': '排序',
|
||||
'content.col.sort': '排序值',
|
||||
'content.col.preview': '预览',
|
||||
'content.col.title': '标题/摘要',
|
||||
'content.col.player_visible': '玩家可见',
|
||||
@@ -857,7 +875,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'content.field.start_time': '开始时间',
|
||||
'content.field.end_time': '结束时间',
|
||||
'content.field.title': '标题',
|
||||
'content.field.title_ph': '选填,可与正文相同',
|
||||
'content.field.title_ph': '通知标题,玩家端详情页展示',
|
||||
'content.field.body': '正文',
|
||||
'content.field.announce_text': '滚动文案',
|
||||
'content.field.image_url': '图片地址',
|
||||
@@ -868,8 +886,8 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'content.upload.size_error': '图片大小不能超过 5MB',
|
||||
'content.upload.remove': '移除图片',
|
||||
'content.upload.pick_media': '从媒体库选择',
|
||||
'content.upload.pick_media_title': '选择 Banner 图片',
|
||||
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
|
||||
'content.upload.pick_media_title': '选择图片',
|
||||
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
||||
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
||||
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
||||
'content.link.none': '无跳转',
|
||||
@@ -1911,11 +1929,29 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'content.btn.enable': 'Enable',
|
||||
'content.btn.disable': 'Disable',
|
||||
'content.dialog.create': 'New public content',
|
||||
'content.dialog.edit': 'Edit public content',
|
||||
'content.dialog.create_banner': 'New home promotion',
|
||||
'content.dialog.create_notice': 'Publish notification',
|
||||
'content.dialog.edit': 'Edit content',
|
||||
'content.dialog.edit_banner': 'Edit home promotion',
|
||||
'content.dialog.edit_notice': 'Edit notification',
|
||||
'content.confirm_delete': 'Delete "{title}"?',
|
||||
'content.type.BANNER': 'Home banners',
|
||||
'content.type.ANNOUNCEMENT': 'Announcements',
|
||||
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
|
||||
'content.type.BANNER': 'Home promotions',
|
||||
'content.type.ANNOUNCEMENT': 'Notifications',
|
||||
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
|
||||
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
|
||||
'content.section.publish': 'Publish settings',
|
||||
'content.section.content': 'Notification content',
|
||||
'content.field.publish_kind': 'Publish type',
|
||||
'content.publish_kind.notice': 'Full notification (detail page)',
|
||||
'content.publish_kind.ticker': 'Ticker text only',
|
||||
'content.publish_kind.hint': 'Full notifications appear in the list and detail page; ticker-only shows one scrolling line at the top.',
|
||||
'content.field.cover_image': 'Cover image',
|
||||
'content.field.ticker_title_ph': 'Ticker title (optional)',
|
||||
'content.field.ticker_body_ph': 'Text shown in the marquee',
|
||||
'content.field.ticker_hint': 'Ticker shows plain text only — no images or rich formatting.',
|
||||
'content.editor.placeholder': 'Write the notification body; you can insert images and lists…',
|
||||
'content.editor.insert_image': 'Insert image',
|
||||
'content.upload.cover_size_hint': 'Recommended width 860px+. Cover and inline images scale to fit on the player detail page.',
|
||||
'content.status.DRAFT': 'Draft',
|
||||
'content.status.ACTIVE': 'Active',
|
||||
'content.status.INACTIVE': 'Inactive',
|
||||
@@ -1930,7 +1966,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'content.field.start_time': 'Start time',
|
||||
'content.field.end_time': 'End time',
|
||||
'content.field.title': 'Title',
|
||||
'content.field.title_ph': 'Optional; can match body',
|
||||
'content.field.title_ph': 'Title shown on the player detail page',
|
||||
'content.field.body': 'Body',
|
||||
'content.field.announce_text': 'Marquee text',
|
||||
'content.field.image_url': 'Image URL',
|
||||
@@ -1941,8 +1977,8 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'content.upload.size_error': 'Image must be under 5 MB',
|
||||
'content.upload.remove': 'Remove image',
|
||||
'content.upload.pick_media': 'Pick from library',
|
||||
'content.upload.pick_media_title': 'Select Banner Image',
|
||||
'content.upload.no_media': 'No banner images in library — upload one first',
|
||||
'content.upload.pick_media_title': 'Select image',
|
||||
'content.upload.no_media': 'No images in library — upload one first',
|
||||
'content.upload.url_placeholder': 'Or paste image URL',
|
||||
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
|
||||
'content.link.none': 'No link',
|
||||
|
||||
@@ -8,15 +8,17 @@ const loaders: Record<AdminLocale, () => Promise<{ default: Record<string, strin
|
||||
};
|
||||
|
||||
const inflight = new Map<AdminLocale, Promise<void>>();
|
||||
const loaded = new Set<AdminLocale>();
|
||||
|
||||
/** 按语言动态加载文案包(仅当前 locale 进入主路径,其余为独立 chunk)。 */
|
||||
export async function ensureAdminLocaleLoaded(locale: AdminLocale): Promise<void> {
|
||||
if (Object.keys(adminMessages[locale]).length > 0) return;
|
||||
if (loaded.has(locale)) return;
|
||||
const pending = inflight.get(locale);
|
||||
if (pending) return pending;
|
||||
|
||||
const task = loaders[locale]().then((mod) => {
|
||||
adminMessages[locale] = mod.default;
|
||||
Object.assign(adminMessages[locale], mod.default);
|
||||
loaded.add(locale);
|
||||
});
|
||||
inflight.set(locale, task);
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,10 @@ const adminPages: Record<string, string> = {
|
||||
'user.filter.agent': 'Agent',
|
||||
'user.filter.agent_ph': 'All',
|
||||
'user.col.username': 'Username',
|
||||
'user.col.online': 'Online',
|
||||
'user.online_yes': 'Online now',
|
||||
'user.presence_online': 'Online',
|
||||
'user.presence_offline': 'Offline',
|
||||
'user.col.agent': 'Agent',
|
||||
'user.col.agent_cashback': 'Agent cashback',
|
||||
'user.col.player_cashback': 'Player cashback',
|
||||
@@ -786,15 +790,43 @@ const adminPages: Record<string, string> = {
|
||||
'msg.outright_odds_saved': 'Outright odds saved',
|
||||
'msg.load_failed': 'Load failed',
|
||||
|
||||
'content.upload.pick_media_title': 'Select image',
|
||||
'content.upload.no_media': 'No images in library — upload one first',
|
||||
'content.btn.create': 'New content',
|
||||
'content.btn.enable': 'Enable',
|
||||
'content.btn.disable': 'Disable',
|
||||
'content.dialog.create': 'New public content',
|
||||
'content.dialog.edit': 'Edit public content',
|
||||
'content.dialog.create_banner': 'New home promotion',
|
||||
'content.dialog.create_notice': 'Publish notification',
|
||||
'content.dialog.edit': 'Edit content',
|
||||
'content.dialog.edit_banner': 'Edit home promotion',
|
||||
'content.dialog.edit_notice': 'Edit notification',
|
||||
'content.confirm_delete': 'Delete "{title}"?',
|
||||
'content.type.BANNER': 'Home banners',
|
||||
'content.type.ANNOUNCEMENT': 'Announcements',
|
||||
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
|
||||
'content.type.BANNER': 'Home promotions',
|
||||
'content.type.ANNOUNCEMENT': 'Notifications',
|
||||
'content.type.INBOX_NOTIFY': 'Inbox notify',
|
||||
'content.inbox_notify.inbox_enabled': 'Player inbox',
|
||||
'content.inbox_notify.inbox_enabled_hint': 'When off, the player hub opens support only (no mailbox tab)',
|
||||
'content.inbox_notify.deposit': 'Deposit results',
|
||||
'content.inbox_notify.deposit_hint': 'Auto-send inbox messages on approve or reject',
|
||||
'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:',
|
||||
'content.inbox_notify.banner_note': 'Homepage promo',
|
||||
'content.inbox_notify.announcement_note': 'Announcements / ticker',
|
||||
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
|
||||
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
|
||||
'content.section.publish': 'Publish settings',
|
||||
'content.section.content': 'Notification content',
|
||||
'content.field.publish_kind': 'Publish type',
|
||||
'content.publish_kind.notice': 'Full notification (detail page)',
|
||||
'content.publish_kind.ticker': 'Ticker text only',
|
||||
'content.publish_kind.hint': 'Full notifications appear in the list and detail page; ticker-only shows one scrolling line at the top.',
|
||||
'content.field.cover_image': 'Cover image',
|
||||
'content.field.ticker_title_ph': 'Ticker title (optional)',
|
||||
'content.field.ticker_body_ph': 'Text shown in the marquee',
|
||||
'content.field.ticker_hint': 'Ticker shows plain text only — no images or rich formatting.',
|
||||
'content.editor.placeholder': 'Write the notification body; you can insert images and lists…',
|
||||
'content.editor.insert_image': 'Insert image',
|
||||
'content.upload.cover_size_hint': 'Recommended width 860px+. Cover and inline images scale to fit on the player detail page.',
|
||||
'content.status.DRAFT': 'Draft',
|
||||
'content.status.ACTIVE': 'Active',
|
||||
'content.status.INACTIVE': 'Inactive',
|
||||
@@ -808,8 +840,11 @@ const adminPages: Record<string, string> = {
|
||||
'content.field.link_target': 'Link target',
|
||||
'content.field.start_time': 'Start time',
|
||||
'content.field.end_time': 'End time',
|
||||
'content.field.notify_inbox': 'Inbox notification',
|
||||
'content.field.notify_inbox_hint': 'Send an inbox message to all players about this promotion after saving',
|
||||
'content.msg.notify_sent': 'Inbox notification sent to {count} player(s)',
|
||||
'content.field.title': 'Title',
|
||||
'content.field.title_ph': 'Optional; can match body',
|
||||
'content.field.title_ph': 'Title shown on the player detail page',
|
||||
'content.field.body': 'Body',
|
||||
'content.field.announce_text': 'Marquee text',
|
||||
'content.field.image_url': 'Image URL',
|
||||
@@ -820,8 +855,6 @@ const adminPages: Record<string, string> = {
|
||||
'content.upload.size_error': 'Image must be under 5 MB',
|
||||
'content.upload.remove': 'Remove image',
|
||||
'content.upload.pick_media': 'Pick from library',
|
||||
'content.upload.pick_media_title': 'Select Banner Image',
|
||||
'content.upload.no_media': 'No banner images in library — upload one first',
|
||||
'content.upload.url_placeholder': 'Or paste image URL',
|
||||
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
|
||||
'content.link.none': 'No link',
|
||||
|
||||
@@ -28,6 +28,10 @@ const adminPages: Record<string, string> = {
|
||||
'user.filter.agent': '所属代理',
|
||||
'user.filter.agent_ph': '全部',
|
||||
'user.col.username': '用户名',
|
||||
'user.col.online': '在线',
|
||||
'user.online_yes': '当前在线',
|
||||
'user.presence_online': '在线',
|
||||
'user.presence_offline': '离线',
|
||||
'user.col.agent': '所属代理',
|
||||
'user.col.agent_cashback': '代理返水率',
|
||||
'user.col.player_cashback': '玩家返水率',
|
||||
@@ -790,15 +794,43 @@ const adminPages: Record<string, string> = {
|
||||
'content.btn.enable': '启用',
|
||||
'content.btn.disable': '停用',
|
||||
'content.dialog.create': '新建公共内容',
|
||||
'content.dialog.edit': '编辑公共内容',
|
||||
'content.dialog.create_banner': '新建首页推广',
|
||||
'content.dialog.create_notice': '发布通知公告',
|
||||
'content.dialog.edit': '编辑内容',
|
||||
'content.dialog.edit_banner': '编辑首页推广',
|
||||
'content.dialog.edit_notice': '编辑通知公告',
|
||||
'content.confirm_delete': '确定删除「{title}」?',
|
||||
'content.type.BANNER': '首页轮播',
|
||||
'content.type.ANNOUNCEMENT': '公告滚动',
|
||||
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
|
||||
'content.type.BANNER': '首页推广',
|
||||
'content.type.ANNOUNCEMENT': '通知公告',
|
||||
'content.type.INBOX_NOTIFY': '邮箱通知',
|
||||
'content.inbox_notify.inbox_enabled': '站内邮箱',
|
||||
'content.inbox_notify.inbox_enabled_hint': '关闭后玩家端入口直达客服,不展示邮箱标签页',
|
||||
'content.inbox_notify.deposit': '充值结果',
|
||||
'content.inbox_notify.deposit_hint': '审核通过或拒绝时自动发送站内信',
|
||||
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
|
||||
'content.inbox_notify.banner_note': '首页推广',
|
||||
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',
|
||||
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
|
||||
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
|
||||
'content.section.publish': '发布设置',
|
||||
'content.section.content': '通知内容',
|
||||
'content.field.publish_kind': '发布类型',
|
||||
'content.publish_kind.notice': '完整通知(详情页)',
|
||||
'content.publish_kind.ticker': '仅跑马灯文字',
|
||||
'content.publish_kind.hint': '完整通知会出现在公告列表与详情页;跑马灯仅显示顶部滚动一行文字。',
|
||||
'content.field.cover_image': '封面图',
|
||||
'content.field.ticker_title_ph': '跑马灯标题(选填)',
|
||||
'content.field.ticker_body_ph': '跑马灯显示的文字',
|
||||
'content.field.ticker_hint': '跑马灯只显示纯文字,不支持图片与富文本格式。',
|
||||
'content.editor.placeholder': '输入通知正文,可插入图片、列表等…',
|
||||
'content.editor.insert_image': '插入图片',
|
||||
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
|
||||
'content.upload.pick_media_title': '选择图片',
|
||||
'content.upload.no_media': '媒体库中暂无图片,请先上传',
|
||||
'content.status.DRAFT': '草稿',
|
||||
'content.status.ACTIVE': '已启用',
|
||||
'content.status.INACTIVE': '已停用',
|
||||
'content.col.sort': '排序',
|
||||
'content.col.sort': '排序值',
|
||||
'content.col.preview': '预览',
|
||||
'content.col.title': '标题/摘要',
|
||||
'content.col.player_visible': '玩家可见',
|
||||
@@ -808,8 +840,11 @@ const adminPages: Record<string, string> = {
|
||||
'content.field.link_target': '链接目标',
|
||||
'content.field.start_time': '开始时间',
|
||||
'content.field.end_time': '结束时间',
|
||||
'content.field.notify_inbox': '邮箱通知',
|
||||
'content.field.notify_inbox_hint': '保存后向全部玩家发送站内信,通知查看此推广',
|
||||
'content.msg.notify_sent': '已向 {count} 位玩家发送邮箱通知',
|
||||
'content.field.title': '标题',
|
||||
'content.field.title_ph': '选填,可与正文相同',
|
||||
'content.field.title_ph': '通知标题,玩家端详情页展示',
|
||||
'content.field.body': '正文',
|
||||
'content.field.announce_text': '滚动文案',
|
||||
'content.field.image_url': '图片地址',
|
||||
@@ -820,8 +855,6 @@ const adminPages: Record<string, string> = {
|
||||
'content.upload.size_error': '图片大小不能超过 5MB',
|
||||
'content.upload.remove': '移除图片',
|
||||
'content.upload.pick_media': '从媒体库选择',
|
||||
'content.upload.pick_media_title': '选择 Banner 图片',
|
||||
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
|
||||
'content.upload.url_placeholder': '或手动粘贴图片 URL',
|
||||
'content.upload.recommended_size': '建议尺寸:860 x 360 px,或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
|
||||
'content.link.none': '无跳转',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AdminPerm } from '../constants/permissions';
|
||||
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
|
||||
import AdminNavIcon from '../components/AdminNavIcon.vue';
|
||||
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
|
||||
import { useDepositPendingCount } from '../composables/useDepositPendingCount';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -16,11 +17,18 @@ const auth = useAuthStore();
|
||||
const { t } = useAdminLocale();
|
||||
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
|
||||
const { hasPermission, role: adminRole } = usePermissions();
|
||||
const { pendingCount: depositPendingCount, startDepositPendingPolling, stopDepositPendingPolling } =
|
||||
useDepositPendingCount();
|
||||
|
||||
const canSeeDepositPending = computed(
|
||||
() => auth.isAdmin.value && hasPermission(AdminPerm.depositReview, AdminPerm.depositManage),
|
||||
);
|
||||
|
||||
const sidebarOpen = ref(false);
|
||||
const isMobileNav = ref(false);
|
||||
|
||||
type AdminMenuItem = {
|
||||
key: string;
|
||||
path: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
@@ -34,23 +42,30 @@ function menuVisible(item: AdminMenuItem): boolean {
|
||||
const code = adminRole.value;
|
||||
if (item.path === '/smoke-tests' && smokeTestsAllowed.value === false) return false;
|
||||
if (code && code !== 'SUPER_ADMIN' && item.excludeRoles?.includes(code)) return false;
|
||||
return hasPermission(...item.permissions);
|
||||
if (!hasPermission(...item.permissions)) return false;
|
||||
|
||||
const visible = auth.user.value?.visibleMenus;
|
||||
if (visible) {
|
||||
const list = visible.split(',');
|
||||
return list.includes(item.key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const adminMenus = computed(() => {
|
||||
const items: AdminMenuItem[] = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
|
||||
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
|
||||
{ path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
|
||||
{ path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
|
||||
{ path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
|
||||
{ path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
|
||||
{ path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
|
||||
{ path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
|
||||
{ path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
|
||||
{ path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
|
||||
{ path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
|
||||
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
|
||||
{ key: 'dashboard', path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
|
||||
{ key: 'matches', path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
|
||||
{ key: 'users', path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
|
||||
{ key: 'finance-logs', path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
|
||||
{ key: 'deposit', path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
|
||||
{ key: 'cashback', path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
|
||||
{ key: 'bets', path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
|
||||
{ key: 'contents', path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
|
||||
{ key: 'media', path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
|
||||
{ key: 'audit', path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
|
||||
{ key: 'staff', path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
|
||||
{ key: 'smoke-tests', path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
|
||||
];
|
||||
return items.filter(menuVisible);
|
||||
});
|
||||
@@ -149,11 +164,13 @@ onMounted(() => {
|
||||
window.addEventListener('resize', syncMobileNav);
|
||||
if (auth.isAdmin.value) {
|
||||
void ensureSmokeTestsAllowed();
|
||||
if (canSeeDepositPending.value) startDepositPendingPolling();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', syncMobileNav);
|
||||
stopDepositPendingPolling();
|
||||
});
|
||||
|
||||
watch(() => route.path, () => {
|
||||
@@ -177,6 +194,7 @@ watch(() => route.path, () => {
|
||||
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||
<div class="brand">
|
||||
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
|
||||
<span class="brand-title">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
@@ -197,7 +215,16 @@ watch(() => route.path, () => {
|
||||
@click="onNavClick"
|
||||
>
|
||||
<AdminNavIcon :name="m.icon" />
|
||||
<span class="nav-label">{{ m.label }}</span>
|
||||
<span class="nav-label">
|
||||
{{ m.label }}
|
||||
<span
|
||||
v-if="'path' in m && m.path === '/deposit' && depositPendingCount > 0"
|
||||
class="nav-pending-badge"
|
||||
:title="t('deposit.pending_badge', { n: depositPendingCount })"
|
||||
>
|
||||
{{ depositPendingCount > 99 ? '99+' : depositPendingCount }}
|
||||
</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
@@ -245,7 +272,6 @@ watch(() => route.path, () => {
|
||||
</div>
|
||||
</div>
|
||||
<AdminLocaleSwitcher />
|
||||
<div class="portal-tag">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</div>
|
||||
<button class="btn-logout" @click="logout">{{ t('logout') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -285,17 +311,26 @@ watch(() => route.path, () => {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.brand-logo {
|
||||
max-width: 118px;
|
||||
max-height: 34px;
|
||||
max-width: 52px;
|
||||
max-height: 32px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: #f0f0f0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -324,8 +359,26 @@ watch(() => route.path, () => {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.nav-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-pending-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: #f56c6c;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.25);
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -584,14 +637,23 @@ watch(() => route.path, () => {
|
||||
.brand {
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
padding: 0 16px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
max-width: 132px;
|
||||
max-height: 38px;
|
||||
max-width: 56px;
|
||||
max-height: 34px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: #2a2824;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface StaffUser {
|
||||
maxAgentLevel?: number | null;
|
||||
canManageSubAgents?: boolean;
|
||||
inviteCode?: string | null;
|
||||
visibleMenus?: string | null;
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'manage_token';
|
||||
@@ -98,6 +99,7 @@ export function reconcileStaffSessionFromToken(): boolean {
|
||||
role: claims.role ?? user.value?.role,
|
||||
permissions: user.value?.permissions,
|
||||
inviteCode: user.value?.inviteCode,
|
||||
visibleMenus: user.value?.visibleMenus,
|
||||
};
|
||||
user.value = next;
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(next));
|
||||
|
||||
12
apps/admin/src/utils/html.ts
Normal file
12
apps/admin/src/utils/html.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** 去除 HTML 标签,用于列表摘要与跑马灯纯文本 */
|
||||
export function stripHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
if (!/[<>]/.test(html)) return html.trim();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** 判断富文本是否实质为空 */
|
||||
export function isHtmlEmpty(html: string): boolean {
|
||||
return !stripHtml(html);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ interface CreditTxRow {
|
||||
const items = ref<CreditTxRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const pageSize = ref(10);
|
||||
const keyword = ref('');
|
||||
const agentId = ref('');
|
||||
const transactionType = ref('');
|
||||
|
||||
@@ -77,6 +77,7 @@ import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '.
|
||||
import InviteCodePanel from '../components/InviteCodePanel.vue';
|
||||
import InviteManageDialog from '../components/InviteManageDialog.vue';
|
||||
import AdminTableWrap from '../components/AdminTableWrap.vue';
|
||||
import AdminPlayerStatusCell from '../components/AdminPlayerStatusCell.vue';
|
||||
import AdminAgentRowActions from '../components/AdminAgentRowActions.vue';
|
||||
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
|
||||
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
|
||||
@@ -94,7 +95,7 @@ const inviteDialogOpen = ref(false);
|
||||
const tier1Agents = ref<AgentRow[]>([]);
|
||||
const tier1Total = ref(0);
|
||||
const tier1Page = ref(1);
|
||||
const tier1PageSize = ref(20);
|
||||
const tier1PageSize = ref(10);
|
||||
const tier1Keyword = ref('');
|
||||
const tier1FilterStatus = ref('');
|
||||
|
||||
@@ -117,7 +118,7 @@ function ensureSubAgentState(level: number): SubAgentLevelState {
|
||||
agents: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
filterStatus: '',
|
||||
};
|
||||
@@ -166,7 +167,7 @@ const activeViewTab = ref('players');
|
||||
const allPlayers = ref<PlayerRow[]>([]);
|
||||
const playerTotal = ref(0);
|
||||
const playerPage = ref(1);
|
||||
const playerPageSize = ref(20);
|
||||
const playerPageSize = ref(10);
|
||||
const playerKeyword = ref('');
|
||||
const playerFilterStatus = ref('');
|
||||
const playerFilterAgent = ref('');
|
||||
@@ -1557,11 +1558,11 @@ function creditTypeLabel(type: string) {
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column type="index" :index="(i: number) => (playerPage - 1) * playerPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<el-table-column :label="t('common.status')" min-width="128">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.agent')" min-width="200">
|
||||
@@ -1671,19 +1672,19 @@ function creditTypeLabel(type: string) {
|
||||
</div>
|
||||
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
||||
<template #empty><AdminTableEmpty /></template>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||
<el-table-column :label="t('common.status')" min-width="120">
|
||||
<template #default="{ row: player }">
|
||||
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||||
<template #default="{ row: player }">
|
||||
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="80">
|
||||
<template #default="{ row: player }">
|
||||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
||||
<template #default="{ row: player }">
|
||||
<el-tooltip :content="`${formatAmountFull(player.availableBalance)} / ${formatAmountFull(player.frozenBalance)}`" placement="top">
|
||||
@@ -1717,8 +1718,7 @@ function creditTypeLabel(type: string) {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="userId" label="ID" min-width="64" />
|
||||
<el-table-column type="index" :index="(i: number) => (tier1Page - 1) * tier1PageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column :label="t('common.status')" min-width="72">
|
||||
<template #default="{ row }">
|
||||
@@ -1837,19 +1837,19 @@ function creditTypeLabel(type: string) {
|
||||
</div>
|
||||
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
||||
<template #empty><AdminTableEmpty /></template>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||
<el-table-column :label="t('common.status')" min-width="120">
|
||||
<template #default="{ row: player }">
|
||||
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
|
||||
<template #default="{ row: player }">
|
||||
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="80">
|
||||
<template #default="{ row: player }">
|
||||
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
||||
<template #default="{ row: player }">
|
||||
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
|
||||
@@ -1875,7 +1875,7 @@ function creditTypeLabel(type: string) {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userId" label="ID" min-width="64" />
|
||||
<el-table-column type="index" :index="(i: number) => (ensureSubAgentState(agentLevel).page - 1) * ensureSubAgentState(agentLevel).pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column :label="t('agent.col.parent_chain')" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ parentChainLabel(row) }}</template>
|
||||
|
||||
@@ -174,6 +174,7 @@ async function openDetail(row: BetListRow) {
|
||||
<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" />
|
||||
<el-table-column prop="id" :label="t('bet.col.serial')" width="64" align="center" />
|
||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -453,6 +453,7 @@ onMounted(loadHistory);
|
||||
<template #empty>
|
||||
<AdminTableEmpty :text="t('cashback.history_empty')" />
|
||||
</template>
|
||||
<el-table-column type="index" :index="(i: number) => (historyPage - 1) * historyPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cashback.col.period')" min-width="190">
|
||||
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { usePermissions } from '../composables/usePermissions';
|
||||
import { AdminPerm } from '../constants/permissions';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import ContentImageField from '../components/ContentImageField.vue';
|
||||
import ContentRichEditor from '../components/ContentRichEditor.vue';
|
||||
import { stripHtml } from '../utils/html';
|
||||
import {
|
||||
normalizeStartTimeForApi,
|
||||
normalizeStartTimeForPicker,
|
||||
@@ -16,91 +19,8 @@ const { t, localeTag } = useAdminLocale();
|
||||
const { hasPermission } = usePermissions();
|
||||
const canManageContent = computed(() => hasPermission(AdminPerm.content));
|
||||
|
||||
/* ── Image upload helpers ── */
|
||||
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
|
||||
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
interface MediaFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
category: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
url: string;
|
||||
inUse: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Per-locale uploading state */
|
||||
const uploadingLocale = ref<string | null>(null);
|
||||
|
||||
/** Media picker state */
|
||||
const mediaPickerVisible = ref(false);
|
||||
const mediaPickerLocale = ref('');
|
||||
const mediaFiles = ref<MediaFile[]>([]);
|
||||
const mediaLoading = ref(false);
|
||||
|
||||
async function uploadBannerImage(locale: string, file: File) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
ElMessage.error(t('content.upload.size_error'));
|
||||
return;
|
||||
}
|
||||
uploadingLocale.value = locale;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const { data } = await api.post('/admin/uploads?category=banners', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const url = data.data?.url as string;
|
||||
if (url) {
|
||||
const tr = form.value.translations.find((item) => item.locale === locale);
|
||||
if (tr) tr.imageUrl = url;
|
||||
ElMessage.success(t('content.upload.success'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || t('content.upload.failed');
|
||||
ElMessage.error(String(msg));
|
||||
} finally {
|
||||
uploadingLocale.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onBannerFileChange(e: Event, locale: string) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.[0]) {
|
||||
void uploadBannerImage(locale, input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeBannerImage(locale: string) {
|
||||
const tr = form.value.translations.find((item) => item.locale === locale);
|
||||
if (tr) tr.imageUrl = '';
|
||||
}
|
||||
|
||||
async function openMediaPicker(locale: string) {
|
||||
mediaPickerLocale.value = locale;
|
||||
mediaPickerVisible.value = true;
|
||||
mediaLoading.value = true;
|
||||
try {
|
||||
const res = await api.get('/admin/files', { params: { category: 'banners', pageSize: 200 } });
|
||||
mediaFiles.value = res.data.data.items ?? [];
|
||||
} catch {
|
||||
mediaFiles.value = [];
|
||||
} finally {
|
||||
mediaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickMediaFile(file: MediaFile) {
|
||||
const tr = form.value.translations.find((item) => item.locale === mediaPickerLocale.value);
|
||||
if (tr) tr.imageUrl = file.url;
|
||||
mediaPickerVisible.value = false;
|
||||
}
|
||||
|
||||
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
|
||||
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
|
||||
type AdminTab = 'BANNER' | 'ANNOUNCEMENT' | 'INBOX_NOTIFY';
|
||||
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
|
||||
|
||||
interface TranslationForm {
|
||||
@@ -126,7 +46,7 @@ interface ContentItem {
|
||||
translations: TranslationForm[];
|
||||
}
|
||||
|
||||
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT'];
|
||||
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT', 'INBOX_NOTIFY'];
|
||||
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||||
|
||||
const activeType = ref<AdminTab>('BANNER');
|
||||
@@ -142,9 +62,24 @@ const selectedRows = ref<ContentItem[]>([]);
|
||||
|
||||
const hasSelection = computed(() => selectedRows.value.length > 0);
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const bannerDialogVisible = ref(false);
|
||||
const announcementDialogVisible = ref(false);
|
||||
const bannerEditorRef = ref<InstanceType<typeof ContentRichEditor> | null>(null);
|
||||
const editingId = ref<string | null>(null);
|
||||
const editingContentType = ref<StoredContentType>('NOTICE');
|
||||
const editingContentType = ref<StoredContentType>('TICKER');
|
||||
const activeLocale = ref<string>('zh-CN');
|
||||
const notifyInbox = ref(false);
|
||||
|
||||
interface InboxNotifySettings {
|
||||
inboxEnabled: boolean;
|
||||
deposit: boolean;
|
||||
}
|
||||
|
||||
const inboxNotifySettings = ref<InboxNotifySettings>({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
});
|
||||
const inboxNotifySaving = ref(false);
|
||||
|
||||
const form = ref({
|
||||
sortOrder: 0,
|
||||
@@ -200,12 +135,63 @@ function formatTime(v: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
const isBanner = computed(() => activeType.value === 'BANNER');
|
||||
const isAnnouncement = computed(() => activeType.value === 'ANNOUNCEMENT');
|
||||
const dialogTitle = computed(() =>
|
||||
editingId.value ? t('content.dialog.edit') : t('content.dialog.create'),
|
||||
function previewText(row: ContentItem) {
|
||||
const raw = row.previewTitle || row.translations.find((tr) => tr.body)?.body || '';
|
||||
return stripHtml(raw) || '—';
|
||||
}
|
||||
|
||||
const isBannerTab = computed(() => activeType.value === 'BANNER');
|
||||
const isInboxNotifyTab = computed(() => activeType.value === 'INBOX_NOTIFY');
|
||||
|
||||
const activeTranslation = computed(
|
||||
() =>
|
||||
form.value.translations.find((tr) => tr.locale === activeLocale.value) ??
|
||||
form.value.translations[0],
|
||||
);
|
||||
|
||||
const bannerDialogTitle = computed(() =>
|
||||
editingId.value ? t('content.dialog.edit_banner') : t('content.dialog.create_banner'),
|
||||
);
|
||||
|
||||
const announcementDialogTitle = computed(() =>
|
||||
editingId.value ? t('content.dialog.edit_notice') : t('content.dialog.create_notice'),
|
||||
);
|
||||
|
||||
async function loadInboxNotifySettings() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/contents/inbox-notify-settings');
|
||||
inboxNotifySettings.value = {
|
||||
inboxEnabled: data.data?.inboxEnabled !== false,
|
||||
deposit: Boolean(data.data?.deposit),
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInboxNotifySettings() {
|
||||
if (!canManageContent.value) return;
|
||||
inboxNotifySaving.value = true;
|
||||
try {
|
||||
const { data } = await api.put('/admin/contents/inbox-notify-settings', {
|
||||
inboxEnabled: inboxNotifySettings.value.inboxEnabled,
|
||||
deposit: inboxNotifySettings.value.deposit,
|
||||
});
|
||||
inboxNotifySettings.value = {
|
||||
inboxEnabled: data.data?.inboxEnabled !== false,
|
||||
deposit: Boolean(data.data?.deposit),
|
||||
};
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
await loadInboxNotifySettings();
|
||||
} finally {
|
||||
inboxNotifySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -244,6 +230,10 @@ watch([activeType, filterStatus], () => {
|
||||
page.value = 1;
|
||||
selectedRows.value = [];
|
||||
tableRef.value?.clearSelection();
|
||||
if (activeType.value === 'INBOX_NOTIFY') {
|
||||
void loadInboxNotifySettings();
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
});
|
||||
|
||||
@@ -311,6 +301,8 @@ function batchDelete() {
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
activeLocale.value = 'zh-CN';
|
||||
notifyInbox.value = false;
|
||||
form.value = {
|
||||
sortOrder: 0,
|
||||
status: 'DRAFT',
|
||||
@@ -322,15 +314,7 @@ function resetForm() {
|
||||
};
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: ContentItem) {
|
||||
editingId.value = row.id;
|
||||
editingContentType.value = row.contentType;
|
||||
function loadRowIntoForm(row: ContentItem, plainBody = false) {
|
||||
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
|
||||
form.value = {
|
||||
sortOrder: row.sortOrder,
|
||||
@@ -341,33 +325,57 @@ function openEdit(row: ContentItem) {
|
||||
endTime: normalizeStartTimeForPicker(row.endTime ?? undefined),
|
||||
translations: LOCALES.map((locale) => {
|
||||
const tr = byLocale.get(locale);
|
||||
const rawBody = tr?.body ?? '';
|
||||
return {
|
||||
locale,
|
||||
title: tr?.title ?? '',
|
||||
body: tr?.body ?? '',
|
||||
body: plainBody ? stripHtml(rawBody) : rawBody,
|
||||
imageUrl: tr?.imageUrl ?? '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const contentType: StoredContentType = editingId.value
|
||||
? editingContentType.value
|
||||
: isBanner.value
|
||||
? 'BANNER'
|
||||
: 'NOTICE';
|
||||
function openCreate() {
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
if (isBannerTab.value) {
|
||||
editingContentType.value = 'BANNER';
|
||||
bannerDialogVisible.value = true;
|
||||
} else {
|
||||
editingContentType.value = 'TICKER';
|
||||
announcementDialogVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: ContentItem) {
|
||||
editingId.value = row.id;
|
||||
editingContentType.value = row.contentType;
|
||||
activeLocale.value = 'zh-CN';
|
||||
if (row.contentType === 'BANNER') {
|
||||
loadRowIntoForm(row, false);
|
||||
bannerDialogVisible.value = true;
|
||||
} else {
|
||||
loadRowIntoForm(row, true);
|
||||
announcementDialogVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildScheduleFields() {
|
||||
return {
|
||||
contentType,
|
||||
sortOrder: form.value.sortOrder,
|
||||
status: form.value.status,
|
||||
linkType: isBanner.value && form.value.linkType ? form.value.linkType : null,
|
||||
linkTarget:
|
||||
isBanner.value && form.value.linkType ? form.value.linkTarget.trim() : null,
|
||||
startTime: form.value.startTime ? normalizeStartTimeForApi(form.value.startTime) : null,
|
||||
endTime: form.value.endTime ? normalizeStartTimeForApi(form.value.endTime) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBannerPayload() {
|
||||
const payload = {
|
||||
contentType: 'BANNER' as const,
|
||||
...buildScheduleFields(),
|
||||
linkType: form.value.linkType ? form.value.linkType : null,
|
||||
linkTarget: form.value.linkType ? form.value.linkTarget.trim() : null,
|
||||
translations: form.value.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title.trim() || undefined,
|
||||
@@ -375,25 +383,94 @@ function buildPayload() {
|
||||
imageUrl: tr.imageUrl.trim() || undefined,
|
||||
})),
|
||||
};
|
||||
if (!editingId.value) {
|
||||
return { ...payload, notifyInbox: notifyInbox.value };
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (uploadingLocale.value) {
|
||||
ElMessage.warning(t('content.upload.uploading'));
|
||||
return;
|
||||
function buildAnnouncementPayload() {
|
||||
const contentType: StoredContentType = editingId.value ? editingContentType.value : 'TICKER';
|
||||
const payload = {
|
||||
contentType,
|
||||
...buildScheduleFields(),
|
||||
linkType: null,
|
||||
linkTarget: null,
|
||||
translations: form.value.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title.trim() || undefined,
|
||||
body: tr.body.trim() || undefined,
|
||||
})),
|
||||
};
|
||||
if (!editingId.value) {
|
||||
return { ...payload, notifyInbox: notifyInbox.value };
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function submitBannerForm() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const editor = bannerEditorRef.value;
|
||||
if (editor) {
|
||||
activeTranslation.value.body = editor.getHtml();
|
||||
for (const tr of form.value.translations) {
|
||||
tr.body = await editor.uploadPendingImages(tr.body);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = buildBannerPayload();
|
||||
const isCreate = !editingId.value;
|
||||
if (editingId.value) {
|
||||
const { contentType: _type, ...updateBody } = payload;
|
||||
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<typeof buildBannerPayload> & { notifyInbox?: boolean };
|
||||
await api.put(`/admin/contents/${editingId.value}`, updateBody);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} else {
|
||||
await api.post('/admin/contents', payload);
|
||||
const { data } = await api.post('/admin/contents', payload);
|
||||
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
|
||||
if (notifyInbox.value && notifiedCount > 0) {
|
||||
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
|
||||
} else {
|
||||
ElMessage.success(t('msg.saved'));
|
||||
}
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
dialogVisible.value = false;
|
||||
bannerDialogVisible.value = false;
|
||||
if (isCreate) page.value = 1;
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
|
||||
const msg = err.response?.data?.error
|
||||
?? (Array.isArray(err.response?.data?.message)
|
||||
? err.response?.data?.message.join(', ')
|
||||
: err.response?.data?.message)
|
||||
?? (e instanceof Error ? e.message : t('msg.save_failed'));
|
||||
ElMessage.error(String(msg));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAnnouncementForm() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildAnnouncementPayload();
|
||||
const isCreate = !editingId.value;
|
||||
if (editingId.value) {
|
||||
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<
|
||||
typeof buildAnnouncementPayload
|
||||
> & { notifyInbox?: boolean };
|
||||
await api.put(`/admin/contents/${editingId.value}`, updateBody);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} else {
|
||||
const { data } = await api.post('/admin/contents', payload);
|
||||
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
|
||||
if (notifyInbox.value && notifiedCount > 0) {
|
||||
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
|
||||
} else {
|
||||
ElMessage.success(t('msg.saved'));
|
||||
}
|
||||
}
|
||||
announcementDialogVisible.value = false;
|
||||
if (isCreate) page.value = 1;
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
@@ -426,7 +503,7 @@ async function setStatus(row: ContentItem, status: ContentStatus) {
|
||||
async function removeItem(row: ContentItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('content.confirm_delete', { title: row.previewTitle || row.id }),
|
||||
t('content.confirm_delete', { title: previewText(row) }),
|
||||
{ type: 'warning' },
|
||||
);
|
||||
} catch {
|
||||
@@ -459,8 +536,42 @@ void load();
|
||||
:name="tp"
|
||||
/>
|
||||
</el-tabs>
|
||||
<p v-if="isAnnouncement" class="type-hint">{{ t('content.hint.announcement') }}</p>
|
||||
<el-form inline class="filter-row">
|
||||
|
||||
<template v-if="isInboxNotifyTab">
|
||||
<div class="inbox-notify-panel">
|
||||
<div class="inbox-notify-row">
|
||||
<div class="inbox-notify-row-text">
|
||||
<span class="inbox-notify-label">{{ t('content.inbox_notify.inbox_enabled') }}</span>
|
||||
<span class="inbox-notify-hint">{{ t('content.inbox_notify.inbox_enabled_hint') }}</span>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="inboxNotifySettings.inboxEnabled"
|
||||
:disabled="!canManageContent || inboxNotifySaving"
|
||||
@change="saveInboxNotifySettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-row">
|
||||
<div class="inbox-notify-row-text">
|
||||
<span class="inbox-notify-label">{{ t('content.inbox_notify.deposit') }}</span>
|
||||
<span class="inbox-notify-hint">{{ t('content.inbox_notify.deposit_hint') }}</span>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="inboxNotifySettings.deposit"
|
||||
:disabled="!canManageContent || inboxNotifySaving"
|
||||
@change="saveInboxNotifySettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes-title">{{ t('content.inbox_notify.manual_title') }}</p>
|
||||
<ul v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes">
|
||||
<li>{{ t('content.inbox_notify.banner_note') }}</li>
|
||||
<li>{{ t('content.inbox_notify.announcement_note') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form v-else inline class="filter-row">
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" clearable style="width: 140px">
|
||||
<el-option :label="t('common.all')" value="" />
|
||||
@@ -478,38 +589,24 @@ void load();
|
||||
{{ t('content.btn.create') }}
|
||||
</el-button>
|
||||
<template v-if="canManageContent">
|
||||
<span v-if="hasSelection" class="batch-hint">
|
||||
{{ t('content.batch.selected', { n: selectedRows.length }) }}
|
||||
</span>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="!hasSelection || saving"
|
||||
@click="batchEnable"
|
||||
>
|
||||
{{ t('content.batch.enable') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="!hasSelection || saving"
|
||||
@click="batchDisable"
|
||||
>
|
||||
{{ t('content.batch.disable') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
:disabled="!hasSelection || saving"
|
||||
@click="batchDelete"
|
||||
>
|
||||
{{ t('content.batch.delete') }}
|
||||
</el-button>
|
||||
<span v-if="hasSelection" class="batch-hint">
|
||||
{{ t('content.batch.selected', { n: selectedRows.length }) }}
|
||||
</span>
|
||||
<el-button size="small" :disabled="!hasSelection || saving" @click="batchEnable">
|
||||
{{ t('content.batch.enable') }}
|
||||
</el-button>
|
||||
<el-button size="small" :disabled="!hasSelection || saving" @click="batchDisable">
|
||||
{{ t('content.batch.disable') }}
|
||||
</el-button>
|
||||
<el-button type="danger" plain size="small" :disabled="!hasSelection || saving" @click="batchDelete">
|
||||
{{ t('content.batch.delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="loading" class="data-card" shadow="never">
|
||||
<el-card v-if="!isInboxNotifyTab" v-loading="loading" class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
@@ -523,21 +620,17 @@ void load();
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column type="selection" width="44" :selectable="() => !saving" />
|
||||
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column prop="sortOrder" :label="t('content.col.sort')" width="64" align="center" />
|
||||
<el-table-column v-if="isBanner" :label="t('content.col.preview')" width="88" align="center">
|
||||
<el-table-column :label="t('content.col.preview')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<img
|
||||
v-if="row.previewImageUrl"
|
||||
:src="row.previewImageUrl"
|
||||
alt=""
|
||||
class="thumb"
|
||||
/>
|
||||
<img v-if="row.previewImageUrl" :src="row.previewImageUrl" alt="" class="thumb" />
|
||||
<span v-else class="thumb-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('content.col.title')" min-width="160">
|
||||
<el-table-column :label="t('content.col.title')" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="preview-title">{{ row.previewTitle || '—' }}</span>
|
||||
<span class="preview-title">{{ previewText(row) }}</span>
|
||||
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
|
||||
{{ hiddenTip(row.playerHiddenReason) }}
|
||||
</p>
|
||||
@@ -564,7 +657,7 @@ void load();
|
||||
<span class="schedule-line">{{ formatTime(row.endTime) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isBanner" :label="t('content.col.link')" min-width="120">
|
||||
<el-table-column :label="t('content.col.link')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.linkType">
|
||||
{{ row.linkType }} · {{ row.linkTarget || '—' }}
|
||||
@@ -586,13 +679,7 @@ void load();
|
||||
>
|
||||
{{ t('content.btn.enable') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
link
|
||||
type="warning"
|
||||
:disabled="saving"
|
||||
@click="setStatus(row, 'INACTIVE')"
|
||||
>
|
||||
<el-button v-else link type="warning" :disabled="saving" @click="setStatus(row, 'INACTIVE')">
|
||||
{{ t('content.btn.disable') }}
|
||||
</el-button>
|
||||
<el-button link type="danger" :disabled="saving" @click="removeItem(row)">
|
||||
@@ -616,134 +703,261 @@ void load();
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close>
|
||||
<el-form label-width="96px" size="small">
|
||||
<el-form-item :label="t('content.col.sort')">
|
||||
<el-input-number v-model="form.sortOrder" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="form.status" style="width: 160px">
|
||||
<el-option
|
||||
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
||||
:key="st"
|
||||
:label="statusLabel(st)"
|
||||
:value="st"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="isBanner">
|
||||
<el-form-item :label="t('content.field.link_type')">
|
||||
<el-select v-model="form.linkType" clearable style="width: 160px">
|
||||
<el-option :label="t('content.link.none')" value="" />
|
||||
<el-option label="ROUTE" value="ROUTE" />
|
||||
<el-option label="URL" value="URL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.linkType" :label="t('content.field.link_target')">
|
||||
<el-input
|
||||
v-model="form.linkTarget"
|
||||
:placeholder="form.linkType === 'ROUTE' ? '/football' : 'https://'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="t('content.field.start_time')">
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('content.field.end_time')">
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-dialog
|
||||
v-model="bannerDialogVisible"
|
||||
:title="bannerDialogTitle"
|
||||
width="1000px"
|
||||
class="content-publish-dialog"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="84px" size="small" class="publish-form">
|
||||
<div class="publish-layout">
|
||||
<aside class="publish-meta">
|
||||
<section class="publish-section publish-section--compact">
|
||||
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
|
||||
|
||||
<div v-for="tr in form.translations" :key="tr.locale" class="locale-block">
|
||||
<div class="locale-head">{{ localeLabel(tr.locale) }}</div>
|
||||
<el-form-item :label="t('content.field.title')">
|
||||
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="isBanner"
|
||||
:label="t('content.field.image_url')"
|
||||
:required="form.status === 'ACTIVE'"
|
||||
>
|
||||
<div class="banner-upload-field">
|
||||
<p class="banner-size-hint">{{ t('content.upload.recommended_size') }}</p>
|
||||
<!-- Image preview -->
|
||||
<div v-if="tr.imageUrl" class="banner-preview">
|
||||
<img :src="tr.imageUrl" alt="" class="banner-preview-img" />
|
||||
<button type="button" class="banner-preview-remove" :title="t('content.upload.remove')" @click="removeBannerImage(tr.locale)">×</button>
|
||||
</div>
|
||||
<!-- Upload actions -->
|
||||
<div class="banner-upload-actions">
|
||||
<label
|
||||
class="banner-upload-btn"
|
||||
:class="{ 'is-uploading': uploadingLocale === tr.locale }"
|
||||
<el-row :gutter="8" class="publish-grid">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option
|
||||
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
||||
:key="st"
|
||||
:label="statusLabel(st)"
|
||||
:value="st"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
|
||||
<el-input-number
|
||||
v-model="form.sortOrder"
|
||||
:min="0"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="8" class="publish-grid">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item :label="t('content.field.link_type')" label-position="top" class="publish-field">
|
||||
<el-select v-model="form.linkType" clearable style="width: 100%">
|
||||
<el-option :label="t('content.link.none')" value="" />
|
||||
<el-option label="ROUTE" value="ROUTE" />
|
||||
<el-option label="URL" value="URL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="form.linkType"
|
||||
:label="t('content.field.link_target')"
|
||||
label-position="top"
|
||||
class="publish-field"
|
||||
>
|
||||
<el-input
|
||||
v-model="form.linkTarget"
|
||||
:placeholder="form.linkType === 'ROUTE' ? '/bet' : 'https://'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
|
||||
<template #label>{{ t('content.field.notify_inbox') }}</template>
|
||||
<div class="notify-inbox-block">
|
||||
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
|
||||
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="publish-section publish-meta-fields">
|
||||
<h3 class="section-title">{{ t('content.section.content') }}</h3>
|
||||
<el-tabs v-model="activeLocale" class="locale-tabs">
|
||||
<el-tab-pane
|
||||
v-for="tr in form.translations"
|
||||
:key="tr.locale"
|
||||
:label="localeLabel(tr.locale)"
|
||||
:name="tr.locale"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
:accept="IMAGE_ACCEPT"
|
||||
style="display: none"
|
||||
:disabled="uploadingLocale === tr.locale"
|
||||
@change="onBannerFileChange($event, tr.locale)"
|
||||
/>
|
||||
{{ uploadingLocale === tr.locale ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
|
||||
</label>
|
||||
<button type="button" class="banner-pick-btn" @click="openMediaPicker(tr.locale)">
|
||||
{{ t('content.upload.pick_media') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Manual URL fallback -->
|
||||
<el-input
|
||||
v-model="tr.imageUrl"
|
||||
:placeholder="t('content.upload.url_placeholder')"
|
||||
size="small"
|
||||
class="banner-url-input"
|
||||
/>
|
||||
<el-form-item
|
||||
:label="t('content.field.title')"
|
||||
:required="form.status === 'ACTIVE'"
|
||||
>
|
||||
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
:label="t('content.field.cover_image')"
|
||||
:required="form.status === 'ACTIVE'"
|
||||
>
|
||||
<ContentImageField
|
||||
v-model="tr.imageUrl"
|
||||
category="banners"
|
||||
size-hint-key="content.upload.recommended_size"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="publish-body">
|
||||
<div class="publish-body-head">
|
||||
<span class="publish-body-label">
|
||||
{{ t('content.field.body') }}
|
||||
<span v-if="form.status === 'ACTIVE'" class="required-mark">*</span>
|
||||
</span>
|
||||
<span class="locale-badge">{{ localeLabel(activeLocale) }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="isAnnouncement ? t('content.field.announce_text') : t('content.field.body')"
|
||||
:required="isAnnouncement && form.status === 'ACTIVE'"
|
||||
>
|
||||
<el-input v-model="tr.body" type="textarea" :rows="isAnnouncement ? 2 : 3" />
|
||||
</el-form-item>
|
||||
|
||||
<ContentRichEditor
|
||||
ref="bannerEditorRef"
|
||||
v-model="activeTranslation.body"
|
||||
fill
|
||||
upload-category="banners"
|
||||
:placeholder="t('content.editor.placeholder')"
|
||||
class="publish-rich-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!!uploadingLocale" @click="submitForm">
|
||||
<el-button @click="bannerDialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitBannerForm">
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Media picker dialog -->
|
||||
<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-picker-loading">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="mediaFiles.length === 0" class="media-picker-empty">{{ t('content.upload.no_media') }}</div>
|
||||
<div v-else class="media-picker-grid">
|
||||
<div
|
||||
v-for="file in mediaFiles"
|
||||
:key="file.id"
|
||||
class="media-picker-card"
|
||||
@click="pickMediaFile(file)"
|
||||
>
|
||||
<div class="media-picker-thumb">
|
||||
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
|
||||
<div v-else class="media-picker-svg">SVG</div>
|
||||
</div>
|
||||
<div class="media-picker-name" :title="file.filename">{{ file.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog
|
||||
v-model="announcementDialogVisible"
|
||||
:title="announcementDialogTitle"
|
||||
width="640px"
|
||||
class="announcement-dialog"
|
||||
destroy-on-close
|
||||
>
|
||||
<p class="announcement-hint">{{ t('content.hint.announcement') }}</p>
|
||||
<el-form label-width="96px" size="small" class="announcement-form">
|
||||
<section class="announcement-publish">
|
||||
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
|
||||
<el-row :gutter="8" class="announcement-publish-row">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option
|
||||
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
||||
:key="st"
|
||||
:label="statusLabel(st)"
|
||||
:value="st"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
|
||||
<el-input-number
|
||||
v-model="form.sortOrder"
|
||||
:min="0"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
|
||||
<template #label>{{ t('content.field.notify_inbox') }}</template>
|
||||
<div class="notify-inbox-block">
|
||||
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
|
||||
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="announcement-content">
|
||||
<h3 class="section-title">{{ t('content.section.content') }}</h3>
|
||||
<el-tabs v-model="activeLocale" class="locale-tabs">
|
||||
<el-tab-pane
|
||||
v-for="tr in form.translations"
|
||||
:key="tr.locale"
|
||||
:label="localeLabel(tr.locale)"
|
||||
:name="tr.locale"
|
||||
>
|
||||
<el-form-item :label="t('content.field.title')">
|
||||
<el-input v-model="tr.title" :placeholder="t('content.field.ticker_title_ph')" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="t('content.field.announce_text')"
|
||||
:required="form.status === 'ACTIVE'"
|
||||
>
|
||||
<el-input
|
||||
v-model="tr.body"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="t('content.field.ticker_body_ph')"
|
||||
/>
|
||||
<p class="field-hint compact-hint">{{ t('content.field.ticker_hint') }}</p>
|
||||
</el-form-item>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="announcementDialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitAnnouncementForm">
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -753,11 +967,27 @@ void load();
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
.field-hint,
|
||||
.batch-hint,
|
||||
.schedule-line,
|
||||
.schedule-sep,
|
||||
.thumb-empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.field-hint.inline-hint,
|
||||
.field-hint.compact-hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.field-hint.inline-hint {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
@@ -766,7 +996,6 @@ void load();
|
||||
|
||||
.batch-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -776,313 +1005,257 @@ void load();
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.thumb-empty {
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
background: #f4f0e8;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden-tip {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #c9a227;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.schedule-line {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.schedule-sep {
|
||||
margin: 0 4px;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.locale-block {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
.content-publish-dialog :deep(.el-dialog__body) {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.locale-head {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
.content-publish-dialog :deep(.el-dialog__footer) {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* ── Banner image upload widget ── */
|
||||
.banner-upload-field {
|
||||
.publish-layout {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.publish-meta {
|
||||
flex: 0 0 380px;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.banner-size-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #777;
|
||||
.publish-meta-fields {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.banner-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
aspect-ratio: 43 / 18;
|
||||
.publish-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 480px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #252525;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.banner-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.banner-preview-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
border: 1px solid #333;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.banner-preview-remove:hover {
|
||||
background: rgba(224, 85, 85, 0.85);
|
||||
}
|
||||
|
||||
.banner-upload-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(212, 175, 55, 0.5);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: var(--gold-text);
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.banner-upload-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.banner-upload-btn.is-uploading {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.banner-pick-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.banner-pick-btn:hover {
|
||||
border-color: #444;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.banner-url-input {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.banner-url-input :deep(.el-input__wrapper) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Media picker ── */
|
||||
.media-picker-loading,
|
||||
.media-picker-empty {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.media-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.media-picker-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid #1e1e1e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.media-picker-card:hover {
|
||||
border-color: rgba(212, 175, 55, 0.5);
|
||||
box-shadow: 0 2px 12px rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
|
||||
.media-picker-thumb {
|
||||
height: 80px;
|
||||
background: #111;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-picker-thumb img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.media-picker-svg {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.media-picker-name {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contents-page {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.type-hint,
|
||||
.batch-hint,
|
||||
.banner-size-hint,
|
||||
.schedule-line,
|
||||
.schedule-sep,
|
||||
.media-picker-loading,
|
||||
.media-picker-empty,
|
||||
.media-picker-name,
|
||||
.thumb-empty,
|
||||
.locale-head {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden-tip {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.thumb,
|
||||
.banner-preview,
|
||||
.media-picker-thumb {
|
||||
background: #f4f0e8;
|
||||
}
|
||||
|
||||
.banner-preview,
|
||||
.locale-block {
|
||||
border-color: var(--border);
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.banner-preview-remove {
|
||||
border-color: rgba(255, 255, 255, 0.36);
|
||||
background: rgba(31, 35, 32, 0.78);
|
||||
.publish-body-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.banner-preview-remove:hover {
|
||||
background: rgba(159, 47, 45, 0.92);
|
||||
}
|
||||
|
||||
.banner-upload-btn,
|
||||
.banner-pick-btn {
|
||||
border-radius: 7px;
|
||||
.publish-body-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.banner-upload-btn {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.banner-upload-btn:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.banner-pick-btn {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.banner-pick-btn:hover {
|
||||
border-color: #d5cfc3;
|
||||
background: var(--accent-hover);
|
||||
color: var(--text);
|
||||
.required-mark {
|
||||
color: var(--el-color-danger);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.media-picker-card {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.media-picker-card:hover {
|
||||
border-color: #d5cfc3;
|
||||
box-shadow: 0 8px 22px rgba(56, 49, 37, 0.08);
|
||||
}
|
||||
|
||||
.media-picker-svg {
|
||||
.locale-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.publish-rich-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-form :deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.publish-form :deep(.el-form-item__label) {
|
||||
padding-right: 8px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.publish-section {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.publish-section--compact {
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
|
||||
.publish-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.publish-section-head {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.publish-section--compact :deep(.publish-field.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.publish-section--compact :deep(.publish-field.el-form-item:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.publish-section--compact :deep(.publish-field .el-form-item__label) {
|
||||
padding: 0 0 4px;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.publish-section--compact :deep(.publish-field .el-form-item__content) {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.publish-grid {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.publish-grid :deep(.publish-field.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.notify-inbox-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notify-inbox-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.notify-inbox-field {
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.publish-grid--secondary {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.announcement-dialog :deep(.el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.announcement-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.announcement-publish {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fbfaf7;
|
||||
}
|
||||
|
||||
.announcement-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.announcement-publish-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.publish-layout {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.publish-meta {
|
||||
flex: none;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-body {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.locale-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.locale-tabs :deep(.el-tabs__content) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
@@ -1091,10 +1264,56 @@ void load();
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-preview,
|
||||
.banner-url-input {
|
||||
max-width: 100%;
|
||||
}
|
||||
.inbox-notify-panel {
|
||||
padding: 4px 0 12px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.inbox-notify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.inbox-notify-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inbox-notify-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.inbox-notify-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inbox-notify-notes-title {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.inbox-notify-notes {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.inbox-notify-notes li {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveApiError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import { refreshDepositPendingCount } from '../composables/useDepositPendingCount';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
@@ -45,7 +46,7 @@ interface DepositAuditLogRow {
|
||||
const items = ref<DepositOrderRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const pageSize = ref(10);
|
||||
const loading = ref(false);
|
||||
|
||||
// Filters
|
||||
@@ -85,6 +86,7 @@ async function fetchList() {
|
||||
const result = data.data ?? { items: [], total: 0 };
|
||||
items.value = result.items ?? [];
|
||||
total.value = result.total ?? 0;
|
||||
void refreshDepositPendingCount();
|
||||
} catch { /* */ } finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -238,6 +240,10 @@ function statusLabel(s: string) {
|
||||
return '● ' + t('deposit.status_pending');
|
||||
}
|
||||
|
||||
function refreshList() {
|
||||
void fetchList();
|
||||
}
|
||||
|
||||
function prevPage() { if (page.value > 1) { page.value--; fetchList(); } }
|
||||
function nextPage() { if (page.value * pageSize.value < total.value) { page.value++; fetchList(); } }
|
||||
|
||||
@@ -268,6 +274,9 @@ onMounted(fetchList);
|
||||
@keydown.enter="page = 1; fetchList()"
|
||||
/>
|
||||
<button class="btn-search" @click="page = 1; fetchList()">{{ t('common.search') }}</button>
|
||||
<button class="btn-refresh" type="button" :disabled="loading" @click="refreshList">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableEmpty v-if="!loading && !items.length" />
|
||||
@@ -275,6 +284,7 @@ onMounted(fetchList);
|
||||
<table v-if="items.length" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px; text-align: center;">{{ t('common.seq') }}</th>
|
||||
<th>{{ t('deposit.order_no') }}</th>
|
||||
<th>{{ t('deposit.player') }}</th>
|
||||
<th>{{ t('common.type') }}</th>
|
||||
@@ -289,7 +299,8 @@ onMounted(fetchList);
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<tr v-for="(row, idx) in items" :key="row.id">
|
||||
<td style="text-align: center;">{{ (page - 1) * pageSize + idx + 1 }}</td>
|
||||
<td class="mono">{{ row.orderNo }}</td>
|
||||
<td>{{ row.playerUsername || row.playerId }}</td>
|
||||
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
|
||||
@@ -445,6 +456,24 @@ onMounted(fetchList);
|
||||
.filters select, .filters input { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 13px; }
|
||||
.filters input { min-width: 160px; }
|
||||
.btn-search { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; font-weight: 600; font-size: 13px; }
|
||||
.btn-refresh {
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-refresh:hover:not(:disabled) {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
}
|
||||
.btn-refresh:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.data-table th, .data-table td { padding: 10px 6px; border-bottom: 1px solid #333; text-align: left; }
|
||||
.data-table th { font-weight: 700; color: #aaa; font-size: 11px; text-transform: uppercase; }
|
||||
|
||||
@@ -47,12 +47,12 @@ interface TransferTxRow {
|
||||
const creditItems = ref<CreditTxRow[]>([]);
|
||||
const creditTotal = ref(0);
|
||||
const creditPage = ref(1);
|
||||
const creditPageSize = ref(20);
|
||||
const creditPageSize = ref(10);
|
||||
|
||||
const transferItems = ref<TransferTxRow[]>([]);
|
||||
const transferTotal = ref(0);
|
||||
const transferPage = ref(1);
|
||||
const transferPageSize = ref(20);
|
||||
const transferPageSize = ref(10);
|
||||
|
||||
const keyword = ref('');
|
||||
const agentId = ref('');
|
||||
@@ -320,6 +320,7 @@ watch(
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column type="index" :index="(i: number) => (creditPage - 1) * creditPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column :label="t('audit.col.time')" min-width="158">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
@@ -390,6 +391,7 @@ watch(
|
||||
<template #empty>
|
||||
<AdminTableEmpty />
|
||||
</template>
|
||||
<el-table-column type="index" :index="(i: number) => (transferPage - 1) * transferPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column :label="t('audit.col.time')" min-width="158">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -442,7 +442,7 @@ function onLeagueArchived() {
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="league-cell">
|
||||
|
||||
@@ -194,7 +194,7 @@ function isLeagueExpanded(id: string) {
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="league-cell">
|
||||
|
||||
@@ -196,6 +196,7 @@ onMounted(fetchList);
|
||||
<table v-if="filteredItems.length" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: center; width: 60px;">{{ t('common.seq') }}</th>
|
||||
<th>{{ t('common.type') }}</th>
|
||||
<th>{{ t('deposit.display_name') }}</th>
|
||||
<th>{{ t('deposit.details') }}</th>
|
||||
@@ -205,7 +206,8 @@ onMounted(fetchList);
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredItems" :key="row.id">
|
||||
<tr v-for="(row, index) in filteredItems" :key="row.id">
|
||||
<td style="text-align: center;">{{ index + 1 }}</td>
|
||||
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
|
||||
<td>{{ row.displayName || '-' }}</td>
|
||||
<td class="details-cell">
|
||||
|
||||
@@ -851,6 +851,7 @@ onMounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
|
||||
<el-table-column type="index" :index="(i: number) => (previewItemsPage.page - 1) * previewItemsPage.pageSize + i + 1" :label="t('common.seq')" width="55" align="center" />
|
||||
<el-table-column :label="t('bet.col.bet_no')" prop="betNo" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('common.type')" width="68">
|
||||
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
|
||||
@@ -935,6 +936,7 @@ onMounted(() => {
|
||||
stripe
|
||||
class="stats-table"
|
||||
>
|
||||
<el-table-column type="index" :index="(i: number) => ((stats?.bets.page ?? 1) - 1) * (stats?.bets.pageSize ?? 10) + i + 1" :label="t('common.seq')" width="55" align="center" />
|
||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="140" />
|
||||
<el-table-column prop="username" :label="t('bet.col.player')" width="96" />
|
||||
<el-table-column :label="t('common.type')" width="72">
|
||||
|
||||
@@ -247,6 +247,7 @@ onMounted(async () => {
|
||||
<span v-else class="case-details-empty">{{ t('smoke.no_steps') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
|
||||
<el-table-column prop="id" :label="t('smoke.col.id')" width="88" />
|
||||
<el-table-column :label="t('smoke.col.suite')" width="120">
|
||||
<template #default="{ row }">{{ suiteName(row.suite) }}</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api from '../api';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import AdminTableWrap from '../components/AdminTableWrap.vue';
|
||||
|
||||
@@ -14,6 +15,7 @@ interface StaffRow {
|
||||
roleName: string | null;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
visibleMenus: string | null;
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
@@ -23,22 +25,46 @@ interface RoleOption {
|
||||
}
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const authStore = useAuthStore();
|
||||
const currentUserId = computed(() => authStore.user.value?.id);
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<StaffRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const pageSize = ref(10);
|
||||
const keyword = ref('');
|
||||
const roles = ref<RoleOption[]>([]);
|
||||
|
||||
const menuOptions = [
|
||||
{ key: 'dashboard', label: 'nav.dashboard' },
|
||||
{ key: 'matches', label: 'nav.matches' },
|
||||
{ key: 'users', label: 'nav.agents_players' },
|
||||
{ key: 'finance-logs', label: 'nav.finance_logs' },
|
||||
{ key: 'deposit', label: 'nav.deposit_manage' },
|
||||
{ key: 'cashback', label: 'nav.cashback' },
|
||||
{ key: 'bets', label: 'nav.bets' },
|
||||
{ key: 'contents', label: 'nav.contents' },
|
||||
{ key: 'media', label: 'nav.media' },
|
||||
{ key: 'audit', label: 'nav.audit' },
|
||||
{ key: 'staff', label: 'nav.staff' },
|
||||
{ key: 'smoke-tests', label: 'nav.smoke_tests' },
|
||||
];
|
||||
|
||||
const ROLE_DEFAULT_MENUS: Record<string, string[]> = {
|
||||
SUPER_ADMIN: ['dashboard', 'matches', 'users', 'finance-logs', 'deposit', 'cashback', 'bets', 'contents', 'media', 'audit', 'staff', 'smoke-tests'],
|
||||
MATCH_ADMIN: ['matches', 'bets', 'contents', 'media'],
|
||||
FINANCE_ADMIN: ['dashboard', 'users', 'finance-logs', 'deposit', 'cashback', 'bets'],
|
||||
SUPPORT: ['users', 'bets', 'contents', 'media'],
|
||||
};
|
||||
|
||||
const createVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const createForm = ref({ username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN' });
|
||||
const createForm = ref({ username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN', checkedMenus: [] as string[] });
|
||||
|
||||
const editVisible = ref(false);
|
||||
const editLoading = ref(false);
|
||||
const editForm = ref({ id: '', username: '', status: 'ACTIVE', roleCode: 'MATCH_ADMIN', password: '' });
|
||||
const editForm = ref({ id: '', username: '', status: 'ACTIVE', roleCode: 'MATCH_ADMIN', password: '', checkedMenus: [] as string[] });
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
@@ -80,8 +106,18 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateRoleChange(newRole: string) {
|
||||
createForm.value.checkedMenus = [...(ROLE_DEFAULT_MENUS[newRole] || [])];
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN' };
|
||||
createForm.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
roleCode: 'MATCH_ADMIN',
|
||||
checkedMenus: [...ROLE_DEFAULT_MENUS['MATCH_ADMIN']],
|
||||
};
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -105,6 +141,7 @@ async function submitCreate() {
|
||||
username: f.username.trim(),
|
||||
password: f.password,
|
||||
roleCode: f.roleCode,
|
||||
visibleMenus: f.checkedMenus.join(','),
|
||||
});
|
||||
ElMessage.success(t('msg.saved'));
|
||||
createVisible.value = false;
|
||||
@@ -117,6 +154,10 @@ async function submitCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
function onEditRoleChange(newRole: string) {
|
||||
editForm.value.checkedMenus = [...(ROLE_DEFAULT_MENUS[newRole] || [])];
|
||||
}
|
||||
|
||||
function openEdit(row: StaffRow) {
|
||||
editForm.value = {
|
||||
id: row.id,
|
||||
@@ -124,6 +165,9 @@ function openEdit(row: StaffRow) {
|
||||
status: row.status,
|
||||
roleCode: row.role ?? 'MATCH_ADMIN',
|
||||
password: '',
|
||||
checkedMenus: row.visibleMenus
|
||||
? row.visibleMenus.split(',').filter(Boolean)
|
||||
: [...(ROLE_DEFAULT_MENUS[row.role ?? 'MATCH_ADMIN'] || [])],
|
||||
};
|
||||
editVisible.value = true;
|
||||
}
|
||||
@@ -139,6 +183,7 @@ async function submitEdit() {
|
||||
const payload: Record<string, string> = {
|
||||
status: f.status,
|
||||
roleCode: f.roleCode,
|
||||
visibleMenus: f.checkedMenus.join(','),
|
||||
};
|
||||
if (f.password.trim()) payload.password = f.password.trim();
|
||||
const { data } = await api.patch(`/admin/staff/${f.id}`, payload);
|
||||
@@ -157,6 +202,49 @@ async function submitEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFreeze(row: StaffRow, targetStatus: string) {
|
||||
const actionText = targetStatus === 'SUSPENDED' ? t('common.freeze') : t('common.unfreeze');
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要${actionText}管理员「${row.username}」吗?`,
|
||||
t('msg.freeze_confirm_title', { action: actionText }),
|
||||
{ type: 'warning' },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.patch(`/admin/staff/${row.id}`, { status: targetStatus });
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStaff(row: StaffRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除管理员「${row.username}」吗?此操作无法撤销。`,
|
||||
t('common.delete'),
|
||||
{ type: 'warning' },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/admin/staff/${row.id}`);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRoles();
|
||||
await load();
|
||||
@@ -191,19 +279,61 @@ onMounted(async () => {
|
||||
<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" />
|
||||
<el-table-column prop="username" :label="t('login.username')" min-width="120" />
|
||||
<el-table-column :label="t('staff.col.role')" min-width="140">
|
||||
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="100">
|
||||
<template #default="{ row }">{{ row.status }}</template>
|
||||
<el-table-column :label="t('common.status')" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="row.status === 'ACTIVE' ? 'success' : row.status === 'SUSPENDED' ? 'warning' : 'danger'"
|
||||
effect="dark"
|
||||
>
|
||||
{{ t(`user.status.${row.status}`) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('staff.col.last_login')" min-width="160">
|
||||
<template #default="{ row }">{{ formatTime(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="100" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="220" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">{{ t('common.edit') }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">
|
||||
{{ t('common.edit') }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="row.status === 'ACTIVE'"
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="row.id === currentUserId"
|
||||
@click="toggleFreeze(row, 'SUSPENDED')"
|
||||
>
|
||||
{{ t('common.freeze') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
link
|
||||
type="success"
|
||||
size="small"
|
||||
:disabled="row.id === currentUserId"
|
||||
@click="toggleFreeze(row, 'ACTIVE')"
|
||||
>
|
||||
{{ t('common.unfreeze') }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="row.id === currentUserId"
|
||||
@click="deleteStaff(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -229,7 +359,7 @@ onMounted(async () => {
|
||||
<el-input v-model="createForm.username" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('staff.col.role')" required>
|
||||
<el-select v-model="createForm.roleCode" style="width: 100%">
|
||||
<el-select v-model="createForm.roleCode" style="width: 100%" @change="onCreateRoleChange">
|
||||
<el-option
|
||||
v-for="r in roles.filter((x) => x.code !== 'SUPER_ADMIN')"
|
||||
:key="r.code"
|
||||
@@ -238,6 +368,19 @@ onMounted(async () => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('staff.field.visible_menus')">
|
||||
<el-checkbox-group v-model="createForm.checkedMenus">
|
||||
<div class="menu-grid">
|
||||
<el-checkbox
|
||||
v-for="item in menuOptions"
|
||||
:key="item.key"
|
||||
:label="item.key"
|
||||
>
|
||||
{{ t(item.label) }}
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('login.password')" required>
|
||||
<el-input v-model="createForm.password" type="password" autocomplete="new-password" />
|
||||
</el-form-item>
|
||||
@@ -257,7 +400,7 @@ onMounted(async () => {
|
||||
<el-input :model-value="editForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('staff.col.role')">
|
||||
<el-select v-model="editForm.roleCode" style="width: 100%" :disabled="editForm.roleCode === 'SUPER_ADMIN'">
|
||||
<el-select v-model="editForm.roleCode" style="width: 100%" :disabled="editForm.roleCode === 'SUPER_ADMIN'" @change="onEditRoleChange">
|
||||
<el-option
|
||||
v-for="r in roles"
|
||||
:key="r.code"
|
||||
@@ -266,11 +409,24 @@ onMounted(async () => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('staff.field.visible_menus')">
|
||||
<el-checkbox-group v-model="editForm.checkedMenus">
|
||||
<div class="menu-grid">
|
||||
<el-checkbox
|
||||
v-for="item in menuOptions"
|
||||
:key="item.key"
|
||||
:label="item.key"
|
||||
>
|
||||
{{ t(item.label) }}
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="editForm.status" style="width: 100%">
|
||||
<el-option label="ACTIVE" value="ACTIVE" />
|
||||
<el-option label="SUSPENDED" value="SUSPENDED" />
|
||||
<el-option label="DISABLED" value="DISABLED" />
|
||||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||
<el-option :label="t('user.status.DISABLED')" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.reset_password')">
|
||||
@@ -291,4 +447,10 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
shouldCompactAmount as shouldCompact,
|
||||
} from '../utils/format-amount';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import AdminPlayerStatusCell from '../components/AdminPlayerStatusCell.vue';
|
||||
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
|
||||
import AdminDetailItem from '../components/AdminDetailItem.vue';
|
||||
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
||||
@@ -511,11 +512,9 @@ function statusLabel(s: string) {
|
||||
</template>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<el-table-column :label="t('common.status')" min-width="128">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.col.agent')" min-width="120">
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface AdminDashboard {
|
||||
playersActive: number;
|
||||
playersSuspended: number;
|
||||
playersDirect: number;
|
||||
playersOnlineNow: number;
|
||||
agentsTotal: number;
|
||||
agentsActive: number;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,11 @@ const kpiPlayer = computed(() => {
|
||||
value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`,
|
||||
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
|
||||
},
|
||||
{
|
||||
label: t('dash.players_online'),
|
||||
value: fmtCount(s.value.users.playersOnlineNow ?? 0),
|
||||
sub: t('dash.players_online_hint'),
|
||||
},
|
||||
{
|
||||
label: t('dash.kpi_agents_active'),
|
||||
value: fmtCount(s.value.users.agentsActive),
|
||||
@@ -55,6 +60,7 @@ const userDistributionOption = computed(() => {
|
||||
const userSegs = u
|
||||
? [
|
||||
{ label: t('dash.user_active'), value: u.playersActive, color: '#346538' },
|
||||
{ label: t('dash.user_online'), value: u.playersOnlineNow ?? 0, color: '#2d8a4e' },
|
||||
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#9f2f2d' },
|
||||
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#1f6c9f' },
|
||||
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#956400' },
|
||||
|
||||
@@ -30,7 +30,7 @@ const archiveTitle = ref('');
|
||||
const matches = ref<unknown[]>([]);
|
||||
const loading = ref(false);
|
||||
const matchPage = ref(1);
|
||||
const matchPageSize = ref(20);
|
||||
const matchPageSize = ref(10);
|
||||
const matchTotal = ref(0);
|
||||
let loadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -334,7 +334,7 @@ defineExpose({ reload: load });
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
||||
<el-table-column prop="id" label="ID" width="64" />
|
||||
<el-table-column type="index" :index="(i: number) => (matchPage - 1) * matchPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="matchup-link">{{ matchTitle(row) }}</span>
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface PlayerRow {
|
||||
availableBalance: string;
|
||||
frozenBalance: string;
|
||||
lastLoginAt: string | null;
|
||||
isOnline?: boolean;
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
totalReturn: string;
|
||||
|
||||
Reference in New Issue
Block a user