优化游戏实时对局页面
This commit is contained in:
@@ -5,6 +5,7 @@ namespace app\admin\controller\game;
|
|||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
use app\common\library\admin\PushChannelConfigHelper;
|
use app\common\library\admin\PushChannelConfigHelper;
|
||||||
use app\common\service\GameLiveService;
|
use app\common\service\GameLiveService;
|
||||||
|
use app\common\service\GameRecordService;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
use Webman\Http\Request as WebmanRequest;
|
use Webman\Http\Request as WebmanRequest;
|
||||||
|
|
||||||
@@ -102,4 +103,50 @@ class Live extends Backend
|
|||||||
$okMsg = $res['msg'] ?? '';
|
$okMsg = $res['msg'] ?? '';
|
||||||
return $this->success(is_string($okMsg) ? $okMsg : '', $res);
|
return $this->success(is_string($okMsg) ? $okMsg : '', $res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏运行开关:关闭时禁止下注、派彩结束后不自动开新期,但当局仍自动开奖并结算;重新开启且无进行中局时立即创建新一期。
|
||||||
|
*/
|
||||||
|
public function runtime(WebmanRequest $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
if ($request->method() !== 'POST') {
|
||||||
|
return $this->error(__('Parameter error'));
|
||||||
|
}
|
||||||
|
$raw = $request->post('enabled');
|
||||||
|
$enabled = $raw === true || $raw === '1' || $raw === 1;
|
||||||
|
GameRecordService::setLiveRuntimeEnabled($enabled);
|
||||||
|
if ($enabled) {
|
||||||
|
GameRecordService::bootstrapPeriodWhenRuntimeEnabled();
|
||||||
|
}
|
||||||
|
return $this->success('', GameLiveService::buildSnapshot(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作废当前对局(下注/封盘阶段),填写原因后退款待开奖注单并关闭运行开关。
|
||||||
|
*/
|
||||||
|
public function voidPeriod(WebmanRequest $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
if ($request->method() !== 'POST') {
|
||||||
|
return $this->error(__('Parameter error'));
|
||||||
|
}
|
||||||
|
$recordIdRaw = $request->post('record_id');
|
||||||
|
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
||||||
|
$voidReason = $request->post('void_reason');
|
||||||
|
$reasonStr = is_string($voidReason) ? $voidReason : '';
|
||||||
|
$res = GameLiveService::voidCurrentPeriod($recordId, $reasonStr);
|
||||||
|
if (!($res['ok'] ?? false)) {
|
||||||
|
$errMsg = $res['msg'] ?? null;
|
||||||
|
return $this->error(is_string($errMsg) ? $errMsg : __('Void failed'));
|
||||||
|
}
|
||||||
|
$okMsg = $res['msg'] ?? '';
|
||||||
|
return $this->success(is_string($okMsg) ? $okMsg : '', GameLiveService::buildSnapshot(null));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use app\common\model\GameRecord;
|
|||||||
use app\common\model\UserWalletRecord;
|
use app\common\model\UserWalletRecord;
|
||||||
use app\common\service\GameHotDataCoordinator;
|
use app\common\service\GameHotDataCoordinator;
|
||||||
use app\common\service\GameHotDataRedis;
|
use app\common\service\GameHotDataRedis;
|
||||||
|
use app\common\service\GameRecordService;
|
||||||
use app\common\service\UserPushService;
|
use app\common\service\UserPushService;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
use Webman\Http\Request;
|
use Webman\Http\Request;
|
||||||
@@ -48,6 +49,7 @@ class Game extends MobileBase
|
|||||||
$user = $this->auth->getUser();
|
$user = $this->auth->getUser();
|
||||||
return $this->mobileSuccess([
|
return $this->mobileSuccess([
|
||||||
'server_time' => $now,
|
'server_time' => $now,
|
||||||
|
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||||||
'period' => [
|
'period' => [
|
||||||
'period_no' => (string) ($periodRow['period_no'] ?? ''),
|
'period_no' => (string) ($periodRow['period_no'] ?? ''),
|
||||||
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
||||||
@@ -126,6 +128,7 @@ class Game extends MobileBase
|
|||||||
$now = time();
|
$now = time();
|
||||||
$startAt = $this->intValue($periodRow['period_start_at'] ?? 0);
|
$startAt = $this->intValue($periodRow['period_start_at'] ?? 0);
|
||||||
return $this->mobileSuccess([
|
return $this->mobileSuccess([
|
||||||
|
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
|
||||||
'period_id' => $periodRow['id'],
|
'period_id' => $periodRow['id'],
|
||||||
'period_no' => $periodRow['period_no'],
|
'period_no' => $periodRow['period_no'],
|
||||||
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
||||||
@@ -168,6 +171,9 @@ class Game extends MobileBase
|
|||||||
return $this->mobileError(1003, 'Invalid parameter value');
|
return $this->mobileError(1003, 'Invalid parameter value');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!GameRecordService::isLiveRuntimeEnabled()) {
|
||||||
|
return $this->mobileError(3001, 'Game is paused');
|
||||||
|
}
|
||||||
$period = GameRecord::where('period_no', $periodNo)->find();
|
$period = GameRecord::where('period_no', $periodNo)->find();
|
||||||
if (!$period) {
|
if (!$period) {
|
||||||
return $this->mobileError(2002, 'Game period does not exist');
|
return $this->mobileError(2002, 'Game period does not exist');
|
||||||
@@ -382,6 +388,9 @@ class Game extends MobileBase
|
|||||||
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
|
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
|
||||||
return 'settling';
|
return 'settling';
|
||||||
}
|
}
|
||||||
|
if ($this->intValue($status) === 5) {
|
||||||
|
return 'void';
|
||||||
|
}
|
||||||
return 'finished';
|
return 'finished';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,14 @@ return [
|
|||||||
'Calculation failed' => 'Calculation failed',
|
'Calculation failed' => 'Calculation failed',
|
||||||
'Please enter the draw number' => 'Please enter the draw number',
|
'Please enter the draw number' => 'Please enter the draw number',
|
||||||
'Schedule failed' => 'Schedule failed',
|
'Schedule failed' => 'Schedule failed',
|
||||||
|
'Game runtime is paused' => 'Game runtime is paused',
|
||||||
|
'Void reason is required' => 'Void reason is required',
|
||||||
|
'Void reason is too long' => 'Void reason is too long',
|
||||||
|
'Void reason is too short' => 'Void reason is too short',
|
||||||
|
'Current period cannot be voided' => 'This period cannot be voided (only during betting or locked phase)',
|
||||||
|
'Void failed' => 'Void failed',
|
||||||
|
'Period voided' => 'Period voided',
|
||||||
|
'Concurrent balance update; please retry' => 'Concurrent balance update; please retry',
|
||||||
|
'Bet order state changed; please retry' => 'Bet order state changed; please retry',
|
||||||
|
'Period void refund' => 'Period void refund',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,4 +21,14 @@ return [
|
|||||||
'Calculation failed' => '计算失败',
|
'Calculation failed' => '计算失败',
|
||||||
'Please enter the draw number' => '请填写开奖号码',
|
'Please enter the draw number' => '请填写开奖号码',
|
||||||
'Schedule failed' => '预约失败',
|
'Schedule failed' => '预约失败',
|
||||||
|
'Game runtime is paused' => '游戏已暂停(运行开关关闭)',
|
||||||
|
'Void reason is required' => '请填写作废原因',
|
||||||
|
'Void reason is too long' => '作废原因过长',
|
||||||
|
'Void reason is too short' => '作废原因过短',
|
||||||
|
'Current period cannot be voided' => '当前期次不可作废(仅下注或封盘阶段可作废)',
|
||||||
|
'Void failed' => '作废失败',
|
||||||
|
'Period voided' => '本期已作废',
|
||||||
|
'Concurrent balance update; please retry' => '余额并发变更,请重试',
|
||||||
|
'Bet order state changed; please retry' => '注单状态已变更,请重试',
|
||||||
|
'Period void refund' => '期次作废退款',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
use app\common\library\game\StreakWinReward;
|
use app\common\library\game\StreakWinReward;
|
||||||
|
use app\common\model\UserWalletRecord;
|
||||||
use app\common\service\GameHotDataCoordinator;
|
use app\common\service\GameHotDataCoordinator;
|
||||||
use app\common\service\GameHotDataLock;
|
use app\common\service\GameHotDataLock;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
@@ -98,6 +99,11 @@ final class GameLiveService
|
|||||||
&& $elapsed >= $betSeconds
|
&& $elapsed >= $betSeconds
|
||||||
&& $elapsed < $periodSeconds;
|
&& $elapsed < $periodSeconds;
|
||||||
|
|
||||||
|
$runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
|
||||||
|
$hasActiveRound = GameRecordService::hasActiveRecord();
|
||||||
|
/** 关服且已无进行中局:派彩结束后的「完整维护」态(仅此时展示维护中 UI) */
|
||||||
|
$maintenanceUi = !$runtimeEnabled && !$hasActiveRound;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'record' => $record,
|
'record' => $record,
|
||||||
'bets' => array_map(static function (array $row): array {
|
'bets' => array_map(static function (array $row): array {
|
||||||
@@ -123,6 +129,9 @@ final class GameLiveService
|
|||||||
'bet_remaining_seconds' => $betRemaining,
|
'bet_remaining_seconds' => $betRemaining,
|
||||||
'payout_remaining_seconds' => $payoutRemaining,
|
'payout_remaining_seconds' => $payoutRemaining,
|
||||||
'is_payout_phase' => $status === 3,
|
'is_payout_phase' => $status === 3,
|
||||||
|
'runtime_enabled' => $runtimeEnabled,
|
||||||
|
'maintenance_ui' => $maintenanceUi,
|
||||||
|
/** 关闭游戏(维护)时仍允许完成当局、计算与预约开奖;仅阻止新用户下注与结束后自动开新期 */
|
||||||
'can_calculate' => $canCalculate,
|
'can_calculate' => $canCalculate,
|
||||||
'can_draw' => $canScheduleDraw,
|
'can_draw' => $canScheduleDraw,
|
||||||
'can_schedule_draw' => $canScheduleDraw,
|
'can_schedule_draw' => $canScheduleDraw,
|
||||||
@@ -135,6 +144,10 @@ final class GameLiveService
|
|||||||
*/
|
*/
|
||||||
private static function emptySnapshotPayload(): array
|
private static function emptySnapshotPayload(): array
|
||||||
{
|
{
|
||||||
|
$runtimeEnabled = GameRecordService::isLiveRuntimeEnabled();
|
||||||
|
$hasActiveRound = GameRecordService::hasActiveRecord();
|
||||||
|
$maintenanceUi = !$runtimeEnabled && !$hasActiveRound;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'record' => null,
|
'record' => null,
|
||||||
'bets' => [],
|
'bets' => [],
|
||||||
@@ -150,6 +163,8 @@ final class GameLiveService
|
|||||||
'bet_remaining_seconds' => 0,
|
'bet_remaining_seconds' => 0,
|
||||||
'payout_remaining_seconds' => 0,
|
'payout_remaining_seconds' => 0,
|
||||||
'is_payout_phase' => false,
|
'is_payout_phase' => false,
|
||||||
|
'runtime_enabled' => $runtimeEnabled,
|
||||||
|
'maintenance_ui' => $maintenanceUi,
|
||||||
'can_calculate' => false,
|
'can_calculate' => false,
|
||||||
'can_draw' => false,
|
'can_draw' => false,
|
||||||
'can_schedule_draw' => false,
|
'can_schedule_draw' => false,
|
||||||
@@ -449,6 +464,151 @@ final class GameLiveService
|
|||||||
self::drawResult((int) $record['id'], null);
|
self::drawResult((int) $record['id'], null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作废当前期(仅 status 为下注/封盘):待开奖注单退款,本期置为已作废,并关闭运行开关。
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, msg?: string, record?: array|null}
|
||||||
|
*/
|
||||||
|
public static function voidCurrentPeriod(?int $recordId, string $voidReason): array
|
||||||
|
{
|
||||||
|
$reason = trim($voidReason);
|
||||||
|
if ($reason === '') {
|
||||||
|
return ['ok' => false, 'msg' => __('Void reason is required')];
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strlen')) {
|
||||||
|
if (mb_strlen($reason) > 255) {
|
||||||
|
return ['ok' => false, 'msg' => __('Void reason is too long')];
|
||||||
|
}
|
||||||
|
} elseif (strlen($reason) > 255) {
|
||||||
|
return ['ok' => false, 'msg' => __('Void reason is too long')];
|
||||||
|
}
|
||||||
|
if (strlen($reason) < 2) {
|
||||||
|
return ['ok' => false, 'msg' => __('Void reason is too short')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = self::resolveRecord($recordId);
|
||||||
|
if (!$record) {
|
||||||
|
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||||
|
}
|
||||||
|
$st = (int) $record['status'];
|
||||||
|
if (!in_array($st, [0, 1], true)) {
|
||||||
|
return ['ok' => false, 'msg' => __('Current period cannot be voided')];
|
||||||
|
}
|
||||||
|
$rid = (int) $record['id'];
|
||||||
|
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, 3000);
|
||||||
|
if (!$lock['acquired']) {
|
||||||
|
return ['ok' => false, 'msg' => __('Another operation is in progress for this period; please try again later')];
|
||||||
|
}
|
||||||
|
$refundedUserIds = [];
|
||||||
|
try {
|
||||||
|
$now = time();
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$refundedUserIds = self::refundPendingBetsForPeriodLocked($rid, $now);
|
||||||
|
Db::name('game_record')->where('id', $rid)->update([
|
||||||
|
'status' => 5,
|
||||||
|
'void_reason' => $reason,
|
||||||
|
'pending_draw_number' => null,
|
||||||
|
'payout_until' => null,
|
||||||
|
'ai_locked_number' => null,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
Db::commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
|
||||||
|
}
|
||||||
|
GameRecordService::setLiveRuntimeEnabled(false);
|
||||||
|
GameHotDataCoordinator::afterGameRecordCommitted($rid);
|
||||||
|
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_LIVE_RUNTIME);
|
||||||
|
foreach ($refundedUserIds as $uid) {
|
||||||
|
if ($uid > 0) {
|
||||||
|
GameHotDataCoordinator::afterUserCommitted($uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::publishSnapshot(null);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'msg' => __('Period voided'),
|
||||||
|
'record' => self::reloadRecord($rid),
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array
|
||||||
|
{
|
||||||
|
$userIdSet = [];
|
||||||
|
$bets = Db::name('bet_order')
|
||||||
|
->where('period_id', $periodId)
|
||||||
|
->where('status', 1)
|
||||||
|
->order('id', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
foreach ($bets as $bet) {
|
||||||
|
$betId = (int) ($bet['id'] ?? 0);
|
||||||
|
$userId = (int) ($bet['user_id'] ?? 0);
|
||||||
|
$totalRaw = $bet['total_amount'] ?? '0';
|
||||||
|
$total = is_string($totalRaw) ? $totalRaw : (string) $totalRaw;
|
||||||
|
if ($betId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($userId <= 0 || bccomp($total, '0', 4) <= 0) {
|
||||||
|
Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||||
|
'status' => 3,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||||||
|
$after = bcadd($before, $total, 4);
|
||||||
|
$u = Db::name('user')->where('id', $userId)->where('coin', $before)->update([
|
||||||
|
'coin' => $after,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
if ($u !== 1) {
|
||||||
|
throw new \RuntimeException((string) __('Concurrent balance update; please retry'));
|
||||||
|
}
|
||||||
|
$bo = Db::name('bet_order')->where('id', $betId)->where('status', 1)->update([
|
||||||
|
'status' => 3,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
if ($bo !== 1) {
|
||||||
|
throw new \RuntimeException((string) __('Bet order state changed; please retry'));
|
||||||
|
}
|
||||||
|
$channelIdRaw = $bet['channel_id'] ?? null;
|
||||||
|
$channelId = filter_var($channelIdRaw, FILTER_VALIDATE_INT);
|
||||||
|
if ($channelId === false) {
|
||||||
|
$channelId = null;
|
||||||
|
}
|
||||||
|
UserWalletRecord::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'biz_type' => 'bet_void',
|
||||||
|
'direction' => 1,
|
||||||
|
'amount' => $total,
|
||||||
|
'balance_before' => $before,
|
||||||
|
'balance_after' => $after,
|
||||||
|
'ref_type' => 'bet_order',
|
||||||
|
'remark' => (string) __('Period void refund'),
|
||||||
|
'create_time' => $now,
|
||||||
|
]);
|
||||||
|
$userIdSet[$userId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach (array_keys($userIdSet) as $uid) {
|
||||||
|
$out[] = (int) $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
public static function publishSnapshot(?int $recordId = null): void
|
public static function publishSnapshot(?int $recordId = null): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -575,6 +735,9 @@ final class GameLiveService
|
|||||||
*/
|
*/
|
||||||
private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string
|
private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string
|
||||||
{
|
{
|
||||||
|
if ($dbStatus === 5) {
|
||||||
|
return 'void';
|
||||||
|
}
|
||||||
if ($dbStatus === 0) {
|
if ($dbStatus === 0) {
|
||||||
return $betCloseIn > 0 ? 'betting' : 'locked';
|
return $betCloseIn > 0 ? 'betting' : 'locked';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ final class GameRecordService
|
|||||||
|
|
||||||
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
|
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
|
||||||
|
|
||||||
|
/** 后台「游戏实时对局」运行开关:0=暂停自动开奖与派彩后自动创建下一期;1=运行 */
|
||||||
|
public const KEY_LIVE_RUNTIME = 'game_live_runtime_enabled';
|
||||||
|
|
||||||
private const ACTIVE_STATUSES = [0, 1, 2, 3];
|
private const ACTIVE_STATUSES = [0, 1, 2, 3];
|
||||||
|
|
||||||
public static function getConfigBool(string $key): bool
|
public static function getConfigBool(string $key): bool
|
||||||
@@ -50,6 +53,9 @@ final class GameRecordService
|
|||||||
|
|
||||||
public static function tickAutoCreate(): void
|
public static function tickAutoCreate(): void
|
||||||
{
|
{
|
||||||
|
if (!self::isLiveRuntimeEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
|
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,12 +86,55 @@ final class GameRecordService
|
|||||||
|
|
||||||
public static function createNextRecordAfterDraw(): ?string
|
public static function createNextRecordAfterDraw(): ?string
|
||||||
{
|
{
|
||||||
|
if (!self::isLiveRuntimeEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (self::hasActiveRecord()) {
|
if (self::hasActiveRecord()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return self::createNextRecordRow();
|
return self::createNextRecordRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未配置键时视为开启(兼容旧库未跑迁移)。
|
||||||
|
*/
|
||||||
|
public static function isLiveRuntimeEnabled(): bool
|
||||||
|
{
|
||||||
|
$row = GameHotDataRedis::gameConfigRow(self::KEY_LIVE_RUNTIME);
|
||||||
|
if ($row === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$v = $row['config_value'] ?? '';
|
||||||
|
return $v === '1' || $v === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setLiveRuntimeEnabled(bool $enabled): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$v = $enabled ? '1' : '0';
|
||||||
|
self::upsertConfig(
|
||||||
|
self::KEY_LIVE_RUNTIME,
|
||||||
|
$v,
|
||||||
|
'int',
|
||||||
|
'后台「游戏实时对局」运行开关:0=维护(禁止下注、结束后不自动开新期,当局仍自动开奖并结算);1=运行',
|
||||||
|
$now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新开启游戏时:若无进行中/未结清对局,则立即创建新一期(与定时任务「无局时自动创建」语义一致,供开关打开时立刻开局)。
|
||||||
|
*/
|
||||||
|
public static function bootstrapPeriodWhenRuntimeEnabled(): void
|
||||||
|
{
|
||||||
|
if (self::hasActiveRecord()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
self::createNextRecordRow();
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static function createNextRecordRow(): string
|
private static function createNextRecordRow(): string
|
||||||
{
|
{
|
||||||
$periodNo = self::generatePeriodNo();
|
$periodNo = self::generatePeriodNo();
|
||||||
|
|||||||
@@ -26,4 +26,17 @@ export default {
|
|||||||
pick_numbers: 'Pick numbers',
|
pick_numbers: 'Pick numbers',
|
||||||
total_amount: 'Total bet amount',
|
total_amount: 'Total bet amount',
|
||||||
streak_at_bet: 'Streak at bet',
|
streak_at_bet: 'Streak at bet',
|
||||||
|
runtime_switch: 'Game runtime',
|
||||||
|
countdown_maintenance: 'Maintenance',
|
||||||
|
runtime_draining_banner:
|
||||||
|
'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.',
|
||||||
|
runtime_maintenance_banner:
|
||||||
|
'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.',
|
||||||
|
runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.',
|
||||||
|
void_btn: 'Void round',
|
||||||
|
void_dialog_title: 'Void current round',
|
||||||
|
void_reason_label: 'Reason',
|
||||||
|
void_reason_placeholder: 'Enter the reason (stored on the record; pending bets will be refunded).',
|
||||||
|
void_submit: 'Confirm void',
|
||||||
|
void_reason_too_short: 'Reason must be at least 2 characters',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,16 @@ export default {
|
|||||||
pick_numbers: '压注号码',
|
pick_numbers: '压注号码',
|
||||||
total_amount: '压注总额',
|
total_amount: '压注总额',
|
||||||
streak_at_bet: '下注时连胜',
|
streak_at_bet: '下注时连胜',
|
||||||
|
runtime_switch: '游戏运行',
|
||||||
|
countdown_maintenance: '维护中',
|
||||||
|
runtime_draining_banner:
|
||||||
|
'已关闭游戏:当前局将正常进行至开奖、结算并完成派彩;全部结束后进入维护模式(倒计时与操作区将切换为维护中)。',
|
||||||
|
runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「游戏运行」恢复;若无进行中的局将自动创建新一期。',
|
||||||
|
runtime_off_tip: '开启「游戏运行」后,若无进行中的局将立即创建新一期。',
|
||||||
|
void_btn: '作废本局',
|
||||||
|
void_dialog_title: '作废本局',
|
||||||
|
void_reason_label: '作废原因',
|
||||||
|
void_reason_placeholder: '请填写本期作废原因(将写入对局记录并退款待开奖注单)',
|
||||||
|
void_submit: '确认作废',
|
||||||
|
void_reason_too_short: '作废原因至少 2 个字符',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,33 @@
|
|||||||
<div class="default-main">
|
<div class="default-main">
|
||||||
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
|
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
|
||||||
<el-alert :type="pushConnected ? 'success' : 'error'" :title="pushConnected ? t('game.live.push_connected') : t('game.live.push_disconnected')" show-icon class="mb-12" />
|
<el-alert :type="pushConnected ? 'success' : 'error'" :title="pushConnected ? t('game.live.push_connected') : t('game.live.push_disconnected')" show-icon class="mb-12" />
|
||||||
|
<el-alert
|
||||||
|
v-if="snapshot.runtime_enabled === false && !snapshot.maintenance_ui"
|
||||||
|
type="warning"
|
||||||
|
:title="t('game.live.runtime_draining_banner')"
|
||||||
|
show-icon
|
||||||
|
class="mb-12"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="snapshot.maintenance_ui"
|
||||||
|
type="warning"
|
||||||
|
:title="t('game.live.runtime_maintenance_banner')"
|
||||||
|
show-icon
|
||||||
|
class="mb-12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="live-top-toolbar">
|
||||||
|
<div class="live-top-toolbar__row">
|
||||||
|
<span class="live-top-toolbar__label">{{ t('game.live.runtime_switch') }}</span>
|
||||||
|
<el-switch
|
||||||
|
:model-value="snapshot.runtime_enabled"
|
||||||
|
:loading="runtimeSwitchLoading"
|
||||||
|
:disabled="runtimeSwitchLoading || voidSubmitting"
|
||||||
|
@change="onRuntimeSwitch"
|
||||||
|
/>
|
||||||
|
<span v-if="snapshot.maintenance_ui" class="live-top-toolbar__hint">{{ t('game.live.runtime_off_tip') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" class="mb-12 live-control-card">
|
<el-card shadow="never" class="mb-12 live-control-card">
|
||||||
<el-alert v-if="snapshot.is_payout_phase" type="warning" :title="t('game.live.payout_phase')" show-icon class="mb-12" />
|
<el-alert v-if="snapshot.is_payout_phase" type="warning" :title="t('game.live.payout_phase')" show-icon class="mb-12" />
|
||||||
@@ -21,7 +48,10 @@
|
|||||||
|
|
||||||
<div class="countdown-block">
|
<div class="countdown-block">
|
||||||
<div class="countdown-block__title">{{ t('game.live.countdown') }}</div>
|
<div class="countdown-block__title">{{ t('game.live.countdown') }}</div>
|
||||||
<div class="countdown-cards">
|
<div v-if="snapshot.maintenance_ui" class="countdown-maintenance">
|
||||||
|
{{ t('game.live.countdown_maintenance') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="countdown-cards">
|
||||||
<div class="cd-card">
|
<div class="cd-card">
|
||||||
<span class="cd-card__label">{{ t('game.live.bet_countdown') }}</span>
|
<span class="cd-card__label">{{ t('game.live.bet_countdown') }}</span>
|
||||||
<span class="cd-card__val">{{ countdownParts.bet }}s</span>
|
<span class="cd-card__val">{{ countdownParts.bet }}s</span>
|
||||||
@@ -49,8 +79,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="live-control-aside">
|
<aside class="live-control-aside" :class="{ 'is-locked': asideOperationLocked }">
|
||||||
<div class="aside-title">{{ t('game.live.action_panel') }}</div>
|
<div class="aside-title">{{ t('game.live.action_panel') }}</div>
|
||||||
|
<el-button
|
||||||
|
class="aside-void-btn"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:disabled="asideOperationLocked || !canVoidPeriod || voidSubmitting || runtimeSwitchLoading"
|
||||||
|
@click="openVoidDialog"
|
||||||
|
>
|
||||||
|
{{ t('game.live.void_btn') }}
|
||||||
|
</el-button>
|
||||||
<div class="aside-field">
|
<div class="aside-field">
|
||||||
<span class="aside-field__label">{{ t('game.live.manual_draw_number') }}</span>
|
<span class="aside-field__label">{{ t('game.live.manual_draw_number') }}</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -59,17 +98,27 @@
|
|||||||
:min="1"
|
:min="1"
|
||||||
:max="snapshot.draw_number_max ?? 36"
|
:max="snapshot.draw_number_max ?? 36"
|
||||||
:step="1"
|
:step="1"
|
||||||
|
:disabled="asideOperationLocked"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="aside-btns">
|
<div class="aside-btns">
|
||||||
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
|
<el-button
|
||||||
|
:loading="calcLoading"
|
||||||
|
:disabled="asideOperationLocked || !snapshot.can_calculate"
|
||||||
|
@click="onCalculate"
|
||||||
|
>
|
||||||
{{ t('game.live.btn_calc') }}
|
{{ t('game.live.btn_calc') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" :loading="drawLoading" :disabled="!snapshot.can_schedule_draw" @click="onDraw">
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="drawLoading"
|
||||||
|
:disabled="asideOperationLocked || !snapshot.can_schedule_draw"
|
||||||
|
@click="onDraw"
|
||||||
|
>
|
||||||
{{ t('game.live.btn_draw') }}
|
{{ t('game.live.btn_draw') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
<el-button :loading="loading" :disabled="asideOperationLocked" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,12 +151,45 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="voidDialogVisible"
|
||||||
|
class="live-void-dialog"
|
||||||
|
:title="t('game.live.void_dialog_title')"
|
||||||
|
width="520px"
|
||||||
|
align-center
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@closed="voidReason = ''"
|
||||||
|
>
|
||||||
|
<el-form class="live-void-form" label-position="top" @submit.prevent>
|
||||||
|
<el-form-item :label="t('game.live.void_reason_label')">
|
||||||
|
<el-input
|
||||||
|
v-model="voidReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 12 }"
|
||||||
|
:placeholder="t('game.live.void_reason_placeholder')"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="live-void-footer">
|
||||||
|
<el-button @click="voidDialogVisible = false">{{ t('Cancel') }}</el-button>
|
||||||
|
<el-button type="primary" :loading="voidSubmitting" @click="submitVoidPeriod">{{ t('game.live.void_submit') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import createAxios, { getPushScriptUrl } from '/@/utils/axios'
|
import createAxios, { getPushScriptUrl } from '/@/utils/axios'
|
||||||
|
|
||||||
interface Snapshot {
|
interface Snapshot {
|
||||||
@@ -129,6 +211,10 @@ interface Snapshot {
|
|||||||
can_draw?: boolean
|
can_draw?: boolean
|
||||||
can_schedule_draw?: boolean
|
can_schedule_draw?: boolean
|
||||||
server_time?: number
|
server_time?: number
|
||||||
|
/** 游戏运行开关:false 表示已关服(当局仍可进行至派彩结束) */
|
||||||
|
runtime_enabled?: boolean
|
||||||
|
/** 完整维护 UI:关服且当前无进行中/未结清对局(派彩已全部完成) */
|
||||||
|
maintenance_ui?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -151,9 +237,15 @@ const snapshot = reactive<Snapshot>({
|
|||||||
can_calculate: false,
|
can_calculate: false,
|
||||||
can_draw: false,
|
can_draw: false,
|
||||||
can_schedule_draw: false,
|
can_schedule_draw: false,
|
||||||
|
runtime_enabled: true,
|
||||||
|
maintenance_ui: false,
|
||||||
})
|
})
|
||||||
const calcLoading = ref(false)
|
const calcLoading = ref(false)
|
||||||
const drawLoading = ref(false)
|
const drawLoading = ref(false)
|
||||||
|
const runtimeSwitchLoading = ref(false)
|
||||||
|
const voidDialogVisible = ref(false)
|
||||||
|
const voidReason = ref('')
|
||||||
|
const voidSubmitting = ref(false)
|
||||||
const manualNumber = ref<number | null>(1)
|
const manualNumber = ref<number | null>(1)
|
||||||
const calcResultNumber = ref<number | null>(null)
|
const calcResultNumber = ref<number | null>(null)
|
||||||
const calcEstimatedLoss = ref<string>('0.0000')
|
const calcEstimatedLoss = ref<string>('0.0000')
|
||||||
@@ -175,6 +267,47 @@ function formatPicks(v: unknown): string {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canVoidPeriod = computed(() => {
|
||||||
|
const r = snapshot.record
|
||||||
|
if (!r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const s = Number(r.status)
|
||||||
|
return s === 0 || s === 1
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */
|
||||||
|
const asideOperationLocked = computed(() => snapshot.maintenance_ui === true)
|
||||||
|
|
||||||
|
function mergeLiveSnapshot(data: anyObj): void {
|
||||||
|
if (data.record !== undefined) {
|
||||||
|
snapshot.record = data.record
|
||||||
|
}
|
||||||
|
snapshot.bets = data.bets || []
|
||||||
|
snapshot.candidate_numbers = data.candidate_numbers || []
|
||||||
|
snapshot.ai_default_number = data.ai_default_number ?? null
|
||||||
|
snapshot.pending_draw_number = typeof data.pending_draw_number === 'number' ? data.pending_draw_number : null
|
||||||
|
snapshot.period_seconds = data.period_seconds ?? 30
|
||||||
|
snapshot.bet_seconds = data.bet_seconds ?? 20
|
||||||
|
snapshot.pick_max_number_count = data.pick_max_number_count ?? 10
|
||||||
|
snapshot.draw_number_max = data.draw_number_max ?? 36
|
||||||
|
snapshot.remaining_seconds = data.remaining_seconds ?? 0
|
||||||
|
snapshot.bet_remaining_seconds = data.bet_remaining_seconds ?? 0
|
||||||
|
snapshot.payout_remaining_seconds = data.payout_remaining_seconds ?? 0
|
||||||
|
snapshot.is_payout_phase = !!data.is_payout_phase
|
||||||
|
snapshot.can_calculate = !!data.can_calculate
|
||||||
|
snapshot.can_draw = !!data.can_draw
|
||||||
|
snapshot.can_schedule_draw = !!(data.can_schedule_draw || data.can_draw)
|
||||||
|
if (typeof data.runtime_enabled === 'boolean') {
|
||||||
|
snapshot.runtime_enabled = data.runtime_enabled
|
||||||
|
}
|
||||||
|
if (typeof data.maintenance_ui === 'boolean') {
|
||||||
|
snapshot.maintenance_ui = data.maintenance_ui
|
||||||
|
}
|
||||||
|
syncServerClock(data.server_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function syncServerClock(serverTime: unknown): void {
|
function syncServerClock(serverTime: unknown): void {
|
||||||
if (typeof serverTime === 'number' && Number.isFinite(serverTime)) {
|
if (typeof serverTime === 'number' && Number.isFinite(serverTime)) {
|
||||||
serverSkewSeconds.value = serverTime - Math.floor(Date.now() / 1000)
|
serverSkewSeconds.value = serverTime - Math.floor(Date.now() / 1000)
|
||||||
@@ -219,24 +352,7 @@ async function loadSnapshot() {
|
|||||||
try {
|
try {
|
||||||
const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false })
|
const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false })
|
||||||
if (res.code === 1 && res.data) {
|
if (res.code === 1 && res.data) {
|
||||||
snapshot.record = res.data.record
|
mergeLiveSnapshot(res.data as anyObj)
|
||||||
snapshot.bets = res.data.bets || []
|
|
||||||
snapshot.candidate_numbers = res.data.candidate_numbers || []
|
|
||||||
snapshot.ai_default_number = res.data.ai_default_number
|
|
||||||
snapshot.pending_draw_number =
|
|
||||||
typeof res.data.pending_draw_number === 'number' ? res.data.pending_draw_number : null
|
|
||||||
snapshot.period_seconds = res.data.period_seconds ?? 30
|
|
||||||
snapshot.bet_seconds = res.data.bet_seconds ?? 20
|
|
||||||
snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10
|
|
||||||
snapshot.draw_number_max = res.data.draw_number_max ?? 36
|
|
||||||
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
|
|
||||||
snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0
|
|
||||||
snapshot.payout_remaining_seconds = res.data.payout_remaining_seconds ?? 0
|
|
||||||
snapshot.is_payout_phase = !!res.data.is_payout_phase
|
|
||||||
snapshot.can_calculate = !!res.data.can_calculate
|
|
||||||
snapshot.can_draw = !!res.data.can_draw
|
|
||||||
snapshot.can_schedule_draw = !!res.data.can_schedule_draw || !!res.data.can_draw
|
|
||||||
syncServerClock(res.data.server_time)
|
|
||||||
const dmax = res.data.draw_number_max ?? 36
|
const dmax = res.data.draw_number_max ?? 36
|
||||||
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1
|
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1
|
||||||
}
|
}
|
||||||
@@ -245,6 +361,62 @@ async function loadSnapshot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onRuntimeSwitch(val: boolean | string | number): void {
|
||||||
|
const on = val === true || val === 'true' || val === 1
|
||||||
|
runtimeSwitchLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await createAxios({
|
||||||
|
url: '/admin/game.Live/runtime',
|
||||||
|
method: 'post',
|
||||||
|
data: { enabled: on ? 1 : 0 },
|
||||||
|
showSuccessMessage: true,
|
||||||
|
})
|
||||||
|
if (res.code === 1 && res.data) {
|
||||||
|
mergeLiveSnapshot(res.data as anyObj)
|
||||||
|
} else {
|
||||||
|
await loadSnapshot()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await loadSnapshot()
|
||||||
|
} finally {
|
||||||
|
runtimeSwitchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVoidDialog(): void {
|
||||||
|
voidReason.value = ''
|
||||||
|
voidDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitVoidPeriod(): Promise<void> {
|
||||||
|
const reason = voidReason.value.trim()
|
||||||
|
if (reason.length < 2) {
|
||||||
|
ElMessage.warning(t('game.live.void_reason_too_short'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!snapshot.record) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voidSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const res = await createAxios({
|
||||||
|
url: '/admin/game.Live/voidPeriod',
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
record_id: snapshot.record.id,
|
||||||
|
void_reason: reason,
|
||||||
|
},
|
||||||
|
showSuccessMessage: true,
|
||||||
|
})
|
||||||
|
if (res.code === 1 && res.data) {
|
||||||
|
mergeLiveSnapshot(res.data as anyObj)
|
||||||
|
}
|
||||||
|
voidDialogVisible.value = false
|
||||||
|
} finally {
|
||||||
|
voidSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initPush() {
|
async function initPush() {
|
||||||
const cfgRes = await createAxios({ url: '/admin/game.Live/pushConfig', method: 'get', showCodeMessage: false })
|
const cfgRes = await createAxios({ url: '/admin/game.Live/pushConfig', method: 'get', showCodeMessage: false })
|
||||||
if (cfgRes.code !== 1 || !cfgRes.data) {
|
if (cfgRes.code !== 1 || !cfgRes.data) {
|
||||||
@@ -277,24 +449,7 @@ async function initPush() {
|
|||||||
stopPolling()
|
stopPolling()
|
||||||
pushChannel.on(event, (payload: anyObj) => {
|
pushChannel.on(event, (payload: anyObj) => {
|
||||||
pushConnected.value = true
|
pushConnected.value = true
|
||||||
snapshot.record = payload.record || null
|
mergeLiveSnapshot(payload)
|
||||||
snapshot.bets = payload.bets || []
|
|
||||||
snapshot.candidate_numbers = payload.candidate_numbers || []
|
|
||||||
snapshot.ai_default_number = payload.ai_default_number ?? null
|
|
||||||
snapshot.pending_draw_number =
|
|
||||||
typeof payload.pending_draw_number === 'number' ? payload.pending_draw_number : null
|
|
||||||
snapshot.period_seconds = payload.period_seconds ?? 30
|
|
||||||
snapshot.bet_seconds = payload.bet_seconds ?? 20
|
|
||||||
snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10
|
|
||||||
snapshot.draw_number_max = payload.draw_number_max ?? 36
|
|
||||||
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
|
|
||||||
snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0
|
|
||||||
snapshot.payout_remaining_seconds = payload.payout_remaining_seconds ?? 0
|
|
||||||
snapshot.is_payout_phase = !!payload.is_payout_phase
|
|
||||||
snapshot.can_calculate = !!payload.can_calculate
|
|
||||||
snapshot.can_draw = !!payload.can_draw
|
|
||||||
snapshot.can_schedule_draw = !!payload.can_schedule_draw || !!payload.can_draw
|
|
||||||
syncServerClock(payload.server_time)
|
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
pushConnected.value = false
|
pushConnected.value = false
|
||||||
@@ -444,6 +599,34 @@ function stopPushWatchdog() {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-top-toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: var(--el-bg-color-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-top-toolbar__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-top-toolbar__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-top-toolbar__hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
flex: 1 1 220px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.live-control-card {
|
.live-control-card {
|
||||||
:deep(.el-card__body) {
|
:deep(.el-card__body) {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
@@ -496,6 +679,17 @@ function stopPushWatchdog() {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.countdown-maintenance {
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-color-warning-dark-2);
|
||||||
|
background: var(--el-color-warning-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
.countdown-cards {
|
.countdown-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -602,6 +796,11 @@ function stopPushWatchdog() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
|
&.is-locked {
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: grayscale(0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
@@ -620,6 +819,11 @@ function stopPushWatchdog() {
|
|||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aside-void-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.aside-field {
|
.aside-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -657,3 +861,67 @@ function stopPushWatchdog() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* Dialog teleport 到 body,独立块以便样式命中 */
|
||||||
|
.live-void-dialog.el-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: min(92vh, 720px);
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-dialog .el-dialog__header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-dialog .el-dialog__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-dialog .el-dialog__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-form .el-form-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.live-void-dialog.el-dialog {
|
||||||
|
width: calc(100vw - 24px) !important;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-dialog .el-dialog__title {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-footer {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-void-footer .el-button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user