feat(admin,player,api): 公共管理与优胜冠军国旗、玩家端内容对接

新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 10:25:42 +08:00
parent 27580b2479
commit f76728dc3e
21 changed files with 1966 additions and 136 deletions

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
BUILTIN_COUNTRIES,
countryFlagUrl,
getBuiltinCountry,
searchBuiltinCountries,
type BuiltinCountry,
} from '../../data/builtinCountries';
import { useAdminLocale } from '../../composables/useAdminLocale';
const props = defineProps<{
modelValue: string;
size?: 'small' | 'default' | 'large';
disabled?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [code: string];
pick: [country: BuiltinCountry];
}>();
const { t, locale } = useAdminLocale();
const filterKeyword = ref('');
const options = computed(() => searchBuiltinCountries(filterKeyword.value));
const selected = computed(() => getBuiltinCountry(props.modelValue));
function optionLabel(c: BuiltinCountry) {
return locale.value === 'en-US'
? `${c.nameEn} (${c.code})`
: `${c.nameZh} (${c.code})`;
}
function onFilter(q: string) {
filterKeyword.value = q;
}
function onChange(code: string | undefined) {
const value = code ?? '';
emit('update:modelValue', value);
const country = getBuiltinCountry(value);
if (country) emit('pick', country);
}
</script>
<template>
<div class="country-flag-select">
<el-select
:model-value="modelValue || undefined"
:size="size ?? 'small'"
:disabled="disabled"
filterable
clearable
:filter-method="onFilter"
:placeholder="t('outright.country_ph')"
class="country-select"
@update:model-value="onChange"
@visible-change="(v: boolean) => { if (v) filterKeyword = ''; }"
>
<el-option
v-for="c in options"
:key="c.code"
:label="optionLabel(c)"
:value="c.code"
>
<div class="country-option">
<img :src="countryFlagUrl(c)" alt="" class="country-option-flag" loading="lazy" />
<span class="country-option-name">{{ c.nameZh }} · {{ c.nameEn }}</span>
<span class="country-option-code">{{ c.code }}</span>
</div>
</el-option>
</el-select>
<img
v-if="selected"
:src="countryFlagUrl(selected)"
alt=""
class="country-preview"
loading="lazy"
/>
</div>
</template>
<style scoped>
.country-flag-select {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.country-select {
flex: 1;
min-width: 0;
}
.country-preview {
width: 32px;
height: 22px;
object-fit: cover;
border-radius: 2px;
flex-shrink: 0;
background: #222;
}
.country-option {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.country-option-flag {
width: 24px;
height: 16px;
object-fit: cover;
border-radius: 2px;
flex-shrink: 0;
}
.country-option-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.country-option-code {
font-size: 11px;
color: #888;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,127 @@
/** 内置国家队(世界杯 48 强 + 常用队),供管理端选择国旗与自动填充队名 */
export type BuiltinCountry = {
code: string;
nameZh: string;
nameEn: string;
iso: string;
};
export const BUILTIN_COUNTRIES: BuiltinCountry[] = [
{ code: 'FRA', nameZh: '法国', nameEn: 'France', iso: 'fr' },
{ code: 'ESP', nameZh: '西班牙', nameEn: 'Spain', iso: 'es' },
{ code: 'ENG', nameZh: '英格兰', nameEn: 'England', iso: 'gb-eng' },
{ code: 'BRA', nameZh: '巴西', nameEn: 'Brazil', iso: 'br' },
{ code: 'ARG', nameZh: '阿根廷', nameEn: 'Argentina', iso: 'ar' },
{ code: 'POR', nameZh: '葡萄牙', nameEn: 'Portugal', iso: 'pt' },
{ code: 'GER', nameZh: '德国', nameEn: 'Germany', iso: 'de' },
{ code: 'NED', nameZh: '荷兰', nameEn: 'Netherlands', iso: 'nl' },
{ code: 'NOR', nameZh: '挪威', nameEn: 'Norway', iso: 'no' },
{ code: 'BEL', nameZh: '比利时', nameEn: 'Belgium', iso: 'be' },
{ code: 'COL', nameZh: '哥伦比亚', nameEn: 'Colombia', iso: 'co' },
{ code: 'JPN', nameZh: '日本', nameEn: 'Japan', iso: 'jp' },
{ code: 'URU', nameZh: '乌拉圭', nameEn: 'Uruguay', iso: 'uy' },
{ code: 'USA', nameZh: '美国', nameEn: 'USA', iso: 'us' },
{ code: 'MAR', nameZh: '摩洛哥', nameEn: 'Morocco', iso: 'ma' },
{ code: 'CRO', nameZh: '克罗地亚', nameEn: 'Croatia', iso: 'hr' },
{ code: 'MEX', nameZh: '墨西哥', nameEn: 'Mexico', iso: 'mx' },
{ code: 'SUI', nameZh: '瑞士', nameEn: 'Switzerland', iso: 'ch' },
{ code: 'TUR', nameZh: '土耳其', nameEn: 'Turkey', iso: 'tr' },
{ code: 'SEN', nameZh: '塞内加尔', nameEn: 'Senegal', iso: 'sn' },
{ code: 'KOR', nameZh: '韩国', nameEn: 'South Korea', iso: 'kr' },
{ code: 'AUT', nameZh: '奥地利', nameEn: 'Austria', iso: 'at' },
{ code: 'ECU', nameZh: '厄瓜多尔', nameEn: 'Ecuador', iso: 'ec' },
{ code: 'SWE', nameZh: '瑞典', nameEn: 'Sweden', iso: 'se' },
{ code: 'IRN', nameZh: '伊朗', nameEn: 'Iran', iso: 'ir' },
{ code: 'GHA', nameZh: '加纳', nameEn: 'Ghana', iso: 'gh' },
{ code: 'ALG', nameZh: '阿尔及利亚', nameEn: 'Algeria', iso: 'dz' },
{ code: 'BIH', nameZh: '波黑', nameEn: 'Bosnia', iso: 'ba' },
{ code: 'EGY', nameZh: '埃及', nameEn: 'Egypt', iso: 'eg' },
{ code: 'TUN', nameZh: '突尼斯', nameEn: 'Tunisia', iso: 'tn' },
{ code: 'CAN', nameZh: '加拿大', nameEn: 'Canada', iso: 'ca' },
{ code: 'PAN', nameZh: '巴拿马', nameEn: 'Panama', iso: 'pa' },
{ code: 'AUS', nameZh: '澳大利亚', nameEn: 'Australia', iso: 'au' },
{ code: 'CZE', nameZh: '捷克', nameEn: 'Czech Republic', iso: 'cz' },
{ code: 'KSA', nameZh: '沙特阿拉伯', nameEn: 'Saudi Arabia', iso: 'sa' },
{ code: 'NZL', nameZh: '新西兰', nameEn: 'New Zealand', iso: 'nz' },
{ code: 'COD', nameZh: '刚果(金)', nameEn: 'DR Congo', iso: 'cd' },
{ code: 'UZB', nameZh: '乌兹别克斯坦', nameEn: 'Uzbekistan', iso: 'uz' },
{ code: 'IRQ', nameZh: '伊拉克', nameEn: 'Iraq', iso: 'iq' },
{ code: 'RSA', nameZh: '南非', nameEn: 'South Africa', iso: 'za' },
{ code: 'CIV', nameZh: '科特迪瓦', nameEn: 'Ivory Coast', iso: 'ci' },
{ code: 'JOR', nameZh: '约旦', nameEn: 'Jordan', iso: 'jo' },
{ code: 'PAR', nameZh: '巴拉圭', nameEn: 'Paraguay', iso: 'py' },
{ code: 'HAI', nameZh: '海地', nameEn: 'Haiti', iso: 'ht' },
{ code: 'QAT', nameZh: '卡塔尔', nameEn: 'Qatar', iso: 'qa' },
{ code: 'CPV', nameZh: '佛得角', nameEn: 'Cape Verde', iso: 'cv' },
{ code: 'CUW', nameZh: '库拉索', nameEn: 'Curacao', iso: 'cw' },
{ code: 'SCO', nameZh: '苏格兰', nameEn: 'Scotland', iso: 'gb-sct' },
{ code: 'CHN', nameZh: '中国', nameEn: 'China', iso: 'cn' },
{ code: 'ITA', nameZh: '意大利', nameEn: 'Italy', iso: 'it' },
{ code: 'WAL', nameZh: '威尔士', nameEn: 'Wales', iso: 'gb-wls' },
{ code: 'UKR', nameZh: '乌克兰', nameEn: 'Ukraine', iso: 'ua' },
{ code: 'POL', nameZh: '波兰', nameEn: 'Poland', iso: 'pl' },
{ code: 'DEN', nameZh: '丹麦', nameEn: 'Denmark', iso: 'dk' },
{ code: 'FIN', nameZh: '芬兰', nameEn: 'Finland', iso: 'fi' },
{ code: 'IRL', nameZh: '爱尔兰', nameEn: 'Ireland', iso: 'ie' },
{ code: 'ISL', nameZh: '冰岛', nameEn: 'Iceland', iso: 'is' },
{ code: 'GRE', nameZh: '希腊', nameEn: 'Greece', iso: 'gr' },
{ code: 'SRB', nameZh: '塞尔维亚', nameEn: 'Serbia', iso: 'rs' },
{ code: 'ROU', nameZh: '罗马尼亚', nameEn: 'Romania', iso: 'ro' },
{ code: 'HUN', nameZh: '匈牙利', nameEn: 'Hungary', iso: 'hu' },
{ code: 'SVK', nameZh: '斯洛伐克', nameEn: 'Slovakia', iso: 'sk' },
{ code: 'SVN', nameZh: '斯洛文尼亚', nameEn: 'Slovenia', iso: 'si' },
{ code: 'NGA', nameZh: '尼日利亚', nameEn: 'Nigeria', iso: 'ng' },
{ code: 'CMR', nameZh: '喀麦隆', nameEn: 'Cameroon', iso: 'cm' },
{ code: 'CHI', nameZh: '智利', nameEn: 'Chile', iso: 'cl' },
{ code: 'PER', nameZh: '秘鲁', nameEn: 'Peru', iso: 'pe' },
{ code: 'VEN', nameZh: '委内瑞拉', nameEn: 'Venezuela', iso: 've' },
{ code: 'CRC', nameZh: '哥斯达黎加', nameEn: 'Costa Rica', iso: 'cr' },
{ code: 'JAM', nameZh: '牙买加', nameEn: 'Jamaica', iso: 'jm' },
{ code: 'UAE', nameZh: '阿联酋', nameEn: 'UAE', iso: 'ae' },
{ code: 'THA', nameZh: '泰国', nameEn: 'Thailand', iso: 'th' },
{ code: 'VIE', nameZh: '越南', nameEn: 'Vietnam', iso: 'vn' },
{ code: 'IDN', nameZh: '印度尼西亚', nameEn: 'Indonesia', iso: 'id' },
{ code: 'MAS', nameZh: '马来西亚', nameEn: 'Malaysia', iso: 'my' },
];
const byCode = new Map(BUILTIN_COUNTRIES.map((c) => [c.code, c]));
export function getBuiltinCountry(code?: string | null): BuiltinCountry | undefined {
const key = (code ?? '').trim().toUpperCase();
return key ? byCode.get(key) : undefined;
}
export function countryFlagUrl(country: BuiltinCountry | string): string {
const c = typeof country === 'string' ? getBuiltinCountry(country) : country;
if (!c) return '';
return `https://flagcdn.com/w40/${c.iso}.png`;
}
export function searchBuiltinCountries(keyword: string): BuiltinCountry[] {
const k = keyword.trim().toLowerCase();
if (!k) return BUILTIN_COUNTRIES;
return BUILTIN_COUNTRIES.filter(
(c) =>
c.code.toLowerCase().includes(k) ||
c.nameZh.includes(keyword.trim()) ||
c.nameEn.toLowerCase().includes(k) ||
c.iso.toLowerCase().includes(k),
);
}
export function resolveCountryCode(
teamCode?: string,
logoUrl?: string | null,
): string {
const fromCode = getBuiltinCountry(teamCode);
if (fromCode) return fromCode.code;
if (logoUrl) {
const m = logoUrl.match(/flagcdn\.com\/w\d+\/([a-z0-9-]+)\.png/i);
if (m) {
const iso = m[1].toLowerCase();
const hit = BUILTIN_COUNTRIES.find((c) => c.iso === iso);
if (hit) return hit.code;
}
}
return (teamCode ?? '').trim().toUpperCase();
}

View File

@@ -36,6 +36,7 @@ const zh: Record<string, string> = {
'nav.outrights': '优胜冠军',
'nav.bets': '注单管理',
'nav.cashback': '返水管理',
'nav.contents': '公共管理',
'nav.audit': '操作日志',
'nav.players': '直属玩家',
'nav.subAgents': '下级代理',
@@ -187,6 +188,7 @@ const en: Record<string, string> = {
'nav.outrights': 'Outrights',
'nav.bets': 'Bets',
'nav.cashback': 'Cashback',
'nav.contents': 'Public Content',
'nav.audit': 'Audit Log',
'nav.players': 'My Players',
'nav.subAgents': 'Sub-Agents',
@@ -338,6 +340,7 @@ const ms: Record<string, string> = {
'nav.outrights': 'Juara',
'nav.bets': 'Pertaruhan',
'nav.cashback': 'Rebat',
'nav.contents': 'Kandungan awam',
'nav.audit': 'Log audit',
'nav.players': 'Pemain saya',
'nav.subAgents': 'Sub-ejen',

View File

@@ -249,13 +249,61 @@ export const adminPagesMs: Record<string, string> = {
'msg.outright_odds_saved': 'Odds juara disimpan',
'msg.load_failed': 'Gagal memuatkan',
'content.btn.create': 'Kandungan baharu',
'content.btn.enable': 'Aktifkan',
'content.btn.disable': 'Nyahaktif',
'content.dialog.create': 'Kandungan awam baharu',
'content.dialog.edit': 'Edit kandungan awam',
'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.status.DRAFT': 'Draf',
'content.status.ACTIVE': 'Aktif',
'content.status.INACTIVE': 'Tidak aktif',
'content.col.sort': 'Susunan',
'content.col.preview': 'Pratonton',
'content.col.title': 'Tajuk / ringkasan',
'content.col.player_visible': 'Pemain nampak',
'content.col.schedule': 'Jadual',
'content.col.link': 'Pautan',
'content.field.link_type': 'Jenis pautan',
'content.field.link_target': 'Sasaran pautan',
'content.field.start_time': 'Masa mula',
'content.field.end_time': 'Masa tamat',
'content.field.title': 'Tajuk',
'content.field.title_ph': 'Pilihan',
'content.field.body': 'Kandungan',
'content.field.announce_text': 'Teks ticker',
'content.field.image_url': 'URL imej',
'content.link.none': 'Tiada pautan',
'content.locale.zh-CN': 'Cina Ringkas',
'content.locale.en-US': 'English',
'content.locale.ms-MY': 'Bahasa Melayu',
'content.hidden_reason.NOT_ACTIVE': 'Tidak aktif atau draf',
'content.hidden_reason.NOT_STARTED': 'Belum bermula',
'content.hidden_reason.EXPIRED': 'Tamat tempoh',
'content.hidden_reason.INCOMPLETE': 'Terjemahan tidak lengkap',
'content.batch.selected': '{n} dipilih',
'content.batch.enable': 'Aktifkan dipilih',
'content.batch.disable': 'Nyahaktif dipilih',
'content.batch.delete': 'Padam dipilih',
'content.confirm_batch_enable': 'Aktifkan {n} item dipilih?',
'content.confirm_batch_disable': 'Nyahaktif {n} item dipilih?',
'content.confirm_batch_delete': 'Padam {n} item dipilih?',
'content.batch.all_ok': '{n} item berjaya',
'content.batch.partial': '{ok} berjaya, {fail} gagal',
'page.outrights.title': 'Juara',
'page.outrights.desc': 'Cipta dan edit pasaran juara; Piala Dunia 2026 boleh import asas',
'outright.col.rank': 'Kedudukan',
'outright.col.team_zh': 'Pasukan (ZH)',
'outright.col.team_en': 'Pasukan (EN)',
'outright.col.code': 'Kod',
'outright.col.country': 'Negara',
'outright.col.odds': 'Odds juara',
'outright.country_ph': 'Cari atau pilih negara',
'outright.err_country': 'Sila pilih negara',
'outright.btn.save_odds': 'Simpan semua odds',
'outright.btn.apply_canonical': 'Guna data jadual asas',
'msg.outright_canonical_applied': 'Odds 48 pasukan telah dikemas kini',

View File

@@ -249,13 +249,61 @@ export const adminPagesZh: Record<string, string> = {
'msg.outright_odds_saved': '夺冠赔率已保存',
'msg.load_failed': '加载失败',
'content.btn.create': '新建内容',
'content.btn.enable': '启用',
'content.btn.disable': '停用',
'content.dialog.create': '新建公共内容',
'content.dialog.edit': '编辑公共内容',
'content.confirm_delete': '确定删除「{title}」?',
'content.type.BANNER': '首页轮播',
'content.type.ANNOUNCEMENT': '公告滚动',
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
'content.status.DRAFT': '草稿',
'content.status.ACTIVE': '已启用',
'content.status.INACTIVE': '已停用',
'content.col.sort': '排序',
'content.col.preview': '预览',
'content.col.title': '标题/摘要',
'content.col.player_visible': '玩家可见',
'content.col.schedule': '展示时段',
'content.col.link': '跳转',
'content.field.link_type': '链接类型',
'content.field.link_target': '链接目标',
'content.field.start_time': '开始时间',
'content.field.end_time': '结束时间',
'content.field.title': '标题',
'content.field.title_ph': '选填,可与正文相同',
'content.field.body': '正文',
'content.field.announce_text': '滚动文案',
'content.field.image_url': '图片地址',
'content.link.none': '无跳转',
'content.locale.zh-CN': '简体中文',
'content.locale.en-US': 'English',
'content.locale.ms-MY': 'Bahasa Melayu',
'content.hidden_reason.NOT_ACTIVE': '未启用或草稿',
'content.hidden_reason.NOT_STARTED': '未到开始时间',
'content.hidden_reason.EXPIRED': '已过结束时间',
'content.hidden_reason.INCOMPLETE': '多语言内容不完整',
'content.batch.selected': '已选 {n} 项',
'content.batch.enable': '批量启用',
'content.batch.disable': '批量停用',
'content.batch.delete': '批量删除',
'content.confirm_batch_enable': '确定启用选中的 {n} 项?',
'content.confirm_batch_disable': '确定停用选中的 {n} 项?',
'content.confirm_batch_delete': '确定删除选中的 {n} 项?',
'content.batch.all_ok': '已成功处理 {n} 项',
'content.batch.partial': '成功 {ok} 项,失败 {fail} 项',
'page.outrights.title': '优胜冠军',
'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
'outright.col.rank': '排名',
'outright.col.team_zh': '队伍(中文)',
'outright.col.team_en': '队伍(英文)',
'outright.col.code': '代码',
'outright.col.country': '国家/地区',
'outright.col.odds': '夺冠赔率',
'outright.country_ph': '搜索或选择国家',
'outright.err_country': '请选择国家',
'outright.btn.save_odds': '保存全部赔率',
'outright.btn.save_meta': '保存赛事信息',
'outright.btn.publish': '发布',
@@ -553,13 +601,61 @@ export const adminPagesEn: Record<string, string> = {
'msg.outright_odds_saved': 'Outright odds saved',
'msg.load_failed': 'Load failed',
'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.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.status.DRAFT': 'Draft',
'content.status.ACTIVE': 'Active',
'content.status.INACTIVE': 'Inactive',
'content.col.sort': 'Sort',
'content.col.preview': 'Preview',
'content.col.title': 'Title / summary',
'content.col.player_visible': 'Player visible',
'content.col.schedule': 'Schedule',
'content.col.link': 'Link',
'content.field.link_type': 'Link type',
'content.field.link_target': 'Link target',
'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.body': 'Body',
'content.field.announce_text': 'Marquee text',
'content.field.image_url': 'Image URL',
'content.link.none': 'No link',
'content.locale.zh-CN': 'Chinese (Simplified)',
'content.locale.en-US': 'English',
'content.locale.ms-MY': 'Malay',
'content.hidden_reason.NOT_ACTIVE': 'Not active or draft',
'content.hidden_reason.NOT_STARTED': 'Not started yet',
'content.hidden_reason.EXPIRED': 'Expired',
'content.hidden_reason.INCOMPLETE': 'Incomplete translations',
'content.batch.selected': '{n} selected',
'content.batch.enable': 'Enable selected',
'content.batch.disable': 'Disable selected',
'content.batch.delete': 'Delete selected',
'content.confirm_batch_enable': 'Enable {n} selected item(s)?',
'content.confirm_batch_disable': 'Disable {n} selected item(s)?',
'content.confirm_batch_delete': 'Delete {n} selected item(s)?',
'content.batch.all_ok': '{n} item(s) processed',
'content.batch.partial': '{ok} succeeded, {fail} failed',
'page.outrights.title': 'Outrights',
'page.outrights.desc': 'Create and edit any winner market; WC 2026 has one-click baseline import',
'outright.col.rank': 'Rank',
'outright.col.team_zh': 'Team (ZH)',
'outright.col.team_en': 'Team (EN)',
'outright.col.code': 'Code',
'outright.col.country': 'Country',
'outright.col.odds': 'Winner odds',
'outright.country_ph': 'Search or select country',
'outright.err_country': 'Please select a country',
'outright.btn.save_odds': 'Save all odds',
'outright.btn.save_meta': 'Save event info',
'outright.btn.publish': 'Publish',

View File

@@ -18,6 +18,7 @@ const adminMenus = computed(() => [
{ path: '/outrights', label: t('nav.outrights'), matchPrefix: true },
{ path: '/bets', label: t('nav.bets') },
{ path: '/cashback', label: t('nav.cashback') },
{ path: '/contents', label: t('nav.contents') },
{ path: '/audit', label: t('nav.audit') },
]);

View File

@@ -54,6 +54,11 @@ const router = createRouter({
component: () => import('../views/Cashback.vue'),
meta: { adminOnly: true },
},
{
path: 'contents',
component: () => import('../views/Contents.vue'),
meta: { adminOnly: true },
},
{
path: 'audit',
component: () => import('../views/Audit.vue'),

View File

@@ -0,0 +1,8 @@
import { countryFlagUrl, getBuiltinCountry } from '../data/builtinCountries';
export { countryFlagUrl, getBuiltinCountry };
export function suggestTeamFlagUrl(code?: string): string {
const c = getBuiltinCountry(code);
return c ? countryFlagUrl(c) : '';
}

View File

@@ -0,0 +1,633 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { TableInstance } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const { t, localeTag } = useAdminLocale();
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
interface TranslationForm {
locale: string;
title: string;
body: string;
imageUrl: string;
}
interface ContentItem {
id: string;
contentType: StoredContentType;
sortOrder: number;
status: ContentStatus;
linkType: string | null;
linkTarget: string | null;
startTime: string | null;
endTime: string | null;
previewTitle: string;
previewImageUrl: string | null;
playerVisible: boolean;
playerHiddenReason: string | null;
translations: TranslationForm[];
}
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT'];
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
const activeType = ref<AdminTab>('BANNER');
const filterStatus = ref<ContentStatus | ''>('');
const loading = ref(false);
const saving = ref(false);
const items = ref<ContentItem[]>([]);
const tableRef = ref<TableInstance>();
const selectedRows = ref<ContentItem[]>([]);
const hasSelection = computed(() => selectedRows.value.length > 0);
const dialogVisible = ref(false);
const editingId = ref<string | null>(null);
const editingContentType = ref<StoredContentType>('NOTICE');
const form = ref({
sortOrder: 0,
status: 'DRAFT' as ContentStatus,
linkType: '' as '' | 'ROUTE' | 'URL',
linkTarget: '',
startTime: '' as string,
endTime: '' as string,
translations: emptyTranslations(),
});
function emptyTranslations(): TranslationForm[] {
return LOCALES.map((locale) => ({
locale,
title: '',
body: '',
imageUrl: '',
}));
}
function localeLabel(code: string) {
const key = `content.locale.${code}`;
const label = t(key);
return label === key ? code : label;
}
function statusLabel(status: string) {
const key = `content.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(status: string) {
if (status === 'ACTIVE') return 'success';
if (status === 'DRAFT') return 'info';
return 'warning';
}
function hiddenTip(reason: string | null) {
if (!reason) return '';
const key = `content.hidden_reason.${reason}`;
const label = t(key);
return label === key ? reason : label;
}
function formatTime(v: string | null) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
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'),
);
async function load() {
loading.value = true;
try {
const { data } = await api.get('/admin/contents', {
params: {
type: activeType.value,
status: filterStatus.value || undefined,
},
});
items.value = data.data ?? [];
selectedRows.value = [];
tableRef.value?.clearSelection();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
watch([activeType, filterStatus], () => {
selectedRows.value = [];
tableRef.value?.clearSelection();
void load();
});
function onSelectionChange(rows: ContentItem[]) {
selectedRows.value = rows;
}
async function runBatch(
action: (row: ContentItem) => Promise<void>,
confirmKey?: string,
) {
const rows = [...selectedRows.value];
if (!rows.length) return;
if (confirmKey) {
try {
await ElMessageBox.confirm(t(confirmKey, { n: rows.length }), { type: 'warning' });
} catch {
return;
}
}
saving.value = true;
let ok = 0;
let fail = 0;
try {
for (const row of rows) {
try {
await action(row);
ok += 1;
} catch {
fail += 1;
}
}
if (fail === 0) {
ElMessage.success(t('content.batch.all_ok', { n: ok }));
} else {
ElMessage.warning(t('content.batch.partial', { ok, fail }));
}
await load();
} finally {
saving.value = false;
}
}
function batchEnable() {
void runBatch(
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'ACTIVE' }),
'content.confirm_batch_enable',
);
}
function batchDisable() {
void runBatch(
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'INACTIVE' }),
'content.confirm_batch_disable',
);
}
function batchDelete() {
void runBatch(
(row) => api.delete(`/admin/contents/${row.id}`),
'content.confirm_batch_delete',
);
}
function resetForm() {
form.value = {
sortOrder: items.value.length + 1,
status: 'DRAFT',
linkType: '',
linkTarget: '',
startTime: '',
endTime: '',
translations: emptyTranslations(),
};
}
function openCreate() {
editingId.value = null;
resetForm();
dialogVisible.value = true;
}
function openEdit(row: ContentItem) {
editingId.value = row.id;
editingContentType.value = row.contentType;
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
form.value = {
sortOrder: row.sortOrder,
status: row.status,
linkType: (row.linkType as '' | 'ROUTE' | 'URL') || '',
linkTarget: row.linkTarget ?? '',
startTime: row.startTime ? row.startTime.slice(0, 19) : '',
endTime: row.endTime ? row.endTime.slice(0, 19) : '',
translations: LOCALES.map((locale) => {
const tr = byLocale.get(locale);
return {
locale,
title: tr?.title ?? '',
body: tr?.body ?? '',
imageUrl: tr?.imageUrl ?? '',
};
}),
};
dialogVisible.value = true;
}
function buildPayload() {
const contentType: StoredContentType = editingId.value
? editingContentType.value
: isBanner.value
? 'BANNER'
: 'NOTICE';
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 || null,
endTime: form.value.endTime || null,
translations: form.value.translations.map((tr) => ({
locale: tr.locale,
title: tr.title.trim() || undefined,
body: tr.body.trim() || undefined,
imageUrl: tr.imageUrl.trim() || undefined,
})),
};
}
async function submitForm() {
saving.value = true;
try {
const payload = buildPayload();
if (editingId.value) {
const { contentType: _type, ...updateBody } = payload;
await api.put(`/admin/contents/${editingId.value}`, updateBody);
} else {
await api.post('/admin/contents', payload);
}
ElMessage.success(t('msg.saved'));
dialogVisible.value = false;
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)
?? t('msg.save_failed');
ElMessage.error(String(msg));
} finally {
saving.value = false;
}
}
async function setStatus(row: ContentItem, status: ContentStatus) {
saving.value = true;
try {
await api.patch(`/admin/contents/${row.id}/status`, { status });
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'));
} finally {
saving.value = false;
}
}
async function removeItem(row: ContentItem) {
try {
await ElMessageBox.confirm(
t('content.confirm_delete', { title: row.previewTitle || row.id }),
{ type: 'warning' },
);
} catch {
return;
}
saving.value = true;
try {
await api.delete(`/admin/contents/${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'));
} finally {
saving.value = false;
}
}
void load();
</script>
<template>
<div class="admin-list-page contents-page">
<el-card class="filter-card" shadow="never">
<el-tabs v-model="activeType" class="type-tabs">
<el-tab-pane
v-for="tp in ADMIN_TABS"
:key="tp"
:label="t(`content.type.${tp}`)"
:name="tp"
/>
</el-tabs>
<p v-if="isAnnouncement" class="type-hint">{{ t('content.hint.announcement') }}</p>
<el-form 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="" />
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="load">{{ t('common.search') }}</el-button>
<el-button type="primary" plain size="small" @click="openCreate">
{{ t('content.btn.create') }}
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="loading" class="data-card" shadow="never">
<div v-if="hasSelection" class="table-toolbar">
<span class="batch-hint">{{ t('content.batch.selected', { n: selectedRows.length }) }}</span>
<el-button size="small" :disabled="saving" @click="batchEnable">
{{ t('content.batch.enable') }}
</el-button>
<el-button size="small" :disabled="saving" @click="batchDisable">
{{ t('content.batch.disable') }}
</el-button>
<el-button size="small" type="danger" :disabled="saving" @click="batchDelete">
{{ t('content.batch.delete') }}
</el-button>
</div>
<div class="table-wrap">
<el-table
ref="tableRef"
:data="items"
row-key="id"
stripe
size="small"
empty-text=""
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="44" :selectable="() => !saving" />
<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">
<template #default="{ row }">
<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">
<template #default="{ row }">
<span class="preview-title">{{ row.previewTitle || '—' }}</span>
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
{{ hiddenTip(row.playerHiddenReason) }}
</p>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="96" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)" effect="dark">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('content.col.player_visible')" width="88" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.playerVisible ? 'success' : 'warning'" effect="plain">
{{ row.playerVisible ? t('common.yes') : t('common.no') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('content.col.schedule')" min-width="140">
<template #default="{ row }">
<span class="schedule-line">{{ formatTime(row.startTime) }}</span>
<span class="schedule-sep"></span>
<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">
<template #default="{ row }">
<template v-if="row.linkType">
{{ row.linkType }} · {{ row.linkTarget || '' }}
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">
{{ t('common.edit') }}
</el-button>
<el-button
v-if="row.status !== 'ACTIVE'"
link
type="success"
:disabled="saving"
@click="setStatus(row, 'ACTIVE')"
>
{{ t('content.btn.enable') }}
</el-button>
<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)">
{{ t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</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>
<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'"
>
<el-input v-model="tr.imageUrl" placeholder="/uploads/banners/welcome.svg" />
</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>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.contents-page .type-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.table-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 12px;
border-bottom: 1px solid #222;
flex-shrink: 0;
}
.batch-hint {
font-size: 12px;
color: #888;
margin-right: 4px;
}
.type-hint {
margin: 0 0 8px;
font-size: 12px;
color: #666;
line-height: 1.5;
}
.filter-row {
margin-top: 4px;
}
.thumb {
width: 56px;
height: 32px;
object-fit: cover;
border-radius: 4px;
background: #222;
}
.thumb-empty {
color: #555;
font-size: 12px;
}
.preview-title {
font-size: 13px;
color: #ccc;
}
.hidden-tip {
margin: 4px 0 0;
font-size: 11px;
color: #c9a227;
}
.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);
}
.locale-head {
font-size: 12px;
font-weight: 700;
color: #888;
margin-bottom: 8px;
}
</style>

View File

@@ -4,6 +4,13 @@ import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import CountryFlagSelect from '../../components/outright/CountryFlagSelect.vue';
import {
countryFlagUrl,
getBuiltinCountry,
resolveCountryCode,
type BuiltinCountry,
} from '../../data/builtinCountries';
const route = useRoute();
const router = useRouter();
@@ -20,11 +27,14 @@ interface SelectionRow {
odds: string;
oddsVersion: string;
status: string;
logoUrl: string | null;
editOdds: number;
editCountryCode: string;
}
const loading = ref(false);
const saving = ref(false);
const savingRowId = ref<string | null>(null);
const meta = ref({
leagueZh: '',
leagueEn: '',
@@ -39,12 +49,29 @@ const selections = ref<SelectionRow[]>([]);
const addVisible = ref(false);
const addForm = ref({
countryCode: '',
teamCode: '',
teamZh: '',
teamEn: '',
odds: 10,
});
function applyCountry(target: {
countryCode: string;
teamCode: string;
teamZh: string;
teamEn: string;
}, country: BuiltinCountry) {
target.countryCode = country.code;
target.teamCode = country.code;
target.teamZh = country.nameZh;
target.teamEn = country.nameEn;
}
function onAddCountryPick(country: BuiltinCountry) {
applyCountry(addForm.value, country);
}
async function load() {
if (!matchId.value) return;
loading.value = true;
@@ -59,7 +86,7 @@ async function load() {
expectedCanonicalCount: number | null;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionRow[];
selections: Array<SelectionRow & { logoUrl?: string | null }>;
};
meta.value = {
leagueZh: payload.leagueZh,
@@ -73,7 +100,9 @@ async function load() {
};
selections.value = payload.selections.map((s) => ({
...s,
logoUrl: s.logoUrl ?? null,
editOdds: Number(s.odds),
editCountryCode: resolveCountryCode(s.teamCode, s.logoUrl ?? null),
}));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -127,21 +156,27 @@ async function saveAllOdds() {
}
async function submitAdd() {
if (!addForm.value.teamCode.trim()) {
ElMessage.warning(t('outright.err_team_code'));
if (!addForm.value.countryCode) {
ElMessage.warning(t('outright.err_country'));
return;
}
const country = getBuiltinCountry(addForm.value.countryCode);
if (!country) {
ElMessage.warning(t('outright.err_country'));
return;
}
saving.value = true;
try {
await api.post(`/admin/outrights/${matchId.value}/selections`, {
teamCode: addForm.value.teamCode.trim().toUpperCase(),
teamZh: addForm.value.teamZh,
teamEn: addForm.value.teamEn,
teamCode: country.code,
teamZh: country.nameZh,
teamEn: country.nameEn,
logoUrl: countryFlagUrl(country),
odds: addForm.value.odds,
});
ElMessage.success(t('msg.saved'));
addVisible.value = false;
addForm.value = { teamCode: '', teamZh: '', teamEn: '', odds: 10 };
addForm.value = { countryCode: '', teamCode: '', teamZh: '', teamEn: '', odds: 10 };
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -200,6 +235,71 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
meta.value.status = status;
await saveMeta();
}
function rowDisplayName(row: SelectionRow, field: 'zh' | 'en') {
const picked = getBuiltinCountry(row.editCountryCode);
if (picked && row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl)) {
return field === 'zh' ? picked.nameZh : picked.nameEn;
}
return field === 'zh' ? row.teamZh : row.teamEn;
}
function rowDisplayCode(row: SelectionRow) {
const picked = getBuiltinCountry(row.editCountryCode);
if (picked && row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl)) {
return picked.code;
}
return row.teamCode;
}
function isRowDirty(row: SelectionRow) {
const countryDirty =
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
const oddsDirty = row.editOdds !== Number(row.odds);
return countryDirty || oddsDirty;
}
async function saveRow(row: SelectionRow) {
if (!isRowDirty(row)) return;
if (!row.editOdds || row.editOdds <= 1) {
ElMessage.warning(t('outright.err_odds_min'));
return;
}
savingRowId.value = row.id;
try {
const country = getBuiltinCountry(row.editCountryCode);
const countryDirty =
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
if (countryDirty) {
if (!country) {
ElMessage.warning(t('outright.err_country'));
return;
}
await api.patch(`/admin/outrights/${matchId.value}/selections/${row.id}`, {
teamCode: country.code,
teamZh: country.nameZh,
teamEn: country.nameEn,
logoUrl: countryFlagUrl(country),
});
}
if (row.editOdds !== Number(row.odds)) {
await api.put(`/admin/outrights/${matchId.value}/odds`, {
updates: [{ selectionId: row.id, odds: row.editOdds }],
});
}
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'));
} finally {
savingRowId.value = null;
}
}
</script>
<template>
@@ -269,9 +369,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
<div class="table-wrap">
<el-table :data="selections" stripe size="small" empty-text="">
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="120" />
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
<el-table-column :label="t('outright.col.country')" min-width="220">
<template #default="{ row }">
<CountryFlagSelect
v-model="row.editCountryCode"
:disabled="!!savingRowId"
/>
</template>
</el-table-column>
<el-table-column :label="t('outright.col.team_zh')" min-width="120">
<template #default="{ row }">{{ rowDisplayName(row, 'zh') }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.team_en')" min-width="140">
<template #default="{ row }">{{ rowDisplayName(row, 'en') }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.code')" width="88">
<template #default="{ row }">{{ rowDisplayCode(row) }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
<template #default="{ row }">
<el-input-number
@@ -285,9 +399,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
/>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" align="center">
<el-table-column :label="t('common.actions')" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeSelection(row)">
<el-button
link
type="primary"
:loading="savingRowId === row.id"
:disabled="!isRowDirty(row) || (!!savingRowId && savingRowId !== row.id)"
@click="saveRow(row)"
>
{{ t('common.save') }}
</el-button>
<el-button
link
type="danger"
:disabled="!!savingRowId"
@click="removeSelection(row)"
>
{{ t('common.delete') }}
</el-button>
</template>
@@ -303,16 +431,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
</div>
</section>
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="420px">
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="440px">
<el-form label-width="100px">
<el-form-item :label="t('outright.col.code')">
<el-input v-model="addForm.teamCode" placeholder="FRA" />
<el-form-item :label="t('outright.col.country')" required>
<CountryFlagSelect
v-model="addForm.countryCode"
size="default"
@pick="onAddCountryPick"
/>
</el-form-item>
<el-form-item :label="t('outright.col.team_zh')">
<el-input v-model="addForm.teamZh" />
<el-form-item v-if="addForm.teamCode" :label="t('outright.col.code')">
<span class="readonly-field">{{ addForm.teamCode }}</span>
</el-form-item>
<el-form-item :label="t('outright.col.team_en')">
<el-input v-model="addForm.teamEn" />
<el-form-item v-if="addForm.teamZh" :label="t('outright.col.team_zh')">
<span class="readonly-field">{{ addForm.teamZh }}</span>
</el-form-item>
<el-form-item v-if="addForm.teamEn" :label="t('outright.col.team_en')">
<span class="readonly-field">{{ addForm.teamEn }}</span>
</el-form-item>
<el-form-item :label="t('outright.col.odds')">
<el-input-number v-model="addForm.odds" :min="1.01" :step="0.05" :precision="2" />
@@ -430,4 +565,9 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
min-height: 80px;
overflow: auto;
}
.readonly-field {
font-size: 13px;
color: #aaa;
}
</style>

View File

@@ -5,6 +5,7 @@ import {
Get,
Post,
Put,
Patch,
Body,
Param,
Query,
@@ -304,6 +305,113 @@ class AddOutrightSelectionDto {
@IsNumber()
@Min(1.01)
odds!: number;
@IsOptional()
@IsString()
logoUrl?: string;
}
class UpdateOutrightSelectionTeamDto {
@IsOptional()
@IsString()
teamCode?: string;
@IsOptional()
@IsString()
teamZh?: string;
@IsOptional()
@IsString()
teamEn?: string;
@IsOptional()
@IsString()
logoUrl?: string | null;
}
class ContentTranslationDto {
@IsString()
locale!: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
body?: string;
@IsOptional()
@IsString()
imageUrl?: string;
}
class CreateContentDto {
@IsString()
@IsIn(['BANNER', 'NOTICE', 'TICKER'])
contentType!: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
status?: string;
@IsOptional()
@IsString()
linkType?: string | null;
@IsOptional()
@IsString()
linkTarget?: string | null;
@IsOptional()
@IsString()
startTime?: string | null;
@IsOptional()
@IsString()
endTime?: string | null;
@IsArray()
translations!: ContentTranslationDto[];
}
class UpdateContentDto {
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
status?: string;
@IsOptional()
@IsString()
linkType?: string | null;
@IsOptional()
@IsString()
linkTarget?: string | null;
@IsOptional()
@IsString()
startTime?: string | null;
@IsOptional()
@IsString()
endTime?: string | null;
@IsOptional()
@IsArray()
translations?: ContentTranslationDto[];
}
class ContentStatusDto {
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
status!: string;
}
class CashbackPreviewDto {
@@ -790,6 +898,20 @@ export class AdminController {
return jsonResponse(data);
}
@Patch('outrights/:matchId/selections/:selectionId')
async updateOutrightSelectionTeam(
@Param('matchId') matchId: string,
@Param('selectionId') selectionId: string,
@Body() dto: UpdateOutrightSelectionTeamDto,
) {
const data = await this.outright.updateSelectionTeam(
BigInt(matchId),
BigInt(selectionId),
dto,
);
return jsonResponse(data);
}
@Delete('outrights/:matchId/selections/:selectionId')
async removeOutrightSelection(
@Param('matchId') matchId: string,
@@ -875,17 +997,47 @@ export class AdminController {
}
@Get('contents')
async listContents(@Query('type') type?: string) {
const items = await this.content.listAll(type);
async listContents(
@Query('type') type?: string,
@Query('status') status?: string,
) {
const items = await this.content.listForAdmin(type, status);
return jsonResponse(items);
}
@Get('contents/:id')
async getContent(@Param('id') id: string) {
const item = await this.content.getForAdmin(BigInt(id));
return jsonResponse(item);
}
@Post('contents')
async createContent(@Body() dto: Parameters<ContentService['create']>[0]) {
async createContent(@Body() dto: CreateContentDto) {
const item = await this.content.create(dto);
return jsonResponse(item);
}
@Put('contents/:id')
async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
const item = await this.content.update(BigInt(id), dto);
return jsonResponse(item);
}
@Patch('contents/:id/status')
async updateContentStatus(
@Param('id') id: string,
@Body() dto: ContentStatusDto,
) {
const item = await this.content.updateStatus(BigInt(id), dto.status);
return jsonResponse(item);
}
@Delete('contents/:id')
async deleteContent(@Param('id') id: string) {
const result = await this.content.remove(BigInt(id));
return jsonResponse(result);
}
@Get('i18n/messages')
async getMessages(@Query('locale') locale = 'en-US') {
const messages = await this.i18n.getMessages(locale);

View File

@@ -109,17 +109,19 @@ export class PlayerController {
@Get('home')
async home(@CurrentUser('locale') locale: string) {
const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([
const [banners, announcements, hotMatches, todayMatches] = await Promise.all([
this.content.listActive('BANNER', locale),
this.content.listActive('NOTICE', locale),
this.content.listActive('TICKER', locale),
this.content.listActiveAnnouncements(locale),
this.matches.listPublished(locale),
this.matches.listPublished(locale),
]);
return jsonResponse({
banners,
notices,
ticker,
announcements,
/** @deprecated 使用 announcements */
ticker: announcements,
/** @deprecated 使用 announcements */
notices: announcements,
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
todayMatches,
});

View File

@@ -138,6 +138,7 @@ export class OutrightService {
rank: sel.sortOrder + 1 || index + 1,
teamZh: teamZh || sel.selectionName,
teamEn: teamEn || sel.selectionName,
logoUrl: team?.logoUrl ?? null,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
status: sel.status,
@@ -243,6 +244,7 @@ export class OutrightService {
teamZh: string;
teamEn: string;
odds: number;
logoUrl?: string;
},
) {
if (!data.teamCode?.trim()) {
@@ -256,10 +258,19 @@ export class OutrightService {
const market = await this.ensureOutrightMarket(match.id);
const code = data.teamCode.trim().toUpperCase();
const logoUrl =
data.logoUrl === undefined
? undefined
: data.logoUrl.trim()
? data.logoUrl.trim()
: null;
const team = await this.prisma.team.upsert({
where: { code },
create: { code },
update: {},
create: {
code,
...(logoUrl !== undefined ? { logoUrl } : {}),
},
update: logoUrl !== undefined ? { logoUrl } : {},
});
await this.upsertTeamTranslations(team.id, {
'zh-CN': data.teamZh.trim() || data.teamEn,
@@ -292,6 +303,77 @@ export class OutrightService {
return this.getForAdmin(matchId);
}
async updateSelectionTeam(
matchId: bigint,
selectionId: bigint,
data: {
teamCode?: string;
teamZh?: string;
teamEn?: string;
logoUrl?: string | null;
},
) {
const match = await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(match.id);
const sel = await this.prisma.marketSelection.findFirst({
where: { id: selectionId, marketId: market.id },
});
if (!sel) throw new NotFoundException('Selection not found');
const nextCode = data.teamCode?.trim().toUpperCase() || sel.selectionCode;
if (nextCode !== sel.selectionCode) {
const dup = await this.prisma.marketSelection.findFirst({
where: {
marketId: market.id,
selectionCode: nextCode,
id: { not: selectionId },
},
});
if (dup) {
throw new BadRequestException('Selection already exists for this team code');
}
await this.prisma.marketSelection.update({
where: { id: selectionId },
data: {
selectionCode: nextCode,
selectionName:
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
},
});
} else if (data.teamZh?.trim() || data.teamEn?.trim()) {
await this.prisma.marketSelection.update({
where: { id: selectionId },
data: {
selectionName:
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
},
});
}
const team = await this.prisma.team.upsert({
where: { code: nextCode },
create: { code: nextCode },
update: {},
});
if (data.teamZh !== undefined || data.teamEn !== undefined) {
await this.upsertTeamTranslations(team.id, {
'zh-CN': data.teamZh?.trim() || data.teamEn?.trim() || nextCode,
'en-US': data.teamEn?.trim() || data.teamZh?.trim() || nextCode,
});
}
if (data.logoUrl !== undefined) {
const logoUrl = data.logoUrl?.trim() ? data.logoUrl.trim() : null;
await this.prisma.team.update({
where: { id: team.id },
data: { logoUrl },
});
}
return this.getForAdmin(matchId);
}
async closeSelection(matchId: bigint, selectionId: bigint) {
const match = await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(match.id);
@@ -399,6 +481,7 @@ export class OutrightService {
id: sel.id.toString(),
teamCode: sel.selectionCode,
teamName,
logoUrl: team?.logoUrl ?? null,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};

View File

@@ -1,11 +1,37 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
export const CONTENT_TYPES = ['BANNER', 'NOTICE', 'TICKER'] as const;
export type ContentType = (typeof CONTENT_TYPES)[number];
/** 管理端合并 Tab公告与滚动条在玩家端均为顶部跑马灯 */
export const ANNOUNCEMENT_ADMIN_TYPE = 'ANNOUNCEMENT';
export const ANNOUNCEMENT_TYPES: ContentType[] = ['NOTICE', 'TICKER'];
export const CONTENT_STATUSES = ['DRAFT', 'ACTIVE', 'INACTIVE'] as const;
export type ContentStatus = (typeof CONTENT_STATUSES)[number];
export const CONTENT_LINK_TYPES = ['ROUTE', 'URL'] as const;
export type ContentLinkType = (typeof CONTENT_LINK_TYPES)[number];
export const CONTENT_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
export type ContentTranslationInput = {
locale: string;
title?: string;
body?: string;
imageUrl?: string;
};
function pickContentTranslation<T extends { locale: string }>(
translations: T[],
locale: string,
): T | undefined {
const chain = [locale, 'en-US', 'zh-CN'];
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
for (const loc of chain) {
const hit = translations.find((tr) => tr.locale === loc);
if (hit) return hit;
@@ -13,10 +39,194 @@ function pickContentTranslation<T extends { locale: string }>(
return translations[0];
}
function normalizeOptionalUrl(value?: string | null) {
const v = value?.trim();
return v || null;
}
@Injectable()
export class ContentService {
constructor(private prisma: PrismaService) {}
private assertContentType(type: string): ContentType {
if (!CONTENT_TYPES.includes(type as ContentType)) {
throw new BadRequestException(`Invalid contentType: ${type}`);
}
return type as ContentType;
}
private assertStatus(status: string): ContentStatus {
if (!CONTENT_STATUSES.includes(status as ContentStatus)) {
throw new BadRequestException(`Invalid status: ${status}`);
}
return status as ContentStatus;
}
private validateSchedule(startTime?: Date | null, endTime?: Date | null) {
if (startTime && endTime && endTime <= startTime) {
throw new BadRequestException('endTime must be after startTime');
}
}
private validateTranslations(
contentType: ContentType,
translations: ContentTranslationInput[],
status: ContentStatus,
) {
if (!translations.length) {
throw new BadRequestException('At least one translation required');
}
const locales = new Set<string>();
for (const tr of translations) {
if (!tr.locale?.trim()) {
throw new BadRequestException('Translation locale required');
}
if (locales.has(tr.locale)) {
throw new BadRequestException(`Duplicate locale: ${tr.locale}`);
}
locales.add(tr.locale);
}
if (status !== 'ACTIVE') return;
const hasUsable = translations.some((tr) => {
if (contentType === 'BANNER') {
return !!normalizeOptionalUrl(tr.imageUrl);
}
if (contentType === 'NOTICE') {
return !!(tr.title?.trim() || tr.body?.trim());
}
return !!tr.body?.trim();
});
if (!hasUsable) {
throw new BadRequestException(
contentType === 'BANNER'
? 'ACTIVE banner requires imageUrl in at least one locale'
: contentType === 'NOTICE'
? 'ACTIVE notice requires title or body in at least one locale'
: 'ACTIVE ticker requires body in at least one locale',
);
}
}
private validateLink(linkType?: string | null, linkTarget?: string | null) {
if (!linkType) return;
if (!CONTENT_LINK_TYPES.includes(linkType as ContentLinkType)) {
throw new BadRequestException(`Invalid linkType: ${linkType}`);
}
if (!linkTarget?.trim()) {
throw new BadRequestException('linkTarget required when linkType is set');
}
}
playerVisibility(
item: {
status: string;
startTime: Date | null;
endTime: Date | null;
contentType: string;
translations: Array<{
locale: string;
title?: string | null;
body?: string | null;
imageUrl?: string | null;
}>;
},
now = new Date(),
): { playerVisible: boolean; playerHiddenReason: string | null } {
if (item.status !== 'ACTIVE') {
return { playerVisible: false, playerHiddenReason: 'NOT_ACTIVE' };
}
if (item.startTime && item.startTime > now) {
return { playerVisible: false, playerHiddenReason: 'NOT_STARTED' };
}
if (item.endTime && item.endTime < now) {
return { playerVisible: false, playerHiddenReason: 'EXPIRED' };
}
const type = item.contentType as ContentType;
const ok = item.translations.some((tr) => {
if (type === 'BANNER') return !!normalizeOptionalUrl(tr.imageUrl);
if (type === 'NOTICE') return !!(tr.title?.trim() || tr.body?.trim());
return !!tr.body?.trim();
});
if (!ok) {
return { playerVisible: false, playerHiddenReason: 'INCOMPLETE' };
}
return { playerVisible: true, playerHiddenReason: null };
}
private mapAdminItem(
item: Awaited<ReturnType<typeof this.getRawById>>,
now = new Date(),
) {
const visibility = this.playerVisibility(item, now);
const preview =
pickContentTranslation(item.translations, 'zh-CN') ??
pickContentTranslation(item.translations, 'en-US');
return {
id: item.id.toString(),
contentType: item.contentType,
sortOrder: item.sortOrder,
status: item.status,
linkType: item.linkType,
linkTarget: item.linkTarget,
startTime: item.startTime?.toISOString() ?? null,
endTime: item.endTime?.toISOString() ?? null,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
previewTitle: preview?.title ?? preview?.body?.slice(0, 40) ?? '',
previewImageUrl: preview?.imageUrl ?? null,
playerVisible: visibility.playerVisible,
playerHiddenReason: visibility.playerHiddenReason,
translations: item.translations.map((tr) => ({
locale: tr.locale,
title: tr.title ?? '',
body: tr.body ?? '',
imageUrl: tr.imageUrl ?? '',
})),
};
}
async getRawById(id: bigint) {
const item = await this.prisma.content.findUnique({
where: { id },
include: { translations: true },
});
if (!item) throw new NotFoundException('Content not found');
return item;
}
async listActiveAnnouncements(locale: string) {
const now = new Date();
const items = await this.prisma.content.findMany({
where: {
contentType: { in: [...ANNOUNCEMENT_TYPES] },
status: 'ACTIVE',
OR: [{ startTime: null }, { startTime: { lte: now } }],
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
});
return items
.filter((item) => this.playerVisibility(item, now).playerVisible)
.map((item) => {
const tr = pickContentTranslation(item.translations, locale);
return {
id: item.id.toString(),
contentType: item.contentType,
sortOrder: item.sortOrder,
translation: tr,
};
});
}
async listActive(contentType: string, locale: string) {
const now = new Date();
const items = await this.prisma.content.findMany({
@@ -30,39 +240,208 @@ export class ContentService {
orderBy: { sortOrder: 'asc' },
});
return items.map((item) => {
const t = pickContentTranslation(item.translations, locale);
return { ...item, translation: t };
return items
.filter((item) => this.playerVisibility(item, now).playerVisible)
.map((item) => {
const t = pickContentTranslation(item.translations, locale);
return {
id: item.id.toString(),
contentType: item.contentType,
sortOrder: item.sortOrder,
linkType: item.linkType,
linkTarget: item.linkTarget,
translation: t,
};
});
}
async listForAdmin(contentType?: string, status?: string) {
const typeWhere =
contentType === ANNOUNCEMENT_ADMIN_TYPE
? { contentType: { in: [...ANNOUNCEMENT_TYPES] } }
: contentType
? { contentType }
: {};
const items = await this.prisma.content.findMany({
where: {
...typeWhere,
...(status ? { status } : {}),
},
include: { translations: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return items.map((item) => this.mapAdminItem(item));
}
async getForAdmin(id: bigint) {
return this.mapAdminItem(await this.getRawById(id));
}
async create(data: {
contentType: string;
sortOrder?: number;
linkType?: string;
linkTarget?: string;
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
status?: string;
linkType?: string | null;
linkTarget?: string | null;
startTime?: string | null;
endTime?: string | null;
translations: ContentTranslationInput[];
}) {
return this.prisma.content.create({
const contentType = this.assertContentType(data.contentType);
const status = this.assertStatus(data.status ?? 'DRAFT');
this.validateLink(data.linkType, data.linkTarget);
this.validateTranslations(contentType, data.translations, status);
const startTime = data.startTime ? new Date(data.startTime) : null;
const endTime = data.endTime ? new Date(data.endTime) : null;
this.validateSchedule(startTime, endTime);
const item = await this.prisma.content.create({
data: {
contentType: data.contentType,
contentType,
sortOrder: data.sortOrder ?? 0,
linkType: data.linkType,
linkTarget: data.linkTarget,
status: 'ACTIVE',
status,
linkType: data.linkType?.trim() || null,
linkTarget: data.linkTarget?.trim() || null,
startTime,
endTime,
translations: {
create: data.translations,
create: data.translations.map((tr) => ({
locale: tr.locale.trim(),
title: tr.title?.trim() || null,
body: tr.body?.trim() || null,
imageUrl: normalizeOptionalUrl(tr.imageUrl),
})),
},
},
include: { translations: true },
});
return this.mapAdminItem(item);
}
async listAll(contentType?: string) {
return this.prisma.content.findMany({
where: contentType ? { contentType } : {},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
async update(
id: bigint,
data: {
sortOrder?: number;
status?: string;
linkType?: string | null;
linkTarget?: string | null;
startTime?: string | null;
endTime?: string | null;
translations?: ContentTranslationInput[];
},
) {
const existing = await this.getRawById(id);
const contentType = existing.contentType as ContentType;
const status = data.status
? this.assertStatus(data.status)
: (existing.status as ContentStatus);
const linkType =
data.linkType !== undefined
? data.linkType?.trim() || null
: existing.linkType;
const linkTarget =
data.linkTarget !== undefined
? data.linkTarget?.trim() || null
: existing.linkTarget;
this.validateLink(linkType, linkTarget);
const startTime =
data.startTime !== undefined
? data.startTime
? new Date(data.startTime)
: null
: existing.startTime;
const endTime =
data.endTime !== undefined
? data.endTime
? new Date(data.endTime)
: null
: existing.endTime;
this.validateSchedule(startTime, endTime);
const translations =
data.translations ??
existing.translations.map((tr) => ({
locale: tr.locale,
title: tr.title ?? undefined,
body: tr.body ?? undefined,
imageUrl: tr.imageUrl ?? undefined,
}));
this.validateTranslations(contentType, translations, status);
await this.prisma.content.update({
where: { id },
data: {
sortOrder: data.sortOrder ?? existing.sortOrder,
status,
linkType,
linkTarget,
startTime,
endTime,
},
});
if (data.translations) {
for (const tr of data.translations) {
await this.prisma.contentTranslation.upsert({
where: {
contentId_locale: {
contentId: id,
locale: tr.locale.trim(),
},
},
create: {
contentId: id,
locale: tr.locale.trim(),
title: tr.title?.trim() || null,
body: tr.body?.trim() || null,
imageUrl: normalizeOptionalUrl(tr.imageUrl),
},
update: {
title: tr.title?.trim() || null,
body: tr.body?.trim() || null,
imageUrl: normalizeOptionalUrl(tr.imageUrl),
},
});
}
}
return this.getForAdmin(id);
}
async updateStatus(id: bigint, status: string) {
const existing = await this.getRawById(id);
const next = this.assertStatus(status);
this.validateTranslations(
existing.contentType as ContentType,
existing.translations.map((tr) => ({
locale: tr.locale,
title: tr.title ?? undefined,
body: tr.body ?? undefined,
imageUrl: tr.imageUrl ?? undefined,
})),
next,
);
await this.prisma.content.update({
where: { id },
data: { status: next },
});
return this.getForAdmin(id);
}
async remove(id: bigint) {
await this.getRawById(id);
await this.prisma.contentTranslation.deleteMany({ where: { contentId: id } });
await this.prisma.content.delete({ where: { id } });
return { ok: true };
}
/** @deprecated use listForAdmin */
async listAll(contentType?: string) {
return this.listForAdmin(contentType);
}
}

View File

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

View File

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

View File

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

View 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,
};
}

View File

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

View File

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

View File

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