1052 lines
36 KiB
HTML
1052 lines
36 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||
<meta name="color-scheme" content="dark">
|
||
<title>星河幸运房</title>
|
||
<style>
|
||
:root {
|
||
--ink: #fff;
|
||
--muted: rgba(241, 235, 255, .68);
|
||
--line: rgba(255, 255, 255, .1);
|
||
--panel: rgba(22, 14, 49, .78);
|
||
--violet: #8e5bff;
|
||
--pink: #ff5fa7;
|
||
--gold: #ffd572;
|
||
--green: #68e4b4;
|
||
--danger: #ff7c93;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
html, body { min-height: 100%; }
|
||
|
||
body {
|
||
margin: 0;
|
||
color: var(--ink);
|
||
font-family: Inter, ui-rounded, "SF Pro Rounded", "PingFang SC", system-ui, sans-serif;
|
||
background:
|
||
radial-gradient(circle at 15% 0%, rgba(128, 66, 255, .34), transparent 34rem),
|
||
radial-gradient(circle at 90% 90%, rgba(255, 79, 159, .23), transparent 35rem),
|
||
#090616;
|
||
overscroll-behavior: none;
|
||
}
|
||
|
||
button, select { font: inherit; }
|
||
button { color: inherit; }
|
||
|
||
.app {
|
||
position: relative;
|
||
width: min(100%, 460px);
|
||
min-height: 100svh;
|
||
margin: 0 auto;
|
||
overflow: hidden;
|
||
background:
|
||
radial-gradient(ellipse at 50% 13%, rgba(162, 102, 255, .35), transparent 38%),
|
||
linear-gradient(180deg, #231450 0%, #130c32 50%, #0b081b 100%);
|
||
box-shadow: 0 0 80px rgba(0, 0, 0, .45);
|
||
isolation: isolate;
|
||
}
|
||
|
||
.app::before,
|
||
.app::after {
|
||
content: "";
|
||
position: absolute;
|
||
z-index: -1;
|
||
border-radius: 999px;
|
||
filter: blur(2px);
|
||
opacity: .65;
|
||
}
|
||
|
||
.app::before {
|
||
width: 280px;
|
||
height: 280px;
|
||
top: 80px;
|
||
left: -130px;
|
||
background: radial-gradient(circle, rgba(88, 113, 255, .22), transparent 66%);
|
||
}
|
||
|
||
.app::after {
|
||
width: 340px;
|
||
height: 340px;
|
||
right: -180px;
|
||
top: 240px;
|
||
background: radial-gradient(circle, rgba(255, 77, 172, .18), transparent 67%);
|
||
}
|
||
|
||
.topbar {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
padding: calc(18px + env(safe-area-inset-top)) 18px 12px;
|
||
}
|
||
|
||
.room-title { min-width: 0; }
|
||
|
||
.room-title h1 {
|
||
margin: 0 0 5px;
|
||
font-size: 20px;
|
||
line-height: 1.2;
|
||
letter-spacing: .01em;
|
||
}
|
||
|
||
.room-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.live-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: var(--green);
|
||
box-shadow: 0 0 0 4px rgba(104, 228, 180, .13), 0 0 12px rgba(104, 228, 180, .8);
|
||
}
|
||
|
||
.icon-button {
|
||
width: 38px;
|
||
height: 38px;
|
||
flex: 0 0 38px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, .08);
|
||
cursor: pointer;
|
||
transition: transform .18s ease, background .18s ease;
|
||
}
|
||
|
||
.icon-button:active { transform: scale(.94); }
|
||
|
||
.strategy-strip {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto auto;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 0 16px 12px;
|
||
padding: 9px 11px;
|
||
border: 1px solid rgba(178, 143, 255, .15);
|
||
border-radius: 13px;
|
||
color: var(--muted);
|
||
background: rgba(13, 8, 34, .36);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.strategy-strip strong { color: #eee7ff; font-weight: 700; }
|
||
|
||
.room-stage {
|
||
position: relative;
|
||
min-height: 392px;
|
||
padding: 4px 20px 16px;
|
||
}
|
||
|
||
.room-stage::before {
|
||
content: "";
|
||
position: absolute;
|
||
width: 210px;
|
||
height: 210px;
|
||
top: 14px;
|
||
left: calc(50% - 105px);
|
||
border: 1px solid rgba(197, 165, 255, .12);
|
||
border-radius: 50%;
|
||
box-shadow: inset 0 0 60px rgba(124, 80, 255, .08), 0 0 70px rgba(104, 77, 255, .1);
|
||
}
|
||
|
||
.seats {
|
||
position: relative;
|
||
z-index: 2;
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 24px 8px;
|
||
padding-top: 24px;
|
||
}
|
||
|
||
.seat {
|
||
position: relative;
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 7px;
|
||
text-align: center;
|
||
}
|
||
|
||
.seat.host {
|
||
grid-column: 3;
|
||
grid-row: 1;
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
.seat.host::after {
|
||
content: "主播";
|
||
position: absolute;
|
||
top: -9px;
|
||
padding: 2px 7px;
|
||
border-radius: 999px;
|
||
color: #4b2b00;
|
||
background: linear-gradient(90deg, #ffdd78, #ffb74f);
|
||
font-size: 9px;
|
||
font-weight: 800;
|
||
box-shadow: 0 4px 12px rgba(255, 189, 71, .3);
|
||
}
|
||
|
||
.seat.sender { grid-column: 1; grid-row: 2; }
|
||
|
||
.avatar {
|
||
position: relative;
|
||
display: grid;
|
||
width: 52px;
|
||
height: 52px;
|
||
place-items: center;
|
||
border: 2px solid rgba(255, 255, 255, .16);
|
||
border-radius: 50%;
|
||
background: linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.04));
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, .2);
|
||
font-size: 23px;
|
||
}
|
||
|
||
.host .avatar {
|
||
width: 66px;
|
||
height: 66px;
|
||
border-color: rgba(255, 210, 113, .74);
|
||
background: linear-gradient(145deg, #ff8abc, #845cff);
|
||
box-shadow: 0 0 0 5px rgba(255, 196, 102, .08), 0 0 27px rgba(255, 112, 185, .28);
|
||
font-size: 29px;
|
||
}
|
||
|
||
.sender .avatar {
|
||
border-color: rgba(119, 218, 255, .7);
|
||
background: linear-gradient(145deg, #4fc1ff, #6954e8);
|
||
}
|
||
|
||
.empty .avatar {
|
||
color: rgba(255, 255, 255, .24);
|
||
border-style: dashed;
|
||
background: rgba(255, 255, 255, .035);
|
||
box-shadow: none;
|
||
font-size: 17px;
|
||
}
|
||
|
||
.seat-name {
|
||
max-width: 68px;
|
||
overflow: hidden;
|
||
color: rgba(255,255,255,.82);
|
||
font-size: 10px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.seat-id { color: rgba(255,255,255,.35); font-size: 8px; }
|
||
|
||
.mic {
|
||
position: absolute;
|
||
right: -2px;
|
||
bottom: 19px;
|
||
display: grid;
|
||
width: 17px;
|
||
height: 17px;
|
||
place-items: center;
|
||
border-radius: 50%;
|
||
background: #6a4cbd;
|
||
font-size: 9px;
|
||
}
|
||
|
||
.activity-feed {
|
||
position: absolute;
|
||
z-index: 3;
|
||
right: 17px;
|
||
bottom: 9px;
|
||
left: 17px;
|
||
display: flex;
|
||
height: 66px;
|
||
flex-direction: column;
|
||
justify-content: flex-end;
|
||
gap: 5px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.feed-line {
|
||
width: fit-content;
|
||
max-width: 88%;
|
||
overflow: hidden;
|
||
padding: 5px 9px;
|
||
border-radius: 8px 12px 12px 2px;
|
||
color: rgba(255,255,255,.78);
|
||
background: rgba(7, 5, 22, .48);
|
||
font-size: 10px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
animation: feed-in .28s ease-out;
|
||
}
|
||
|
||
.feed-line strong { color: var(--gold); }
|
||
|
||
.drawer {
|
||
position: relative;
|
||
z-index: 5;
|
||
padding: 15px 16px calc(15px + env(safe-area-inset-bottom));
|
||
border-top: 1px solid rgba(255,255,255,.09);
|
||
border-radius: 23px 23px 0 0;
|
||
background: linear-gradient(180deg, rgba(26, 17, 54, .96), rgba(12, 9, 27, .98));
|
||
box-shadow: 0 -20px 55px rgba(0, 0, 0, .2);
|
||
backdrop-filter: blur(22px);
|
||
}
|
||
|
||
.drawer-head,
|
||
.balance-row,
|
||
.send-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.drawer-head { margin-bottom: 12px; }
|
||
|
||
.drawer-title { font-size: 14px; font-weight: 800; }
|
||
|
||
.balance {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
color: var(--gold);
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.coin {
|
||
display: inline-grid;
|
||
width: 17px;
|
||
height: 17px;
|
||
place-items: center;
|
||
border: 1px solid #ffdc6c;
|
||
border-radius: 50%;
|
||
color: #7a4b00;
|
||
background: linear-gradient(145deg, #fff096, #f0a82d);
|
||
font-size: 10px;
|
||
box-shadow: 0 0 12px rgba(255, 200, 75, .26);
|
||
}
|
||
|
||
.gift-card {
|
||
display: grid;
|
||
grid-template-columns: 76px 1fr;
|
||
gap: 13px;
|
||
align-items: center;
|
||
margin-bottom: 13px;
|
||
padding: 11px;
|
||
border: 1px solid rgba(186, 142, 255, .26);
|
||
border-radius: 17px;
|
||
background: linear-gradient(110deg, rgba(130, 77, 255, .15), rgba(255, 85, 165, .08));
|
||
}
|
||
|
||
.gift-visual {
|
||
position: relative;
|
||
display: grid;
|
||
width: 76px;
|
||
height: 76px;
|
||
place-items: center;
|
||
border-radius: 19px;
|
||
background:
|
||
radial-gradient(circle at 60% 30%, rgba(255,255,255,.45), transparent 7%),
|
||
radial-gradient(circle, rgba(174, 116, 255, .32), rgba(93, 51, 181, .08));
|
||
font-size: 40px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.gift-visual::after {
|
||
content: "";
|
||
position: absolute;
|
||
width: 90px;
|
||
height: 11px;
|
||
transform: rotate(-34deg) translate(-21px, 29px);
|
||
background: linear-gradient(90deg, transparent, rgba(121, 208, 255, .48), rgba(255, 116, 197, .7));
|
||
filter: blur(3px);
|
||
}
|
||
|
||
.gift-info h2 { margin: 0 0 4px; font-size: 16px; }
|
||
.gift-info p { margin: 0; color: var(--muted); font-size: 11px; }
|
||
.gift-info strong { color: var(--gold); }
|
||
|
||
.count-chips {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 7px;
|
||
margin-bottom: 13px;
|
||
}
|
||
|
||
.count-chip {
|
||
min-height: 34px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
color: var(--muted);
|
||
background: rgba(255, 255, 255, .045);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.count-chip.active {
|
||
border-color: rgba(255, 202, 105, .55);
|
||
color: #3b2600;
|
||
background: linear-gradient(135deg, #ffe490, #ffb84f);
|
||
font-weight: 850;
|
||
box-shadow: 0 5px 18px rgba(255, 184, 79, .18);
|
||
}
|
||
|
||
.send-meta { min-width: 0; }
|
||
|
||
.send-meta .target {
|
||
max-width: 180px;
|
||
overflow: hidden;
|
||
color: rgba(255,255,255,.84);
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.send-meta .cost { margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||
|
||
.send-button {
|
||
min-width: 138px;
|
||
min-height: 46px;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
color: #fff;
|
||
background: linear-gradient(100deg, #8c5bff, #ff5fa8);
|
||
box-shadow: 0 10px 28px rgba(218, 78, 196, .25);
|
||
cursor: pointer;
|
||
font-weight: 850;
|
||
letter-spacing: .02em;
|
||
transition: filter .2s ease, transform .2s ease;
|
||
}
|
||
|
||
.send-button:active { transform: scale(.97); }
|
||
.send-button:disabled { cursor: wait; filter: grayscale(.6) opacity(.55); }
|
||
|
||
.debug {
|
||
margin-top: 13px;
|
||
border-top: 1px solid var(--line);
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
}
|
||
|
||
.debug summary { padding: 10px 0 0; cursor: pointer; }
|
||
|
||
.debug pre {
|
||
max-height: 170px;
|
||
overflow: auto;
|
||
margin: 9px 0 0;
|
||
padding: 10px;
|
||
border-radius: 10px;
|
||
color: #cfc5ed;
|
||
background: #090616;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.setup-mask {
|
||
position: absolute;
|
||
z-index: 30;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 30px;
|
||
background: rgba(8, 5, 21, .84);
|
||
backdrop-filter: blur(20px);
|
||
transition: opacity .25s ease, visibility .25s ease;
|
||
}
|
||
|
||
.setup-mask.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
|
||
|
||
.setup-card { width: min(100%, 310px); text-align: center; }
|
||
|
||
.setup-orbit {
|
||
position: relative;
|
||
display: grid;
|
||
width: 86px;
|
||
height: 86px;
|
||
margin: 0 auto 20px;
|
||
place-items: center;
|
||
border: 1px solid rgba(255,255,255,.13);
|
||
border-radius: 50%;
|
||
font-size: 40px;
|
||
}
|
||
|
||
.setup-orbit::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: -8px;
|
||
border: 2px solid transparent;
|
||
border-top-color: var(--pink);
|
||
border-right-color: var(--violet);
|
||
border-radius: 50%;
|
||
animation: spin 1.1s linear infinite;
|
||
}
|
||
|
||
.setup-card h2 { margin: 0 0 8px; font-size: 20px; }
|
||
.setup-card p { min-height: 38px; margin: 0 0 18px; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||
|
||
.retry-button {
|
||
display: none;
|
||
width: 100%;
|
||
min-height: 44px;
|
||
border: 1px solid rgba(255,255,255,.17);
|
||
border-radius: 13px;
|
||
background: rgba(255,255,255,.09);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.retry-button.visible { display: block; }
|
||
|
||
.meteor-layer,
|
||
.celebration-layer {
|
||
position: absolute;
|
||
z-index: 20;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.meteor-flight {
|
||
position: absolute;
|
||
width: 45px;
|
||
height: 45px;
|
||
margin: -22px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 65% 35%, #fff 0 8%, #ffd2f0 12%, #d868ff 38%, rgba(119, 89, 255, .1) 65%);
|
||
box-shadow: -22px 18px 14px rgba(92, 185, 255, .38), -45px 36px 20px rgba(255, 83, 175, .24), 0 0 22px #d77aff;
|
||
animation: meteor var(--flight, .62s) cubic-bezier(.22,.75,.2,1) forwards;
|
||
}
|
||
|
||
.win-banner {
|
||
position: absolute;
|
||
top: 27%;
|
||
right: 18px;
|
||
left: 18px;
|
||
display: grid;
|
||
min-height: 112px;
|
||
place-items: center;
|
||
padding: 14px;
|
||
border: 1px solid rgba(255, 221, 124, .6);
|
||
border-radius: 20px;
|
||
opacity: 0;
|
||
text-align: center;
|
||
background:
|
||
linear-gradient(100deg, rgba(77, 31, 145, .96), rgba(167, 45, 125, .96)),
|
||
#4a2078;
|
||
box-shadow: 0 0 50px rgba(255, 92, 194, .28), inset 0 0 35px rgba(255, 220, 112, .08);
|
||
transform: scale(.76) translateY(14px);
|
||
}
|
||
|
||
.win-banner.show { animation: banner 1.8s ease forwards; }
|
||
.win-banner.big { border-color: #fff0a5; box-shadow: 0 0 85px rgba(255, 201, 82, .58); }
|
||
.win-kicker { color: #ffdfa0; font-size: 11px; font-weight: 800; letter-spacing: .18em; }
|
||
.win-amount { margin: 4px 0; color: #fff4be; font-size: 29px; font-weight: 950; text-shadow: 0 3px 20px rgba(255, 213, 89, .5); }
|
||
.win-detail { color: rgba(255,255,255,.66); font-size: 10px; }
|
||
|
||
.spark {
|
||
position: absolute;
|
||
top: -20px;
|
||
width: 8px;
|
||
height: 15px;
|
||
border-radius: 3px;
|
||
background: var(--spark-color);
|
||
animation: fall var(--fall-time) linear forwards;
|
||
}
|
||
|
||
.toast {
|
||
position: absolute;
|
||
z-index: 40;
|
||
top: calc(68px + env(safe-area-inset-top));
|
||
right: 18px;
|
||
left: 18px;
|
||
padding: 10px 13px;
|
||
border: 1px solid rgba(255,255,255,.13);
|
||
border-radius: 12px;
|
||
opacity: 0;
|
||
color: #fff;
|
||
background: rgba(12, 9, 28, .9);
|
||
box-shadow: 0 12px 30px rgba(0,0,0,.25);
|
||
pointer-events: none;
|
||
transform: translateY(-10px);
|
||
transition: opacity .2s ease, transform .2s ease;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.toast.show { opacity: 1; transform: translateY(0); }
|
||
.toast.error { border-color: rgba(255,124,147,.5); color: #ffd9e0; }
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
@keyframes feed-in { from { opacity: 0; transform: translateY(7px); } }
|
||
@keyframes meteor {
|
||
0% { transform: translate(0, 0) rotate(-35deg) scale(.35); opacity: 0; }
|
||
12% { opacity: 1; }
|
||
82% { transform: translate(var(--dx), var(--dy)) rotate(-35deg) scale(1); opacity: 1; }
|
||
100% { transform: translate(var(--dx), var(--dy)) rotate(-35deg) scale(1.8); opacity: 0; }
|
||
}
|
||
@keyframes banner {
|
||
0% { opacity: 0; transform: scale(.76) translateY(14px); }
|
||
16%, 78% { opacity: 1; transform: scale(1) translateY(0); }
|
||
100% { opacity: 0; transform: scale(1.03) translateY(-7px); }
|
||
}
|
||
@keyframes fall {
|
||
to { transform: translate3d(var(--drift), calc(100svh + 70px), 0) rotate(720deg); opacity: .15; }
|
||
}
|
||
|
||
@media (min-width: 700px) {
|
||
body { padding: 20px 0; }
|
||
.app { min-height: min(900px, calc(100svh - 40px)); border: 1px solid rgba(255,255,255,.08); border-radius: 28px; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="app">
|
||
<div class="meteor-layer" id="meteorLayer" aria-hidden="true"></div>
|
||
<div class="celebration-layer" id="celebrationLayer" aria-hidden="true"></div>
|
||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||
|
||
<section class="setup-mask" id="setupMask" aria-live="polite">
|
||
<div class="setup-card">
|
||
<div class="setup-orbit">☄️</div>
|
||
<h2>准备真实联调数据</h2>
|
||
<p id="setupStatus">正在创建用户、语音房并设置 1,000,000 金币…</p>
|
||
<button class="retry-button" id="retryButton" type="button">重试</button>
|
||
</div>
|
||
</section>
|
||
|
||
<header class="topbar">
|
||
<div class="room-title">
|
||
<h1 id="roomName">星河幸运房</h1>
|
||
<div class="room-meta"><span class="live-dot"></span><span>真实房间</span><span>·</span><span id="roomID">准备中</span></div>
|
||
</div>
|
||
<button class="icon-button" id="resetButton" type="button" aria-label="重置测试数据" title="重置为 100 万金币">↻</button>
|
||
</header>
|
||
|
||
<section class="strategy-strip" aria-label="幸运礼物策略状态">
|
||
<span><strong id="strategyName">dynamic_v3</strong> · 实时结算</span>
|
||
<span id="rtpValue">RTP --</span>
|
||
<span id="ruleVersion">规则 --</span>
|
||
</section>
|
||
|
||
<section class="room-stage" aria-label="语音房麦位">
|
||
<div class="seats">
|
||
<article class="seat host" id="hostSeat">
|
||
<div class="avatar">🎙️</div>
|
||
<div class="seat-name" id="hostName">星河主播</div>
|
||
<div class="seat-id" id="hostID">UID --</div>
|
||
<span class="mic">♫</span>
|
||
</article>
|
||
|
||
<article class="seat sender" id="senderSeat">
|
||
<div class="avatar">🧑🏻</div>
|
||
<div class="seat-name" id="senderName">幸运玩家</div>
|
||
<div class="seat-id" id="senderID">UID --</div>
|
||
<span class="mic">♫</span>
|
||
</article>
|
||
|
||
<article class="seat empty" style="grid-column:2;grid-row:2"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
<article class="seat empty" style="grid-column:3;grid-row:2"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
<article class="seat empty" style="grid-column:4;grid-row:2"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
<article class="seat empty" style="grid-column:5;grid-row:2"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
<article class="seat empty" style="grid-column:1;grid-row:3"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
<article class="seat empty" style="grid-column:2;grid-row:3"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
<article class="seat empty" style="grid-column:4;grid-row:3"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
<article class="seat empty" style="grid-column:5;grid-row:3"><div class="avatar">+</div><div class="seat-name">空麦</div></article>
|
||
</div>
|
||
<div class="activity-feed" id="activityFeed" aria-live="polite"></div>
|
||
</section>
|
||
|
||
<section class="drawer" aria-label="礼物面板">
|
||
<div class="drawer-head">
|
||
<span class="drawer-title">幸运礼物</span>
|
||
<span class="balance"><span class="coin">◆</span><span id="balance">--</span></span>
|
||
</div>
|
||
|
||
<article class="gift-card">
|
||
<div class="gift-visual">☄️</div>
|
||
<div class="gift-info">
|
||
<h2 id="giftName">幸运流星</h2>
|
||
<p><strong id="giftPrice">100</strong> 金币 / 个 · <span id="eligibility">资格检查中</span></p>
|
||
</div>
|
||
</article>
|
||
|
||
<div class="count-chips" id="countChips" aria-label="礼物数量">
|
||
<button class="count-chip active" type="button" data-count="1">× 1</button>
|
||
<button class="count-chip" type="button" data-count="9">× 9</button>
|
||
<button class="count-chip" type="button" data-count="99">× 99</button>
|
||
<button class="count-chip" type="button" data-count="999">× 999</button>
|
||
</div>
|
||
|
||
<div class="send-row">
|
||
<div class="send-meta">
|
||
<div class="target">送给 <strong id="targetName">星河主播</strong></div>
|
||
<div class="cost">本次 <span id="totalCost">100</span> 金币</div>
|
||
</div>
|
||
<button class="send-button" id="sendButton" type="button" disabled>送出幸运流星</button>
|
||
</div>
|
||
|
||
<details class="debug">
|
||
<summary>真实接口数据</summary>
|
||
<pre id="debugOutput">尚未送礼</pre>
|
||
</details>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
(() => {
|
||
"use strict";
|
||
|
||
const state = {
|
||
fixture: null,
|
||
count: 1,
|
||
busy: false,
|
||
eligible: false,
|
||
lastResponse: null,
|
||
heartbeatTimer: null,
|
||
};
|
||
|
||
const elements = Object.fromEntries([
|
||
"setupMask", "setupStatus", "retryButton", "resetButton", "roomName", "roomID",
|
||
"strategyName", "rtpValue", "ruleVersion", "hostSeat", "hostName", "hostID",
|
||
"senderSeat", "senderName", "senderID", "activityFeed", "balance", "giftName",
|
||
"giftPrice", "eligibility", "countChips", "targetName", "totalCost", "sendButton",
|
||
"debugOutput", "meteorLayer", "celebrationLayer", "toast",
|
||
].map((id) => [id, document.getElementById(id)]));
|
||
|
||
const formatCoins = (value) => new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
function randomHex(bytes = 6) {
|
||
const data = new Uint8Array(bytes);
|
||
crypto.getRandomValues(data);
|
||
return [...data].map((item) => item.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
function nextID(prefix) {
|
||
return `web-${prefix}-${Date.now()}-${randomHex()}`;
|
||
}
|
||
|
||
async function requestJSON(path, options = {}) {
|
||
const response = await fetch(path, options);
|
||
let payload;
|
||
try {
|
||
payload = await response.json();
|
||
} catch {
|
||
throw new Error(`${path} 返回了非 JSON 响应 (${response.status})`);
|
||
}
|
||
if (!response.ok || payload.code !== "OK") {
|
||
throw new Error(payload.message || `${path} 请求失败 (${response.status})`);
|
||
}
|
||
return payload.data;
|
||
}
|
||
|
||
async function gatewayJSON(path, { method = "GET", body, rawBody } = {}) {
|
||
if (!state.fixture) throw new Error("真实测试数据尚未准备完成");
|
||
const headers = {
|
||
"Accept": "application/json",
|
||
"Authorization": `Bearer ${state.fixture.sender_token}`,
|
||
"X-App-Code": state.fixture.app_code,
|
||
"X-App-Platform": "web",
|
||
"X-App-Language": "zh-CN",
|
||
};
|
||
const options = { method, headers };
|
||
if (body !== undefined || rawBody !== undefined) {
|
||
headers["Content-Type"] = "application/json";
|
||
options.body = rawBody !== undefined ? rawBody : JSON.stringify(body);
|
||
}
|
||
return requestJSON(path, options);
|
||
}
|
||
|
||
function showToast(message, error = false) {
|
||
elements.toast.textContent = message;
|
||
elements.toast.classList.toggle("error", error);
|
||
elements.toast.classList.add("show");
|
||
clearTimeout(showToast.timer);
|
||
showToast.timer = setTimeout(() => elements.toast.classList.remove("show"), 2600);
|
||
}
|
||
|
||
function addFeed(html) {
|
||
const item = document.createElement("div");
|
||
item.className = "feed-line";
|
||
item.innerHTML = html;
|
||
elements.activityFeed.append(item);
|
||
while (elements.activityFeed.children.length > 3) elements.activityFeed.firstElementChild.remove();
|
||
}
|
||
|
||
function safeFixtureForDisplay(fixture) {
|
||
// Token 仅保留在页面内存中用于调用真实 gateway;调试面板永不渲染认证凭据或设备标识。
|
||
return {
|
||
app_code: fixture.app_code,
|
||
room_id: fixture.room_id,
|
||
gift_id: fixture.gift_id,
|
||
pool_id: fixture.pool_id,
|
||
rule_version: fixture.rule_version,
|
||
target_rtp_ppm: fixture.target_rtp_ppm,
|
||
initial_coins: fixture.initial_coins,
|
||
sender: { user_id: fixture.sender.user_id, display_user_id: fixture.sender.display_user_id, name: fixture.sender.name },
|
||
host: { user_id: fixture.host.user_id, display_user_id: fixture.host.display_user_id, name: fixture.host.name },
|
||
};
|
||
}
|
||
|
||
function renderFixture() {
|
||
const fixture = state.fixture;
|
||
elements.roomName.textContent = fixture.room_name;
|
||
elements.roomID.textContent = fixture.room_id;
|
||
elements.hostName.textContent = fixture.host.name;
|
||
elements.hostID.textContent = `UID ${fixture.host.display_user_id}`;
|
||
elements.senderName.textContent = fixture.sender.name;
|
||
elements.senderID.textContent = `UID ${fixture.sender.display_user_id}`;
|
||
elements.targetName.textContent = fixture.host.name;
|
||
elements.giftName.textContent = fixture.gift_name;
|
||
elements.giftPrice.textContent = formatCoins(fixture.gift_price);
|
||
elements.strategyName.textContent = "dynamic_v3";
|
||
elements.rtpValue.textContent = `RTP ${(fixture.target_rtp_ppm / 10000).toFixed(0)}%`;
|
||
elements.ruleVersion.textContent = `规则 v${fixture.rule_version}`;
|
||
elements.balance.textContent = formatCoins(fixture.initial_coins);
|
||
renderCost();
|
||
elements.debugOutput.textContent = JSON.stringify({ fixture: safeFixtureForDisplay(fixture) }, null, 2);
|
||
}
|
||
|
||
function renderCost() {
|
||
if (!state.fixture) return;
|
||
elements.totalCost.textContent = formatCoins(state.fixture.gift_price * state.count);
|
||
}
|
||
|
||
async function setupFixture(reset = false) {
|
||
if (state.busy) return;
|
||
state.busy = true;
|
||
state.eligible = false;
|
||
elements.setupMask.classList.remove("hidden");
|
||
elements.retryButton.classList.remove("visible");
|
||
elements.setupStatus.textContent = reset
|
||
? "正在恢复真实钱包到 1,000,000 金币并刷新房间在线状态…"
|
||
: "正在创建用户、语音房并设置 1,000,000 金币…";
|
||
elements.sendButton.disabled = true;
|
||
try {
|
||
state.fixture = await requestJSON("/__local/setup", { method: "POST" });
|
||
renderFixture();
|
||
await checkEligibility();
|
||
await refreshBalances();
|
||
addFeed(`<strong>${state.fixture.sender.name}</strong> 进入了房间`);
|
||
elements.setupMask.classList.add("hidden");
|
||
startHeartbeat();
|
||
} catch (error) {
|
||
elements.setupStatus.textContent = error.message;
|
||
elements.retryButton.classList.add("visible");
|
||
} finally {
|
||
state.busy = false;
|
||
elements.sendButton.disabled = !state.eligible;
|
||
}
|
||
}
|
||
|
||
async function checkEligibility() {
|
||
const fixture = state.fixture;
|
||
const data = await gatewayJSON("/api/v1/activities/lucky-gifts/check", {
|
||
method: "POST",
|
||
body: { room_id: fixture.room_id, gift_id: fixture.gift_id, pool_id: fixture.pool_id },
|
||
});
|
||
state.eligible = Boolean(data.enabled);
|
||
elements.eligibility.textContent = data.enabled ? "可参与" : `不可参与:${data.reason || "未启用"}`;
|
||
if (!data.enabled) throw new Error(`幸运礼物资格检查未通过:${data.reason || "unknown"}`);
|
||
}
|
||
|
||
function rawV2SendBody(commandID, comboSessionID) {
|
||
const fixture = state.fixture;
|
||
const targetID = String(fixture.host.user_id).trim();
|
||
if (!/^\d+$/.test(targetID)) throw new Error("目标内部用户 ID 不是有效整数");
|
||
|
||
// V2 HTTP 契约要求 target_user_id 为 JSON int64,不能用字符串;JS Number 会截断雪花 ID。
|
||
// 先序列化其余字段,再把已校验的十进制文本拼成无引号 JSON 数值,确保线上真实 ID 字节不变。
|
||
const base = JSON.stringify({
|
||
room_id: fixture.room_id,
|
||
command_id: commandID,
|
||
target_type: "user",
|
||
gift_id: fixture.gift_id,
|
||
gift_count: state.count,
|
||
pool_id: fixture.pool_id,
|
||
source: "local_voice_room_html",
|
||
combo_session_id: comboSessionID,
|
||
batch_seq: 1,
|
||
});
|
||
return `${base.slice(0, -1)},"target_user_id":${targetID}}`;
|
||
}
|
||
|
||
function assertV2Settlement(v2, commandID, comboSessionID) {
|
||
if (!v2 || Number(v2.api_version) < 2) throw new Error("服务端没有返回 V2 结算结果");
|
||
if (v2.operation_status !== "committed") throw new Error(`送礼未提交:${v2.operation_status || "unknown"}`);
|
||
if (v2.command_id !== commandID || v2.combo_session_id !== comboSessionID || Number(v2.batch_seq) !== 1) {
|
||
throw new Error("V2 结算标识与本次送礼不一致");
|
||
}
|
||
}
|
||
|
||
async function sendGift() {
|
||
if (state.busy || !state.fixture || !state.eligible) return;
|
||
state.busy = true;
|
||
elements.sendButton.disabled = true;
|
||
elements.sendButton.textContent = "结算中…";
|
||
const commandID = nextID("gift");
|
||
const comboSessionID = nextID("combo");
|
||
try {
|
||
await checkEligibility();
|
||
launchMeteor();
|
||
const response = await gatewayJSON("/api/v2/rooms/gift/send", {
|
||
method: "POST",
|
||
rawBody: rawV2SendBody(commandID, comboSessionID),
|
||
});
|
||
assertV2Settlement(response.v2, commandID, comboSessionID);
|
||
state.lastResponse = response;
|
||
elements.debugOutput.textContent = JSON.stringify({
|
||
fixture: safeFixtureForDisplay(state.fixture),
|
||
settlement: response,
|
||
}, null, 2);
|
||
|
||
// 幂等重放是同一财务事实,客户端必须保持静默,不能把旧结果再次渲染成新中奖。
|
||
if (!response.v2.idempotent_replay) {
|
||
await playSettlement(response.v2);
|
||
const hits = response.v2.lucky_hits || [];
|
||
const reward = hits.reduce((sum, hit) => sum + Number(hit.reward_coins || 0), 0);
|
||
addFeed(`<strong>${state.fixture.sender.name}</strong> 送出 ${state.fixture.gift_name} × ${state.count}${reward > 0 ? `,返奖 ${formatCoins(reward)}` : ""}`);
|
||
}
|
||
await pollAuthoritativeState();
|
||
showToast(`真实送礼已提交 · ${response.v2.settled_gift_count} 个`);
|
||
} catch (error) {
|
||
showToast(error.message, true);
|
||
addFeed(`送礼失败:${escapeHTML(error.message)}`);
|
||
} finally {
|
||
state.busy = false;
|
||
elements.sendButton.disabled = !state.eligible;
|
||
elements.sendButton.textContent = "送出幸运流星";
|
||
}
|
||
}
|
||
|
||
function escapeHTML(value) {
|
||
const node = document.createElement("span");
|
||
node.textContent = String(value);
|
||
return node.innerHTML;
|
||
}
|
||
|
||
function launchMeteor() {
|
||
const from = elements.senderSeat.getBoundingClientRect();
|
||
const to = elements.hostSeat.getBoundingClientRect();
|
||
const layer = elements.meteorLayer.getBoundingClientRect();
|
||
const meteor = document.createElement("div");
|
||
meteor.className = "meteor-flight";
|
||
const x = from.left + from.width / 2 - layer.left;
|
||
const y = from.top + from.height / 2 - layer.top;
|
||
meteor.style.left = `${x}px`;
|
||
meteor.style.top = `${y}px`;
|
||
meteor.style.setProperty("--dx", `${to.left + to.width / 2 - from.left - from.width / 2}px`);
|
||
meteor.style.setProperty("--dy", `${to.top + to.height / 2 - from.top - from.height / 2}px`);
|
||
elements.meteorLayer.append(meteor);
|
||
setTimeout(() => meteor.remove(), 900);
|
||
}
|
||
|
||
async function playSettlement(v2) {
|
||
await sleep(520);
|
||
const hits = [...(v2.lucky_hits || [])].sort((left, right) => Number(left.gift_index) - Number(right.gift_index));
|
||
if (hits.length === 0) {
|
||
await showWinBanner({ reward_coins: 0, multiplier_ppm: 0, gift_index: 0 }, "本次未中奖");
|
||
return;
|
||
}
|
||
|
||
// 单次 99/999 连送可能产生大量真实命中;页面保留完整响应,只把前 8 条逐次动画化,
|
||
// 其余命中以真实汇总展示,避免几十分钟的阻塞动画拖住下一笔交易。
|
||
for (const hit of hits.slice(0, 8)) await showWinBanner(hit);
|
||
if (hits.length > 8) {
|
||
const remaining = hits.slice(8);
|
||
const reward = remaining.reduce((sum, hit) => sum + Number(hit.reward_coins || 0), 0);
|
||
await showWinBanner({ reward_coins: reward, multiplier_ppm: 0, gift_index: `${remaining.length} 次` }, "其余命中汇总");
|
||
}
|
||
}
|
||
|
||
async function showWinBanner(hit, title = "幸运命中") {
|
||
const reward = Number(hit.reward_coins || 0);
|
||
const multiplier = Number(hit.multiplier_ppm || 0) / 1_000_000;
|
||
const banner = document.createElement("div");
|
||
const big = Number(hit.multiplier_ppm || 0) >= 5_000_000 || reward >= 5_000;
|
||
banner.className = `win-banner${big ? " big" : ""}`;
|
||
banner.innerHTML = `
|
||
<div>
|
||
<div class="win-kicker">${escapeHTML(title)}</div>
|
||
<div class="win-amount">${reward > 0 ? `+ ${formatCoins(reward)} 金币` : "擦肩而过"}</div>
|
||
<div class="win-detail">${multiplier > 0 ? `${multiplier.toFixed(multiplier % 1 ? 1 : 0)}× · ` : ""}第 ${escapeHTML(hit.gift_index || 1)} 个礼物</div>
|
||
</div>`;
|
||
elements.celebrationLayer.append(banner);
|
||
requestAnimationFrame(() => banner.classList.add("show"));
|
||
if (big) rainSparks();
|
||
await sleep(1850);
|
||
banner.remove();
|
||
}
|
||
|
||
function rainSparks() {
|
||
const colors = ["#ffd86f", "#ff70bd", "#8f79ff", "#69dfff"];
|
||
for (let index = 0; index < 34; index += 1) {
|
||
const spark = document.createElement("i");
|
||
spark.className = "spark";
|
||
spark.style.left = `${Math.random() * 100}%`;
|
||
spark.style.setProperty("--spark-color", colors[index % colors.length]);
|
||
spark.style.setProperty("--drift", `${(Math.random() - .5) * 160}px`);
|
||
spark.style.setProperty("--fall-time", `${1.2 + Math.random() * 1.2}s`);
|
||
spark.style.animationDelay = `${Math.random() * .35}s`;
|
||
elements.celebrationLayer.append(spark);
|
||
setTimeout(() => spark.remove(), 2900);
|
||
}
|
||
}
|
||
|
||
async function refreshBalances() {
|
||
const data = await gatewayJSON("/api/v1/wallet/me/balances?asset_type=COIN");
|
||
const coin = (data.balances || []).find((item) => String(item.asset_type).toUpperCase() === "COIN");
|
||
elements.balance.textContent = formatCoins(coin ? coin.available_amount : 0);
|
||
}
|
||
|
||
async function refreshLocalState() {
|
||
const data = await requestJSON("/__local/state");
|
||
elements.balance.textContent = formatCoins(data.sender_balance);
|
||
return data;
|
||
}
|
||
|
||
async function pollAuthoritativeState() {
|
||
// 返奖和扣费都以钱包 owner 的查询结果为准;页面不拿本地“余额 ± 奖励”推算财务事实。
|
||
for (const delay of [0, 500, 1500, 3000]) {
|
||
if (delay) await sleep(delay);
|
||
try {
|
||
await Promise.all([refreshBalances(), refreshLocalState()]);
|
||
} catch (error) {
|
||
if (delay === 3000) throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
function startHeartbeat() {
|
||
clearInterval(state.heartbeatTimer);
|
||
state.heartbeatTimer = setInterval(async () => {
|
||
try {
|
||
await requestJSON("/__local/heartbeat", { method: "POST" });
|
||
} catch (error) {
|
||
showToast(`房间心跳失败:${error.message}`, true);
|
||
}
|
||
}, 30_000);
|
||
}
|
||
|
||
elements.countChips.addEventListener("click", (event) => {
|
||
const button = event.target.closest("button[data-count]");
|
||
if (!button || state.busy) return;
|
||
state.count = Number(button.dataset.count);
|
||
for (const chip of elements.countChips.querySelectorAll("button")) chip.classList.toggle("active", chip === button);
|
||
renderCost();
|
||
});
|
||
elements.sendButton.addEventListener("click", sendGift);
|
||
elements.retryButton.addEventListener("click", () => setupFixture(Boolean(state.fixture)));
|
||
elements.resetButton.addEventListener("click", () => setupFixture(true));
|
||
setupFixture(false);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|