- 在赔率行数据结构和接口中新增 provider_code 字段支持多开注商 - 管理后台赔率配置页新增开注商筛选,支持查看和编辑指定开注商赔率 - 未指定专属赔率时,自动继承通用(Global)赔率并显示继承标识 - 新增赔率快照展示组件,显示票据下注时锁定的开注商赔率快照 - 优化赔率保存流程,支持基于开注商区分并批量更新赔率数据 - 更新多语言文案,增加开注商与赔率来源标识翻译 - 调整环境配置文档和默认域名为新的开注商域名地址 - 优化界面标签样式,清晰区分通用、专属和继承赔率来源 - Tickets模块新增赔率快照数据类型及详细展示表格 - 修复管理后台及票据详情页部分链接和操作按钮,提升用户体验
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
LOTTERY_API_UPSTREAM=http://127.0.0.1:8000
|
LOTTERY_API_UPSTREAM=http://127.0.0.1:8000
|
||||||
# 线上(Laravel 在本机时用内网地址更快)
|
# 线上(Laravel 在本机时用内网地址更快)
|
||||||
# LOTTERY_API_UPSTREAM=http://127.0.0.1:8000
|
# LOTTERY_API_UPSTREAM=http://127.0.0.1:8000
|
||||||
# LOTTERY_API_UPSTREAM=https://lotterylaravel.tanumo.com
|
# LOTTERY_API_UPSTREAM=https://lottery-api.cjdhr.top
|
||||||
|
|
||||||
# Next 开发:局域网 IP 访问(逗号分隔 host,无协议)
|
# Next 开发:局域网 IP 访问(逗号分隔 host,无协议)
|
||||||
# ALLOWED_DEV_ORIGINS=192.168.0.101
|
# ALLOWED_DEV_ORIGINS=192.168.0.101
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ This version has breaking changes — APIs, conventions, and file structure may
|
|||||||
- 客户对外文档:`/docs`、`/docs/integration`、`/docs/admin` 公开免登录;读者为接入方与站点运营/代理;顶栏「管理后台」链 `/admin`;SSO 无登录换票、JWT+`player/me`、iframe `data.token`;Docs 侧栏 `--docs-sticky-top`;静态校验用 `document.body.innerText`。
|
- 客户对外文档:`/docs`、`/docs/integration`、`/docs/admin` 公开免登录;读者为接入方与站点运营/代理;顶栏「管理后台」链 `/admin`;SSO 无登录换票、JWT+`player/me`、iframe `data.token`;Docs 侧栏 `--docs-sticky-top`;静态校验用 `document.body.innerText`。
|
||||||
- `SettlementBillRow` 无 `currency_code`;账单金额展示用玩家 `default_currency`。
|
- `SettlementBillRow` 无 `currency_code`;账单金额展示用玩家 `default_currency`。
|
||||||
- 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。
|
- 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。
|
||||||
- Tanumo 联调/生产默认:H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com`。
|
- 当前联调/生产默认:H5 `https://lottery-front.cjdhr.top`、API `https://lottery-api.cjdhr.top`、管理端/文档 `https://lottery-admin.cjdhr.top`。
|
||||||
- 风控页默认:风险池仅显示有占用/高风险;单号详情可看占用来源;组合明细在注单详情页。
|
- 风控页默认:风险池仅显示有占用/高风险;单号详情可看占用来源;组合明细在注单详情页。
|
||||||
- 模块边界:对账中心=主站↔彩票钱包划转;报表中心=钱包盘经营分析;结算中心=信用账期占成/收付。
|
- 模块边界:对账中心=主站↔彩票钱包划转;报表中心=钱包盘经营分析;结算中心=信用账期占成/收付。
|
||||||
- 报表中心:默认近 30 天;盈亏类按 `draws.business_date`;金额读 API `currency_code`;导出仅后端 `report-jobs` 全量(按筛选,无需先预览);注单盈亏≠信用账期结算。
|
- 报表中心:默认近 30 天;盈亏类按 `draws.business_date`;金额读 API `currency_code`;导出仅后端 `report-jobs` 全量(按筛选,无需先预览);注单盈亏≠信用账期结算。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
/** 改成服务器上的绝对路径,例如 /www/wwwroot/lotteryadmin.tanumo.com */
|
/** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery-admin.cjdhr.top */
|
||||||
const APP_CWD = path.resolve(__dirname);
|
const APP_CWD = path.resolve(__dirname);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ export async function postOddsVersion(body?: {
|
|||||||
export async function putOddsItems(
|
export async function putOddsItems(
|
||||||
id: number,
|
id: number,
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
provider_code?: string;
|
||||||
play_code: string;
|
play_code: string;
|
||||||
prize_scope: string;
|
prize_scope: string;
|
||||||
|
dimension?: number | null;
|
||||||
odds_value: number;
|
odds_value: number;
|
||||||
rebate_rate?: number;
|
rebate_rate?: number;
|
||||||
commission_rate?: number;
|
commission_rate?: number;
|
||||||
|
|||||||
@@ -245,6 +245,11 @@
|
|||||||
"readOnlyHint": "This version is read-only. Create a draft before editing odds.",
|
"readOnlyHint": "This version is read-only. Create a draft before editing odds.",
|
||||||
"rebateRate": "Base rebate rate (%)",
|
"rebateRate": "Base rebate rate (%)",
|
||||||
"rebateRateHint": "This is the platform base rebate. It writes rebate_rate to all prize scopes under this play type; agent/player rebate is added on top of it.",
|
"rebateRateHint": "This is the platform base rebate. It writes rebate_rate to all prize scopes under this play type; agent/player rebate is added on top of it.",
|
||||||
|
"scopeSource": {
|
||||||
|
"global": "Global",
|
||||||
|
"provider": "Provider",
|
||||||
|
"inherited": "Inherited"
|
||||||
|
},
|
||||||
"rollbackDialog": {
|
"rollbackDialog": {
|
||||||
"confirm": "Confirm rollback",
|
"confirm": "Confirm rollback",
|
||||||
"description": "A new draft will be cloned from version v{{version}}. The active version will not be overwritten directly.",
|
"description": "A new draft will be cloned from version v{{version}}. The active version will not be overwritten directly.",
|
||||||
|
|||||||
@@ -54,6 +54,13 @@
|
|||||||
"backToList": "Back to ticket list",
|
"backToList": "Back to ticket list",
|
||||||
"viewTicketDetail": "View ticket detail",
|
"viewTicketDetail": "View ticket detail",
|
||||||
"combinationCount": "Expanded combinations",
|
"combinationCount": "Expanded combinations",
|
||||||
|
"oddsSnapshotTitle": "Odds snapshot",
|
||||||
|
"oddsSnapshotHint": "Odds rows locked when the ticket was placed. Provider-specific tiers show that provider; inherited tiers show GLOBAL.",
|
||||||
|
"oddsSnapshotEmpty": "No odds snapshot",
|
||||||
|
"prizeScope": "Prize tier",
|
||||||
|
"oddsValue": "Odds",
|
||||||
|
"rebateRate": "Rebate",
|
||||||
|
"commissionRate": "Commission",
|
||||||
"combinationsTitle": "Combination breakdown",
|
"combinationsTitle": "Combination breakdown",
|
||||||
"combinationsHint": "One bet line may expand into multiple 4D numbers for risk and settlement.",
|
"combinationsHint": "One bet line may expand into multiple 4D numbers for risk and settlement.",
|
||||||
"number4d": "4D number",
|
"number4d": "4D number",
|
||||||
|
|||||||
@@ -245,6 +245,11 @@
|
|||||||
"readOnlyHint": "यो संस्करण केवल पढ्न मिल्ने छ। अड्स सम्पादन गर्नुअघि ड्राफ्ट बनाउनुहोस्।",
|
"readOnlyHint": "यो संस्करण केवल पढ्न मिल्ने छ। अड्स सम्पादन गर्नुअघि ड्राफ्ट बनाउनुहोस्।",
|
||||||
"rebateRate": "रिबेट दर (%)",
|
"rebateRate": "रिबेट दर (%)",
|
||||||
"rebateRateHint": "यसले यो खेल प्रकारअन्तर्गत सबै prize scope मा rebate_rate लेख्छ।",
|
"rebateRateHint": "यसले यो खेल प्रकारअन्तर्गत सबै prize scope मा rebate_rate लेख्छ।",
|
||||||
|
"scopeSource": {
|
||||||
|
"global": "Global",
|
||||||
|
"provider": "Provider",
|
||||||
|
"inherited": "Inherited"
|
||||||
|
},
|
||||||
"rollbackDialog": {
|
"rollbackDialog": {
|
||||||
"confirm": "रोलब्याक पुष्टि गर्नुहोस्",
|
"confirm": "रोलब्याक पुष्टि गर्नुहोस्",
|
||||||
"description": "संस्करण v{{version}} बाट नयाँ ड्राफ्ट क्लोन हुनेछ। हाल सक्रिय संस्करण प्रत्यक्ष रूपमा ओभरराइट हुँदैन।",
|
"description": "संस्करण v{{version}} बाट नयाँ ड्राफ्ट क्लोन हुनेछ। हाल सक्रिय संस्करण प्रत्यक्ष रूपमा ओभरराइट हुँदैन।",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"combinationsTitle": "संयोजन विवरण",
|
"combinationsTitle": "संयोजन विवरण",
|
||||||
"comboBetAmount": "संयोजन बेट",
|
"comboBetAmount": "संयोजन बेट",
|
||||||
"comboEstimatedPayout": "अनुमानित भुक्तानी रिजर्भ",
|
"comboEstimatedPayout": "अनुमानित भुक्तानी रिजर्भ",
|
||||||
|
"commissionRate": "Commission",
|
||||||
"detailLoadFailed": "टिकट विवरण लोड असफल",
|
"detailLoadFailed": "टिकट विवरण लोड असफल",
|
||||||
"detailTitle": "टिकट विवरण",
|
"detailTitle": "टिकट विवरण",
|
||||||
"drawNo": "ड्रअ नं.",
|
"drawNo": "ड्रअ नं.",
|
||||||
@@ -24,15 +25,21 @@
|
|||||||
"number4d": "4D नम्बर",
|
"number4d": "4D नम्बर",
|
||||||
"numberKeyword": "नम्बर / टिकट / अर्डर",
|
"numberKeyword": "नम्बर / टिकट / अर्डर",
|
||||||
"numberKeywordPlaceholder": "नम्बर, टिकट नं. वा अर्डर नं. बाट खोज्नुहोस्",
|
"numberKeywordPlaceholder": "नम्बर, टिकट नं. वा अर्डर नं. बाट खोज्नुहोस्",
|
||||||
|
"oddsSnapshotEmpty": "Odds snapshot छैन",
|
||||||
|
"oddsSnapshotHint": "टिकट राख्दा लक भएको odds पंक्ति। Provider-specific tier मा provider देखिन्छ; inherited tier मा GLOBAL देखिन्छ।",
|
||||||
|
"oddsSnapshotTitle": "Odds snapshot",
|
||||||
|
"oddsValue": "Odds",
|
||||||
"orderNo": "अर्डर नं.",
|
"orderNo": "अर्डर नं.",
|
||||||
"placedAt": "बेट समय",
|
"placedAt": "बेट समय",
|
||||||
"placedDateRange": "बेट गरिएको मिति दायरा",
|
"placedDateRange": "बेट गरिएको मिति दायरा",
|
||||||
"playCode": "प्ले",
|
"playCode": "प्ले",
|
||||||
"player": "खेलाडी",
|
"player": "खेलाडी",
|
||||||
|
"prizeScope": "Prize tier",
|
||||||
"playerId": "खेलाडी ID / खाता",
|
"playerId": "खेलाडी ID / खाता",
|
||||||
"playerIdPlaceholder": "सबै देखाउन खाली छोड्नुहोस्; ID वा खाता लेख्नुहोस्",
|
"playerIdPlaceholder": "सबै देखाउन खाली छोड्नुहोस्; ID वा खाता लेख्नुहोस्",
|
||||||
"playerTicketQuery": "टिकट खोज",
|
"playerTicketQuery": "टिकट खोज",
|
||||||
"query": "खोज",
|
"query": "खोज",
|
||||||
|
"rebateRate": "Rebate",
|
||||||
"refreshCurrentPage": "हालको पृष्ठ रिफ्रेस",
|
"refreshCurrentPage": "हालको पृष्ठ रिफ्रेस",
|
||||||
"resetFilters": "फिल्टर रिसेट",
|
"resetFilters": "फिल्टर रिसेट",
|
||||||
"status": "स्थिति",
|
"status": "स्थिति",
|
||||||
|
|||||||
@@ -542,6 +542,11 @@
|
|||||||
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
||||||
"rebateRate": "基础回水 (%)",
|
"rebateRate": "基础回水 (%)",
|
||||||
"rebateRateHint": "输入基础回水比例,如 5 表示 5%。",
|
"rebateRateHint": "输入基础回水比例,如 5 表示 5%。",
|
||||||
|
"scopeSource": {
|
||||||
|
"global": "通用",
|
||||||
|
"provider": "专属",
|
||||||
|
"inherited": "继承通用"
|
||||||
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"multiplier": "请输入赔率倍数",
|
"multiplier": "请输入赔率倍数",
|
||||||
"rebateRate": "请输入基础回水比例"
|
"rebateRate": "请输入基础回水比例"
|
||||||
|
|||||||
@@ -54,6 +54,13 @@
|
|||||||
"backToList": "返回注单列表",
|
"backToList": "返回注单列表",
|
||||||
"viewTicketDetail": "查看注单详情",
|
"viewTicketDetail": "查看注单详情",
|
||||||
"combinationCount": "展开组合数",
|
"combinationCount": "展开组合数",
|
||||||
|
"oddsSnapshotTitle": "赔率快照",
|
||||||
|
"oddsSnapshotHint": "下注成功时锁定的赔率行;开注商专属档位显示对应开注商,未单独配置的档位显示 GLOBAL。",
|
||||||
|
"oddsSnapshotEmpty": "暂无赔率快照",
|
||||||
|
"prizeScope": "奖项档位",
|
||||||
|
"oddsValue": "赔率",
|
||||||
|
"rebateRate": "回水",
|
||||||
|
"commissionRate": "佣金",
|
||||||
"combinationsTitle": "展开组合明细",
|
"combinationsTitle": "展开组合明细",
|
||||||
"combinationsHint": "系统按玩法规则将一注展开为多个 4D 号码;此处为风控与结算用的组合列表。",
|
"combinationsHint": "系统按玩法规则将一注展开为多个 4D 号码;此处为风控与结算用的组合列表。",
|
||||||
"number4d": "4D 号码",
|
"number4d": "4D 号码",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { OddsItemRow } from "@/types/api/admin-config";
|
|||||||
|
|
||||||
function oddsItemFingerprint(row: OddsItemRow): string {
|
function oddsItemFingerprint(row: OddsItemRow): string {
|
||||||
return [
|
return [
|
||||||
|
row.provider_code ?? "GLOBAL",
|
||||||
row.play_code,
|
row.play_code,
|
||||||
row.prize_scope,
|
row.prize_scope,
|
||||||
row.odds_value,
|
row.odds_value,
|
||||||
@@ -17,10 +18,10 @@ export function oddsDraftIsDirty(draftRows: OddsItemRow[], savedRows: OddsItemRo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = new Map(savedRows.map((row) => [`${row.play_code}|${row.prize_scope}`, row]));
|
const saved = new Map(savedRows.map((row) => [`${row.provider_code ?? "GLOBAL"}|${row.play_code}|${row.prize_scope}`, row]));
|
||||||
|
|
||||||
for (const draft of draftRows) {
|
for (const draft of draftRows) {
|
||||||
const key = `${draft.play_code}|${draft.prize_scope}`;
|
const key = `${draft.provider_code ?? "GLOBAL"}|${draft.play_code}|${draft.prize_scope}`;
|
||||||
const baseline = saved.get(key);
|
const baseline = saved.get(key);
|
||||||
if (!baseline) {
|
if (!baseline) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
publishOddsVersion,
|
publishOddsVersion,
|
||||||
putOddsItems,
|
putOddsItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
|
import { getAdminBetProviders } from "@/api/admin-bet-providers";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
@@ -50,6 +51,7 @@ import type {
|
|||||||
OddsItemRow,
|
OddsItemRow,
|
||||||
OddsVersionDetail,
|
OddsVersionDetail,
|
||||||
} from "@/types/api/admin-config";
|
} from "@/types/api/admin-config";
|
||||||
|
import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider";
|
||||||
|
|
||||||
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
|
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
|
||||||
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
|
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
|
||||||
@@ -74,6 +76,7 @@ import {
|
|||||||
} from "@/modules/config/doc/prize-scopes";
|
} from "@/modules/config/doc/prize-scopes";
|
||||||
|
|
||||||
type CatTab = OddsCategoryTab;
|
type CatTab = OddsCategoryTab;
|
||||||
|
const GLOBAL_PROVIDER_CODE = "GLOBAL";
|
||||||
|
|
||||||
function oddsMultiplierLabel(oddsValue: number): string {
|
function oddsMultiplierLabel(oddsValue: number): string {
|
||||||
return (oddsValue / 10000).toFixed(4);
|
return (oddsValue / 10000).toFixed(4);
|
||||||
@@ -112,6 +115,7 @@ export function OddsConfigDocScreen({
|
|||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||||
|
const [betProviders, setBetProviders] = useState<AdminBetProviderRow[]>([]);
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
const [internalSelectedId, setInternalSelectedId] = useState("");
|
const [internalSelectedId, setInternalSelectedId] = useState("");
|
||||||
const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId;
|
const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId;
|
||||||
@@ -135,6 +139,7 @@ export function OddsConfigDocScreen({
|
|||||||
const resolvedError = workspace?.error ?? error;
|
const resolvedError = workspace?.error ?? error;
|
||||||
|
|
||||||
const [catTab, setCatTab] = useState<CatTab>("all");
|
const [catTab, setCatTab] = useState<CatTab>("all");
|
||||||
|
const [providerCode, setProviderCode] = useState<string>(GLOBAL_PROVIDER_CODE);
|
||||||
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
|
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
|
||||||
const [playCode, setPlayCode] = useState<string>("");
|
const [playCode, setPlayCode] = useState<string>("");
|
||||||
|
|
||||||
@@ -157,6 +162,15 @@ export function OddsConfigDocScreen({
|
|||||||
}
|
}
|
||||||
}, [tRef]);
|
}, [tRef]);
|
||||||
|
|
||||||
|
const refreshBetProviders = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getAdminBetProviders();
|
||||||
|
setBetProviders(data.items);
|
||||||
|
} catch {
|
||||||
|
setBetProviders([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
const refreshList = useCallback(async () => {
|
||||||
setLoadingList(true);
|
setLoadingList(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -177,8 +191,15 @@ export function OddsConfigDocScreen({
|
|||||||
if (workspace) {
|
if (workspace) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void Promise.all([refreshTypes(), refreshList()]);
|
void Promise.all([refreshTypes(), refreshList(), refreshBetProviders()]);
|
||||||
}, [workspace]);
|
}, [refreshBetProviders, workspace]);
|
||||||
|
|
||||||
|
useAsyncEffect(() => {
|
||||||
|
if (!workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshBetProviders();
|
||||||
|
}, [refreshBetProviders, workspace]);
|
||||||
|
|
||||||
const loadDetail = useCallback(async (id: number) => {
|
const loadDetail = useCallback(async (id: number) => {
|
||||||
setLoadingDetail(true);
|
setLoadingDetail(true);
|
||||||
@@ -284,13 +305,32 @@ export function OddsConfigDocScreen({
|
|||||||
if (row.play_code !== resolvedPlayCode) {
|
if (row.play_code !== resolvedPlayCode) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE;
|
||||||
|
if (rowProviderCode !== providerCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const scope = row.prize_scope as PrizeScopeCode;
|
const scope = row.prize_scope as PrizeScopeCode;
|
||||||
if (PRIZE_SCOPE_ORDER.includes(scope)) {
|
if (PRIZE_SCOPE_ORDER.includes(scope)) {
|
||||||
rows[scope] = row;
|
rows[scope] = row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providerCode !== GLOBAL_PROVIDER_CODE) {
|
||||||
|
for (const row of resolvedDraftRows) {
|
||||||
|
if (row.play_code !== resolvedPlayCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE;
|
||||||
|
if (rowProviderCode !== GLOBAL_PROVIDER_CODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const scope = row.prize_scope as PrizeScopeCode;
|
||||||
|
if (PRIZE_SCOPE_ORDER.includes(scope) && !rows[scope]) {
|
||||||
|
rows[scope] = { ...row, id: 0, provider_code: providerCode };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [resolvedDraftRows, resolvedPlayCode]);
|
}, [providerCode, resolvedDraftRows, resolvedPlayCode]);
|
||||||
|
|
||||||
const rebatePercentUi = useMemo(() => {
|
const rebatePercentUi = useMemo(() => {
|
||||||
const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean);
|
const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean);
|
||||||
@@ -300,8 +340,13 @@ export function OddsConfigDocScreen({
|
|||||||
return ratioToPercentUi(String(first.rebate_rate));
|
return ratioToPercentUi(String(first.rebate_rate));
|
||||||
}, [scopeRows]);
|
}, [scopeRows]);
|
||||||
|
|
||||||
function rowIndex(play_code: string, prize_scope: string): number {
|
function rowIndex(play_code: string, prize_scope: string, nextProviderCode = providerCode): number {
|
||||||
return resolvedDraftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope);
|
return resolvedDraftRows.findIndex(
|
||||||
|
(r) =>
|
||||||
|
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === nextProviderCode &&
|
||||||
|
r.play_code === play_code &&
|
||||||
|
r.prize_scope === prize_scope,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOddsRow(idx: number, patch: Partial<OddsItemRow>) {
|
function updateOddsRow(idx: number, patch: Partial<OddsItemRow>) {
|
||||||
@@ -312,35 +357,73 @@ export function OddsConfigDocScreen({
|
|||||||
const idx = rowIndex(resolvedPlayCode, scope);
|
const idx = rowIndex(resolvedPlayCode, scope);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
updateOddsRow(idx, patch);
|
updateOddsRow(idx, patch);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const globalIdx = rowIndex(resolvedPlayCode, scope, GLOBAL_PROVIDER_CODE);
|
||||||
|
const globalRow = globalIdx >= 0 ? resolvedDraftRows[globalIdx] : null;
|
||||||
|
if (!globalRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResolvedDraftRows((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ ...globalRow, id: 0, provider_code: providerCode, ...patch },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRebateForPlayPercent(percentStr: string) {
|
function setRebateForPlayPercent(percentStr: string) {
|
||||||
const p = Number.parseFloat(percentStr);
|
const p = Number.parseFloat(percentStr);
|
||||||
const rate = Number.isFinite(p) ? p / 100 : 0;
|
const rate = Number.isFinite(p) ? p / 100 : 0;
|
||||||
setResolvedDraftRows((prev) =>
|
setResolvedDraftRows((prev) =>
|
||||||
prev.map((r) =>
|
{
|
||||||
r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r,
|
let rows = prev.map((r) =>
|
||||||
),
|
r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode
|
||||||
|
? { ...r, rebate_rate: String(rate) }
|
||||||
|
: r,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providerCode !== GLOBAL_PROVIDER_CODE) {
|
||||||
|
const hasProviderRows = rows.some(
|
||||||
|
(r) => r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode,
|
||||||
|
);
|
||||||
|
if (!hasProviderRows) {
|
||||||
|
const copies = rows
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
r.play_code === resolvedPlayCode &&
|
||||||
|
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE,
|
||||||
|
)
|
||||||
|
.map((r) => ({ ...r, id: 0, provider_code: providerCode, rebate_rate: String(rate) }));
|
||||||
|
rows = [...rows, ...copies];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOddsItemsPayload(rows: OddsItemRow[]) {
|
||||||
|
return rows.map((r) => ({
|
||||||
|
play_code: r.play_code,
|
||||||
|
provider_code: r.provider_code ?? GLOBAL_PROVIDER_CODE,
|
||||||
|
prize_scope: r.prize_scope,
|
||||||
|
dimension: r.dimension ?? null,
|
||||||
|
odds_value: r.odds_value,
|
||||||
|
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||||
|
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||||
|
currency_code: r.currency_code,
|
||||||
|
extra_config_json: r.extra_config_json,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!resolvedDetail || !canEditDraft) {
|
if (!resolvedDetail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = resolvedDraftRows.map((r) => ({
|
const d = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows));
|
||||||
play_code: r.play_code,
|
|
||||||
prize_scope: r.prize_scope,
|
|
||||||
odds_value: r.odds_value,
|
|
||||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
|
||||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
|
||||||
currency_code: r.currency_code,
|
|
||||||
extra_config_json: r.extra_config_json,
|
|
||||||
}));
|
|
||||||
const d = await putOddsItems(resolvedDetail.id, payload);
|
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
workspace.applyDetail(d);
|
workspace.applyDetail(d);
|
||||||
} else {
|
} else {
|
||||||
@@ -362,7 +445,8 @@ export function OddsConfigDocScreen({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const d = await publishOddsVersion(resolvedDetail.id);
|
const saved = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows));
|
||||||
|
const d = await publishOddsVersion(saved.id);
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
workspace.applyDetail(d);
|
workspace.applyDetail(d);
|
||||||
} else {
|
} else {
|
||||||
@@ -479,8 +563,28 @@ export function OddsConfigDocScreen({
|
|||||||
const selectedPlay = resolvedPlayCode;
|
const selectedPlay = resolvedPlayCode;
|
||||||
|
|
||||||
return PRIZE_SCOPE_ORDER.map((scope) => {
|
return PRIZE_SCOPE_ORDER.map((scope) => {
|
||||||
const next = resolvedDraftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
|
const next = resolvedDraftRows.find(
|
||||||
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
|
(r) =>
|
||||||
|
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode &&
|
||||||
|
r.play_code === selectedPlay &&
|
||||||
|
r.prize_scope === scope,
|
||||||
|
) ?? resolvedDraftRows.find(
|
||||||
|
(r) =>
|
||||||
|
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE &&
|
||||||
|
r.play_code === selectedPlay &&
|
||||||
|
r.prize_scope === scope,
|
||||||
|
);
|
||||||
|
const old = activeCompareRows.find(
|
||||||
|
(r) =>
|
||||||
|
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode &&
|
||||||
|
r.play_code === selectedPlay &&
|
||||||
|
r.prize_scope === scope,
|
||||||
|
) ?? activeCompareRows.find(
|
||||||
|
(r) =>
|
||||||
|
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE &&
|
||||||
|
r.play_code === selectedPlay &&
|
||||||
|
r.prize_scope === scope,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
scope,
|
scope,
|
||||||
label: prizeScopeLabel(scope, t),
|
label: prizeScopeLabel(scope, t),
|
||||||
@@ -488,7 +592,7 @@ export function OddsConfigDocScreen({
|
|||||||
newValue: next?.odds_value ?? null,
|
newValue: next?.odds_value ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [activeCompareRows, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]);
|
}, [activeCompareRows, providerCode, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]);
|
||||||
|
|
||||||
const catTabs: { id: CatTab; label: string }[] = [
|
const catTabs: { id: CatTab; label: string }[] = [
|
||||||
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
|
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
|
||||||
@@ -500,9 +604,29 @@ export function OddsConfigDocScreen({
|
|||||||
const activePlayLabel = resolvedPlayCode
|
const activePlayLabel = resolvedPlayCode
|
||||||
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
||||||
: "—";
|
: "—";
|
||||||
|
const activeProviderLabel = providerCode === GLOBAL_PROVIDER_CODE
|
||||||
|
? t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })
|
||||||
|
: (betProviders.find((provider) => provider.code === providerCode)?.name ?? providerCode);
|
||||||
|
|
||||||
const filtersInner = (
|
const filtersInner = (
|
||||||
<>
|
<>
|
||||||
|
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
|
||||||
|
<ConfigChip
|
||||||
|
active={providerCode === GLOBAL_PROVIDER_CODE}
|
||||||
|
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||||
|
>
|
||||||
|
{t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })}
|
||||||
|
</ConfigChip>
|
||||||
|
{betProviders.map((provider) => (
|
||||||
|
<ConfigChip
|
||||||
|
key={provider.code}
|
||||||
|
active={providerCode === provider.code}
|
||||||
|
onClick={() => setProviderCode(provider.code)}
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</ConfigChip>
|
||||||
|
))}
|
||||||
|
</ConfigChipGroup>
|
||||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||||
{catTabs.map((tab) => (
|
{catTabs.map((tab) => (
|
||||||
<ConfigChip
|
<ConfigChip
|
||||||
@@ -608,8 +732,18 @@ export function OddsConfigDocScreen({
|
|||||||
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
|
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
|
||||||
const row = scopeRows[scope];
|
const row = scopeRows[scope];
|
||||||
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||||
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
|
const hasProviderRow = providerCode === GLOBAL_PROVIDER_CODE || rowIndex(resolvedPlayCode, scope) >= 0;
|
||||||
return { scope, row, hint, idx };
|
const sourceLabel = providerCode === GLOBAL_PROVIDER_CODE
|
||||||
|
? t("odds.scopeSource.global", { ns: "config" })
|
||||||
|
: hasProviderRow
|
||||||
|
? t("odds.scopeSource.provider", { ns: "config" })
|
||||||
|
: t("odds.scopeSource.inherited", { ns: "config" });
|
||||||
|
const sourceClassName = providerCode === GLOBAL_PROVIDER_CODE
|
||||||
|
? "border-slate-200 bg-slate-50 text-slate-600"
|
||||||
|
: hasProviderRow
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||||
|
: "border-amber-200 bg-amber-50 text-amber-700";
|
||||||
|
return { scope, row, hint, sourceLabel, sourceClassName };
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergedOddsTable = (
|
const mergedOddsTable = (
|
||||||
@@ -621,14 +755,21 @@ export function OddsConfigDocScreen({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
|
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
|
||||||
<TableRow key={scope}>
|
<TableRow key={scope}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{prizeScopeLabel(scope, t)}
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{hint ? <span className="ml-1 text-xs font-normal text-muted-foreground">{hint}</span> : null}
|
<span>{prizeScopeLabel(scope, t)}</span>
|
||||||
|
{hint ? <span className="text-xs font-normal text-muted-foreground">{hint}</span> : null}
|
||||||
|
{row ? (
|
||||||
|
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{row && idx >= 0 ? (
|
{row ? (
|
||||||
canEditDraft ? (
|
canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -682,13 +823,20 @@ export function OddsConfigDocScreen({
|
|||||||
|
|
||||||
const classicOddsGrid = (
|
const classicOddsGrid = (
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
||||||
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
|
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
|
||||||
<div key={scope} className="grid min-w-0 gap-1.5">
|
<div key={scope} className="grid min-w-0 gap-1.5">
|
||||||
<Label className="truncate text-xs font-medium text-muted-foreground">
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
{prizeScopeLabel(scope, t)}
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
{prizeScopeLabel(scope, t)}
|
||||||
</Label>
|
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
||||||
{row && idx >= 0 ? (
|
</Label>
|
||||||
|
{row ? (
|
||||||
|
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{row ? (
|
||||||
canEditDraft ? (
|
canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -829,7 +977,24 @@ export function OddsConfigDocScreen({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{toolbarBlock}
|
{toolbarBlock}
|
||||||
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
|
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
|
||||||
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
<aside className="space-y-4 border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
||||||
|
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
|
||||||
|
<ConfigChip
|
||||||
|
active={providerCode === GLOBAL_PROVIDER_CODE}
|
||||||
|
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||||
|
>
|
||||||
|
{t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })}
|
||||||
|
</ConfigChip>
|
||||||
|
{betProviders.map((provider) => (
|
||||||
|
<ConfigChip
|
||||||
|
key={provider.code}
|
||||||
|
active={providerCode === provider.code}
|
||||||
|
onClick={() => setProviderCode(provider.code)}
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</ConfigChip>
|
||||||
|
))}
|
||||||
|
</ConfigChipGroup>
|
||||||
<OddsConfigPlayNav
|
<OddsConfigPlayNav
|
||||||
catTab={catTab}
|
catTab={catTab}
|
||||||
onCatTabChange={setCatTab}
|
onCatTabChange={setCatTab}
|
||||||
@@ -841,6 +1006,7 @@ export function OddsConfigDocScreen({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
|
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
|
||||||
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
|
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{activeProviderLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
|
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
|
||||||
{isDraft && canManage ? (
|
{isDraft && canManage ? (
|
||||||
|
|||||||
@@ -325,6 +325,20 @@ export function RebateConfigDocScreen({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOddsItemsPayload(rows: OddsItemRow[]) {
|
||||||
|
return rows.map((r) => ({
|
||||||
|
provider_code: r.provider_code ?? "GLOBAL",
|
||||||
|
play_code: r.play_code,
|
||||||
|
prize_scope: r.prize_scope,
|
||||||
|
dimension: r.dimension ?? null,
|
||||||
|
odds_value: r.odds_value,
|
||||||
|
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
||||||
|
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
||||||
|
currency_code: r.currency_code,
|
||||||
|
extra_config_json: r.extra_config_json,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!resolvedDetail || !canEditDraft) {
|
if (!resolvedDetail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
@@ -332,16 +346,7 @@ export function RebateConfigDocScreen({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
|
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
|
||||||
const payload = nextRows.map((r) => ({
|
const d = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(nextRows));
|
||||||
play_code: r.play_code,
|
|
||||||
prize_scope: r.prize_scope,
|
|
||||||
odds_value: r.odds_value,
|
|
||||||
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
|
|
||||||
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
|
|
||||||
currency_code: r.currency_code,
|
|
||||||
extra_config_json: r.extra_config_json,
|
|
||||||
}));
|
|
||||||
const d = await putOddsItems(resolvedDetail.id, payload);
|
|
||||||
const rows = d.items.map((it) => ({ ...it }));
|
const rows = d.items.map((it) => ({ ...it }));
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
workspace.applyDetail(d);
|
workspace.applyDetail(d);
|
||||||
@@ -367,7 +372,9 @@ export function RebateConfigDocScreen({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const d = await publishOddsVersion(resolvedDetail.id);
|
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
|
||||||
|
const saved = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(nextRows));
|
||||||
|
const d = await publishOddsVersion(saved.id);
|
||||||
const rows = d.items.map((it) => ({ ...it }));
|
const rows = d.items.map((it) => ({ ...it }));
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
workspace.applyDetail(d);
|
workspace.applyDetail(d);
|
||||||
|
|||||||
@@ -448,16 +448,24 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
<AdminRowActionsMenu
|
<AdminRowActionsMenu
|
||||||
actions={
|
actions={
|
||||||
row.player_id
|
[
|
||||||
? [
|
{
|
||||||
{
|
key: "view-ticket-detail",
|
||||||
key: "view-player",
|
label: t("viewTicketDetail"),
|
||||||
label: t("viewPlayer"),
|
icon: Eye,
|
||||||
icon: Eye,
|
href: ticketDetailPath(row.ticket_no),
|
||||||
href: adminPlayerDetailPath(row.player_id),
|
},
|
||||||
},
|
...(row.player_id
|
||||||
]
|
? [
|
||||||
: []
|
{
|
||||||
|
key: "view-player",
|
||||||
|
label: t("viewPlayer"),
|
||||||
|
icon: Eye,
|
||||||
|
href: adminPlayerDetailPath(row.player_id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
|||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminTicketItemDetail } from "@/types/api/admin-tickets";
|
import type { AdminTicketItemDetail, AdminTicketOddsSnapshotRow } from "@/types/api/admin-tickets";
|
||||||
|
|
||||||
function ticketStatusText(value: string, t: (key: string) => string): string {
|
function ticketStatusText(value: string, t: (key: string) => string): string {
|
||||||
const key = `statusOptions.${value}`;
|
const key = `statusOptions.${value}`;
|
||||||
@@ -41,6 +41,22 @@ function providerLabel(providerName?: string | null, providerCode?: string | nul
|
|||||||
return providerName || providerCode || "—";
|
return providerName || providerCode || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oddsSnapshotRows(value: unknown): AdminTicketOddsSnapshotRow[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.filter((row): row is AdminTicketOddsSnapshotRow => typeof row === "object" && row !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oddsMultiplierLabel(value?: number | null): string {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (value / 10000).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
||||||
const { t } = useTranslation(["tickets", "common"]);
|
const { t } = useTranslation(["tickets", "common"]);
|
||||||
const tRef = useTranslationRef(["tickets", "common"]);
|
const tRef = useTranslationRef(["tickets", "common"]);
|
||||||
@@ -97,6 +113,7 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currencyCode = data?.currency_code ?? "NPR";
|
const currencyCode = data?.currency_code ?? "NPR";
|
||||||
|
const oddsRows = oddsSnapshotRows(data?.odds_snapshot_json);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -178,6 +195,53 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="admin-list-card">
|
||||||
|
<CardHeader className="admin-list-header">
|
||||||
|
<CardTitle className="admin-list-title">{t("oddsSnapshotTitle")}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">{t("oddsSnapshotHint")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="admin-list-content">
|
||||||
|
<div className="admin-table-shell">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("provider", { defaultValue: "开注商" })}</TableHead>
|
||||||
|
<TableHead>{t("prizeScope")}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("oddsValue")}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("rebateRate")}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("commissionRate")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && !data ? <AdminTableLoadingRow colSpan={5} /> : null}
|
||||||
|
{!loading && oddsRows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("oddsSnapshotEmpty")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{oddsRows.map((row, index) => (
|
||||||
|
<TableRow key={`${row.provider_code ?? "GLOBAL"}-${row.prize_scope ?? "scope"}-${index}`}>
|
||||||
|
<TableCell className="font-mono text-xs">{row.provider_code ?? "GLOBAL"}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{row.prize_scope ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-center font-semibold tabular-nums">
|
||||||
|
{oddsMultiplierLabel(row.odds_value ?? null)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center font-mono text-xs">
|
||||||
|
{row.rebate_rate ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center font-mono text-xs">
|
||||||
|
{row.commission_rate ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="admin-list-card">
|
<Card className="admin-list-card">
|
||||||
<CardHeader className="admin-list-header">
|
<CardHeader className="admin-list-header">
|
||||||
<CardTitle className="admin-list-title">{t("combinationsTitle")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("combinationsTitle")}</CardTitle>
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ export type PlayConfigVersionDetail = ConfigVersionSummary & {
|
|||||||
|
|
||||||
export type OddsItemRow = {
|
export type OddsItemRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
provider_code?: string;
|
||||||
play_code: string;
|
play_code: string;
|
||||||
prize_scope: string;
|
prize_scope: string;
|
||||||
|
dimension?: number | null;
|
||||||
odds_value: number;
|
odds_value: number;
|
||||||
rebate_rate: string;
|
rebate_rate: string;
|
||||||
commission_rate: string;
|
commission_rate: string;
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export type AdminTicketCombinationRow = {
|
|||||||
estimated_payout: number;
|
estimated_payout: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminTicketOddsSnapshotRow = {
|
||||||
|
provider_code?: string | null;
|
||||||
|
prize_scope?: string | null;
|
||||||
|
odds_value?: number | null;
|
||||||
|
rebate_rate?: string | number | null;
|
||||||
|
commission_rate?: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminTicketItemDetail = {
|
export type AdminTicketItemDetail = {
|
||||||
id: number;
|
id: number;
|
||||||
ticket_no: string;
|
ticket_no: string;
|
||||||
@@ -79,6 +87,7 @@ export type AdminTicketItemDetail = {
|
|||||||
actual_deduct_amount_minor: number;
|
actual_deduct_amount_minor: number;
|
||||||
actual_deduct_amount_formatted: string;
|
actual_deduct_amount_formatted: string;
|
||||||
risk_locked_amount: number;
|
risk_locked_amount: number;
|
||||||
|
odds_snapshot_json: unknown;
|
||||||
status: string;
|
status: string;
|
||||||
fail_reason_code: string | null;
|
fail_reason_code: string | null;
|
||||||
fail_reason_text: string | null;
|
fail_reason_text: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user