feat(player): 优化桌面投注布局与账号功能
This commit is contained in:
148
design-qa.md
Normal file
148
design-qa.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 玩家端 PC 桌面化 Design QA
|
||||
|
||||
final result: passed
|
||||
|
||||
## 基准与实现证据
|
||||
|
||||
- 视觉基准(方案 3):`/Users/wchino/.codex/generated_images/019f8441-2864-75d2-a2a9-69bc25a3bc7a/exec-3297a599-f1a3-40a2-8845-e4d38b19bc52.png`
|
||||
- 1728×907 最终实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/final-1728x907-populated.jpg`
|
||||
- 同画布左右对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/design-comparison-option3-vs-implementation.jpg`
|
||||
- 1024×768 最终实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/final-1024x768.jpg`
|
||||
- 390×844 移动回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/full-390x844.jpg`
|
||||
- 480×844 移动回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/viewport-480x844-full.jpg`
|
||||
- 浏览器批注修正后 1567×899:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/followup-audit/hall-after-1567x899.jpg`
|
||||
|
||||
## 验收状态
|
||||
|
||||
- 桌面基准状态:已登录、信用模式、开放期号;大厅使用 4D、四条有效草稿、一家开注商,用于检验工具区、表格密度和复核栏。
|
||||
- 1024×768:复核摘要位于表格下方,宽度 743px;表格宽度 741px。页面 `scrollWidth === clientWidth === 1024`,横向滚动仅在表格内部。
|
||||
- 1280×800、1440×900:复核摘要位于表格下方;1500px 以上复核栏位于表格右侧,避免中等桌面宽度压缩输入区。页面无全局横向滚动。
|
||||
- 390×844、480×844:继续使用原移动布局和底部提交栏;页面无全局横向滚动。
|
||||
- 主导航、玩法切换、号码和金额输入、预览、提交、注单展开、信用流水筛选均可操作。
|
||||
- Chrome 控制台没有新增 error;只有开发模式 HMR、TokenRefresh 和 React DevTools 提示。
|
||||
|
||||
## 真实数据闭环
|
||||
|
||||
- 测试订单号:`TO20260721111116994982`
|
||||
- 票号:`TK20260721111116970261`
|
||||
- 期号:`20260721-1039`
|
||||
- 测试内容:号码 `3147`、玩法 `大`、Singapore、`NPR 1.00`
|
||||
- 我的注单:总记录从 8 增至 9,订单立即出现并可展开查看票号、玩法、号码、开注商和金额。
|
||||
- 信用流水:新增“下注占用 −NPR 1.00”;提交时可用信用从 `NPR 95,891.00` 变为 `NPR 95,890.00`,已用信用从 `NPR 3,098.00` 变为 `NPR 3,099.00`,与授信 `NPR 98,989.00` 一致。
|
||||
|
||||
## 差异检查与迭代
|
||||
|
||||
1. 初版将三步工具区和复核区按方案 3 重组,保留真实表格列、开注商与提交预览。
|
||||
2. 1024px 首轮发现状态卡金额和第三步操作被压缩,调整状态区为 16rem 信用卡、隐藏窄屏次要授信摘要,并收窄玩法按钮。
|
||||
3. 复查 1024px 后期号、倒计时和信用金额完整显示,第三步操作不再裁切,复核区在表格后保持全宽。
|
||||
4. 1728px 同画布对比确认层级、三步工具区、投注台账、右侧复核栏、主操作位置与方案 3 一致;保留现有产品的真实列和业务文案。
|
||||
5. 最终 P0:0,P1:0,P2:0。
|
||||
|
||||
## 浏览器批注复查
|
||||
|
||||
- 大厅汇总条由单行滚动表改为自适应网格:1567、1440、1280、1024、480、390px 均为 `scrollWidth === clientWidth`;本轮进一步扩展为稳定的全玩法换行网格,移动端不显示横向滚动条。
|
||||
- 投注表格收窄桌面列宽并调整复核栏断点:1567、1440、1280px 表格内部无横向滚动;1024px 与 480/390px 只在投注表格内部保留必要横向滚动,页面本身无横向滚动。
|
||||
- 桌面复核区的可用信用、下注后可用和预计扣款已移除 `NPR` 前缀;“提交前仍会显示最终预览”提示已移除。
|
||||
- 开奖结果、我的注单、信用页复查后未发现新的 P0/P1/P2;规则页空数据状态改为有界居中提示,减少空白拉伸感。
|
||||
- 规则页 1567×899:`scrollWidth === clientWidth === 1567`;Chrome 控制台无新增 error。
|
||||
|
||||
## 浏览器批注复查 2
|
||||
|
||||
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/followup-audit/hall-after-1567x899.jpg`,对应本轮三个浏览器批注前的 1567×899 大厅状态。
|
||||
- 实现截图:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/hall-after-open-1567x899.jpg`;状态为已登录、开放期号、4D、20 条空白行。
|
||||
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/comparison-before-after-open.jpg`。
|
||||
- 汇总区聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/comparison-summary-focused.jpg`;密集表头和金额状态在原始尺寸下可读,因此无需更多局部裁切。
|
||||
- 非零高亮证据:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/hall-highlight-1567x899.jpg`。
|
||||
- 移动换行证据:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-2/hall-after-390x844.jpg`;31 个汇总单元固定六列换行,汇总区 `scrollWidth === clientWidth === 354`,页面 `scrollWidth === clientWidth === 390`。
|
||||
|
||||
### 比较历史与修复
|
||||
|
||||
1. P1:汇总区只显示当前玩法列,切换 4D/3D/2D 时内容变化。修复为稳定的 D4、D3、D2 全玩法集合;实测三个 Tab 的 31 个单元内容和顺序完全一致。
|
||||
2. P1:列同步复选框仅切换状态,关闭时不会清列。修复为开启后把该列输入同步到所有号码有效的行,关闭后清空该列;实测两行号码的 4C 金额同步为 `7`,取消后两行 4C 均为空。
|
||||
3. P1:批量清空会把 20 行缩减为 1 行。修复为只清号码和金额,保留行 ID、开注商和选择类型;实测清空前后均为 20 行,号码和金额为空,20 行默认开注商选择保留。
|
||||
4. P2:零值与非零值缺少视觉区分。零值使用灰色表头/数值,非零值使用蓝色表头与浅蓝底;实测 `4A 8.00` 与总计高亮,其他 `0.00` 保持灰态。
|
||||
|
||||
### 忠实度检查
|
||||
|
||||
- 字体与排版:沿用现有字体、字号和表格字重;长标签在单元格内保持清晰,不截断。
|
||||
- 间距与布局:1567px 使用两行自适应汇总网格,390px 使用六列换行;无新增全局或汇总区横向滚动。
|
||||
- 颜色与状态:灰态和蓝色高亮继续使用现有 slate/品牌蓝色 token,金额状态对比明确。
|
||||
- 图片与资产:本轮没有新增或替换图片、Logo、图标或装饰资产。
|
||||
- 文案与内容:保留全部现有玩法名称、金额格式和业务文案,只调整显示集合与交互行为。
|
||||
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||
|
||||
## 浏览器批注复查 3
|
||||
|
||||
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/hall-before-1221x899.jpg`,对应汇总区仍位于三步工具栏下方的 1221px 大厅状态。
|
||||
- 桌面实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/hall-after-desktop-final-1221x899-cdp.jpg`;视口 1221×899、已登录、开放期号、4D。
|
||||
- 移动实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/hall-after-mobile-final-390x844-cdp.jpg`;视口 390×844。
|
||||
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/comparison-before-after-1221.jpg`。
|
||||
- 汇总位置聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-3/comparison-summary-position-focused.jpg`。
|
||||
|
||||
### 比较历史与修复
|
||||
|
||||
1. P1:汇总区位于三步工具栏和投注表格之间,不符合“位于选择玩法上方”的信息顺序。修复为桌面端在三步工具栏之前渲染汇总区。
|
||||
2. P2:第一次移动后,移动端快捷填单位于汇总区和玩法 Tab 之间,汇总区与玩法 Tab 相距 84px。修复为移动端按“快捷填单、汇总区、玩法 Tab”排列,并统一使用 12px 垂直间距。
|
||||
3. Post-fix:1221px 下汇总区到三步工具栏为 12px,工具栏到投注表格为 13px;390px 下快捷填单到汇总区、汇总区到玩法 Tab 均为 12px。
|
||||
4. Post-fix:1221px 页面 `scrollWidth === clientWidth === 1221`;390px 页面 `scrollWidth === clientWidth === 390`,汇总区 `scrollWidth === clientWidth === 341`。
|
||||
|
||||
### 忠实度检查
|
||||
|
||||
- 字体与排版:玩法标签、金额字号、字重和换行规则保持不变。
|
||||
- 间距与布局:只改变汇总模块顺序并统一相邻模块 12px 节奏,没有改变状态区、工具栏或表格尺寸。
|
||||
- 颜色与状态:零值灰态和非零蓝色高亮保持不变。
|
||||
- 图片与资产:没有新增或替换 Logo、图标及图片资产。
|
||||
- 文案与内容:全部玩法名称、金额、快捷填单和工具栏文案保持不变。
|
||||
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||
|
||||
## 浏览器批注复查 4
|
||||
|
||||
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/hall-before-390x844.png`,对应移动端快捷填单、汇总区和玩法 Tab 尚未形成独立上下间距的状态。
|
||||
- 移动实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/hall-after-390x844.png`;视口 390×844、已登录、开放期号、4D。
|
||||
- 桌面回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/hall-after-1221x899.png`;视口 1221×899。
|
||||
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/mobile-before-after.png`。
|
||||
- 标注区域聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-4/mobile-before-after-focus.png`;快捷填单、玩法 Tab 与相邻模块的边界在原始尺寸下清晰可读,无需继续裁切。
|
||||
|
||||
### 比较历史与修复
|
||||
|
||||
1. P2:移动端快捷填单、汇总区和玩法 Tab 被放在同一垂直组内,快捷填单区域没有形成用户标注所需的独立上、下边界。修复为把全玩法汇总区移出快捷填单组,移动端顺序调整为“汇总区、快捷填单、玩法 Tab、投注表格”。
|
||||
2. Post-fix:390px 下汇总区到快捷填单组为 12px、快捷填单到玩法 Tab 为 12px、玩法组到投注表格为 12px;页面 `scrollWidth === clientWidth === 390`。
|
||||
3. Post-fix:1221px 页面 `scrollWidth === clientWidth === 1221`,桌面汇总区、三步工具栏和投注表格结构不变。
|
||||
|
||||
### 忠实度检查
|
||||
|
||||
- 字体与排版:快捷填单、玩法 Tab、汇总金额和表格字号、字重均未改变。
|
||||
- 间距与布局:只拆分移动端模块分组并统一 12px 垂直节奏,标注区域上下都有可见留白。
|
||||
- 颜色与状态:灰态汇总、品牌蓝和表格状态色保持不变。
|
||||
- 图片与资产:没有新增或替换 Logo、钱包背景、图标及图片资产。
|
||||
- 文案与内容:玩法、金额、快捷填单和业务文案保持不变。
|
||||
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||
|
||||
## 浏览器批注复查 5
|
||||
|
||||
- 源视觉真值:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/hall-before-390x844.png`,对应移动端期号状态卡和可用信用卡直接相贴的状态。
|
||||
- 移动实现:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/hall-after-390x844.png`;视口 390×844、已登录、开放期号、4D。
|
||||
- 桌面回归:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/hall-after-1221x899.png`;视口 1221×899。
|
||||
- 全视图同画布对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/mobile-before-after.png`。
|
||||
- 顶部模块聚焦对比:`/Users/wchino/.codex/visualizations/2026/07/21/019f8441-2864-75d2-a2a9-69bc25a3bc7a/annotation-round-5/mobile-before-after-focus.png`;状态卡和信用卡的边界与留白在原始尺寸下清晰可读。
|
||||
|
||||
### 比较历史与修复
|
||||
|
||||
1. P2:移动端期号状态卡与可用信用卡之间为 0px,两个独立信息模块视觉上粘连。修复为移动端容器使用 12px 垂直间距,并在桌面断点归零以继续使用并排网格。
|
||||
2. Post-fix:390px 下状态卡到信用卡为 12px,信用卡到全玩法汇总区为 12px;页面 `scrollWidth === clientWidth === 390`。
|
||||
3. Post-fix:1221px 下状态卡和信用卡顶部差为 0px,继续保持并排等高布局;页面 `scrollWidth === clientWidth === 1221`。
|
||||
|
||||
### 忠实度检查
|
||||
|
||||
- 字体与排版:期号、时间、倒计时、信用金额及辅助文案均未改变。
|
||||
- 间距与布局:只增加移动端两个顶部模块之间的 12px 间距,桌面网格保持原位。
|
||||
- 颜色与状态:状态色、红色信用卡和页面背景 token 保持不变。
|
||||
- 图片与资产:钱包背景、Logo 和现有图标均未新增或替换。
|
||||
- 文案与内容:期号状态、信用金额和导航文案保持不变。
|
||||
- Chrome 控制台无 error;本轮最终 P0:0,P1:0,P2:0,`final result` 保持 `passed`。
|
||||
|
||||
## 工程验证
|
||||
|
||||
- `npm run lint`:passed
|
||||
- `npm run build`:passed
|
||||
- `git diff --check`:passed
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -2523,6 +2524,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
||||
@@ -27,6 +27,12 @@ export type PlayerAuthLoginData = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PlayerPasswordUpdatePayload = {
|
||||
current_password: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
};
|
||||
|
||||
/** `GET /api/v1/player/auth/captcha`(公开) */
|
||||
export function getPlayerAuthCaptcha(): Promise<PlayerAuthCaptchaResponse> {
|
||||
return lotteryRequest.get<PlayerAuthCaptchaResponse>(`/player/auth/captcha`, {
|
||||
@@ -42,3 +48,10 @@ export function postPlayerAuthLogin(body: PlayerAuthLoginPayload): Promise<Playe
|
||||
...body,
|
||||
});
|
||||
}
|
||||
|
||||
/** `PUT /api/v1/player/password`(仅彩票端账号密码玩家) */
|
||||
export function putPlayerPassword(
|
||||
body: PlayerPasswordUpdatePayload,
|
||||
): Promise<{ password_changed: boolean }> {
|
||||
return lotteryRequest.put<{ password_changed: boolean }>(`/player/password`, body);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,20 @@
|
||||
.player-desktop-shell {
|
||||
--player-sidebar-width: 15rem;
|
||||
}
|
||||
|
||||
.player-pc-page {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.player-pc-empty {
|
||||
min-height: 18rem;
|
||||
}
|
||||
|
||||
.player-pc-table-head {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 玩家端 Toast:顶部居中、紧凑尺寸(位置见 components/ui/sonner.tsx) */
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { PlayerAccountDialog } from "@/features/player/player-account-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PlayerDesktopHeaderProps = {
|
||||
@@ -26,7 +27,7 @@ export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
|
||||
alt="N lotto"
|
||||
width={243}
|
||||
height={84}
|
||||
className="h-7 w-auto max-w-[160px] object-contain object-left"
|
||||
className="h-7 w-auto max-w-[112px] object-contain object-left sm:max-w-[160px]"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
@@ -54,6 +55,7 @@ export function PlayerDesktopHeader({ className }: PlayerDesktopHeaderProps) {
|
||||
showFlag={false}
|
||||
className="rounded-full border border-[#e4eaf4] bg-[#f8fafc] [&_button]:h-8 [&_button]:gap-1 [&_button]:px-2 [&_button]:text-xs lg:[&_button]:h-9 lg:[&_button]:gap-1.5 lg:[&_button]:px-3 lg:[&_button]:text-sm"
|
||||
/>
|
||||
<PlayerAccountDialog />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PlayerPanel({
|
||||
<div className={cn(playerContentColumnClass, containerClassName)}>
|
||||
<section
|
||||
className={cn(
|
||||
"bg-white text-slate-900 lg:bg-transparent",
|
||||
"player-pc-page bg-white text-slate-900 lg:bg-transparent",
|
||||
playerPageShellPadding,
|
||||
className,
|
||||
)}
|
||||
|
||||
238
src/components/ui/field.tsx
Normal file
238
src/components/ui/field.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { HallBetPreviewDialog } from "@/features/hall/hall-bet-preview-dialog";
|
||||
import { HallBetResultDialog } from "@/features/hall/hall-bet-result-dialog";
|
||||
import { mapTicketBetError } from "@/features/hall/hall-bet-errors";
|
||||
import {
|
||||
HallDesktopReviewPanel,
|
||||
HallDesktopWorkflowBar,
|
||||
type HallDesktopReviewModel,
|
||||
} from "@/features/hall/hall-desktop-workspace";
|
||||
import {
|
||||
draftLineIssueReason,
|
||||
playNeedsDigitSlot,
|
||||
@@ -111,6 +116,63 @@ type PlayColumn = {
|
||||
digitSlot?: number;
|
||||
};
|
||||
|
||||
type HallSummaryItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
totalMinor: number;
|
||||
};
|
||||
|
||||
function HallPlaySummaryGrid({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: HallSummaryItem[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-6 overflow-hidden rounded-lg border border-[#dfe6f0] bg-[#f8fafc] text-center tabular-nums shadow-sm sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-[repeat(auto-fit,minmax(3.5rem,1fr))]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const hasValue = item.totalMinor > 0;
|
||||
return (
|
||||
<div
|
||||
key={`summary-${item.key}`}
|
||||
className={cn(
|
||||
"min-w-0 border-b border-r transition-colors",
|
||||
hasValue
|
||||
? "border-[#cbdcf7] bg-[#eef5ff]"
|
||||
: "border-slate-200 bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"flex min-h-7 items-center justify-center px-1 text-[10px] font-bold leading-tight transition-colors lg:min-h-8 lg:text-[11px]",
|
||||
hasValue
|
||||
? "bg-[#2d63e2] text-white"
|
||||
: "bg-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"px-1 py-1 text-[11px] font-black transition-colors lg:py-1.5 lg:text-xs",
|
||||
hasValue ? "text-[#0b3f96]" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{formatMinorAmount(item.totalMinor)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function playCategory(playCode: string): PlayHallCategory {
|
||||
if (playCode.startsWith("pos_3")) return "D3";
|
||||
if (playCode.startsWith("pos_2")) return "D2";
|
||||
@@ -195,13 +257,16 @@ const D4_PLAY_ORDER = [
|
||||
"digit_big",
|
||||
"digit_small",
|
||||
] as const;
|
||||
/** 按大厅 Tab 对应的玩法顺序;汇总条默认以当前 Tab 玩法开头 */
|
||||
/** 按大厅 Tab 对应的投注列顺序。 */
|
||||
const PLAY_ORDER_BY_CATEGORY: Record<PlayHallCategory, readonly string[]> = {
|
||||
D2: D2_PLAY_ORDER,
|
||||
D3: D3_PLAY_ORDER,
|
||||
D4: D4_PLAY_ORDER,
|
||||
};
|
||||
const CATEGORY_ORDER: readonly PlayHallCategory[] = ["D4", "D3", "D2"];
|
||||
const SUMMARY_PLAY_ORDER = CATEGORY_ORDER.flatMap(
|
||||
(category) => PLAY_ORDER_BY_CATEGORY[category],
|
||||
);
|
||||
const MOBILE_QUICK_AMOUNT_PRESETS = ["10", "50", "100"] as const;
|
||||
const DEFAULT_DRAFT_ROW_COUNT = 20;
|
||||
|
||||
@@ -550,9 +615,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
/** 单次预览→确认共用,重试 place 复用,避免重复扣款 */
|
||||
const placeTraceIdRef = useRef<string | null>(null);
|
||||
/** 玩法汇总条:切 Tab 后滚回左侧,保证当前维度玩法从开头可见 */
|
||||
const playSummaryScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const newPlaceTraceId = (): string =>
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
@@ -648,7 +710,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
const openPlays = useMemo(() => {
|
||||
if (catalogState.kind !== "ok") return [];
|
||||
// 4D / 3D / 2D 汇总条与列顺序以当前 Tab 玩法为先,避免切到 4D 仍从 2A 起显示
|
||||
// 投注表格列以当前 Tab 玩法为先;顶部汇总另用稳定的全玩法顺序。
|
||||
const order = playOrderForActiveCategory(activeCategory);
|
||||
const orderSet = new Set(order);
|
||||
return sortByPlayOrder(
|
||||
@@ -659,11 +721,6 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
);
|
||||
}, [activeCategory, catalogState]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = playSummaryScrollRef.current;
|
||||
if (el) el.scrollLeft = 0;
|
||||
}, [activeCategory]);
|
||||
|
||||
const activeCategoryPlays = useMemo(
|
||||
() => openPlays.filter((play) => TRADITIONAL_PLAY_CODES[activeCategory].includes(play.play_code)),
|
||||
[activeCategory, openPlays],
|
||||
@@ -686,6 +743,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
});
|
||||
}, [openPlays]);
|
||||
|
||||
const summaryPlayColumns = useMemo<PlayColumn[]>(() => {
|
||||
return sortByPlayOrder(openPlays, SUMMARY_PLAY_ORDER).flatMap((play) => {
|
||||
const category = playCategory(play.play_code);
|
||||
if (!playNeedsDigitSlot(play.play_code)) {
|
||||
return [{ key: amountKeyForPlay(play.play_code), play }];
|
||||
}
|
||||
return digitSlotOptions(category).map((digitSlot) => ({
|
||||
key: amountKeyForPlay(play.play_code, digitSlot),
|
||||
play,
|
||||
digitSlot,
|
||||
}));
|
||||
});
|
||||
}, [openPlays]);
|
||||
|
||||
const playColumns = useMemo(() => {
|
||||
return playColumnsForCategory(activeCategoryPlays, activeCategory);
|
||||
}, [activeCategory, activeCategoryPlays]);
|
||||
@@ -693,31 +764,31 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
const mobileIndexColClass = "w-[1.75rem] min-w-[1.75rem]";
|
||||
const mobileNumberColClass = "w-[4.25rem] min-w-[4.25rem]";
|
||||
const mobileSelectionTypeColClass = "w-[4rem] min-w-[4rem]";
|
||||
const desktopIndexColClass = "w-[2.15rem] min-w-[2.15rem]";
|
||||
const desktopNumberColClass = "w-[5.5rem] min-w-[5.5rem]";
|
||||
const desktopSelectionTypeColClass = "w-[4.75rem] min-w-[4.75rem]";
|
||||
const desktopIndexColClass = "w-9 min-w-9";
|
||||
const desktopNumberColClass = "w-[5.25rem] min-w-[5.25rem]";
|
||||
const desktopSelectionTypeColClass = "w-[4.5rem] min-w-[4.5rem]";
|
||||
const indexColClass = isMobile ? mobileIndexColClass : desktopIndexColClass;
|
||||
const numberColClass = isMobile ? mobileNumberColClass : desktopNumberColClass;
|
||||
const selectionTypeColClass = isMobile ? mobileSelectionTypeColClass : desktopSelectionTypeColClass;
|
||||
const showSelectionTypeColumn = activeCategory !== "D2";
|
||||
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-[2.15rem]";
|
||||
const stickyNumberLeftClass = isMobile ? "left-[1.75rem]" : "left-9";
|
||||
const amountColClass = !isMobile
|
||||
? "w-[3.9rem] min-w-[3.9rem]"
|
||||
? "w-14 min-w-14"
|
||||
: "w-[4rem] min-w-[4rem]";
|
||||
const providerColClass = !isMobile
|
||||
? "w-[2.85rem] min-w-[2.85rem]"
|
||||
? "w-11 min-w-11"
|
||||
: "w-[2.7rem] min-w-[2.7rem]";
|
||||
const rowTotalColClass = !isMobile
|
||||
? "w-[5.5rem] min-w-[5.5rem]"
|
||||
? "w-20 min-w-20"
|
||||
: "w-[5rem] min-w-[5rem]";
|
||||
|
||||
const tableWidthPx = useMemo(() => {
|
||||
const indexCol = !isMobile ? 34 : 28;
|
||||
const numberCol = !isMobile ? 88 : 68;
|
||||
const selectionTypeCol = !isMobile ? 76 : 64;
|
||||
const providerCol = !isMobile ? 46 : 43;
|
||||
const rowTotalCol = !isMobile ? 88 : 80;
|
||||
const amountCol = !isMobile ? 62 : 64;
|
||||
const indexCol = !isMobile ? 36 : 28;
|
||||
const numberCol = !isMobile ? 84 : 68;
|
||||
const selectionTypeCol = !isMobile ? 72 : 64;
|
||||
const providerCol = !isMobile ? 44 : 43;
|
||||
const rowTotalCol = !isMobile ? 80 : 80;
|
||||
const amountCol = !isMobile ? 56 : 64;
|
||||
return indexCol + numberCol + (showSelectionTypeColumn ? selectionTypeCol : 0) + betProviders.length * providerCol + rowTotalCol + playColumns.length * amountCol;
|
||||
}, [betProviders.length, isMobile, playColumns.length, showSelectionTypeColumn]);
|
||||
|
||||
@@ -841,11 +912,76 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
setActiveRowId(rowId);
|
||||
}, [alertRows, allPlayColumns, liveSoldOutNumbers, liveWarningNumbers, syncAmountColumns]);
|
||||
|
||||
const toggleSyncAmountColumn = useCallback((column: PlayColumn, checked: boolean) => {
|
||||
setSyncAmountColumns((current) => ({
|
||||
...current,
|
||||
[column.key]: checked,
|
||||
}));
|
||||
|
||||
setRows((current) => {
|
||||
if (!checked) {
|
||||
return current.map((row) => ({
|
||||
...row,
|
||||
amounts: { ...row.amounts, [column.key]: "" },
|
||||
}));
|
||||
}
|
||||
|
||||
const sourceAmount = current.find((row) => {
|
||||
const amount = row.amounts[column.key];
|
||||
return Boolean(
|
||||
amount &&
|
||||
draftLineIssueReason(
|
||||
column.play.play_code,
|
||||
row.number,
|
||||
column.digitSlot,
|
||||
) === null,
|
||||
);
|
||||
})?.amounts[column.key];
|
||||
|
||||
if (!sourceAmount) return current;
|
||||
|
||||
return current.map((row) => {
|
||||
const validNumber =
|
||||
draftLineIssueReason(
|
||||
column.play.play_code,
|
||||
row.number,
|
||||
column.digitSlot,
|
||||
) === null;
|
||||
const status = cellRiskState(
|
||||
column.play,
|
||||
row.number,
|
||||
playCategory(column.play.play_code),
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
column.digitSlot,
|
||||
);
|
||||
if (
|
||||
!validNumber ||
|
||||
status === "sold_out" ||
|
||||
(column.play.config !== null && !column.play.config.is_enabled)
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
amounts: { ...row.amounts, [column.key]: sourceAmount },
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [alertRows, liveSoldOutNumbers, liveWarningNumbers]);
|
||||
|
||||
const clearAllRows = () => {
|
||||
if (tableDisabled) return;
|
||||
const next = [newDraftRow()];
|
||||
setRows(next);
|
||||
setActiveRowId(next[0].id);
|
||||
setRows((current) =>
|
||||
current.map((row) => ({
|
||||
...row,
|
||||
number: "",
|
||||
amounts: {},
|
||||
})),
|
||||
);
|
||||
setActiveRowId((current) => current ?? rows[0]?.id ?? null);
|
||||
};
|
||||
|
||||
const clearActiveRowAmounts = useCallback(() => {
|
||||
@@ -1512,11 +1648,72 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
|
||||
const submitActualMinor =
|
||||
previewData?.summary.total_actual_deduct ?? debouncedSummary.actual;
|
||||
const selectedProviderCount = new Set(rows.flatMap((row) => row.providerCodes)).size;
|
||||
const activeDraftRowIds = new Set(draftEntries.map((entry) => entry.rowId));
|
||||
const selectedProviderCount = new Set(
|
||||
rows
|
||||
.filter((row) => activeDraftRowIds.has(row.id))
|
||||
.flatMap((row) => row.providerCodes),
|
||||
).size;
|
||||
const warningRowCount = rows.filter(
|
||||
(row) =>
|
||||
row.number.trim().length > 0 &&
|
||||
playColumns.some(
|
||||
(column) =>
|
||||
cellRiskState(
|
||||
column.play,
|
||||
row.number,
|
||||
playCategory(column.play.play_code),
|
||||
alertRows,
|
||||
liveSoldOutNumbers,
|
||||
liveWarningNumbers,
|
||||
column.digitSlot,
|
||||
) === "warning",
|
||||
),
|
||||
).length;
|
||||
const canSubmit =
|
||||
!tableDisabled && draftEntries.length > 0 && providerMissingRow() === null && availableMinor >= submitActualMinor;
|
||||
const favoriteChips = favorites.slice(0, 10);
|
||||
const historyChips = historyNumbers.slice(0, 20);
|
||||
const summaryItems = [
|
||||
{
|
||||
key: "total",
|
||||
label: t("hall.table.total", { defaultValue: "共" }),
|
||||
totalMinor: submitActualMinor,
|
||||
},
|
||||
...summaryPlayColumns.map((column) => ({
|
||||
key: column.key,
|
||||
label: playColumnHeaderLabel(
|
||||
column.play,
|
||||
playCategory(column.play.play_code),
|
||||
column.digitSlot,
|
||||
t,
|
||||
),
|
||||
totalMinor: rows.reduce((sum, row) => {
|
||||
const amount = row.amounts[column.key];
|
||||
if (
|
||||
!amount ||
|
||||
row.number.trim().length === 0 ||
|
||||
draftLineIssueReason(
|
||||
column.play.play_code,
|
||||
row.number,
|
||||
column.digitSlot,
|
||||
) !== null
|
||||
) {
|
||||
return sum;
|
||||
}
|
||||
return sum + (parseDecimalInputToMinor(amount, currencyCode) ?? 0);
|
||||
}, 0),
|
||||
})),
|
||||
];
|
||||
const desktopReviewModel: HallDesktopReviewModel = {
|
||||
lineCount: draftEntries.length,
|
||||
providerCount: selectedProviderCount,
|
||||
actualAmount: formatMinorAmount(submitActualMinor),
|
||||
availableCredit: formatMinorAmount(availableMinor),
|
||||
remainingCredit: formatMinorAmount(Math.max(0, availableMinor - submitActualMinor)),
|
||||
warningCount: warningRowCount,
|
||||
creditSufficient: availableMinor >= submitActualMinor,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-3" aria-label={t("hall.aria")}>
|
||||
@@ -1543,10 +1740,101 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HallPlaySummaryGrid items={summaryItems} className="hidden lg:grid" />
|
||||
|
||||
<HallDesktopWorkflowBar
|
||||
stepGame={t("hall.desktop.stepGame", { defaultValue: "选择玩法" })}
|
||||
stepFill={t("hall.desktop.stepFill", { defaultValue: "填写号码和金额" })}
|
||||
stepReview={t("hall.desktop.stepReview", { defaultValue: "核对并提交" })}
|
||||
gameControls={(
|
||||
<div className="inline-flex max-w-full items-center gap-1 rounded-lg bg-[#f3f6fb] p-1">
|
||||
{categoryTabs.map((tab) => {
|
||||
const hasPlays = openPlays.some(
|
||||
(play) => playCategory(play.play_code) === tab.value,
|
||||
);
|
||||
const active = activeCategory === tab.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`desktop-${tab.value}`}
|
||||
type="button"
|
||||
disabled={!hasPlays}
|
||||
onClick={() => setActiveCategory(tab.value)}
|
||||
className={cn(
|
||||
"inline-flex min-w-14 items-center justify-center rounded-lg px-2 py-2 text-sm font-bold transition-colors xl:min-w-[4.5rem] xl:px-3.5",
|
||||
active
|
||||
? "bg-[#2d63e2] text-white shadow-[0_4px_12px_rgba(45,99,226,0.24)]"
|
||||
: "text-[#5b7fbf] hover:bg-white hover:text-[#2d63e2]",
|
||||
!hasPlays && "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
fillControls={(
|
||||
<div className="flex min-h-9 min-w-0 flex-wrap items-center gap-1.5">
|
||||
{favoriteChips.length > 0 ? (
|
||||
<>
|
||||
<span className="mr-0.5 text-xs font-bold text-[#d81435]">
|
||||
{t("hall.quickFill.favorites")}
|
||||
</span>
|
||||
{favoriteChips.slice(0, 5).map((number) => (
|
||||
<button
|
||||
key={`desktop-fav-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center rounded-full border border-[#ffd7db] bg-[#fff3f5] px-3 text-xs font-bold text-[#d81435] transition-colors hover:bg-[#ffe9ed]"
|
||||
onClick={() => fillCurrentRow(number)}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<span className="ml-1 mr-0.5 text-xs font-bold text-slate-400">
|
||||
{t("hall.quickFill.history")}
|
||||
</span>
|
||||
{historyChips.length > 0 ? (
|
||||
historyChips.slice(0, 6).map((number) => (
|
||||
<button
|
||||
key={`desktop-his-${number}`}
|
||||
type="button"
|
||||
className="inline-flex h-8 min-w-10 items-center justify-center rounded-full border border-[#d7e5f8] bg-[#f8fbff] px-3 text-xs font-bold text-[#07459f] transition-colors hover:border-[#b9d0f3] hover:bg-[#eef6ff]"
|
||||
onClick={() => fillCurrentRow(number)}
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex h-8 items-center rounded-full border border-dashed border-slate-200 bg-slate-50 px-3 text-xs text-slate-400">
|
||||
{t("hall.quickFill.emptyHistory")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
reviewControls={(
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 flex-1 justify-center rounded-lg border-[#dfe6f0] bg-white text-sm font-bold text-slate-600"
|
||||
onClick={clearAllRows}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("hall.quickFill.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<HallPlaySummaryGrid items={summaryItems} className="lg:hidden" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
!isMobile &&
|
||||
"flex items-center gap-2 rounded-xl border border-[#e3ebf7] bg-white px-2 py-2 shadow-[0_6px_18px_rgba(15,23,42,0.04)]",
|
||||
isMobile ? "space-y-3" : "hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -1835,7 +2123,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
"overflow-x-auto",
|
||||
isMobile
|
||||
? "-mx-1 flex gap-1.5 px-1 pb-1"
|
||||
: "order-first inline-flex w-fit max-w-full shrink-0 items-center gap-1 rounded-lg bg-[#f3f6fb] p-1",
|
||||
: "hidden",
|
||||
)}
|
||||
>
|
||||
{categoryTabs.map((tab) => {
|
||||
@@ -1885,7 +2173,13 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn("min-w-0", !isMobile && "flex items-start gap-3")}>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
!isMobile &&
|
||||
"flex flex-col items-stretch gap-3 min-[1500px]:flex-row min-[1500px]:items-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 overflow-hidden border border-[#e6edf8] bg-white transition-opacity",
|
||||
@@ -1894,71 +2188,20 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
showWideTableHint && "player-table-scroll-wrap",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={playSummaryScrollRef}
|
||||
className="mb-3 overflow-x-auto rounded border border-[#dae3f3] shadow-sm"
|
||||
>
|
||||
<table className="w-full border-collapse text-center text-[11px] tabular-nums">
|
||||
<thead>
|
||||
<tr className="bg-[#2d63e2] text-white">
|
||||
<th className="border-r border-[#4a7aeb] px-2 py-1.5 font-bold">
|
||||
{t("hall.table.total", { defaultValue: "共" })}
|
||||
</th>
|
||||
{allPlayColumns.map((column) => (
|
||||
<th key={column.key} className="border-r border-[#4a7aeb] px-1 py-1.5 font-bold last:border-0">
|
||||
{playColumnHeaderLabel(
|
||||
column.play,
|
||||
playCategory(column.play.play_code),
|
||||
column.digitSlot,
|
||||
t,
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white text-[#17408d]">
|
||||
<tr>
|
||||
<td className="border-r border-[#edf2f8] px-2 py-1.5 font-bold text-[#e5002c]">
|
||||
{formatMinorAmount(submitActualMinor)}
|
||||
</td>
|
||||
{allPlayColumns.map((column) => {
|
||||
const colTotalMinor = rows.reduce((sum, row) => {
|
||||
const amtStr = row.amounts[column.key];
|
||||
if (!amtStr) return sum;
|
||||
if (
|
||||
row.number.trim().length === 0 ||
|
||||
draftLineIssueReason(column.play.play_code, row.number, column.digitSlot) !== null
|
||||
) {
|
||||
return sum;
|
||||
}
|
||||
const minorAmt = parseDecimalInputToMinor(amtStr, currencyCode);
|
||||
return sum + (minorAmt ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<td key={`val-${column.key}`} className="border-r border-[#edf2f8] px-1 py-1.5 font-bold last:border-0">
|
||||
{formatMinorAmount(colTotalMinor)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="player-table-scroll overflow-x-auto overscroll-x-contain">
|
||||
<table
|
||||
className="mx-auto w-max border-collapse text-[11px]"
|
||||
className={cn("mx-auto w-max border-collapse", isMobile ? "text-[11px]" : "text-sm")}
|
||||
style={{ width: tableWidthPx }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-[#edf2f8] bg-[#f8fafd] text-[#58709d]">
|
||||
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", indexColClass)}>
|
||||
<th className={cn("sticky left-0 z-30 bg-[#f8fafd] px-0.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]", isMobile ? "py-1.5" : "py-2.5", indexColClass)}>
|
||||
{t("hall.table.no", { defaultValue: "No." })}
|
||||
</th>
|
||||
<th
|
||||
className={cn(
|
||||
"sticky z-30 bg-[#f8fafd] px-1 py-1.5 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"sticky z-30 bg-[#f8fafd] px-1 text-center font-bold shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
isMobile ? "py-1.5" : "py-2.5",
|
||||
stickyNumberLeftClass,
|
||||
numberColClass,
|
||||
)}
|
||||
@@ -1971,7 +2214,7 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
{playColumns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={cn(amountColClass, "px-0.5 py-1.5 text-center font-bold")}
|
||||
className={cn(amountColClass, "px-0.5 text-center font-bold", isMobile ? "py-1.5" : "py-2.5")}
|
||||
>
|
||||
<span className="block whitespace-nowrap text-[10px] leading-tight">
|
||||
{playColumnHeaderLabel(
|
||||
@@ -1984,17 +2227,16 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
<Checkbox
|
||||
checked={syncAmountColumns[column.key] === true}
|
||||
disabled={tableDisabled}
|
||||
onCheckedChange={(checked) => setSyncAmountColumns((current) => ({
|
||||
...current,
|
||||
[column.key]: checked === true,
|
||||
}))}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleSyncAmountColumn(column, checked === true)
|
||||
}
|
||||
aria-label={t("hall.table.syncColumnAmount", {
|
||||
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
|
||||
})}
|
||||
title={t("hall.table.syncColumnAmount", {
|
||||
column: playColumnHeaderLabel(column.play, playCategory(column.play.play_code), column.digitSlot, t),
|
||||
})}
|
||||
className="mx-auto mt-1 size-3.5"
|
||||
className={cn("mx-auto mt-1", isMobile ? "size-3.5" : "size-4")}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
@@ -2087,49 +2329,35 @@ export function HallBettingGrid({ drawLive }: { drawLive: HallDrawLiveSnapshot }
|
||||
</div>
|
||||
|
||||
{!isMobile ? (
|
||||
<aside className="sticky top-3 z-20 w-52 shrink-0 self-start rounded-xl border border-[#e6edf8] bg-[#f8fbff] px-3.5 py-3.5 text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<p className="text-xs font-black">{t("hall.table.orderSummary", { defaultValue: "Order summary" })}</p>
|
||||
<dl className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<dt className="text-xs text-slate-500">{t("hall.table.lineCount", { defaultValue: "Lines" })}</dt>
|
||||
<dd className="font-mono text-sm font-black tabular-nums">{draftEntries.length}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<dt className="text-xs text-slate-500">{t("hall.table.providerCount", { defaultValue: "Providers" })}</dt>
|
||||
<dd className="font-mono text-sm font-black tabular-nums">{selectedProviderCount}</dd>
|
||||
</div>
|
||||
<div className="border-t border-[#dce6f5] pt-3">
|
||||
<dt className="text-xs text-slate-500">{t("hall.table.actualTotal")}</dt>
|
||||
<dd className="mt-1 text-base font-black tabular-nums text-[#0b3f96]">
|
||||
{formatMinorAsCurrency(submitActualMinor, currencyCode)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canSubmit || previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
className={cn(
|
||||
"mt-4 h-10 w-full gap-2 rounded-lg border-0 text-sm font-bold text-white",
|
||||
!isBettable
|
||||
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
||||
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.26)] hover:bg-[#d10028]",
|
||||
)}
|
||||
>
|
||||
{!isBettable ? (
|
||||
<Lock className="size-4 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Ticket className="size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
{previewLoading
|
||||
<HallDesktopReviewPanel
|
||||
model={desktopReviewModel}
|
||||
disabled={!canSubmit}
|
||||
loading={previewLoading}
|
||||
isBettable={isBettable}
|
||||
onSubmit={() => void handlePreview()}
|
||||
labels={{
|
||||
title: t("hall.desktop.reviewTitle", { defaultValue: "核对与提交" }),
|
||||
lineCount: t("hall.table.lineCount"),
|
||||
providerCount: t("hall.table.providerCount"),
|
||||
actualTotal: t("hall.table.actualTotal"),
|
||||
availableCredit: t("hall.desktop.availableCredit", { defaultValue: "可用信用" }),
|
||||
remainingCredit: t("hall.desktop.remainingCredit", { defaultValue: "下注后可用" }),
|
||||
noWarnings: t("hall.desktop.noWarnings", { defaultValue: "暂无风险提醒" }),
|
||||
warningCount: t("hall.desktop.warningCount", {
|
||||
defaultValue: "{{count}} 行接近售罄",
|
||||
count: warningRowCount,
|
||||
}),
|
||||
creditEnough: t("hall.desktop.creditEnough", { defaultValue: "可用信用充足" }),
|
||||
creditInsufficient: t("hall.desktop.creditInsufficient", { defaultValue: "可用信用不足" }),
|
||||
submit: previewLoading
|
||||
? t("hall.table.previewing")
|
||||
: !isBettable
|
||||
? t("hall.closed.title")
|
||||
: availableMinor < submitActualMinor
|
||||
? t("hall.table.insufficientBalance")
|
||||
: t("hall.table.submitBet")}
|
||||
</Button>
|
||||
</aside>
|
||||
: t("hall.table.submitBet"),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -2377,7 +2605,8 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky left-0 z-20 align-top px-0.5 py-1.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"sticky left-0 z-20 align-top px-0.5 text-center font-black text-[#17408d] shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
isMobile ? "py-1.5" : "py-2",
|
||||
indexColClass,
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
)}
|
||||
@@ -2386,7 +2615,8 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"sticky z-20 align-top px-1 py-1.5 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
"sticky z-20 align-top px-1 shadow-[2px_0_6px_rgba(15,23,42,0.04)]",
|
||||
isMobile ? "py-1.5" : "py-2",
|
||||
stickyNumberLeftClass,
|
||||
numberColClass,
|
||||
rowActive ? "bg-[#f5f9ff]" : "bg-white",
|
||||
@@ -2404,8 +2634,8 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateRowNumber(row.id, event.target.value)}
|
||||
className={cn(
|
||||
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
!isMobile ? "text-[13px]" : "text-sm",
|
||||
"w-full rounded-md border-[#e1e8f3] bg-white px-1 text-center font-mono font-bold tabular-nums text-slate-950 shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
!isMobile ? "h-9 text-sm" : "h-8 text-sm",
|
||||
"tracking-[0.12em]",
|
||||
)}
|
||||
/>
|
||||
@@ -2431,7 +2661,8 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
<td
|
||||
key={`${row.id}-${column.key}`}
|
||||
className={cn(
|
||||
"px-0.5 py-1.5 align-top",
|
||||
"px-0.5 align-top",
|
||||
isMobile ? "py-1.5" : "py-2",
|
||||
status === "warning" && "bg-amber-50/70",
|
||||
status === "sold_out" && "bg-slate-100 text-slate-400",
|
||||
)}
|
||||
@@ -2450,15 +2681,15 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
onClick={() => setActiveRowId(row.id)}
|
||||
onChange={(event) => updateAmount(row.id, column.key, event.target.value)}
|
||||
className={cn(
|
||||
"h-8 w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
!isMobile ? "text-[13px]" : "text-sm",
|
||||
"w-full rounded-md border-[#e1e8f3] bg-white px-0.5 text-center font-bold tabular-nums shadow-sm focus-visible:ring-[#1d57b7]",
|
||||
!isMobile ? "h-9 text-sm" : "h-8 text-sm",
|
||||
hasAmount && "border-[#9bbcff] bg-[#f5f9ff] text-[#0b3f96]",
|
||||
status === "warning" && "border-amber-200 bg-amber-50 text-amber-800",
|
||||
status === "sold_out" && "border-slate-200 bg-slate-100 text-slate-400",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-full rounded-md border border-slate-200 bg-slate-100/70" />
|
||||
<div className={cn("w-full rounded-md border border-slate-200 bg-slate-100/70", isMobile ? "h-8" : "h-9")} />
|
||||
)}
|
||||
{isInputValidForPlay && status === "sold_out" ? (
|
||||
<p className="mt-0.5 text-center text-[10px] font-bold text-slate-500">
|
||||
@@ -2473,12 +2704,12 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
);
|
||||
})}
|
||||
{showSelectionTypeColumn ? (
|
||||
<td className={cn(selectionTypeColClass, "align-top px-1 py-1.5 text-center", rowActive && "bg-[#f5f9ff]")}>
|
||||
<td className={cn(selectionTypeColClass, "align-top px-1 text-center", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#f5f9ff]")}>
|
||||
<select
|
||||
value={row.selectionType}
|
||||
disabled={tableDisabled}
|
||||
onChange={(event) => updateRowSelectionType(row.id, event.target.value as SelectionType)}
|
||||
className="h-8 w-full rounded-md border border-[#e1e8f3] bg-white px-1 text-[10px] font-semibold text-[#304f86]"
|
||||
className={cn("w-full rounded-md border border-[#e1e8f3] bg-white px-1 font-semibold text-[#304f86]", isMobile ? "h-8 text-[10px]" : "h-9 text-xs")}
|
||||
aria-label={t("hall.table.selectionTypeForRow", { row: index + 1 })}
|
||||
>
|
||||
{selectionTypeOptions.map((type) => (
|
||||
@@ -2522,18 +2753,18 @@ const DraftRowItem = memo(function DraftRowItem({
|
||||
{betProviders.map((provider, providerIndex) => (
|
||||
<td
|
||||
key={provider.code}
|
||||
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 py-1.5 text-center")}
|
||||
className={cn(providerColClass, providerColumnTone(providerIndex), "px-0.5 text-center", isMobile ? "py-1.5" : "py-2")}
|
||||
>
|
||||
<Checkbox
|
||||
checked={row.providerCodes.includes(provider.code)}
|
||||
disabled={tableDisabled}
|
||||
onCheckedChange={() => toggleRowProvider(row.id, provider.code)}
|
||||
aria-label={`${provider.name} ${index + 1}`}
|
||||
className="mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800"
|
||||
className={cn("mx-auto border-slate-500 bg-white data-checked:border-slate-800 data-checked:bg-slate-800", !isMobile && "size-4")}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 py-1.5 text-center font-mono font-black tabular-nums text-[#0b3f96]", rowActive && "bg-[#edf5ff]")}>
|
||||
<td className={cn(rowTotalColClass, "bg-[#f7fbff] px-1 text-center font-mono font-black tabular-nums text-[#0b3f96]", isMobile ? "py-1.5" : "py-2", rowActive && "bg-[#edf5ff]")}>
|
||||
{formatMinorAmount(rowTotalMinor)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
184
src/features/hall/hall-desktop-workspace.tsx
Normal file
184
src/features/hall/hall-desktop-workspace.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, CheckCircle2, Lock, Ticket } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HallDesktopWorkflowBarProps = {
|
||||
gameControls: ReactNode;
|
||||
fillControls: ReactNode;
|
||||
reviewControls: ReactNode;
|
||||
stepGame: string;
|
||||
stepFill: string;
|
||||
stepReview: string;
|
||||
};
|
||||
|
||||
export function HallDesktopWorkflowBar({
|
||||
gameControls,
|
||||
fillControls,
|
||||
reviewControls,
|
||||
stepGame,
|
||||
stepFill,
|
||||
stepReview,
|
||||
}: HallDesktopWorkflowBarProps) {
|
||||
const steps = [
|
||||
{ label: stepGame, content: gameControls },
|
||||
{ label: stepFill, content: fillControls },
|
||||
{ label: stepReview, content: reviewControls },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="hidden overflow-hidden rounded-xl border border-[#dfe8f5] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.04)] lg:grid lg:grid-cols-[minmax(10rem,0.8fr)_minmax(16rem,1.5fr)_minmax(10rem,0.65fr)] xl:grid-cols-[minmax(15rem,0.8fr)_minmax(24rem,1.5fr)_minmax(13rem,0.65fr)]">
|
||||
{steps.map((step, index) => (
|
||||
<section
|
||||
key={step.label}
|
||||
className={cn(
|
||||
"min-w-0 px-4 py-3",
|
||||
index > 0 && "border-l border-[#e7edf6]",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-[#eaf2ff] text-xs font-black text-[#1658c4]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h2 className="text-sm font-black text-[#23395f]">{step.label}</h2>
|
||||
</div>
|
||||
{step.content}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type HallDesktopReviewModel = {
|
||||
lineCount: number;
|
||||
providerCount: number;
|
||||
actualAmount: string;
|
||||
availableCredit: string;
|
||||
remainingCredit: string;
|
||||
warningCount: number;
|
||||
creditSufficient: boolean;
|
||||
};
|
||||
|
||||
type HallDesktopReviewPanelProps = {
|
||||
model: HallDesktopReviewModel;
|
||||
labels: {
|
||||
title: string;
|
||||
lineCount: string;
|
||||
providerCount: string;
|
||||
actualTotal: string;
|
||||
availableCredit: string;
|
||||
remainingCredit: string;
|
||||
noWarnings: string;
|
||||
warningCount: string;
|
||||
creditEnough: string;
|
||||
creditInsufficient: string;
|
||||
submit: string;
|
||||
};
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
isBettable: boolean;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function HallDesktopReviewPanel({
|
||||
model,
|
||||
labels,
|
||||
disabled,
|
||||
loading,
|
||||
isBettable,
|
||||
onSubmit,
|
||||
}: HallDesktopReviewPanelProps) {
|
||||
return (
|
||||
<aside className="w-full shrink-0 self-start overflow-hidden rounded-xl border border-[#dfe8f5] bg-white text-[#304f86] shadow-[0_8px_24px_rgba(15,23,42,0.05)] min-[1500px]:sticky min-[1500px]:top-3 min-[1500px]:w-80">
|
||||
<div className="border-b border-[#e7edf6] bg-[#f7faff] px-4 py-3.5">
|
||||
<p className="text-[15px] font-black text-[#20385f]">{labels.title}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 p-4 lg:grid-cols-[repeat(4,minmax(0,1fr))_auto] lg:items-center min-[1500px]:block min-[1500px]:space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-x-5 gap-y-3 lg:contents min-[1500px]:grid">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-slate-500">{labels.lineCount}</dt>
|
||||
<dd className="mt-1 font-mono text-lg font-black tabular-nums text-[#143f86]">
|
||||
{model.lineCount}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-slate-500">{labels.providerCount}</dt>
|
||||
<dd className="mt-1 font-mono text-lg font-black tabular-nums text-[#143f86]">
|
||||
{model.providerCount}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-slate-500">{labels.availableCredit}</dt>
|
||||
<dd className="mt-1 text-sm font-black tabular-nums text-[#143f86]">
|
||||
{model.availableCredit}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-slate-500">{labels.remainingCredit}</dt>
|
||||
<dd className="mt-1 text-sm font-black tabular-nums text-[#143f86]">
|
||||
{model.remainingCredit}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="rounded-lg border border-[#d8e5f7] bg-[#f7fbff] px-3 py-2.5 lg:min-w-36 min-[1500px]:px-4 min-[1500px]:py-3.5">
|
||||
<p className="text-xs font-semibold text-slate-500">{labels.actualTotal}</p>
|
||||
<p className="mt-1 text-lg font-black tabular-nums text-[#0b3f96] min-[1500px]:text-2xl">
|
||||
{model.actualAmount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 lg:min-w-48 min-[1500px]:border-t min-[1500px]:border-[#e7edf6] min-[1500px]:pt-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs font-bold",
|
||||
model.creditSufficient ? "text-emerald-700" : "text-red-600",
|
||||
)}
|
||||
>
|
||||
{model.creditSufficient ? (
|
||||
<CheckCircle2 className="size-4 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className="size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
<span>{model.creditSufficient ? labels.creditEnough : labels.creditInsufficient}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||
{model.warningCount > 0 ? (
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-600" aria-hidden />
|
||||
) : (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-slate-400" aria-hidden />
|
||||
)}
|
||||
<span>
|
||||
{model.warningCount > 0
|
||||
? labels.warningCount
|
||||
: labels.noWarnings}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:min-w-52 min-[1500px]:border-t min-[1500px]:border-[#e7edf6] min-[1500px]:pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
onClick={onSubmit}
|
||||
className={cn(
|
||||
"h-11 w-full gap-2 rounded-lg border-0 text-sm font-black text-white",
|
||||
!isBettable
|
||||
? "bg-slate-500 shadow-none hover:bg-slate-500 disabled:opacity-100"
|
||||
: "bg-[#e5002c] shadow-[0_8px_20px_rgba(229,0,44,0.24)] hover:bg-[#d10028]",
|
||||
)}
|
||||
>
|
||||
{!isBettable ? (
|
||||
<Lock className="size-4 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Ticket className="size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
{labels.submit}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function ScheduleAnchorTime({
|
||||
<span className="font-mono text-base font-black tabular-nums tracking-tight text-[#0b3f96]">
|
||||
{time}
|
||||
</span>
|
||||
{date ? <span className="text-sm text-slate-400">{date}</span> : null}
|
||||
{date ? <span className="hidden text-sm text-slate-400 2xl:inline">{date}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -268,11 +268,11 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
</div>
|
||||
|
||||
{/* Desktop: 单行工具条 — 左期号状态,右时间/倒计时,与右侧钱包卡同高 */}
|
||||
<div className="hidden h-full items-center justify-between gap-6 px-4 py-2.5 lg:flex">
|
||||
<div className="hidden h-full items-center justify-between gap-3 px-4 py-2.5 lg:flex xl:gap-6">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">{t("draw.issueNo")}</span>
|
||||
<span className="inline-flex items-center rounded-md bg-[#eef3ff] px-2 py-0.5 font-mono text-sm font-black tabular-nums text-[#0b4ab3]">
|
||||
<span className="whitespace-nowrap text-sm text-slate-500">{t("draw.issueNo")}</span>
|
||||
<span className="inline-flex items-center whitespace-nowrap rounded-md bg-[#eef3ff] px-2 py-0.5 font-mono text-sm font-black tabular-nums text-[#0b4ab3]">
|
||||
{display.draw_no}
|
||||
</span>
|
||||
</div>
|
||||
@@ -281,7 +281,7 @@ export function HallDrawPanel({ drawLive }: { drawLive: HallDrawLiveSnapshot })
|
||||
{t(hud.labelKey, { defaultValue: hud.labelKey })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-5 gap-y-1">
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1 xl:gap-x-5">
|
||||
<ScheduleAnchorTime payload={display} layout="desktop" />
|
||||
<CloseTime
|
||||
key={`desk-${display.draw_no}-${display.status}-${display.seconds_to_draw ?? ""}-${display.seconds_remaining_in_cooldown ?? ""}`}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function HallScreen() {
|
||||
<span>{blockedNotice}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-stretch lg:gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div className="space-y-3 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-stretch lg:gap-3 lg:space-y-0 xl:grid-cols-[minmax(0,1fr)_20rem] xl:gap-4">
|
||||
<HallDrawPanel drawLive={drawLive} />
|
||||
<aside className="min-w-0">
|
||||
<HallWalletStrip />
|
||||
|
||||
@@ -84,7 +84,12 @@ export function HallWalletStrip() {
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl bg-[#e5002c] text-white shadow-[0_8px_24px_rgba(229,0,44,0.22)]",
|
||||
isMobile ? "px-3 py-2.5" : "px-5 py-3 lg:h-[5.5rem] lg:px-4 lg:pr-14",
|
||||
isMobile
|
||||
? "px-3 py-2.5"
|
||||
: cn(
|
||||
"px-5 py-3 lg:h-[5.5rem]",
|
||||
isCreditPlayer ? "lg:px-3 xl:px-4" : "lg:px-4 lg:pr-14",
|
||||
),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
@@ -95,7 +100,7 @@ export function HallWalletStrip() {
|
||||
className="pointer-events-none object-cover object-center"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "h-full gap-4")}>
|
||||
<div className={cn("relative flex items-center", isMobile ? "gap-2.5" : "h-full gap-3 xl:gap-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-full bg-white text-[#d81435] shadow-sm",
|
||||
@@ -112,8 +117,14 @@ export function HallWalletStrip() {
|
||||
!isMobile && "flex items-center gap-3 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className={cn("min-w-0", !isMobile && "flex shrink-0 items-baseline gap-2")}>
|
||||
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-sm")}>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
!isMobile &&
|
||||
"flex shrink-0 flex-col items-start gap-1 overflow-hidden xl:flex-row xl:items-baseline xl:gap-2",
|
||||
)}
|
||||
>
|
||||
<p className={cn("font-semibold text-white/90", isMobile ? "text-[13px]" : "text-xs xl:text-sm")}>
|
||||
{label}
|
||||
</p>
|
||||
{loading ? (
|
||||
@@ -129,7 +140,7 @@ export function HallWalletStrip() {
|
||||
currency={currency}
|
||||
className={cn(
|
||||
"text-white",
|
||||
isMobile ? "mt-0.5" : "text-xl",
|
||||
isMobile ? "mt-0.5" : "text-lg xl:text-xl",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -138,7 +149,7 @@ export function HallWalletStrip() {
|
||||
<p
|
||||
className={cn(
|
||||
"text-white/80",
|
||||
isMobile ? "mt-1 text-[11px]" : "truncate text-xs",
|
||||
isMobile ? "mt-1 text-[11px]" : "hidden min-w-0 truncate text-xs xl:block",
|
||||
)}
|
||||
>
|
||||
{t("wallet.creditSummary", {
|
||||
|
||||
@@ -480,7 +480,7 @@ export function TicketOrdersListScreen() {
|
||||
|
||||
<div className="space-y-3 lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white">
|
||||
{/* PC 顶栏:统计 + 筛选 + 操作 */}
|
||||
<div className="hidden lg:block lg:border-b lg:border-[#eef3fa] lg:bg-white lg:px-5 lg:py-3">
|
||||
<div className="hidden lg:block lg:border-b lg:border-[#e5edf8] lg:bg-[#f8fbff] lg:px-5 lg:py-3.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<p className="text-[11px] font-bold text-[#7890b8]">
|
||||
@@ -533,7 +533,7 @@ export function TicketOrdersListScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center lg:rounded-none lg:border-0 lg:bg-white lg:px-5 lg:py-12">
|
||||
<div className="player-pc-empty flex flex-col items-center justify-center rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center lg:rounded-none lg:border-0 lg:bg-white lg:px-5 lg:py-12">
|
||||
<p className="text-sm font-bold text-slate-700">{t("orders.empty")}</p>
|
||||
<Link
|
||||
href="/hall"
|
||||
@@ -683,25 +683,25 @@ export function TicketOrdersListScreen() {
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="border-[#eef3fa] hover:bg-transparent">
|
||||
<TableHead className="h-11 w-[22%] bg-white px-5 text-xs font-bold text-[#59739f]">
|
||||
<TableHead className="player-pc-table-head h-12 w-[22%] bg-[#f8fbff] px-5 font-bold text-[#59739f]">
|
||||
{t("orders.drawNo")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[24%] bg-white px-3 text-xs font-bold text-[#59739f]">
|
||||
<TableHead className="player-pc-table-head h-12 w-[24%] bg-[#f8fbff] px-3 font-bold text-[#59739f]">
|
||||
{t("orders.betItems")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[12%] bg-white px-3 text-xs font-bold text-[#59739f]">
|
||||
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 font-bold text-[#59739f]">
|
||||
{t("hall.providers.title")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[12%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
|
||||
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
|
||||
{t("orders.stake")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[12%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
|
||||
<TableHead className="player-pc-table-head h-12 w-[12%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
|
||||
{t("orders.deduction")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-[14%] bg-white px-3 text-right text-xs font-bold text-[#59739f]">
|
||||
<TableHead className="player-pc-table-head h-12 w-[14%] bg-[#f8fbff] px-3 text-right font-bold text-[#59739f]">
|
||||
{t("orders.status")}
|
||||
</TableHead>
|
||||
<TableHead className="h-11 w-10 bg-white px-3" aria-hidden />
|
||||
<TableHead className="h-12 w-10 bg-[#f8fbff] px-3" aria-hidden />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -900,4 +900,3 @@ export function TicketOrdersListScreen() {
|
||||
</PlayerPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
279
src/features/player/player-account-dialog.tsx
Normal file
279
src/features/player/player-account-dialog.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, KeyRound, Loader2, LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { putPlayerPassword } from "@/api/player-auth";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { validatePlayerLoginPassword } from "@/lib/player-input-validation";
|
||||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
type AccountView = "profile" | "password";
|
||||
|
||||
function ProfileRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7.5rem_minmax(0,1fr)] items-start gap-3 py-2.5 text-sm">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-right font-medium text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function accountInitial(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? "P" : trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
export function PlayerAccountDialog() {
|
||||
const { t } = useTranslation("player");
|
||||
const router = useRouter();
|
||||
const profile = usePlayerSessionStore((state) => state.profile);
|
||||
const clearBearerToken = usePlayerSessionStore((state) => state.clearBearerToken);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [view, setView] = useState<AccountView>("profile");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const displayName =
|
||||
profile?.nickname?.trim() ||
|
||||
profile?.username?.trim() ||
|
||||
(profile?.id != null ? t("player.fallback", { id: profile.id }) : t("account.loading"));
|
||||
const isNative = profile?.auth_source === "lottery_native";
|
||||
|
||||
function resetPasswordForm() {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setView("profile");
|
||||
resetPasswordForm();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
handleOpenChange(false);
|
||||
clearBearerToken();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (currentPassword === "" || newPassword === "" || confirmPassword === "") {
|
||||
toast.error(t("account.password.required"));
|
||||
return;
|
||||
}
|
||||
if (validatePlayerLoginPassword(newPassword) === "too_short") {
|
||||
toast.error(t("account.password.tooShort"));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t("account.password.mismatch"));
|
||||
return;
|
||||
}
|
||||
if (newPassword === currentPassword) {
|
||||
toast.error(t("account.password.mustDiffer"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await putPlayerPassword({
|
||||
current_password: currentPassword,
|
||||
password: newPassword,
|
||||
password_confirmation: confirmPassword,
|
||||
});
|
||||
handleOpenChange(false);
|
||||
clearBearerToken();
|
||||
router.replace("/login?password=changed");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("account.password.failed"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={profile === null}
|
||||
aria-label={t("account.open")}
|
||||
onClick={() => setOpen(true)}
|
||||
className="max-w-[8.5rem] justify-start rounded-full bg-muted/40 px-1.5 sm:max-w-[11rem]"
|
||||
>
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{displayName}</span>
|
||||
<ChevronDown data-icon="inline-end" className="hidden sm:block" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{view === "profile" ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("account.title")}</DialogTitle>
|
||||
<DialogDescription>{t("account.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="lg">
|
||||
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate">{displayName}</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
{profile?.username || profile?.site_player_id || "—"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant="secondary">
|
||||
{isNative ? t("account.auth.native") : t("account.auth.sso")}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{profile?.funding_mode === "credit"
|
||||
? t("account.funding.credit")
|
||||
: t("account.funding.wallet")}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl>
|
||||
<ProfileRow label={t("account.fields.username")} value={profile?.username || "—"} />
|
||||
<Separator />
|
||||
<ProfileRow label={t("account.fields.site")} value={profile?.site_code || "—"} />
|
||||
<Separator />
|
||||
<ProfileRow
|
||||
label={t("account.fields.playerId")}
|
||||
value={profile?.site_player_id || "—"}
|
||||
/>
|
||||
<Separator />
|
||||
<ProfileRow
|
||||
label={t("account.fields.currency")}
|
||||
value={profile?.default_currency || "—"}
|
||||
/>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isNative ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t("account.ssoManaged")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isNative ? (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="destructive" onClick={handleLogout}>
|
||||
<LogOut data-icon="inline-start" />
|
||||
{t("account.logout")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setView("password")}>
|
||||
<KeyRound data-icon="inline-start" />
|
||||
{t("account.password.action")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("account.password.title")}</DialogTitle>
|
||||
<DialogDescription>{t("account.password.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="player-current-password">
|
||||
{t("account.password.current")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="player-current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="player-new-password">
|
||||
{t("account.password.new")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="player-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
<FieldDescription>{t("account.password.hint")}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="player-confirm-password">
|
||||
{t("account.password.confirm")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="player-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : null}
|
||||
{saving ? t("account.password.saving") : t("account.password.submit")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={saving} onClick={() => setView("profile")}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
const setProfile = usePlayerSessionStore((s) => s.setProfile);
|
||||
const clearBearerToken = usePlayerSessionStore((s) => s.clearBearerToken);
|
||||
const sessionExpiredHandled = useRef(false);
|
||||
const passwordChangedHandled = useRef(false);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -94,6 +95,15 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
stripSearchParamFromBrowserUrl("session");
|
||||
}, [clearBearerToken, searchParams, tErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (passwordChangedHandled.current) return;
|
||||
if (searchParams.get("password") !== "changed") return;
|
||||
|
||||
passwordChangedHandled.current = true;
|
||||
toast.success(t("login.passwordChanged"));
|
||||
stripSearchParamFromBrowserUrl("password");
|
||||
}, [searchParams, t]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
@@ -184,4 +194,4 @@ export function PlayerLoginScreen(): React.ReactElement {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ export function DrawResultsListScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
) : listRows.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500 lg:rounded-none lg:border-0 lg:bg-white lg:py-12">
|
||||
<div className="player-pc-empty flex items-center justify-center rounded-xl border border-dashed border-[#dce7f7] bg-[#f8fbff] px-3 py-8 text-center text-sm text-slate-500 lg:rounded-none lg:border-0 lg:bg-white lg:py-12">
|
||||
{t("results.empty")}
|
||||
</div>
|
||||
) : (
|
||||
@@ -358,18 +358,18 @@ export function DrawResultsListScreen() {
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="border-[#eef3fa] hover:bg-transparent">
|
||||
<TableHead className="h-11 w-[30%] bg-[#f8fbff] px-5 text-xs font-bold text-[#59739f]">
|
||||
<TableHead className="player-pc-table-head h-12 w-[30%] bg-[#f8fbff] px-5 font-bold text-[#59739f]">
|
||||
{t("results.detailTitle")}
|
||||
</TableHead>
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<TableHead
|
||||
key={tier}
|
||||
className="h-11 bg-[#f8fbff] px-3 text-center text-xs font-bold text-[#59739f]"
|
||||
className="player-pc-table-head h-12 bg-[#f8fbff] px-3 text-center font-bold text-[#59739f]"
|
||||
>
|
||||
{t(resultsPrizeLabelKey(tier))}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="h-11 w-12 bg-[#f8fbff] px-3" aria-hidden />
|
||||
<TableHead className="h-12 w-12 bg-[#f8fbff] px-3" aria-hidden />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -391,7 +391,7 @@ export function DrawResultsListScreen() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-5 py-3 align-middle">
|
||||
<TableCell className="px-5 py-3.5 align-middle">
|
||||
<div className="block min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate font-mono text-sm font-bold text-[#0b3f96]">
|
||||
@@ -417,13 +417,13 @@ export function DrawResultsListScreen() {
|
||||
</div>
|
||||
</TableCell>
|
||||
{RESULTS_TOP_PRIZE_KEYS.map((tier) => (
|
||||
<TableCell key={tier} className="px-3 py-3 text-center align-middle">
|
||||
<TableCell key={tier} className="px-3 py-3.5 text-center align-middle">
|
||||
<span className="font-mono text-base font-bold tabular-nums text-[#e5002c]">
|
||||
{row.results[tier]}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="px-3 py-3 text-right align-middle">
|
||||
<TableCell className="px-3 py-3.5 text-right align-middle">
|
||||
<span className="inline-flex size-8 items-center justify-center rounded-lg text-[#7890b8] transition-colors group-hover:bg-[#eaf2ff] group-hover:text-[#0b56b7]">
|
||||
<ChevronRight className="size-4" aria-hidden />
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { BookOpenText, Loader2, TriangleAlert } from "lucide-react";
|
||||
|
||||
import { getPublicSettings } from "@/api";
|
||||
import { PlayerPanel } from "@/components/layout/player-panel";
|
||||
@@ -12,6 +12,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
export function PlayRulesScreen() {
|
||||
const { t, i18n } = useTranslation("player");
|
||||
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
||||
const [contentState, setContentState] = useState<"html" | "empty" | "error">("empty");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,11 +22,14 @@ export function PlayRulesScreen() {
|
||||
const html = resolvePlayRulesHtml(res.items, i18n.language);
|
||||
if (html) {
|
||||
setHtmlContent(html);
|
||||
setContentState("html");
|
||||
} else {
|
||||
setHtmlContent(`<div style="text-align:center;padding:2rem;color:#64748b;">${t("rules.empty", { defaultValue: "暂无玩法规则说明" })}</div>`);
|
||||
setHtmlContent(null);
|
||||
setContentState("empty");
|
||||
}
|
||||
} catch {
|
||||
setHtmlContent(`<div style="text-align:center;padding:2rem;color:#64748b;">${t("rules.loadFailed", { defaultValue: "规则加载失败" })}</div>`);
|
||||
setHtmlContent(null);
|
||||
setContentState("error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,16 +43,31 @@ export function PlayRulesScreen() {
|
||||
backHref="/hall"
|
||||
className="bg-[#f7f9fd]"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Card className="border-[#dbe7fb] bg-white shadow-sm overflow-hidden min-h-[300px]">
|
||||
<CardContent className="p-4">
|
||||
<div className="mx-auto w-full max-w-5xl space-y-3">
|
||||
<Card className="min-h-[300px] overflow-hidden border-[#dbe7fb] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.05)] lg:min-h-[24rem]">
|
||||
<CardContent className="p-4 lg:p-8">
|
||||
{loading ? (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<div className="flex min-h-[18rem] items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : contentState !== "html" ? (
|
||||
<div className="flex min-h-[18rem] flex-col items-center justify-center px-4 text-center">
|
||||
<span className="flex size-12 items-center justify-center rounded-full bg-[#eef4ff] text-[#2d63e2]">
|
||||
{contentState === "error" ? (
|
||||
<TriangleAlert className="size-5" aria-hidden />
|
||||
) : (
|
||||
<BookOpenText className="size-5" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
<p className="mt-3 text-sm font-bold text-slate-700">
|
||||
{contentState === "error"
|
||||
? t("rules.loadFailed", { defaultValue: "规则加载失败" })
|
||||
: t("rules.empty", { defaultValue: "暂无玩法规则说明" })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-slate-700"
|
||||
className="prose prose-sm max-w-none text-slate-700 lg:prose-base lg:leading-7"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent || "" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -189,7 +189,7 @@ export function WalletLogsBlock({
|
||||
</p>
|
||||
<div className={logsLoading ? "opacity-60" : undefined}>
|
||||
{logs.items.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground lg:py-12">
|
||||
<div className="player-pc-empty flex items-center justify-center rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground lg:py-12">
|
||||
{t(creditMode ? "wallet.emptyCreditLogs" : "wallet.emptyLogs", {
|
||||
defaultValue: creditMode ? "暂无信用流水" : "暂无流水",
|
||||
})}
|
||||
@@ -198,24 +198,24 @@ export function WalletLogsBlock({
|
||||
<>
|
||||
<div className="lg:overflow-hidden lg:rounded-xl lg:border lg:border-[#e5edf8] lg:bg-white lg:shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="hidden lg:grid lg:grid-cols-[minmax(10rem,1.2fr)_minmax(9rem,1fr)_minmax(8rem,0.9fr)_minmax(7rem,0.8fr)_minmax(7rem,0.8fr)_minmax(6rem,0.7fr)] lg:items-center lg:gap-3 lg:border-b lg:border-[#e9eff8] lg:bg-[#f8fbff] lg:px-5 lg:py-3">
|
||||
<p className="text-xs font-bold text-[#59739f]">
|
||||
<p className="player-pc-table-head font-bold text-[#59739f]">
|
||||
{t("wallet.logType", { defaultValue: "类型" })}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-[#59739f]">
|
||||
<p className="player-pc-table-head font-bold text-[#59739f]">
|
||||
{t("wallet.logTime", { defaultValue: "时间" })}
|
||||
</p>
|
||||
<p className="text-xs font-bold text-[#59739f]">
|
||||
<p className="player-pc-table-head font-bold text-[#59739f]">
|
||||
{t("wallet.logRef", { defaultValue: "关联单号" })}
|
||||
</p>
|
||||
<p className="text-right text-xs font-bold text-[#59739f]">
|
||||
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
||||
{t("wallet.logAmount", { defaultValue: "金额" })}
|
||||
</p>
|
||||
<p className="text-right text-xs font-bold text-[#59739f]">
|
||||
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
||||
{creditMode
|
||||
? t("wallet.creditAvailableAfter")
|
||||
: t("wallet.balanceAfter")}
|
||||
</p>
|
||||
<p className="text-right text-xs font-bold text-[#59739f]">
|
||||
<p className="player-pc-table-head text-right font-bold text-[#59739f]">
|
||||
{t("orders.status")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"captchaRefresh": "Refresh captcha",
|
||||
"captchaFetch": "Tap to load",
|
||||
"usernameInvalidCharset": "Username may only contain letters, numbers, dots (.), underscores, and hyphens",
|
||||
"passwordMinLength": "Password must be at least 6 characters"
|
||||
"passwordMinLength": "Password must be at least 6 characters",
|
||||
"passwordChanged": "Password changed. Sign in again with your new password."
|
||||
},
|
||||
"errors": {
|
||||
"noToken": "No authorization token found",
|
||||
|
||||
@@ -113,6 +113,44 @@
|
||||
"player": {
|
||||
"fallback": "Player #{{id}}"
|
||||
},
|
||||
"account": {
|
||||
"open": "View player profile",
|
||||
"loading": "Player",
|
||||
"title": "My account",
|
||||
"description": "View the signed-in player profile and account security settings.",
|
||||
"auth": {
|
||||
"native": "Lottery account",
|
||||
"sso": "Main-site API"
|
||||
},
|
||||
"funding": {
|
||||
"credit": "Credit mode",
|
||||
"wallet": "Wallet mode"
|
||||
},
|
||||
"fields": {
|
||||
"username": "Username",
|
||||
"site": "Site",
|
||||
"playerId": "Player ID",
|
||||
"currency": "Default currency"
|
||||
},
|
||||
"ssoManaged": "This sign-in is managed by the main site. Return to the main site to sign out or change your credentials.",
|
||||
"logout": "Sign out",
|
||||
"password": {
|
||||
"action": "Change password",
|
||||
"title": "Change login password",
|
||||
"description": "After the password is changed, all lottery sessions for this account will be signed out.",
|
||||
"current": "Current password",
|
||||
"new": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"hint": "Use at least 6 characters.",
|
||||
"required": "Complete the current, new, and confirmation password fields",
|
||||
"tooShort": "The new password must be at least 6 characters",
|
||||
"mismatch": "The new passwords do not match",
|
||||
"mustDiffer": "The new password must differ from the current password",
|
||||
"submit": "Change password",
|
||||
"saving": "Changing...",
|
||||
"failed": "Failed to change password. Try again later."
|
||||
}
|
||||
},
|
||||
"draw": {
|
||||
"currentIssue": "Current issue",
|
||||
"currentTime": "Current Time",
|
||||
@@ -164,6 +202,20 @@
|
||||
"placeFailed": "Submission failed",
|
||||
"placeSuccess": "Bet submitted. Order {{orderNo}}, deducted {{amount}}.",
|
||||
"placePartialFailed": "{{success}} succeeded, {{failed}} failed",
|
||||
"desktop": {
|
||||
"stepGame": "Choose game",
|
||||
"stepFill": "Enter number and stake",
|
||||
"stepReview": "Review and submit",
|
||||
"reviewTitle": "Review and submit",
|
||||
"availableCredit": "Available credit",
|
||||
"remainingCredit": "Available after bet",
|
||||
"warnings": "Risk alerts",
|
||||
"noWarnings": "No risk alerts",
|
||||
"warningCount": "{{count}} rows near sold out",
|
||||
"creditEnough": "Sufficient available credit",
|
||||
"creditInsufficient": "Insufficient available credit",
|
||||
"secureHint": "A final preview appears before submission"
|
||||
},
|
||||
"placeAllFailed": "All {{failed}} lines failed",
|
||||
"playConfig": {
|
||||
"playClosedDraftCleared": "{{playCode}} is closed. Related draft amounts have been cleared.",
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"captchaRefresh": "क्याप्चा रिफ्रेस",
|
||||
"captchaFetch": "लोड गर्न ट्याप गर्नुहोस्",
|
||||
"usernameInvalidCharset": "प्रयोगकर्ता नाममा अक्षर, अंक, डट (.), अन्डरस्कोर र हाइफन मात्र हुन सक्छ",
|
||||
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ"
|
||||
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||
"passwordChanged": "पासवर्ड बदलियो। नयाँ पासवर्डले फेरि लगइन गर्नुहोस्।"
|
||||
},
|
||||
"errors": {
|
||||
"noToken": "कुनै प्राधिकरण टोकन फेला परेन",
|
||||
|
||||
@@ -113,6 +113,44 @@
|
||||
"player": {
|
||||
"fallback": "खेलाडी #{{id}}"
|
||||
},
|
||||
"account": {
|
||||
"open": "खेलाडी प्रोफाइल हेर्नुहोस्",
|
||||
"loading": "खेलाडी",
|
||||
"title": "मेरो खाता",
|
||||
"description": "हाल लगइन भएको खेलाडी प्रोफाइल र खाता सुरक्षा सेटिङ हेर्नुहोस्।",
|
||||
"auth": {
|
||||
"native": "लटरी खाता",
|
||||
"sso": "मुख्य साइट API"
|
||||
},
|
||||
"funding": {
|
||||
"credit": "क्रेडिट मोड",
|
||||
"wallet": "वालेट मोड"
|
||||
},
|
||||
"fields": {
|
||||
"username": "प्रयोगकर्ता नाम",
|
||||
"site": "साइट",
|
||||
"playerId": "खेलाडी ID",
|
||||
"currency": "पूर्वनिर्धारित मुद्रा"
|
||||
},
|
||||
"ssoManaged": "यो लगइन मुख्य साइटले व्यवस्थापन गर्छ। लगआउट गर्न वा लगइन विवरण बदल्न मुख्य साइटमा फर्कनुहोस्।",
|
||||
"logout": "लगआउट",
|
||||
"password": {
|
||||
"action": "पासवर्ड बदल्नुहोस्",
|
||||
"title": "लगइन पासवर्ड बदल्नुहोस्",
|
||||
"description": "पासवर्ड बदलेपछि यस खाताका सबै लटरी सत्रबाट लगआउट हुनेछ।",
|
||||
"current": "हालको पासवर्ड",
|
||||
"new": "नयाँ पासवर्ड",
|
||||
"confirm": "नयाँ पासवर्ड पुष्टि",
|
||||
"hint": "कम्तीमा ६ वर्ण प्रयोग गर्नुहोस्।",
|
||||
"required": "हालको, नयाँ र पुष्टि पासवर्ड पूरा भर्नुहोस्",
|
||||
"tooShort": "नयाँ पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||
"mismatch": "नयाँ पासवर्डहरू मिलेनन्",
|
||||
"mustDiffer": "नयाँ पासवर्ड हालको पासवर्डभन्दा फरक हुनुपर्छ",
|
||||
"submit": "पासवर्ड बदल्नुहोस्",
|
||||
"saving": "बदल्दै...",
|
||||
"failed": "पासवर्ड बदल्न असफल भयो। पछि फेरि प्रयास गर्नुहोस्।"
|
||||
}
|
||||
},
|
||||
"draw": {
|
||||
"currentIssue": "हालको इश्यू",
|
||||
"currentTime": "हालको समय",
|
||||
@@ -164,6 +202,20 @@
|
||||
"placeFailed": "पेश गर्न असफल",
|
||||
"placeSuccess": "बेट पेश भयो। अर्डर {{orderNo}}, कट्टा {{amount}}।",
|
||||
"placePartialFailed": "{{success}} सफल, {{failed}} असफल",
|
||||
"desktop": {
|
||||
"stepGame": "खेल छान्नुहोस्",
|
||||
"stepFill": "नम्बर र रकम भर्नुहोस्",
|
||||
"stepReview": "जाँच गरी पेश गर्नुहोस्",
|
||||
"reviewTitle": "जाँच र पेश",
|
||||
"availableCredit": "उपलब्ध क्रेडिट",
|
||||
"remainingCredit": "बेटपछि उपलब्ध",
|
||||
"warnings": "जोखिम सूचना",
|
||||
"noWarnings": "जोखिम सूचना छैन",
|
||||
"warningCount": "{{count}} पङ्क्ति बिक्री सकिन नजिक",
|
||||
"creditEnough": "पर्याप्त क्रेडिट उपलब्ध छ",
|
||||
"creditInsufficient": "उपलब्ध क्रेडिट पर्याप्त छैन",
|
||||
"secureHint": "पेश गर्नुअघि अन्तिम पूर्वावलोकन देखाइनेछ"
|
||||
},
|
||||
"placeAllFailed": "सबै {{failed}} लाइन असफल",
|
||||
"playConfig": {
|
||||
"playClosedDraftCleared": "{{playCode}} बन्द छ। सम्बन्धित ड्राफ्ट रकम हटाइएको छ।",
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"captchaRefresh": "刷新验证码",
|
||||
"captchaFetch": "点击获取",
|
||||
"usernameInvalidCharset": "账号只能使用字母、数字、点(.)、下划线和连字符",
|
||||
"passwordMinLength": "密码至少需要 6 个字符"
|
||||
"passwordMinLength": "密码至少需要 6 个字符",
|
||||
"passwordChanged": "密码已修改,请使用新密码重新登录"
|
||||
},
|
||||
"errors": {
|
||||
"noToken": "未发现授权令牌",
|
||||
|
||||
@@ -113,6 +113,44 @@
|
||||
"player": {
|
||||
"fallback": "玩家 #{{id}}"
|
||||
},
|
||||
"account": {
|
||||
"open": "查看玩家资料",
|
||||
"loading": "玩家",
|
||||
"title": "我的账号",
|
||||
"description": "查看当前登录的玩家资料与账号安全设置。",
|
||||
"auth": {
|
||||
"native": "彩票账号登录",
|
||||
"sso": "主站 API 接入"
|
||||
},
|
||||
"funding": {
|
||||
"credit": "信用模式",
|
||||
"wallet": "钱包模式"
|
||||
},
|
||||
"fields": {
|
||||
"username": "登录账号",
|
||||
"site": "所属站点",
|
||||
"playerId": "玩家编号",
|
||||
"currency": "默认币种"
|
||||
},
|
||||
"ssoManaged": "当前登录由主站管理。如需退出或修改登录凭证,请返回主站操作。",
|
||||
"logout": "退出登录",
|
||||
"password": {
|
||||
"action": "修改密码",
|
||||
"title": "修改登录密码",
|
||||
"description": "修改成功后,当前账号的所有彩票端会话都会退出。",
|
||||
"current": "当前密码",
|
||||
"new": "新密码",
|
||||
"confirm": "确认新密码",
|
||||
"hint": "密码至少需要 6 个字符。",
|
||||
"required": "请完整填写当前密码、新密码和确认密码",
|
||||
"tooShort": "新密码至少需要 6 个字符",
|
||||
"mismatch": "两次输入的新密码不一致",
|
||||
"mustDiffer": "新密码不能与当前密码相同",
|
||||
"submit": "确认修改",
|
||||
"saving": "正在修改...",
|
||||
"failed": "修改密码失败,请稍后重试"
|
||||
}
|
||||
},
|
||||
"draw": {
|
||||
"currentIssue": "当前期号",
|
||||
"currentTime": "当前时间",
|
||||
@@ -164,6 +202,20 @@
|
||||
"placeFailed": "提交失败",
|
||||
"placeSuccess": "下注成功,订单号 {{orderNo}},实扣 {{amount}}。",
|
||||
"placePartialFailed": "{{success}} 个成功,{{failed}} 个失败",
|
||||
"desktop": {
|
||||
"stepGame": "选择玩法",
|
||||
"stepFill": "填写号码和金额",
|
||||
"stepReview": "核对并提交",
|
||||
"reviewTitle": "核对与提交",
|
||||
"availableCredit": "可用信用",
|
||||
"remainingCredit": "下注后可用",
|
||||
"warnings": "风险提醒",
|
||||
"noWarnings": "暂无风险提醒",
|
||||
"warningCount": "{{count}} 行接近售罄",
|
||||
"creditEnough": "可用信用充足",
|
||||
"creditInsufficient": "可用信用不足",
|
||||
"secureHint": "提交前仍会显示最终预览"
|
||||
},
|
||||
"playConfig": {
|
||||
"playClosedDraftCleared": "{{playCode}} 已关闭,相关草稿金额已清除。",
|
||||
"playClosed": "{{playCode}} 已关闭。",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** 主滚动区外边距(灰底区域与顶栏/底栏留白) */
|
||||
export const playerMainInset = "px-2 pt-2 pb-2 lg:px-5 lg:pt-5 lg:pb-5";
|
||||
export const playerMainInset = "px-2 pt-2 pb-2 lg:px-4 lg:pt-4 lg:pb-4";
|
||||
|
||||
/** 页内白卡片内边距(桌面主内容已是白卡片,减少二次 padding) */
|
||||
export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-1 lg:pt-1 lg:pb-2";
|
||||
export const playerPageShellPadding = "px-2 pt-2 pb-4 lg:px-0 lg:pt-0 lg:pb-1";
|
||||
|
||||
/** 大厅等直接写在 section 上的内边距 */
|
||||
export const playerPageInset = playerPageShellPadding;
|
||||
|
||||
Reference in New Issue
Block a user