feat(admin,player,api): 公共管理与优胜冠军国旗、玩家端内容对接
新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
137
apps/admin/src/components/outright/CountryFlagSelect.vue
Normal file
137
apps/admin/src/components/outright/CountryFlagSelect.vue
Normal 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>
|
||||
127
apps/admin/src/data/builtinCountries.ts
Normal file
127
apps/admin/src/data/builtinCountries.ts
Normal 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();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
8
apps/admin/src/utils/teamFlag.ts
Normal file
8
apps/admin/src/utils/teamFlag.ts
Normal 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) : '';
|
||||
}
|
||||
633
apps/admin/src/views/Contents.vue
Normal file
633
apps/admin/src/views/Contents.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user