Compare commits
4 Commits
24aab111b5
...
b019fc2a62
| Author | SHA1 | Date | |
|---|---|---|---|
| b019fc2a62 | |||
| cf9373e5c2 | |||
| 92fb40ae80 | |||
| 025cce3e3e |
@@ -19,8 +19,15 @@ DATABASE_HOSTPORT = 3306
|
||||
DATABASE_CHARSET = utf8mb4
|
||||
DATABASE_PREFIX =
|
||||
|
||||
# 缓存(config/cache.php)
|
||||
# 框架缓存驱动(config/cache.php → default,供 Think-ORM 模型 Cache、get_sys_config 等使用;与 Redis 热点缓存无关)
|
||||
# 可选值见 cache.php 中 stores,当前一般为 file
|
||||
CACHE_DRIVER = file
|
||||
|
||||
# 游戏热点数据 Redis(config/game_hot_cache.php,用户 / game_config / game_record,依赖 config/redis.php)
|
||||
GAME_HOT_CACHE_ENABLED = true
|
||||
GAME_HOT_CACHE_TTL_GAME_CONFIG = 86400
|
||||
GAME_HOT_CACHE_TTL_GAME_RECORD = 60
|
||||
GAME_HOT_CACHE_TTL_USER = 90
|
||||
|
||||
# 移动端接口鉴权(/api/v1/authToken)
|
||||
AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace app\admin\controller\config;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\game\DepositTier as DepositTierLib;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use InvalidArgumentException;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
@@ -125,6 +126,8 @@ class DepositTier extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
GameHotDataRedis::gameConfigForget(DepositTierLib::CONFIG_KEY);
|
||||
|
||||
return $this->success(__('Saved successfully'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\admin\controller\config;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\game\StreakWinReward as StreakWinRewardLib;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
@@ -115,6 +116,7 @@ class StreakWinReward extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
StreakWinRewardLib::clearCache();
|
||||
GameHotDataRedis::gameConfigForget(StreakWinRewardLib::CONFIG_KEY);
|
||||
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace app\admin\controller\config;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
|
||||
use InvalidArgumentException;
|
||||
use support\think\Db;
|
||||
@@ -125,6 +126,8 @@ class ZiHuaDictionary extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY);
|
||||
|
||||
return $this->success(__('Saved successfully'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +71,11 @@ class Live extends Backend
|
||||
$manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null;
|
||||
$res = GameLiveService::calculateResult($recordId, $manualNumber);
|
||||
if (!($res['ok'] ?? false)) {
|
||||
return $this->error((string) ($res['msg'] ?? '计算失败'));
|
||||
$errMsg = $res['msg'] ?? null;
|
||||
return $this->error(is_string($errMsg) ? $errMsg : __('Calculation failed'));
|
||||
}
|
||||
return $this->success((string) $res['msg'], $res);
|
||||
$okMsg = $res['msg'] ?? '';
|
||||
return $this->success(is_string($okMsg) ? $okMsg : '', $res);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,13 +94,15 @@ class Live extends Backend
|
||||
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
||||
$manualRaw = $request->post('manual_number');
|
||||
if (!is_numeric((string) $manualRaw)) {
|
||||
return $this->error('请填写开奖号码');
|
||||
return $this->error(__('Please enter the draw number'));
|
||||
}
|
||||
$manualNumber = (int) $manualRaw;
|
||||
$res = GameLiveService::scheduleDraw($recordId, $manualNumber);
|
||||
if (!($res['ok'] ?? false)) {
|
||||
return $this->error((string) ($res['msg'] ?? '预约失败'));
|
||||
$errMsg = $res['msg'] ?? null;
|
||||
return $this->error(is_string($errMsg) ? $errMsg : __('Schedule failed'));
|
||||
}
|
||||
return $this->success((string) $res['msg'], $res);
|
||||
$okMsg = $res['msg'] ?? '';
|
||||
return $this->success(is_string($okMsg) ? $okMsg : '', $res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace app\admin\controller\game;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use InvalidArgumentException;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
@@ -111,6 +112,8 @@ class ZiHuaDictionary extends Backend
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
|
||||
GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY);
|
||||
|
||||
return $this->success(__('Saved successfully'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,34 @@ class Auth extends \ba\Auth
|
||||
|
||||
public function getMenus(int $uid = 0): array
|
||||
{
|
||||
return parent::getMenus($uid ?: $this->id);
|
||||
$menus = parent::getMenus($uid ?: $this->id);
|
||||
// 库内 title 为中文;仅英文界面走 __()。若对 zh-cn 也 __(),Symfony 在找不到键时会 fallback 到 en,
|
||||
// 命中 admin_rule_title 后会把中文标题误译成英文。
|
||||
$localeNorm = str_replace('_', '-', strtolower(locale()));
|
||||
$toEnglish = ($localeNorm === 'en');
|
||||
|
||||
return $this->translateMenuRuleTitles($menus, $toEnglish);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 admin_rule.title 在英文界面译为英文;中文界面保持库内原文。
|
||||
* 英文映射见 app/common/lang/en/admin_rule_title.php
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $menus
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function translateMenuRuleTitles(array $menus, bool $toEnglish): array
|
||||
{
|
||||
foreach ($menus as $k => $item) {
|
||||
if (isset($item['title']) && is_string($item['title']) && $item['title'] !== '') {
|
||||
$menus[$k]['title'] = $toEnglish ? __($item['title']) : $item['title'];
|
||||
}
|
||||
if (!empty($item['children']) && is_array($item['children'])) {
|
||||
$menus[$k]['children'] = $this->translateMenuRuleTitles($item['children'], $toEnglish);
|
||||
}
|
||||
}
|
||||
|
||||
return $menus;
|
||||
}
|
||||
|
||||
public function isSuperAdmin(): bool
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace app\api\controller;
|
||||
|
||||
use app\common\library\game\ZiHuaDictionary;
|
||||
use app\common\model\BetOrder;
|
||||
use app\common\model\GameConfig;
|
||||
use app\common\model\GameRecord;
|
||||
use app\common\model\UserWalletRecord;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use app\common\service\UserPushService;
|
||||
use support\think\Db;
|
||||
use Webman\Http\Request;
|
||||
@@ -25,15 +25,15 @@ class Game extends MobileBase
|
||||
return $response;
|
||||
}
|
||||
|
||||
$period = GameRecord::order('id', 'desc')->find();
|
||||
$periodRow = GameHotDataRedis::gameRecordLatest();
|
||||
$now = time();
|
||||
$startAt = $period ? $this->intValue($period->period_start_at) : $now;
|
||||
$startAt = $periodRow ? $this->intValue($periodRow['period_start_at'] ?? 0) : $now;
|
||||
$lockAt = $startAt + 20;
|
||||
$openAt = $startAt + 22;
|
||||
$countdown = $period ? max(0, ($startAt + 30) - $now) : 0;
|
||||
$countdown = $periodRow ? max(0, ($startAt + 30) - $now) : 0;
|
||||
|
||||
$dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find();
|
||||
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null);
|
||||
$dictionaryConfig = GameHotDataRedis::gameConfigRow(ZiHuaDictionary::CONFIG_KEY) ?? [];
|
||||
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig['config_value'] ?? null);
|
||||
$items = [];
|
||||
foreach ($dictionaryItems as $row) {
|
||||
$items[] = [
|
||||
@@ -48,8 +48,8 @@ class Game extends MobileBase
|
||||
return $this->mobileSuccess([
|
||||
'server_time' => $now,
|
||||
'period' => [
|
||||
'period_no' => $period->period_no ?? '',
|
||||
'status' => $this->mapPeriodStatus($period->status ?? null),
|
||||
'period_no' => (string) ($periodRow['period_no'] ?? ''),
|
||||
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
||||
'countdown' => $countdown,
|
||||
'lock_at' => $lockAt,
|
||||
'open_at' => $openAt,
|
||||
@@ -73,8 +73,8 @@ class Game extends MobileBase
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find();
|
||||
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null);
|
||||
$dictionaryConfig = GameHotDataRedis::gameConfigRow(ZiHuaDictionary::CONFIG_KEY) ?? [];
|
||||
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig['config_value'] ?? null);
|
||||
$items = [];
|
||||
foreach ($dictionaryItems as $row) {
|
||||
$items[] = [
|
||||
@@ -85,7 +85,7 @@ class Game extends MobileBase
|
||||
];
|
||||
}
|
||||
return $this->mobileSuccess([
|
||||
'version' => (string) ($dictionaryConfig->update_time ?? '1'),
|
||||
'version' => (string) ($dictionaryConfig['update_time'] ?? '1'),
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
@@ -118,19 +118,19 @@ class Game extends MobileBase
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
$period = GameRecord::order('id', 'desc')->find();
|
||||
if (!$period) {
|
||||
$periodRow = GameHotDataRedis::gameRecordLatest();
|
||||
if (!$periodRow) {
|
||||
return $this->mobileError(2002, 'Game period does not exist');
|
||||
}
|
||||
$now = time();
|
||||
$startAt = $this->intValue($period->period_start_at);
|
||||
$startAt = $this->intValue($periodRow['period_start_at'] ?? 0);
|
||||
return $this->mobileSuccess([
|
||||
'period_id' => $period->id,
|
||||
'period_no' => $period->period_no,
|
||||
'status' => $this->mapPeriodStatus($period->status),
|
||||
'period_id' => $periodRow['id'],
|
||||
'period_no' => $periodRow['period_no'],
|
||||
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
|
||||
'countdown' => max(0, ($startAt + 30) - $now),
|
||||
'bet_close_in' => max(0, ($startAt + 20) - $now),
|
||||
'result_number' => $period->result_number,
|
||||
'result_number' => $periodRow['result_number'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -218,6 +218,10 @@ class Game extends MobileBase
|
||||
'update_time' => time(),
|
||||
]);
|
||||
Db::commit();
|
||||
$uid = filter_var($user->id, FILTER_VALIDATE_INT);
|
||||
if ($uid !== false) {
|
||||
GameHotDataRedis::userForget($uid);
|
||||
}
|
||||
UserPushService::publish((int) $user->id, UserPushService::EVT_BET_ACCEPTED, [
|
||||
'order_no' => $orderNo,
|
||||
'period_no' => (string) $period->period_no,
|
||||
@@ -355,7 +359,11 @@ class Game extends MobileBase
|
||||
|
||||
private function getConfigValue(string $key, string $default): string
|
||||
{
|
||||
$value = GameConfig::where('config_key', $key)->value('config_value');
|
||||
$row = GameHotDataRedis::gameConfigRow($key);
|
||||
if ($row === null) {
|
||||
return $default;
|
||||
}
|
||||
$value = $row['config_value'] ?? null;
|
||||
if ($value === null || $value === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
103
app/common/lang/en/admin_rule_title.php
Normal file
103
app/common/lang/en/admin_rule_title.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 后台 admin_rule.title 中文(及少数英文占位)→ 英文展示。
|
||||
* 新增菜单时若标题为中文,请在此补充映射,或在库中直接使用英文标题。
|
||||
*/
|
||||
return [
|
||||
// 通用按钮
|
||||
'查看' => 'View',
|
||||
'添加' => 'Add',
|
||||
'编辑' => 'Edit',
|
||||
'删除' => 'Delete',
|
||||
'保存' => 'Save',
|
||||
'快速排序' => 'Quick sort',
|
||||
'还原' => 'Restore',
|
||||
'回滚' => 'Rollback',
|
||||
'查看详情' => 'View details',
|
||||
'生成' => 'Generate',
|
||||
'安装' => 'Install',
|
||||
'调整状态' => 'Change state',
|
||||
'卸载' => 'Uninstall',
|
||||
'更新' => 'Update',
|
||||
'审核通过' => 'Approve',
|
||||
'审核驳回' => 'Reject',
|
||||
|
||||
// 安装包默认菜单
|
||||
'控制台' => 'Dashboard',
|
||||
'权限管理' => 'Permission management',
|
||||
'角色组管理' => 'Role groups',
|
||||
'管理员管理' => 'Administrators',
|
||||
'菜单规则管理' => 'Menu rules',
|
||||
'管理员日志管理' => 'Admin logs',
|
||||
'常规管理' => 'Routine',
|
||||
'系统配置' => 'System configuration',
|
||||
'附件管理' => 'Attachments',
|
||||
'个人资料' => 'Profile',
|
||||
'数据安全管理' => 'Data security',
|
||||
'数据回收站' => 'Data recycle bin',
|
||||
'敏感数据修改记录' => 'Sensitive data change log',
|
||||
'数据回收规则管理' => 'Data recycle rules',
|
||||
'敏感字段规则管理' => 'Sensitive field rules',
|
||||
'模块市场' => 'Module store',
|
||||
'CRUD代码生成' => 'CRUD generator',
|
||||
|
||||
// 会员端 user_rule(若出现在后台接口中)
|
||||
'我的账户' => 'My account',
|
||||
'账户概览' => 'Overview',
|
||||
'积分记录' => 'Score log',
|
||||
'余额记录' => 'Balance log',
|
||||
|
||||
// 业务扩展菜单
|
||||
'用户管理' => 'User management',
|
||||
'用户列表' => 'User list',
|
||||
'订单管理' => 'Order management',
|
||||
'游戏管理' => 'Game management',
|
||||
'配置管理' => 'Configuration',
|
||||
'游戏配置' => 'Game configuration',
|
||||
'常规配置' => 'General settings',
|
||||
'记录管理' => 'Records',
|
||||
'游戏对局' => 'Game rounds',
|
||||
'游戏对局记录' => 'Game records',
|
||||
'游戏实时对局' => 'Game live',
|
||||
'压注订单' => 'Bet orders',
|
||||
'充值订单' => 'Deposit orders',
|
||||
'提现订单' => 'Withdraw orders',
|
||||
'用户钱包流水' => 'User wallet transactions',
|
||||
'36字花字典' => '36 Zihua dictionary',
|
||||
'代理与结算' => 'Agents & settlement',
|
||||
'代理结算周期' => 'Agent settlement periods',
|
||||
'代理佣金记录' => 'Agent commission records',
|
||||
'运营' => 'Operations',
|
||||
'运营公告' => 'Operation notices',
|
||||
'用户阅读记录' => 'User read log',
|
||||
'充值档位' => 'Deposit tiers',
|
||||
'连胜奖励' => 'Win streak rewards',
|
||||
'连胜降低档位' => 'Streak reduction tiers',
|
||||
'钱包加减点' => 'Wallet adjust',
|
||||
'测试' => 'Test',
|
||||
'推送-对局公共频道' => 'Push: public game period',
|
||||
'推送-公告广播频道' => 'Push: operation notices',
|
||||
'推送-用户私有频道' => 'Push: user private',
|
||||
'渠道管理' => 'Channel management',
|
||||
|
||||
// 演示/运营公告标题(若入库为菜单展示)
|
||||
'系统维护通知(演示)' => 'Maintenance notice (demo)',
|
||||
'提现风控升级提醒(演示)' => 'Withdraw risk notice (demo)',
|
||||
'活动奖励发放说明(演示)' => 'Promotion notice (demo)',
|
||||
|
||||
// 部分按钮 title 为动作名(game/live 等迁移写入)
|
||||
'index' => 'View',
|
||||
'snapshot' => 'Snapshot',
|
||||
'pushConfig' => 'Push config',
|
||||
'recordSettings' => 'Period settings',
|
||||
'createNextManual' => 'Create next period manually',
|
||||
'periodSettings' => 'Period settings',
|
||||
'manualSettle' => 'Manual settle',
|
||||
|
||||
// 其它中文按钮文案
|
||||
'期号开关' => 'Period toggle',
|
||||
'手动创建下一期' => 'Create next period manually',
|
||||
];
|
||||
24
app/common/lang/en/game_live.php
Normal file
24
app/common/lang/en/game_live.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'No active game in progress' => 'No active game in progress',
|
||||
'Current game status does not allow calculation' => 'Current game status does not allow calculation',
|
||||
'Betting period has not ended; calculation is not available yet' => 'Betting period has not ended; calculation is not available yet',
|
||||
'Manual draw number is out of the allowed range' => 'Manual draw number is out of the allowed range',
|
||||
'Calculation completed' => 'Calculation completed',
|
||||
'Draw number is out of the allowed range' => 'Draw number is out of the allowed range',
|
||||
'Current game status does not allow scheduling the draw' => 'Current game status does not allow scheduling the draw',
|
||||
'Betting has not ended; cannot schedule the draw' => 'Betting has not ended; cannot schedule the draw',
|
||||
'This period has ended; please refresh the page' => 'This period has ended; please refresh the page',
|
||||
'Draw number scheduled; it will be used when the countdown ends' => 'Draw number scheduled; it will be used when the countdown ends',
|
||||
'Current game status does not allow drawing' => 'Current game status does not allow drawing',
|
||||
'Betting period has not ended; drawing is not available yet' => 'Betting period has not ended; drawing is not available yet',
|
||||
'Period countdown has not ended; cannot draw yet' => 'Period countdown has not ended; cannot draw yet',
|
||||
'Draw completed; paying out' => 'Draw completed; paying out',
|
||||
'Game live: settlement error' => 'Game live: settlement error',
|
||||
'Calculation failed' => 'Calculation failed',
|
||||
'Please enter the draw number' => 'Please enter the draw number',
|
||||
'Schedule failed' => 'Schedule failed',
|
||||
];
|
||||
24
app/common/lang/zh-cn/game_live.php
Normal file
24
app/common/lang/zh-cn/game_live.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'No active game in progress' => '未找到进行中的对局',
|
||||
'Current game status does not allow calculation' => '当前对局状态不可计算',
|
||||
'Betting period has not ended; calculation is not available yet' => '下注开放时长未结束,暂不可计算',
|
||||
'Manual draw number is out of the allowed range' => '手动开奖号码超出允许范围',
|
||||
'Calculation completed' => '计算完成',
|
||||
'Draw number is out of the allowed range' => '开奖号码超出允许范围',
|
||||
'Current game status does not allow scheduling the draw' => '当前对局状态不可预约开奖',
|
||||
'Betting has not ended; cannot schedule the draw' => '下注尚未结束,无法预约开奖',
|
||||
'This period has ended; please refresh the page' => '本期倒计时已结束,请刷新页面',
|
||||
'Draw number scheduled; it will be used when the countdown ends' => '已预约本期开奖号码,倒计时结束后将使用该号码开奖',
|
||||
'Current game status does not allow drawing' => '当前对局状态不可开奖',
|
||||
'Betting period has not ended; drawing is not available yet' => '下注开放时长未结束,不可开奖',
|
||||
'Period countdown has not ended; cannot draw yet' => '本期倒计时未结束,无法开奖',
|
||||
'Draw completed; paying out' => '开奖完成,派彩中',
|
||||
'Game live: settlement error' => '对局结算出错',
|
||||
'Calculation failed' => '计算失败',
|
||||
'Please enter the draw number' => '请填写开奖号码',
|
||||
'Schedule failed' => '预约失败',
|
||||
];
|
||||
@@ -7,6 +7,7 @@ use ba\Random;
|
||||
use support\think\Db;
|
||||
use app\common\model\User;
|
||||
use app\common\facade\Token;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use Webman\Http\Request;
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,7 @@ class Auth extends \ba\Auth
|
||||
Token::tokenExpirationCheck($tokenData);
|
||||
$userId = $tokenData['user_id'];
|
||||
if ($tokenData['type'] == self::TOKEN_TYPE && $userId > 0) {
|
||||
$this->model = User::where('id', $userId)->find();
|
||||
$this->model = GameHotDataRedis::userModelFromCacheOrDb($userId);
|
||||
if (!$this->model) {
|
||||
$this->setError('Account not exist');
|
||||
return false;
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\finance;
|
||||
|
||||
use support\think\Db;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
|
||||
/**
|
||||
* 提现打码量(流水)门槛工具库
|
||||
@@ -38,7 +38,7 @@ final class WithdrawFlow
|
||||
*/
|
||||
public static function ratio(): string
|
||||
{
|
||||
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY)->find();
|
||||
$row = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY);
|
||||
if (!$row) {
|
||||
return self::DEFAULT_RATIO;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\game;
|
||||
|
||||
use support\think\Db;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
|
||||
/**
|
||||
* 连胜奖励(game_config.streak_win_reward):按「连胜档位」1~10 配置赔率系数与是否大奖。
|
||||
@@ -100,7 +100,7 @@ final class StreakWinReward
|
||||
if (self::$cache !== null) {
|
||||
return self::$cache;
|
||||
}
|
||||
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY)->find();
|
||||
$row = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY);
|
||||
self::$cache = self::parseFromConfigValue($row['config_value'] ?? null);
|
||||
|
||||
return self::$cache;
|
||||
|
||||
@@ -23,33 +23,87 @@ class LoadLangPack implements MiddlewareInterface
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前请求语言。
|
||||
* - 后台 admin:优先请求头 think-lang(zh-cn / en),其次 lang 头,再次查询/表单参数 lang(支持 zh→zh-cn)。
|
||||
* - 对外 api:优先查询/表单参数 lang(zh / en),其次 lang 头,再次 think-lang。
|
||||
*/
|
||||
protected function resolveLangSet(Request $request): string
|
||||
{
|
||||
$path = trim($request->path(), '/');
|
||||
$parts = explode('/', $path);
|
||||
$app = $parts[0] ?? 'api';
|
||||
|
||||
$queryRaw = $request->get('lang');
|
||||
if ($queryRaw === null || $queryRaw === '') {
|
||||
$queryRaw = $request->post('lang');
|
||||
}
|
||||
$queryLang = is_string($queryRaw) ? $queryRaw : '';
|
||||
|
||||
$thinkRaw = $request->header('think-lang');
|
||||
$thinkLang = is_string($thinkRaw) ? $thinkRaw : '';
|
||||
|
||||
$headerLangRaw = $request->header('lang');
|
||||
$headerLang = is_string($headerLangRaw) ? $headerLangRaw : '';
|
||||
|
||||
$normalize = static function (string $raw): string {
|
||||
$s = str_replace('_', '-', strtolower(trim($raw)));
|
||||
if ($s === 'zh') {
|
||||
return 'zh-cn';
|
||||
}
|
||||
|
||||
return $s;
|
||||
};
|
||||
|
||||
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
|
||||
|
||||
$candidates = [];
|
||||
if ($app === 'admin') {
|
||||
if ($thinkLang !== '') {
|
||||
$candidates[] = $normalize($thinkLang);
|
||||
}
|
||||
if ($headerLang !== '') {
|
||||
$candidates[] = $normalize($headerLang);
|
||||
}
|
||||
if ($queryLang !== '') {
|
||||
$candidates[] = $normalize($queryLang);
|
||||
}
|
||||
} else {
|
||||
if ($queryLang !== '') {
|
||||
$candidates[] = $normalize($queryLang);
|
||||
}
|
||||
if ($headerLang !== '') {
|
||||
$candidates[] = $normalize($headerLang);
|
||||
}
|
||||
if ($thinkLang !== '') {
|
||||
$candidates[] = $normalize($thinkLang);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidates as $code) {
|
||||
if ($code !== '' && in_array($code, $allowLangList, true)) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
$acceptRaw = $request->header('accept-language');
|
||||
$acceptLang = is_string($acceptRaw) ? $acceptRaw : '';
|
||||
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
|
||||
return 'zh-cn';
|
||||
}
|
||||
if (preg_match('/^en/i', $acceptLang)) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
$defaultRaw = config('lang.default_lang', config('translation.locale', 'zh-cn'));
|
||||
$default = is_string($defaultRaw) ? $defaultRaw : 'zh-cn';
|
||||
|
||||
return str_replace('_', '-', strtolower($default));
|
||||
}
|
||||
|
||||
protected function loadLang(Request $request): void
|
||||
{
|
||||
// 优先从请求头 lang / think-lang 获取前端选择的语言
|
||||
// 支持:lang=en / lang=zh / think-lang=en / think-lang=zh-cn
|
||||
// 未发送时回退到 Accept-Language 或配置默认值
|
||||
$headerLang = $request->header('lang', '');
|
||||
if ($headerLang === '') {
|
||||
$headerLang = $request->header('think-lang', '');
|
||||
}
|
||||
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
|
||||
$normalizedHeaderLang = str_replace('_', '-', strtolower($headerLang));
|
||||
if ($normalizedHeaderLang === 'zh') {
|
||||
$normalizedHeaderLang = 'zh-cn';
|
||||
}
|
||||
if ($headerLang && in_array($normalizedHeaderLang, $allowLangList)) {
|
||||
$langSet = $normalizedHeaderLang;
|
||||
} else {
|
||||
$acceptLang = $request->header('accept-language', '');
|
||||
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
|
||||
$langSet = 'zh-cn';
|
||||
} elseif (preg_match('/^en/i', $acceptLang)) {
|
||||
$langSet = 'en';
|
||||
} else {
|
||||
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
|
||||
}
|
||||
$langSet = str_replace('_', '-', strtolower($langSet));
|
||||
}
|
||||
$langSet = $this->resolveLangSet($request);
|
||||
|
||||
// 设置当前请求的翻译语言,使 __() 和 trans() 使用正确的语言
|
||||
if (function_exists('locale')) {
|
||||
@@ -74,6 +128,12 @@ class LoadLangPack implements MiddlewareInterface
|
||||
$translator->addResource('phpfile', $rootLangFile, $langSet, 'messages');
|
||||
}
|
||||
|
||||
// 1.5 公共语言包(app/common/lang/{locale}/*.php),供 common 服务层与多应用共用
|
||||
$commonLangPattern = base_path() . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'lang' . DIRECTORY_SEPARATOR . $langSet . DIRECTORY_SEPARATOR . '*.php';
|
||||
foreach (glob($commonLangPattern) ?: [] as $commonLangFile) {
|
||||
$translator->addResource('phpfile', $commonLangFile, $langSet, 'messages');
|
||||
}
|
||||
|
||||
// 2. 加载控制器专用语言包(如 zh-cn/auth/group.php),供 get_route_remark 等使用
|
||||
// 同时加载到 messages 域,使 __() 能正确翻译控制器内的文案(如安装页错误提示)
|
||||
$controllerPath = get_controller_path($request);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
@@ -17,4 +19,26 @@ class GameConfig extends Model
|
||||
'create_time' => 'integer',
|
||||
'update_time' => 'integer',
|
||||
];
|
||||
|
||||
public static function onAfterWrite(GameConfig $model): void
|
||||
{
|
||||
$key = trim((string) ($model->getAttr('config_key') ?? ''));
|
||||
if ($key !== '') {
|
||||
GameHotDataRedis::gameConfigForget($key);
|
||||
}
|
||||
if ($key === StreakWinReward::CONFIG_KEY) {
|
||||
StreakWinReward::clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
public static function onAfterDelete(GameConfig $model): void
|
||||
{
|
||||
$key = trim((string) ($model->getAttr('config_key') ?? ''));
|
||||
if ($key !== '') {
|
||||
GameHotDataRedis::gameConfigForget($key);
|
||||
}
|
||||
if ($key === StreakWinReward::CONFIG_KEY) {
|
||||
StreakWinReward::clearCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use support\think\Model;
|
||||
|
||||
class GameRecord extends Model
|
||||
@@ -41,4 +42,16 @@ class GameRecord extends Model
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static function onAfterWrite(GameRecord $model): void
|
||||
{
|
||||
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
|
||||
GameHotDataRedis::gameRecordForget($id === false ? null : $id);
|
||||
}
|
||||
|
||||
public static function onAfterDelete(GameRecord $model): void
|
||||
{
|
||||
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
|
||||
GameHotDataRedis::gameRecordForget($id === false ? null : $id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\service\GameHotDataRedis;
|
||||
use support\think\Model;
|
||||
|
||||
/**
|
||||
@@ -62,4 +63,20 @@ class User extends Model
|
||||
{
|
||||
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword)]);
|
||||
}
|
||||
|
||||
public static function onAfterWrite(User $model): void
|
||||
{
|
||||
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
|
||||
if ($id !== false && $id > 0) {
|
||||
GameHotDataRedis::userForget($id);
|
||||
}
|
||||
}
|
||||
|
||||
public static function onAfterDelete(User $model): void
|
||||
{
|
||||
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
|
||||
if ($id !== false && $id > 0) {
|
||||
GameHotDataRedis::userForget($id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ final class GameBetSettleService
|
||||
'current_streak' => $next,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::userForget($userId);
|
||||
}
|
||||
|
||||
foreach ($aggregateByUser as $userId => $agg) {
|
||||
@@ -272,6 +273,7 @@ final class GameBetSettleService
|
||||
'bet_flow_coin' => Db::raw('bet_flow_coin + ' . $flow),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::userForget($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,6 +321,7 @@ final class GameBetSettleService
|
||||
'coin' => $after,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::userForget($userId);
|
||||
|
||||
return $after;
|
||||
}
|
||||
|
||||
279
app/common/service/GameHotDataRedis.php
Normal file
279
app/common/service/GameHotDataRedis.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\User;
|
||||
use support\Redis;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 热点数据 Redis 缓存:game_config、game_record、user 全行 JSON。
|
||||
* Redis 不可用时自动回退为直连数据库。
|
||||
*/
|
||||
final class GameHotDataRedis
|
||||
{
|
||||
private const KEY_GC = 'dfw:v1:gc:';
|
||||
|
||||
private const KEY_GR_ID = 'dfw:v1:gr:id:';
|
||||
|
||||
private const KEY_GR_ACTIVE = 'dfw:v1:gr:active';
|
||||
|
||||
private const KEY_GR_LATEST = 'dfw:v1:gr:latest';
|
||||
|
||||
private const KEY_USER = 'dfw:v1:user:';
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return config('game_hot_cache.enabled', true) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function gameConfigRow(string $configKey): ?array
|
||||
{
|
||||
if ($configKey === '') {
|
||||
return null;
|
||||
}
|
||||
if (self::enabled()) {
|
||||
$cached = self::redisGet(self::KEY_GC . $configKey);
|
||||
if ($cached !== null && $cached !== '') {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
$row = Db::name('game_config')->where('config_key', $configKey)->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (self::enabled()) {
|
||||
$ttl = self::intConfig('ttl_game_config', 86400);
|
||||
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public static function gameConfigForget(string $configKey): void
|
||||
{
|
||||
if ($configKey === '') {
|
||||
return;
|
||||
}
|
||||
self::redisDel(self::KEY_GC . $configKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function gameRecordById(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$key = self::KEY_GR_ID . $id;
|
||||
if (self::enabled()) {
|
||||
$cached = self::redisGet($key);
|
||||
if ($cached !== null && $cached !== '') {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
$row = Db::name('game_record')->where('id', $id)->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (self::enabled()) {
|
||||
$ttl = self::intConfig('ttl_game_record', 60);
|
||||
self::redisSetEx($key, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前未完全结束的对局(status ∈ 0,1,2,3 中 id 最大的一条)
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function gameRecordActive(): ?array
|
||||
{
|
||||
if (self::enabled()) {
|
||||
$cached = self::redisGet(self::KEY_GR_ACTIVE);
|
||||
if ($cached !== null && $cached !== '') {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
$row = Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (self::enabled()) {
|
||||
$ttl = self::intConfig('ttl_game_record', 60);
|
||||
self::redisSetEx(self::KEY_GR_ACTIVE, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新一条对局(含已结束),与 GameRecord::order('id','desc')->find() 一致
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function gameRecordLatest(): ?array
|
||||
{
|
||||
if (self::enabled()) {
|
||||
$cached = self::redisGet(self::KEY_GR_LATEST);
|
||||
if ($cached !== null && $cached !== '') {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
$row = Db::name('game_record')->order('id', 'desc')->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (self::enabled()) {
|
||||
$ttl = self::intConfig('ttl_game_record', 60);
|
||||
self::redisSetEx(self::KEY_GR_LATEST, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意 game_record 变更后调用:删除活跃局、最新局及指定 id 缓存
|
||||
*/
|
||||
public static function gameRecordForget(?int $id = null): void
|
||||
{
|
||||
$keys = [self::KEY_GR_ACTIVE, self::KEY_GR_LATEST];
|
||||
if ($id !== null && $id > 0) {
|
||||
$keys[] = self::KEY_GR_ID . $id;
|
||||
}
|
||||
self::redisDel(...$keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function userRow(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$key = self::KEY_USER . $userId;
|
||||
if (self::enabled()) {
|
||||
$cached = self::redisGet($key);
|
||||
if ($cached !== null && $cached !== '') {
|
||||
$decoded = json_decode($cached, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
$row = Db::name('user')->where('id', $userId)->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (self::enabled()) {
|
||||
$ttl = self::intConfig('ttl_user', 90);
|
||||
self::redisSetEx($key, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public static function userRemember(User $user): void
|
||||
{
|
||||
if (!self::enabled()) {
|
||||
return;
|
||||
}
|
||||
$id = $user->getAttr('id');
|
||||
$uid = filter_var($id, FILTER_VALIDATE_INT);
|
||||
if ($uid === false || $uid <= 0) {
|
||||
return;
|
||||
}
|
||||
$ttl = self::intConfig('ttl_user', 90);
|
||||
self::redisSetEx(self::KEY_USER . $uid, $ttl, json_encode($user->toArray(), JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public static function userForget(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
self::redisDel(self::KEY_USER . $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用缓存行构造已存在库的 User(供 Auth 等高频读)
|
||||
*/
|
||||
public static function userModelFromCacheOrDb(int $userId): ?User
|
||||
{
|
||||
$row = self::userRow($userId);
|
||||
if ($row === null) {
|
||||
return null;
|
||||
}
|
||||
return (new User())->newInstance($row);
|
||||
}
|
||||
|
||||
private static function intConfig(string $name, int $default): int
|
||||
{
|
||||
$v = config('game_hot_cache.' . $name, $default);
|
||||
if (is_int($v)) {
|
||||
return $v > 0 ? $v : $default;
|
||||
}
|
||||
if (is_numeric($v)) {
|
||||
$n = filter_var($v, FILTER_VALIDATE_INT);
|
||||
if ($n === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $n > 0 ? $n : $default;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private static function redisGet(string $key): ?string
|
||||
{
|
||||
try {
|
||||
$v = Redis::get($key);
|
||||
if ($v === false || $v === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_string($v)) {
|
||||
return null;
|
||||
}
|
||||
return $v;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function redisSetEx(string $key, int $ttl, string $value): void
|
||||
{
|
||||
try {
|
||||
Redis::setEx($key, $ttl, $value);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function redisDel(string ...$keys): void
|
||||
{
|
||||
if ($keys === []) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Redis::del(...$keys);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,27 +159,27 @@ final class GameLiveService
|
||||
{
|
||||
$record = self::resolveRecord($recordId);
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
if (!in_array((int) $record['status'], [0, 1], true)) {
|
||||
return ['ok' => false, 'msg' => '当前对局状态不可计算'];
|
||||
return ['ok' => false, 'msg' => __('Current game status does not allow calculation')];
|
||||
}
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||
if ($elapsed < $betSeconds) {
|
||||
return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算'];
|
||||
return ['ok' => false, 'msg' => __('Betting period has not ended; calculation is not available yet')];
|
||||
}
|
||||
|
||||
self::ensureAiLocked((int) $record['id']);
|
||||
$record = self::reloadRecord((int) $record['id']);
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
|
||||
$pickMax = self::getPickMaxNumberCount();
|
||||
if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX)) {
|
||||
return ['ok' => false, 'msg' => '手动开奖号码超出允许范围'];
|
||||
return ['ok' => false, 'msg' => __('Manual draw number is out of the allowed range')];
|
||||
}
|
||||
|
||||
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
|
||||
@@ -215,7 +215,7 @@ final class GameLiveService
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => '计算完成',
|
||||
'msg' => __('Calculation completed'),
|
||||
'record' => $record,
|
||||
'period_seconds' => $periodSeconds,
|
||||
'bet_seconds' => $betSeconds,
|
||||
@@ -234,23 +234,23 @@ final class GameLiveService
|
||||
public static function scheduleDraw(?int $recordId, int $manualNumber): array
|
||||
{
|
||||
if ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX) {
|
||||
return ['ok' => false, 'msg' => '开奖号码超出允许范围'];
|
||||
return ['ok' => false, 'msg' => __('Draw number is out of the allowed range')];
|
||||
}
|
||||
$record = self::resolveRecord($recordId);
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
if (!in_array((int) $record['status'], [0, 1], true)) {
|
||||
return ['ok' => false, 'msg' => '当前对局状态不可预约开奖'];
|
||||
return ['ok' => false, 'msg' => __('Current game status does not allow scheduling the draw')];
|
||||
}
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||
if ($elapsed < $betSeconds) {
|
||||
return ['ok' => false, 'msg' => '下注尚未结束,无法预约开奖'];
|
||||
return ['ok' => false, 'msg' => __('Betting has not ended; cannot schedule the draw')];
|
||||
}
|
||||
if ($elapsed >= $periodSeconds) {
|
||||
return ['ok' => false, 'msg' => '本期倒计时已结束,请刷新页面'];
|
||||
return ['ok' => false, 'msg' => __('This period has ended; please refresh the page')];
|
||||
}
|
||||
|
||||
self::ensureAiLocked((int) $record['id']);
|
||||
@@ -258,11 +258,12 @@ final class GameLiveService
|
||||
'pending_draw_number' => $manualNumber,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
GameHotDataRedis::gameRecordForget((int) $record['id']);
|
||||
self::publishSnapshot(null);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => '已预约本期开奖号码,倒计时结束后将使用该号码开奖',
|
||||
'msg' => __('Draw number scheduled; it will be used when the countdown ends'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -273,25 +274,25 @@ final class GameLiveService
|
||||
{
|
||||
$record = self::resolveRecord($recordId);
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
if (!in_array((int) $record['status'], [0, 1], true)) {
|
||||
return ['ok' => false, 'msg' => '当前对局状态不可开奖'];
|
||||
return ['ok' => false, 'msg' => __('Current game status does not allow drawing')];
|
||||
}
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||
if ($elapsed < $betSeconds) {
|
||||
return ['ok' => false, 'msg' => '下注开放时长未结束,不可开奖'];
|
||||
return ['ok' => false, 'msg' => __('Betting period has not ended; drawing is not available yet')];
|
||||
}
|
||||
if ($elapsed < $periodSeconds) {
|
||||
return ['ok' => false, 'msg' => '本期倒计时未结束,无法开奖'];
|
||||
return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')];
|
||||
}
|
||||
|
||||
self::ensureAiLocked((int) $record['id']);
|
||||
$record = self::reloadRecord((int) $record['id']);
|
||||
if (!$record) {
|
||||
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||
return ['ok' => false, 'msg' => __('No active game in progress')];
|
||||
}
|
||||
|
||||
$useManual = $manualNumber;
|
||||
@@ -342,9 +343,11 @@ final class GameLiveService
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return ['ok' => false, 'msg' => $e->getMessage()];
|
||||
return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
GameHotDataRedis::gameRecordForget((int) $record['id']);
|
||||
|
||||
try {
|
||||
GameRecordStatService::refreshForRecordId((int) $record['id']);
|
||||
} catch (Throwable) {
|
||||
@@ -357,7 +360,7 @@ final class GameLiveService
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'msg' => '开奖完成,派彩中',
|
||||
'msg' => __('Draw completed; paying out'),
|
||||
'result_number' => $finalNumber,
|
||||
'estimated_loss' => $finalLoss,
|
||||
'payout_until' => $payoutUntil,
|
||||
@@ -392,6 +395,7 @@ final class GameLiveService
|
||||
Db::rollback();
|
||||
return;
|
||||
}
|
||||
GameHotDataRedis::gameRecordForget($id);
|
||||
GameRecordStatService::refreshForRecordId($id);
|
||||
self::publishSnapshot(null);
|
||||
}
|
||||
@@ -565,17 +569,17 @@ final class GameLiveService
|
||||
private static function resolveRecord(?int $recordId): ?array
|
||||
{
|
||||
if ($recordId !== null && $recordId > 0) {
|
||||
$row = Db::name('game_record')->where('id', $recordId)->find();
|
||||
$row = GameHotDataRedis::gameRecordById($recordId);
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
|
||||
return GameHotDataRedis::gameRecordActive();
|
||||
}
|
||||
|
||||
private static function reloadRecord(int $id): ?array
|
||||
{
|
||||
$row = Db::name('game_record')->where('id', $id)->find();
|
||||
$row = GameHotDataRedis::gameRecordById($id);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
@@ -584,7 +588,7 @@ final class GameLiveService
|
||||
*/
|
||||
private static function ensureAiLocked(int $recordId): void
|
||||
{
|
||||
$record = Db::name('game_record')->where('id', $recordId)->find();
|
||||
$record = GameHotDataRedis::gameRecordById($recordId);
|
||||
if (!$record) {
|
||||
return;
|
||||
}
|
||||
@@ -605,6 +609,7 @@ final class GameLiveService
|
||||
'status' => 1,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
GameHotDataRedis::gameRecordForget($recordId);
|
||||
$record['status'] = 1;
|
||||
self::publishPublicPeriodLocked($record);
|
||||
}
|
||||
@@ -624,6 +629,7 @@ final class GameLiveService
|
||||
$update['status'] = 1;
|
||||
}
|
||||
Db::name('game_record')->where('id', $recordId)->update($update);
|
||||
GameHotDataRedis::gameRecordForget($recordId);
|
||||
$record = array_merge($record, $update);
|
||||
if ($st === 0) {
|
||||
self::publishPublicPeriodLocked($record);
|
||||
@@ -653,7 +659,7 @@ final class GameLiveService
|
||||
|
||||
private static function getConfigInt(string $key, int $default): int
|
||||
{
|
||||
$row = Db::name('game_config')->where('config_key', $key)->find();
|
||||
$row = GameHotDataRedis::gameConfigRow($key);
|
||||
if (!$row) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ final class GameRecordService
|
||||
|
||||
public static function getConfigBool(string $key): bool
|
||||
{
|
||||
$row = Db::name('game_config')->where('config_key', $key)->find();
|
||||
$row = GameHotDataRedis::gameConfigRow($key);
|
||||
if (!$row) {
|
||||
return false;
|
||||
}
|
||||
@@ -99,6 +99,7 @@ final class GameRecordService
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::gameRecordForget();
|
||||
return $periodNo;
|
||||
}
|
||||
|
||||
@@ -122,6 +123,7 @@ final class GameRecordService
|
||||
'remark' => $remark,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::gameConfigForget($key);
|
||||
return;
|
||||
}
|
||||
Db::name('game_config')->insert([
|
||||
@@ -132,5 +134,6 @@ final class GameRecordService
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::gameConfigForget($key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ final class GameRecordStatService
|
||||
'winner_user_count' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::gameRecordForget($recordId);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -78,6 +79,7 @@ final class GameRecordStatService
|
||||
'winner_user_count' => count($winnerUserIds),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameHotDataRedis::gameRecordForget($recordId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
28
config/game_hot_cache.php
Normal file
28
config/game_hot_cache.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 游戏侧热点数据 Redis 缓存(用户 / game_config / game_record)
|
||||
*
|
||||
* 环境变量见 .env-example 中 GAME_HOT_CACHE_*
|
||||
*/
|
||||
$envInt = static function (string $key, int $default): int {
|
||||
$raw = env($key, null);
|
||||
if ($raw === null || $raw === '') {
|
||||
return $default;
|
||||
}
|
||||
$n = filter_var($raw, FILTER_VALIDATE_INT);
|
||||
|
||||
return ($n === false || $n < 1) ? $default : $n;
|
||||
};
|
||||
|
||||
return [
|
||||
'enabled' => filter_var(env('GAME_HOT_CACHE_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
|
||||
/** game_config 行缓存(秒),后台修改会主动删除 */
|
||||
'ttl_game_config' => $envInt('GAME_HOT_CACHE_TTL_GAME_CONFIG', 86400),
|
||||
/** game_record 行缓存(秒) */
|
||||
'ttl_game_record' => $envInt('GAME_HOT_CACHE_TTL_GAME_RECORD', 60),
|
||||
/** user 行缓存(秒),余额/连胜变更会主动删除 */
|
||||
'ttl_user' => $envInt('GAME_HOT_CACHE_TTL_USER', 90),
|
||||
];
|
||||
@@ -9,6 +9,8 @@ export default {
|
||||
payout_countdown: 'Payout left',
|
||||
payout_na: '—',
|
||||
payout_phase: 'Payout in progress',
|
||||
action_panel: 'Actions',
|
||||
manual_draw_number: 'Scheduled draw',
|
||||
btn_calc: 'Calculate PnL',
|
||||
btn_draw: 'Schedule draw',
|
||||
calc_result_number: 'Calculated number',
|
||||
|
||||
@@ -9,7 +9,9 @@ export default {
|
||||
payout_countdown: '派彩剩余',
|
||||
payout_na: '—',
|
||||
payout_phase: '派彩中,请稍候',
|
||||
btn_calc: '计算法盈亏',
|
||||
action_panel: '操作区',
|
||||
manual_draw_number: '预约开奖',
|
||||
btn_calc: '计算盈亏',
|
||||
btn_draw: '预约开奖',
|
||||
calc_result_number: '计算开奖号码',
|
||||
calc_estimated_loss: '计算预估赔付',
|
||||
|
||||
@@ -3,31 +3,75 @@
|
||||
<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-card shadow="never" class="mb-12">
|
||||
<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" />
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<div>{{ t('game.live.current_record') }}: {{ snapshot.record?.period_no || '-' }}</div>
|
||||
<div>{{ t('game.live.ai_default_number') }}: {{ snapshot.ai_default_number ?? '-' }}</div>
|
||||
<div v-if="snapshot.pending_draw_number != null">
|
||||
{{ t('game.live.pending_draw') }}: {{ snapshot.pending_draw_number }}
|
||||
<div class="live-control-layout">
|
||||
<div class="live-control-main">
|
||||
<el-descriptions :column="1" border size="small" class="live-desc">
|
||||
<el-descriptions-item :label="t('game.live.current_record')">
|
||||
<span class="period-no">{{ snapshot.record?.period_no || '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('game.live.ai_default_number')">
|
||||
<span class="num-em">{{ snapshot.ai_default_number ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="snapshot.pending_draw_number != null" :label="t('game.live.pending_draw')">
|
||||
<el-tag type="primary" effect="plain">{{ snapshot.pending_draw_number }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="countdown-block">
|
||||
<div class="countdown-block__title">{{ t('game.live.countdown') }}</div>
|
||||
<div class="countdown-cards">
|
||||
<div class="cd-card">
|
||||
<span class="cd-card__label">{{ t('game.live.bet_countdown') }}</span>
|
||||
<span class="cd-card__val">{{ countdownParts.bet }}s</span>
|
||||
</div>
|
||||
<div class="cd-card">
|
||||
<span class="cd-card__label">{{ t('game.live.draw_countdown') }}</span>
|
||||
<span class="cd-card__val">{{ countdownParts.draw }}s</span>
|
||||
</div>
|
||||
<div class="cd-card" :class="{ 'is-active': snapshot.is_payout_phase }">
|
||||
<span class="cd-card__label">{{ t('game.live.payout_countdown') }}</span>
|
||||
<span class="cd-card__val">{{ countdownParts.payout }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calc-result-bar">
|
||||
<span class="calc-result-bar__item">
|
||||
<span class="calc-result-bar__k">{{ t('game.live.calc_result_number') }}</span>
|
||||
<span class="calc-result-bar__v">{{ calcResultNumber ?? '—' }}</span>
|
||||
</span>
|
||||
<span class="calc-result-bar__item">
|
||||
<span class="calc-result-bar__k">{{ t('game.live.calc_estimated_loss') }}</span>
|
||||
<span class="calc-result-bar__v mono">{{ calcEstimatedLoss }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>{{ t('game.live.countdown') }}: {{ countdownText }}</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-input-number v-model="manualNumber" :min="1" :max="snapshot.draw_number_max ?? 36" :step="1" />
|
||||
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
|
||||
{{ t('game.live.btn_calc') }}
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="drawLoading" :disabled="!snapshot.can_schedule_draw" @click="onDraw">
|
||||
{{ t('game.live.btn_draw') }}
|
||||
</el-button>
|
||||
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span>{{ t('game.live.calc_result_number') }}: {{ calcResultNumber ?? '-' }}</span>
|
||||
<span>{{ t('game.live.calc_estimated_loss') }}: {{ calcEstimatedLoss }}</span>
|
||||
|
||||
<aside class="live-control-aside">
|
||||
<div class="aside-title">{{ t('game.live.action_panel') }}</div>
|
||||
<div class="aside-field">
|
||||
<span class="aside-field__label">{{ t('game.live.manual_draw_number') }}</span>
|
||||
<el-input-number
|
||||
v-model="manualNumber"
|
||||
class="aside-field__input"
|
||||
:min="1"
|
||||
:max="snapshot.draw_number_max ?? 36"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
<div class="aside-btns">
|
||||
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
|
||||
{{ t('game.live.btn_calc') }}
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="drawLoading" :disabled="!snapshot.can_schedule_draw" @click="onDraw">
|
||||
{{ t('game.live.btn_draw') }}
|
||||
</el-button>
|
||||
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -313,14 +357,14 @@ async function onDraw() {
|
||||
}
|
||||
}
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const countdownParts = computed(() => {
|
||||
const bet = snapshot.bet_remaining_seconds ?? 0
|
||||
const draw = snapshot.remaining_seconds ?? 0
|
||||
let payoutPart = t('game.live.payout_na')
|
||||
let payout = t('game.live.payout_na')
|
||||
if (snapshot.is_payout_phase && payoutRemainingLive.value !== null) {
|
||||
payoutPart = `${payoutRemainingLive.value}s`
|
||||
payout = `${payoutRemainingLive.value}s`
|
||||
}
|
||||
return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${draw}s / ${t('game.live.payout_countdown')} ${payoutPart}`
|
||||
return { bet, draw, payout }
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -394,20 +438,217 @@ function stopPushWatchdog() {
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.live-control-card {
|
||||
:deep(.el-card__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
.header-actions {
|
||||
|
||||
.live-control-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.live-control-main {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-desc {
|
||||
margin-bottom: 16px;
|
||||
:deep(.el-descriptions__label) {
|
||||
width: 120px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.period-no {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.num-em {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.countdown-block {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.countdown-block__title {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.countdown-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cd-card {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--el-fill-color-blank);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 移动端保持同一排,压缩间距与字号以省纵向空间 */
|
||||
@media (max-width: 768px) {
|
||||
.countdown-block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.countdown-block__title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.countdown-cards {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cd-card {
|
||||
padding: 6px 4px;
|
||||
border-radius: 6px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cd-card__label {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.cd-card__val {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.cd-card.is-active {
|
||||
border-color: var(--el-color-warning-light-5);
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.cd-card__label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cd-card__val {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.calc-result-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.calc-result-bar__item {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.result-row {
|
||||
margin-top: 12px;
|
||||
|
||||
.calc-result-bar__k {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.calc-result-bar__v {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calc-result-bar__v.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.live-control-aside {
|
||||
flex: 0 0 240px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.live-control-aside {
|
||||
flex: 1 1 100%;
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
.aside-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.aside-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aside-field__label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.aside-field__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aside-btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.aside-btns .el-button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.aside-btns .el-button :deep(span) {
|
||||
white-space: normal;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user