|
@@ -17,8 +17,91 @@ import {useNavigate} from "react-router";
|
|
|
import {numFormatter} from "@utils/helpers";
|
|
import {numFormatter} from "@utils/helpers";
|
|
|
import LazyImgix from "@components/Imgix/LazyImgix";
|
|
import LazyImgix from "@components/Imgix/LazyImgix";
|
|
|
import {useSelector} from "react-redux";
|
|
import {useSelector} from "react-redux";
|
|
|
-import {getOrderedGames} from "@features/pageSlice";
|
|
|
|
|
|
|
+import {getOrderedGames, getHotGames} from "@features/pageSlice";
|
|
|
import {getConfig} from "@features/paySlice";
|
|
import {getConfig} from "@features/paySlice";
|
|
|
|
|
+import {getAvatarUrl} from "@utils/avatar";
|
|
|
|
|
+import {getVirtualUsers} from "@constants/virtualUser";
|
|
|
|
|
+
|
|
|
|
|
+// win 金额兜底池:后端返回的 src.win 经常出现 0 / 极小值,看起来很假,
|
|
|
|
|
+// 没有有效金额时按这个权重池随机一个 R$ 数值,落在常见小奖区间。
|
|
|
|
|
+const WIN_AMOUNT_TIERS = [5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000];
|
|
|
|
|
+const WIN_AMOUNT_WEIGHTS= [10,18, 20, 18, 12, 10, 6, 3, 2, 1];
|
|
|
|
|
+const pickWinAmount = () => pickWeighted(WIN_AMOUNT_TIERS, WIN_AMOUNT_WEIGHTS);
|
|
|
|
|
+// 给 win cell 一个像样的金额(< 1 视为无效,重新抽)
|
|
|
|
|
+const sanitizeWin = (raw) => {
|
|
|
|
|
+ const n = Number(raw);
|
|
|
|
|
+ if (!Number.isFinite(n) || n < 1) return pickWinAmount();
|
|
|
|
|
+ return n;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 头像池 1..15,跟个人资料 / Navbar 用同一组(avatar_*.png)。
|
|
|
|
|
+// CDN 上目前只有 15 张(avatar_16 会 404),保持和 new2 一致。
|
|
|
|
|
+// 用 nick 做哈希取模,让同一个昵称在轮播里头像稳定,不抖动。
|
|
|
|
|
+const AVATAR_POOL_SIZE = 15;
|
|
|
|
|
+const avatarIdFromNick = (nick) => {
|
|
|
|
|
+ const s = String(nick || "");
|
|
|
|
|
+ let h = 0;
|
|
|
|
|
+ for (let i = 0; i < s.length; i++) {
|
|
|
|
|
+ h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ return Math.abs(h) % AVATAR_POOL_SIZE + 1;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 把后端返回的 wins 列表展平成 desktop 单行轮播单元,
|
|
|
|
|
+// 65% win / 20% deposit / 15% withdraw。
|
|
|
|
|
+// !!! 注意 !!!
|
|
|
|
|
+// 1. 后端 wins 数据里的 item.img 是圆头像,不是游戏卡片图,所以游戏图必须
|
|
|
|
|
+// 从 hotGames / orderedGames 里抽(80% 热门 / 20% 全量)。
|
|
|
|
|
+// 2. 后端 src.nick / src.win 经常出现重复或 0 值,看起来很假;这里
|
|
|
|
|
+// 昵称统一用 getVirtualUsers 重新生成(90% UXXXX / 10% 脱敏名),
|
|
|
|
|
+// win 金额遇到 0/无效 用 pickWinAmount 兜底。
|
|
|
|
|
+const buildFlatCells = (list, hotGames, orderedGames, depositPicker, count = 30) => {
|
|
|
|
|
+ const cells = [];
|
|
|
|
|
+ if (!list || list.length === 0) return cells;
|
|
|
|
|
+ const pickDeposit = typeof depositPicker === "function" ? depositPicker : pickDepositAmount;
|
|
|
|
|
+ const hot = Array.isArray(hotGames) ? hotGames : [];
|
|
|
|
|
+ const all = Array.isArray(orderedGames) ? orderedGames : [];
|
|
|
|
|
+ const pickGame = () => {
|
|
|
|
|
+ const useHot = Math.random() < 0.8 && hot.length > 0;
|
|
|
|
|
+ const pool = useHot ? hot : (all.length > 0 ? all : hot);
|
|
|
|
|
+ if (pool.length === 0) return null;
|
|
|
|
|
+ return pickWeightedGame(pool);
|
|
|
|
|
+ };
|
|
|
|
|
+ // 一次性生成一组虚拟昵称,每个 cell 拿一个,不重复 src.nick。
|
|
|
|
|
+ const nicks = getVirtualUsers(count);
|
|
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
|
|
+ const src = list[i % list.length];
|
|
|
|
|
+ const nick = nicks[i] || `U${Math.floor(1000 + Math.random() * 9000)}`;
|
|
|
|
|
+ const r = Math.random();
|
|
|
|
|
+ let cell;
|
|
|
|
|
+ if (r < 0.65) {
|
|
|
|
|
+ const game = pickGame();
|
|
|
|
|
+ cell = {
|
|
|
|
|
+ kind: "win",
|
|
|
|
|
+ img: game ? game.img : src.img,
|
|
|
|
|
+ nick,
|
|
|
|
|
+ // src.win 0 或缺失时随机一个,避免出现 R$0.00 这种穿帮金额
|
|
|
|
|
+ amount: sanitizeWin(src.win),
|
|
|
|
|
+ link: game && game.id != null ? `/game/${game.id}` : src.link,
|
|
|
|
|
+ };
|
|
|
|
|
+ } else if (r < 0.85) {
|
|
|
|
|
+ cell = {
|
|
|
|
|
+ kind: "deposit",
|
|
|
|
|
+ nick,
|
|
|
|
|
+ amount: pickDeposit(),
|
|
|
|
|
+ };
|
|
|
|
|
+ } else {
|
|
|
|
|
+ cell = {
|
|
|
|
|
+ kind: "withdraw",
|
|
|
|
|
+ nick,
|
|
|
|
|
+ amount: pickWithdrawAmount(Number(src.win) || 0),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ cell.avatarId = avatarIdFromNick(cell.nick);
|
|
|
|
|
+ cells.push(cell);
|
|
|
|
|
+ }
|
|
|
|
|
+ return cells;
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
|
|
|
|
|
const i18n=global.i18n;
|
|
const i18n=global.i18n;
|
|
@@ -220,6 +303,110 @@ const VRotator=({queue,formatGolds,onClickGame})=>{
|
|
|
}
|
|
}
|
|
|
return <Entry item={item} idx={idx} phase={phase} formatGolds={formatGolds} onClickGame={onClickGame} wrapClass={wrapClass}/>;
|
|
return <Entry item={item} idx={idx} phase={phase} formatGolds={formatGolds} onClickGame={onClickGame} wrapClass={wrapClass}/>;
|
|
|
};
|
|
};
|
|
|
|
|
+// 桌面版双行 Live Feed —— 飞机播报板风格:
|
|
|
|
|
+// · 行内每张卡片 = [游戏图标 / 充值&提现图示] + [统一头像] + [Nick + 标签] + [金额]
|
|
|
|
|
+// · 双行反向滚动 + hover 整体暂停 + 入场 flap-flip 动画
|
|
|
|
|
+// · 仅 win 类型可点击跳游戏;充值/提现卡片不可点击(按需求)
|
|
|
|
|
+const FeedCell = ({cell, formatGolds, onClickGame}) => {
|
|
|
|
|
+ const isWin = cell.kind === "win";
|
|
|
|
|
+ const tagKey = isWin ? "win_list.tag_win" : (cell.kind === "deposit" ? "win_list.tag_deposit" : "win_list.tag_withdraw");
|
|
|
|
|
+ const onClick = isWin && cell.link ? () => onClickGame(cell.link) : undefined;
|
|
|
|
|
+ const cellClass = `${styles.feedCell} ${styles[`feedCell_${cell.kind}`]} ${isWin ? styles.feedCell_clickable : ""}`;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className={cellClass} onClick={onClick} title={isWin ? "" : undefined}>
|
|
|
|
|
+ <div className={styles.feedIcon}>
|
|
|
|
|
+ {isWin ? (
|
|
|
|
|
+ <LazyImgix src={cell.img} alt={"game-icon"} width={64} className={styles.feedGameImg}/>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className={`${styles.feedTxnIcon} ${styles[`feedTxn_${cell.kind}`]}`}>
|
|
|
|
|
+ {cell.kind === "deposit" ? "+" : "↑"}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <img className={styles.feedAvatar} src={getAvatarUrl(cell.avatarId)} alt={"avatar"}/>
|
|
|
|
|
+ <div className={styles.feedMid}>
|
|
|
|
|
+ <div className={styles.feedNick}>{cell.nick}</div>
|
|
|
|
|
+ <div className={`${styles.feedTag} ${styles[`feedTag_${cell.kind}`]}`}>{i18n.t(tagKey)}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className={`${styles.feedAmount} ${styles[`feedAmount_${cell.kind}`]}`}>
|
|
|
|
|
+ {formatGolds(cell.amount)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 桌面 LiveFeed —— 不再用持续滚动,改成"批次秀":
|
|
|
|
|
+// 一批 PER_BATCH 张卡片整批入场(带 stagger)→ 停顿 → 整批出场 → 切下一批,
|
|
|
|
|
+// 每批从 SHOW_MODES 里随机挑一种动画(避免连续重复同一种)。
|
|
|
|
|
+const SHOW_MODES = ["flyRight", "dropTop", "fanCenter", "shuffleAlt", "wipeLR", "popBounce"];
|
|
|
|
|
+const PER_BATCH = 5;
|
|
|
|
|
+
|
|
|
|
|
+const pickNextMode = (prev) => {
|
|
|
|
|
+ const others = SHOW_MODES.filter(m => m !== prev);
|
|
|
|
|
+ return others[Math.floor(Math.random() * others.length)];
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const LiveFeedShow = ({cells, formatGolds, onClickGame}) => {
|
|
|
|
|
+ const total = cells ? cells.length : 0;
|
|
|
|
|
+ const [batchIdx, setBatchIdx] = React.useState(0);
|
|
|
|
|
+ const [mode, setMode] = React.useState(() => SHOW_MODES[Math.floor(Math.random() * SHOW_MODES.length)]);
|
|
|
|
|
+ const [phase, setPhase] = React.useState("in"); // in -> hold -> out
|
|
|
|
|
+
|
|
|
|
|
+ const batch = React.useMemo(() => {
|
|
|
|
|
+ if (total === 0) return [];
|
|
|
|
|
+ const start = (batchIdx * PER_BATCH) % total;
|
|
|
|
|
+ const arr = [];
|
|
|
|
|
+ for (let i = 0; i < PER_BATCH; i++) arr.push(cells[(start + i) % total]);
|
|
|
|
|
+ return arr;
|
|
|
|
|
+ }, [cells, batchIdx, total]);
|
|
|
|
|
+
|
|
|
|
|
+ React.useEffect(() => {
|
|
|
|
|
+ if (total === 0) return;
|
|
|
|
|
+ let t;
|
|
|
|
|
+ const inDur = 700 + (PER_BATCH - 1) * 180; // 整批入场总时长
|
|
|
|
|
+ const holdDur = 2600; // 停顿
|
|
|
|
|
+ const outDur = 600 + (PER_BATCH - 1) * 120; // 整批出场总时长
|
|
|
|
|
+ if (phase === "in") {
|
|
|
|
|
+ t = setTimeout(() => setPhase("hold"), inDur);
|
|
|
|
|
+ } else if (phase === "hold") {
|
|
|
|
|
+ t = setTimeout(() => setPhase("out"), holdDur);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ t = setTimeout(() => {
|
|
|
|
|
+ setMode(prev => pickNextMode(prev));
|
|
|
|
|
+ setBatchIdx(i => i + 1);
|
|
|
|
|
+ setPhase("in");
|
|
|
|
|
+ }, outDur);
|
|
|
|
|
+ }
|
|
|
|
|
+ return () => { if (t) clearTimeout(t); };
|
|
|
|
|
+ }, [phase, total]);
|
|
|
|
|
+
|
|
|
|
|
+ if (total === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className={styles.showStage}>
|
|
|
|
|
+ {batch.map((cell, i) => {
|
|
|
|
|
+ const animKey = phase === "out" ? `out_${mode}` : phase === "in" ? `in_${mode}` : null;
|
|
|
|
|
+ const animClass = animKey ? styles[animKey] : "";
|
|
|
|
|
+ // 入场正向 stagger,出场反向 stagger(最后一张先飞走,看着更自然)
|
|
|
|
|
+ const delaySec = phase === "in"
|
|
|
|
|
+ ? i * 0.18
|
|
|
|
|
+ : phase === "out"
|
|
|
|
|
+ ? (PER_BATCH - 1 - i) * 0.12
|
|
|
|
|
+ : 0;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={`${batchIdx}_${i}`}
|
|
|
|
|
+ className={`${styles.showCell} ${animClass}`}
|
|
|
|
|
+ style={{animationDelay: `${delaySec}s`}}
|
|
|
|
|
+ >
|
|
|
|
|
+ <FeedCell cell={cell} formatGolds={formatGolds} onClickGame={onClickGame}/>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
const AutoWinList=({data})=>{
|
|
const AutoWinList=({data})=>{
|
|
|
const {isMobile} = useThemeProvider();
|
|
const {isMobile} = useThemeProvider();
|
|
|
const [currIndex,setCurrIndex]=useState(0);
|
|
const [currIndex,setCurrIndex]=useState(0);
|
|
@@ -228,6 +415,7 @@ const AutoWinList=({data})=>{
|
|
|
const navigate=useNavigate();
|
|
const navigate=useNavigate();
|
|
|
const lvicon=[lv_1,lv_2,lv_3];
|
|
const lvicon=[lv_1,lv_2,lv_3];
|
|
|
const orderedGames=useSelector(getOrderedGames);
|
|
const orderedGames=useSelector(getOrderedGames);
|
|
|
|
|
+ const hotGames=useSelector(getHotGames);
|
|
|
const rechargeConfig=useSelector(getConfig);
|
|
const rechargeConfig=useSelector(getConfig);
|
|
|
if (data==null)data={};
|
|
if (data==null)data={};
|
|
|
// data={
|
|
// data={
|
|
@@ -492,11 +680,40 @@ const AutoWinList=({data})=>{
|
|
|
navigate(link);
|
|
navigate(link);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ // 桌面 LiveFeed 扁平单元池:一次性生成 100 条数据缓存到 ref,
|
|
|
|
|
+ // 之后所有刷新(包括语言切换、redux 引用变化)都直接复用同一池子,
|
|
|
|
|
+ // i18n.t 只在 render 时换标签文案,nick/avatar/amount/img/kind 不抖。
|
|
|
|
|
+ // 缓存按 tab(currIndex) 维度存放;切 Tab 后再切回也保持一致。
|
|
|
|
|
+ const flatCellsCacheRef = React.useRef({});
|
|
|
|
|
+ const [flatCells, setFlatCells] = React.useState([]);
|
|
|
|
|
+ React.useEffect(() => {
|
|
|
|
|
+ const cache = flatCellsCacheRef.current;
|
|
|
|
|
+ const cached = cache[currIndex];
|
|
|
|
|
+ if (cached && cached.length > 0) {
|
|
|
|
|
+ setFlatCells(cached);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const filtered = getAutoList();
|
|
|
|
|
+ if (!filtered || filtered.length === 0) return;
|
|
|
|
|
+ const hasGames = (hotGames && hotGames.length > 0) || (orderedGames && orderedGames.length > 0);
|
|
|
|
|
+ if (!hasGames) return;
|
|
|
|
|
+ const pool = buildFlatCells(filtered, hotGames, orderedGames, depositPicker, 100);
|
|
|
|
|
+ cache[currIndex] = pool;
|
|
|
|
|
+ setFlatCells(pool);
|
|
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
+ }, [data, currIndex, hotGames, orderedGames, depositPicker]);
|
|
|
|
|
+ const renderLiveFeedDesktop = () => (
|
|
|
|
|
+ <LiveFeedShow
|
|
|
|
|
+ cells={flatCells}
|
|
|
|
|
+ formatGolds={formatGolds}
|
|
|
|
|
+ onClickGame={(link)=>onClickLink(link)}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
const renderDesktop=()=>{
|
|
const renderDesktop=()=>{
|
|
|
return (
|
|
return (
|
|
|
<div className={styles.list_context}>
|
|
<div className={styles.list_context}>
|
|
|
- {renderTabs()}
|
|
|
|
|
- {renderHAuto()}
|
|
|
|
|
|
|
+ {/* 单行 LiveFeed,去掉 All / Slots 标签,UI 更干净 */}
|
|
|
|
|
+ {renderLiveFeedDesktop()}
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -513,7 +730,7 @@ const AutoWinList=({data})=>{
|
|
|
}
|
|
}
|
|
|
return (
|
|
return (
|
|
|
<div className={`w-100 ${isMobile()?styles.win_list_container_m:styles.win_list_container}`}>
|
|
<div className={`w-100 ${isMobile()?styles.win_list_container_m:styles.win_list_container}`}>
|
|
|
- {!isMobile() && renderTitle()}
|
|
|
|
|
|
|
+ {/* "Recent Big Wins" 标题去掉,桌面只剩一条 LiveFeed 跑马灯 */}
|
|
|
{renderContext()}
|
|
{renderContext()}
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|