feat(admin,player,api): 公共管理与优胜冠军国旗、玩家端内容对接
新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,7 @@ export interface OutrightSelection {
|
||||
id: string;
|
||||
teamCode: string;
|
||||
teamName: string;
|
||||
logoUrl?: string | null;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
@@ -85,6 +86,7 @@ const showLoadMore = computed(
|
||||
:key="sel.id"
|
||||
:team-code="sel.teamCode"
|
||||
:team-name="sel.teamName"
|
||||
:logo-url="sel.logoUrl"
|
||||
:odds="sel.odds"
|
||||
@pick="emit('pick', sel)"
|
||||
/>
|
||||
|
||||
@@ -6,11 +6,14 @@ const props = defineProps<{
|
||||
teamCode: string;
|
||||
teamName: string;
|
||||
odds: string;
|
||||
logoUrl?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [] }>();
|
||||
|
||||
const flag = computed(() => teamFlagUrl(props.teamCode, props.teamName));
|
||||
const flag = computed(
|
||||
() => props.logoUrl?.trim() || teamFlagUrl(props.teamCode, props.teamName),
|
||||
);
|
||||
const flagFailed = ref(false);
|
||||
|
||||
function onFlagError() {
|
||||
@@ -18,7 +21,7 @@ function onFlagError() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.teamCode, props.teamName] as const,
|
||||
() => [props.teamCode, props.teamName, props.logoUrl] as const,
|
||||
() => {
|
||||
flagFailed.value = false;
|
||||
},
|
||||
|
||||
@@ -1,38 +1,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
||||
|
||||
function collectAnnouncementLines(data: {
|
||||
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
notices?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
} | null): string[] {
|
||||
const lines: string[] = [];
|
||||
if (!data) return lines;
|
||||
for (const item of data.ticker ?? []) {
|
||||
const text = item.translation?.body || item.translation?.title;
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
for (const item of data.notices ?? []) {
|
||||
const text = item.translation?.title || item.translation?.body;
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
import { usePlayerHome } from './usePlayerHome';
|
||||
|
||||
/** @deprecated 请使用 usePlayerHome */
|
||||
export function useAnnouncements() {
|
||||
const { t } = useI18n();
|
||||
const items = ref<string[]>(resolveAnnouncements([], t('home.announcement_default')));
|
||||
|
||||
async function load() {
|
||||
const fallback = t('home.announcement_default');
|
||||
try {
|
||||
const { data } = await api.get('/player/home');
|
||||
items.value = resolveAnnouncements(collectAnnouncementLines(data.data), fallback);
|
||||
} catch {
|
||||
items.value = resolveAnnouncements([], fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return { items, load };
|
||||
const { announcements, load } = usePlayerHome();
|
||||
return { items: announcements, load };
|
||||
}
|
||||
|
||||
72
apps/player/src/composables/usePlayerHome.ts
Normal file
72
apps/player/src/composables/usePlayerHome.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import type { BannerItem } from '../components/BannerCarousel.vue';
|
||||
import { resolveBanners } from '../constants/defaultBanner';
|
||||
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
||||
|
||||
export interface PlayerHomeMatch {
|
||||
id: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
startTime: string;
|
||||
isHot?: boolean;
|
||||
}
|
||||
|
||||
interface HomePayload {
|
||||
banners?: BannerItem[];
|
||||
announcements?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
notices?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
hotMatches?: PlayerHomeMatch[];
|
||||
}
|
||||
|
||||
const homeRaw = ref<HomePayload | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
function collectAnnouncementLines(data: HomePayload | null): string[] {
|
||||
if (!data) return [];
|
||||
const source =
|
||||
data.announcements && data.announcements.length > 0
|
||||
? data.announcements
|
||||
: [...(data.ticker ?? []), ...(data.notices ?? [])];
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const item of source) {
|
||||
const text = item.translation?.title || item.translation?.body;
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 管理端公共内容 → 玩家端首页/跑马灯(单例,避免重复请求) */
|
||||
export function usePlayerHome() {
|
||||
const { t } = useI18n();
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/home');
|
||||
homeRaw.value = (data.data ?? null) as HomePayload | null;
|
||||
} catch {
|
||||
homeRaw.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const banners = computed(() => resolveBanners(homeRaw.value?.banners));
|
||||
const announcements = computed(() =>
|
||||
resolveAnnouncements(collectAnnouncementLines(homeRaw.value), t('home.announcement_default')),
|
||||
);
|
||||
const hotMatches = computed(() => homeRaw.value?.hotMatches ?? []);
|
||||
|
||||
return {
|
||||
homeRaw,
|
||||
loading,
|
||||
load,
|
||||
banners,
|
||||
announcements,
|
||||
hotMatches,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export function resolveBanners(banners: BannerItem[] | undefined | null): Banner
|
||||
},
|
||||
}));
|
||||
|
||||
if (fromApi.length > 0) return fromApi;
|
||||
|
||||
const defaultSlide: BannerItem = {
|
||||
...DEFAULT_BANNER,
|
||||
translation: {
|
||||
@@ -37,5 +39,5 @@ export function resolveBanners(banners: BannerItem[] | undefined | null): Banner
|
||||
},
|
||||
};
|
||||
|
||||
return [defaultSlide, ...fromApi];
|
||||
return [defaultSlide];
|
||||
}
|
||||
|
||||
@@ -11,25 +11,30 @@ import { useAppLocale } from '../composables/useAppLocale';
|
||||
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
||||
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { useAnnouncements } from '../composables/useAnnouncements';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { initFromUser } = useAppLocale();
|
||||
const route = useRoute();
|
||||
const slip = useBetSlipStore();
|
||||
|
||||
const showAnnouncement = computed(() => !route.path.startsWith('/profile'));
|
||||
const { items: announcements, load: loadAnnouncements } = useAnnouncements();
|
||||
const { announcements, load: loadPlayerHome } = usePlayerHome();
|
||||
|
||||
useOnLocaleChange(loadPlayerHome);
|
||||
|
||||
onMounted(() => {
|
||||
loadAnnouncements();
|
||||
if (auth.user?.locale) initFromUser(auth.user.locale);
|
||||
});
|
||||
|
||||
watch(locale, (next, prev) => {
|
||||
if (prev && next !== prev) void loadAnnouncements();
|
||||
});
|
||||
watch(
|
||||
() => auth.token,
|
||||
(token) => {
|
||||
if (token) void loadPlayerHome();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
import api from '../api';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||
import { resolveBanners } from '../constants/defaultBanner';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const home = ref<{
|
||||
banners: Banner[];
|
||||
hotMatches: Match[];
|
||||
ticker: ContentItem[];
|
||||
notices: ContentItem[];
|
||||
} | null>(null);
|
||||
|
||||
interface ContentItem {
|
||||
translation?: { title?: string; body?: string; imageUrl?: string };
|
||||
}
|
||||
|
||||
interface Banner {
|
||||
id?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
translation?: { title?: string; body?: string; imageUrl?: string };
|
||||
}
|
||||
|
||||
interface Match {
|
||||
id: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
startTime: string;
|
||||
isHot: boolean;
|
||||
}
|
||||
|
||||
const displayBanners = computed(() => resolveBanners(home.value?.banners));
|
||||
|
||||
async function loadHome() {
|
||||
const { data } = await api.get('/player/home');
|
||||
home.value = data.data;
|
||||
}
|
||||
|
||||
useOnLocaleChange(loadHome);
|
||||
const { banners, hotMatches, loading } = usePlayerHome();
|
||||
|
||||
function goMatch(id: string) {
|
||||
router.push(`/match/${id}`);
|
||||
@@ -53,15 +16,15 @@ function goMatch(id: string) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BannerCarousel :banners="displayBanners" />
|
||||
<BannerCarousel :banners="banners" />
|
||||
|
||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
||||
<div v-for="match in home?.hotMatches || []" :key="match.id" class="card match-card" @click="goMatch(match.id)">
|
||||
<div v-for="match in hotMatches" :key="match.id" class="card match-card" @click="goMatch(match.id)">
|
||||
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
|
||||
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="home && !home.hotMatches?.length" class="empty">
|
||||
<div v-if="!loading && !hotMatches.length" class="empty">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||
<p>{{ t('home.no_matches') }}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user