feat(theme-2): sync inbox/announcements/presence/deposit from main

This commit is contained in:
2026-06-18 09:38:33 +08:00
parent ec51b675e3
commit e56029225e
86 changed files with 6828 additions and 928 deletions

View File

@@ -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('');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ export interface AdminDashboard {
playersActive: number;
playersSuspended: number;
playersDirect: number;
playersOnlineNow: number;
agentsTotal: number;
agentsActive: number;
};

View File

@@ -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' },

View File

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

View File

@@ -72,6 +72,7 @@ export interface PlayerRow {
availableBalance: string;
frozenBalance: string;
lastLoginAt: string | null;
isOnline?: boolean;
betCount: number;
totalStake: string;
totalReturn: string;