From f76728dc3e48858f96905589a90babdcec1a91fa Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Thu, 4 Jun 2026 10:25:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin,player,api):=20=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E4=BC=98=E8=83=9C=E5=86=A0=E5=86=9B?= =?UTF-8?q?=E5=9B=BD=E6=97=97=E3=80=81=E7=8E=A9=E5=AE=B6=E7=AB=AF=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。 Co-authored-by: Cursor --- .../components/outright/CountryFlagSelect.vue | 137 ++++ apps/admin/src/data/builtinCountries.ts | 127 ++++ apps/admin/src/i18n/admin-messages.ts | 3 + apps/admin/src/i18n/admin-pages-ms.ts | 48 ++ apps/admin/src/i18n/admin-pages.ts | 96 +++ apps/admin/src/layouts/ManageLayout.vue | 1 + apps/admin/src/router/index.ts | 5 + apps/admin/src/utils/teamFlag.ts | 8 + apps/admin/src/views/Contents.vue | 633 ++++++++++++++++++ .../views/outrights/OutrightEventEditor.vue | 178 ++++- .../applications/admin/admin.controller.ts | 158 ++++- .../applications/player/player.controller.ts | 12 +- .../src/domains/catalog/outright.service.ts | 87 ++- .../operations/content/content.service.ts | 417 +++++++++++- .../outright/OutrightEventSection.vue | 2 + .../outright/OutrightOptionCard.vue | 7 +- .../src/composables/useAnnouncements.ts | 39 +- apps/player/src/composables/usePlayerHome.ts | 72 ++ apps/player/src/constants/defaultBanner.ts | 4 +- apps/player/src/layouts/MainLayout.vue | 19 +- apps/player/src/views/HomeView.vue | 49 +- 21 files changed, 1966 insertions(+), 136 deletions(-) create mode 100644 apps/admin/src/components/outright/CountryFlagSelect.vue create mode 100644 apps/admin/src/data/builtinCountries.ts create mode 100644 apps/admin/src/utils/teamFlag.ts create mode 100644 apps/admin/src/views/Contents.vue create mode 100644 apps/player/src/composables/usePlayerHome.ts diff --git a/apps/admin/src/components/outright/CountryFlagSelect.vue b/apps/admin/src/components/outright/CountryFlagSelect.vue new file mode 100644 index 0000000..0f66a7d --- /dev/null +++ b/apps/admin/src/components/outright/CountryFlagSelect.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/apps/admin/src/data/builtinCountries.ts b/apps/admin/src/data/builtinCountries.ts new file mode 100644 index 0000000..afbf91b --- /dev/null +++ b/apps/admin/src/data/builtinCountries.ts @@ -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(); +} diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index bed23dc..d2b39b6 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -36,6 +36,7 @@ const zh: Record = { 'nav.outrights': '优胜冠军', 'nav.bets': '注单管理', 'nav.cashback': '返水管理', + 'nav.contents': '公共管理', 'nav.audit': '操作日志', 'nav.players': '直属玩家', 'nav.subAgents': '下级代理', @@ -187,6 +188,7 @@ const en: Record = { '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 = { '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', diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index 01e50e5..a90e235 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -249,13 +249,61 @@ export const adminPagesMs: Record = { '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', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 8249405..90a7057 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -249,13 +249,61 @@ export const adminPagesZh: Record = { '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 = { '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', diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index 5a245d2..8650b0e 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -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') }, ]); diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts index e546ddf..96a22f2 100644 --- a/apps/admin/src/router/index.ts +++ b/apps/admin/src/router/index.ts @@ -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'), diff --git a/apps/admin/src/utils/teamFlag.ts b/apps/admin/src/utils/teamFlag.ts new file mode 100644 index 0000000..1f3397a --- /dev/null +++ b/apps/admin/src/utils/teamFlag.ts @@ -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) : ''; +} diff --git a/apps/admin/src/views/Contents.vue b/apps/admin/src/views/Contents.vue new file mode 100644 index 0000000..459d6c0 --- /dev/null +++ b/apps/admin/src/views/Contents.vue @@ -0,0 +1,633 @@ + + + + + diff --git a/apps/admin/src/views/outrights/OutrightEventEditor.vue b/apps/admin/src/views/outrights/OutrightEventEditor.vue index 0686413..6510b33 100644 --- a/apps/admin/src/views/outrights/OutrightEventEditor.vue +++ b/apps/admin/src/views/outrights/OutrightEventEditor.vue @@ -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(null); const meta = ref({ leagueZh: '', leagueEn: '', @@ -39,12 +49,29 @@ const selections = ref([]); 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; }; 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; + } +}