Compare commits
5 Commits
e3f26ba1f7
...
24aab111b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 24aab111b5 | |||
| c184fa8a46 | |||
| 5c07967bf9 | |||
| 68657e2648 | |||
| 7d0f11fe43 |
@@ -5,18 +5,389 @@ declare(strict_types=1);
|
|||||||
namespace app\admin\controller;
|
namespace app\admin\controller;
|
||||||
|
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
|
use support\think\Db;
|
||||||
use Webman\Http\Request;
|
use Webman\Http\Request;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台首页统计:按渠道数据范围(非超管仅本渠道)汇总用户、充值、提现、投注等核心指标。
|
||||||
|
*/
|
||||||
class Dashboard extends Backend
|
class Dashboard extends Backend
|
||||||
{
|
{
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$response = $this->initializeBackend($request);
|
$response = $this->initializeBackend($request);
|
||||||
if ($response !== null) return $response;
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = $this->channelScopeOrNull();
|
||||||
|
|
||||||
|
$todayStart = strtotime(date('Y-m-d'));
|
||||||
|
$todayEnd = $todayStart + 86400 - 1;
|
||||||
|
$yesterdayStart = $todayStart - 86400;
|
||||||
|
$yesterdayEnd = $todayStart - 1;
|
||||||
|
|
||||||
|
$userTotal = $this->countUsers($scope);
|
||||||
|
$newToday = $this->countUsersInRange($scope, $todayStart, $todayEnd);
|
||||||
|
$newYesterday = $this->countUsersInRange($scope, $yesterdayStart, $yesterdayEnd);
|
||||||
|
$growthPct = null;
|
||||||
|
if ($newYesterday > 0) {
|
||||||
|
$growthPct = round(($newToday - $newYesterday) / $newYesterday * 100, 1);
|
||||||
|
} elseif ($newToday > 0) {
|
||||||
|
$growthPct = 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$depositAgg = $this->aggregateDepositToday($scope, $todayStart, $todayEnd);
|
||||||
|
$withdrawPending = $this->countWithdrawPending($scope);
|
||||||
|
$betAgg = $this->aggregateBetToday($scope, $todayStart, $todayEnd);
|
||||||
|
|
||||||
|
$trend = $this->buildSevenDayTrend($scope);
|
||||||
|
$channelShare = $this->buildChannelShare($scope);
|
||||||
|
$depositAmountChannelShare = $this->buildDepositAmountChannelShare($scope);
|
||||||
|
$recentUsers = $this->fetchRecentUsers($scope, 10);
|
||||||
|
|
||||||
return $this->success('', [
|
return $this->success('', [
|
||||||
'remark' => get_route_remark()
|
'remark' => get_route_remark(),
|
||||||
|
'stats' => [
|
||||||
|
'user_total' => $userTotal,
|
||||||
|
'user_new_today' => $newToday,
|
||||||
|
'user_new_yesterday' => $newYesterday,
|
||||||
|
'user_new_growth_pct' => $growthPct,
|
||||||
|
'deposit_today_amount' => $depositAgg['amount'],
|
||||||
|
'deposit_today_count' => $depositAgg['count'],
|
||||||
|
'withdraw_pending' => $withdrawPending,
|
||||||
|
'bet_today_amount' => $betAgg['amount'],
|
||||||
|
'bet_today_count' => $betAgg['count'],
|
||||||
|
],
|
||||||
|
'trend' => $trend,
|
||||||
|
'channel_share' => $channelShare,
|
||||||
|
'deposit_amount_channel_share' => $depositAmountChannelShare,
|
||||||
|
'recent_users' => $recentUsers,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[]|null $scope null=超管不限制;非 null 时 whereIn channel_id
|
||||||
|
*/
|
||||||
|
private function countUsers(?array $scope): int
|
||||||
|
{
|
||||||
|
$q = Db::name('user');
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
return intval($q->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[]|null $scope
|
||||||
|
*/
|
||||||
|
private function countUsersInRange(?array $scope, int $start, int $end): int
|
||||||
|
{
|
||||||
|
$q = Db::name('user')
|
||||||
|
->where('create_time', '>=', $start)
|
||||||
|
->where('create_time', '<=', $end);
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
return intval($q->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日成功充值:status=1,按创建日落在今日(与 mock 即时成功一致)。
|
||||||
|
*
|
||||||
|
* @param int[]|null $scope
|
||||||
|
* @return array{count:int, amount:string}
|
||||||
|
*/
|
||||||
|
private function aggregateDepositToday(?array $scope, int $todayStart, int $todayEnd): array
|
||||||
|
{
|
||||||
|
$q = Db::name('deposit_order')
|
||||||
|
->where('status', 1)
|
||||||
|
->where('create_time', '>=', $todayStart)
|
||||||
|
->where('create_time', '<=', $todayEnd);
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
$rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
$rows = [];
|
||||||
|
}
|
||||||
|
$count = isset($rows['c']) ? intval($rows['c']) : 0;
|
||||||
|
$sum = isset($rows['s']) ? strval($rows['s']) : '0';
|
||||||
|
$amount = $this->formatMoney2($sum);
|
||||||
|
|
||||||
|
return ['count' => $count, 'amount' => $amount];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[]|null $scope
|
||||||
|
*/
|
||||||
|
private function countWithdrawPending(?array $scope): int
|
||||||
|
{
|
||||||
|
$q = Db::name('withdraw_order')->where('status', 0);
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
return intval($q->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今日投注:创建时间在今日且订单未作废(status 1 或 2)。
|
||||||
|
*
|
||||||
|
* @param int[]|null $scope
|
||||||
|
* @return array{count:int, amount:string}
|
||||||
|
*/
|
||||||
|
private function aggregateBetToday(?array $scope, int $todayStart, int $todayEnd): array
|
||||||
|
{
|
||||||
|
$q = Db::name('bet_order')
|
||||||
|
->whereIn('status', [1, 2])
|
||||||
|
->where('create_time', '>=', $todayStart)
|
||||||
|
->where('create_time', '<=', $todayEnd);
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
$rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
$rows = [];
|
||||||
|
}
|
||||||
|
$count = isset($rows['c']) ? intval($rows['c']) : 0;
|
||||||
|
$sum = isset($rows['s']) ? strval($rows['s']) : '0';
|
||||||
|
|
||||||
|
return ['count' => $count, 'amount' => $this->formatMoney2($sum)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[]|null $scope
|
||||||
|
* @return array{days:string[], new_users:int[], deposit_amount:string[], bet_amount:string[]}
|
||||||
|
*/
|
||||||
|
private function buildSevenDayTrend(?array $scope): array
|
||||||
|
{
|
||||||
|
$days = [];
|
||||||
|
$newUsers = [];
|
||||||
|
$depositAmounts = [];
|
||||||
|
$betAmounts = [];
|
||||||
|
|
||||||
|
for ($i = 6; $i >= 0; $i--) {
|
||||||
|
$dayStart = strtotime(date('Y-m-d', strtotime('-' . $i . ' day')));
|
||||||
|
$dayEnd = $dayStart + 86400 - 1;
|
||||||
|
$days[] = date('m-d', $dayStart);
|
||||||
|
|
||||||
|
$newUsers[] = $this->countUsersInRange($scope, $dayStart, $dayEnd);
|
||||||
|
|
||||||
|
$dq = Db::name('deposit_order')
|
||||||
|
->where('status', 1)
|
||||||
|
->where('create_time', '>=', $dayStart)
|
||||||
|
->where('create_time', '<=', $dayEnd);
|
||||||
|
if ($scope !== null) {
|
||||||
|
$dq->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
$drow = $dq->fieldRaw('COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find();
|
||||||
|
$dsum = is_array($drow) && isset($drow['s']) ? strval($drow['s']) : '0';
|
||||||
|
$depositAmounts[] = $this->formatMoney2($dsum);
|
||||||
|
|
||||||
|
$bq = Db::name('bet_order')
|
||||||
|
->whereIn('status', [1, 2])
|
||||||
|
->where('create_time', '>=', $dayStart)
|
||||||
|
->where('create_time', '<=', $dayEnd);
|
||||||
|
if ($scope !== null) {
|
||||||
|
$bq->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
$brow = $bq->fieldRaw('COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find();
|
||||||
|
$bsum = is_array($brow) && isset($brow['s']) ? strval($brow['s']) : '0';
|
||||||
|
$betAmounts[] = $this->formatMoney2($bsum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'days' => $days,
|
||||||
|
'new_users' => $newUsers,
|
||||||
|
'deposit_amount' => $depositAmounts,
|
||||||
|
'bet_amount' => $betAmounts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户按渠道分布(取前 8 名,其余合并为「其他」)。
|
||||||
|
*
|
||||||
|
* @param int[]|null $scope
|
||||||
|
* @return list<array{name:string, value:int}>
|
||||||
|
*/
|
||||||
|
private function buildChannelShare(?array $scope): array
|
||||||
|
{
|
||||||
|
$q = Db::name('user')->fieldRaw('channel_id, COUNT(*) AS c')->group('channel_id');
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
$rows = $q->orderRaw('c DESC')->select()->toArray();
|
||||||
|
if ($rows === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelNames = Db::name('channel')->column('name', 'id');
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$cid = $row['channel_id'];
|
||||||
|
$cnt = intval($row['c'] ?? 0);
|
||||||
|
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
|
||||||
|
$name = '未分配渠道';
|
||||||
|
} else {
|
||||||
|
$id = intval(strval($cid));
|
||||||
|
$name = $channelNames[$id] ?? ('#' . strval($id));
|
||||||
|
}
|
||||||
|
$list[] = ['name' => $name, 'value' => $cnt];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($list) <= 8) {
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
$head = array_slice($list, 0, 8);
|
||||||
|
$rest = array_slice($list, 8);
|
||||||
|
$other = 0;
|
||||||
|
foreach ($rest as $item) {
|
||||||
|
$other += $item['value'];
|
||||||
|
}
|
||||||
|
if ($other > 0) {
|
||||||
|
$head[] = ['name' => '其他', 'value' => $other];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $head;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功充值金额按订单归属渠道汇总(status=1,受渠道范围限制)。
|
||||||
|
*
|
||||||
|
* @param int[]|null $scope
|
||||||
|
* @return list<array{name:string, value:string}> value 为两位小数字符串,供前端饼图展示
|
||||||
|
*/
|
||||||
|
private function buildDepositAmountChannelShare(?array $scope): array
|
||||||
|
{
|
||||||
|
$q = Db::name('deposit_order')
|
||||||
|
->where('status', 1)
|
||||||
|
->fieldRaw('channel_id, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')
|
||||||
|
->group('channel_id');
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
$rows = $q->select()->toArray();
|
||||||
|
if ($rows === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelNames = Db::name('channel')->column('name', 'id');
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$cid = $row['channel_id'];
|
||||||
|
$sumRaw = isset($row['s']) ? strval($row['s']) : '0';
|
||||||
|
$amountStr = $this->formatMoney2($sumRaw);
|
||||||
|
if (bccomp($amountStr, '0', 2) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
|
||||||
|
$name = '未分配渠道';
|
||||||
|
} else {
|
||||||
|
$id = intval(strval($cid));
|
||||||
|
$name = $channelNames[$id] ?? ('#' . strval($id));
|
||||||
|
}
|
||||||
|
$list[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'value' => $amountStr,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($list, static function (array $a, array $b): int {
|
||||||
|
return bccomp($b['value'], $a['value'], 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count($list) <= 8) {
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
$head = array_slice($list, 0, 8);
|
||||||
|
$rest = array_slice($list, 8);
|
||||||
|
$other = '0';
|
||||||
|
foreach ($rest as $item) {
|
||||||
|
$other = bcadd($other, $item['value'], 4);
|
||||||
|
}
|
||||||
|
$otherFormatted = $this->formatMoney2($other);
|
||||||
|
if (bccomp($otherFormatted, '0', 2) > 0) {
|
||||||
|
$head[] = [
|
||||||
|
'name' => '其他',
|
||||||
|
'value' => $otherFormatted,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $head;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[]|null $scope
|
||||||
|
* @return list<array{id:int, username:string, create_time:int, channel_name:string, head_image:string}>
|
||||||
|
*/
|
||||||
|
private function fetchRecentUsers(?array $scope, int $limit): array
|
||||||
|
{
|
||||||
|
$q = Db::name('user')
|
||||||
|
->field(['id', 'username', 'create_time', 'channel_id', 'head_image'])
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit($limit);
|
||||||
|
if ($scope !== null) {
|
||||||
|
$q->whereIn('channel_id', $scope);
|
||||||
|
}
|
||||||
|
$rows = $q->select()->toArray();
|
||||||
|
if ($rows === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelNames = Db::name('channel')->column('name', 'id');
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$cid = $row['channel_id'] ?? null;
|
||||||
|
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
|
||||||
|
$cname = '';
|
||||||
|
} else {
|
||||||
|
$id = intval(strval($cid));
|
||||||
|
$cname = $channelNames[$id] ?? '';
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'id' => intval($row['id'] ?? 0),
|
||||||
|
'username' => strval($row['username'] ?? ''),
|
||||||
|
'create_time' => intval($row['create_time'] ?? 0),
|
||||||
|
'channel_name' => $cname,
|
||||||
|
'head_image' => strval($row['head_image'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMoney2(string $amount): string
|
||||||
|
{
|
||||||
|
if (!is_numeric($amount)) {
|
||||||
|
return '0.00';
|
||||||
|
}
|
||||||
|
$normalized = bcadd($amount, '0', 4);
|
||||||
|
|
||||||
|
return bcadd($normalized, '0', 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非超管:按管理员所属渠道过滤;未绑定渠道时按 channel_id IN (0) 与列表页一致。
|
||||||
|
* 超管:返回 null,表示 SQL 不加渠道条件。
|
||||||
|
*
|
||||||
|
* @return int[]|null
|
||||||
|
*/
|
||||||
|
private function channelScopeOrNull(): ?array
|
||||||
|
{
|
||||||
|
if (!$this->auth || $this->auth->isSuperAdmin()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $this->auth->id)->find();
|
||||||
|
$ids = [];
|
||||||
|
if ($admin && !empty($admin['channel_id'])) {
|
||||||
|
$ids[] = $admin['channel_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ids !== [] ? array_values(array_unique($ids)) : [0];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace app\admin\controller\config;
|
namespace app\admin\controller\config;
|
||||||
|
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\game\DepositTier;
|
||||||
|
use app\common\library\game\StreakWinReward;
|
||||||
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
|
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
use Webman\Http\Request as WebmanRequest;
|
use Webman\Http\Request as WebmanRequest;
|
||||||
@@ -33,7 +35,19 @@ class GameConfig extends Backend
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表:排除独立表单维护的 36 字花字典
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
protected function excludedConfigKeys(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ZiHuaDictionaryLib::CONFIG_KEY,
|
||||||
|
DepositTier::CONFIG_KEY,
|
||||||
|
StreakWinReward::CONFIG_KEY,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表:排除独立表单维护的配置键
|
||||||
*/
|
*/
|
||||||
protected function _index(): Response
|
protected function _index(): Response
|
||||||
{
|
{
|
||||||
@@ -45,7 +59,7 @@ class GameConfig extends Backend
|
|||||||
$table = strtolower($this->model->getTable());
|
$table = strtolower($this->model->getTable());
|
||||||
$mainShort = $alias[$table] ?? '';
|
$mainShort = $alias[$table] ?? '';
|
||||||
if ($mainShort !== '') {
|
if ($mainShort !== '') {
|
||||||
$where[] = [$mainShort . '.config_key', '<>', ZiHuaDictionaryLib::CONFIG_KEY];
|
$where[] = [$mainShort . '.config_key', 'not in', $this->excludedConfigKeys()];
|
||||||
}
|
}
|
||||||
|
|
||||||
$res = $this->model
|
$res = $this->model
|
||||||
@@ -63,4 +77,51 @@ class GameConfig extends Backend
|
|||||||
'remark' => get_route_remark(),
|
'remark' => get_route_remark(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远程下拉:排除独立维护的配置键
|
||||||
|
*/
|
||||||
|
protected function _select(): Response
|
||||||
|
{
|
||||||
|
if (empty($this->model)) {
|
||||||
|
return $this->success('', [
|
||||||
|
'list' => [],
|
||||||
|
'total' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pk = $this->model->getPk();
|
||||||
|
|
||||||
|
$fields = [$pk];
|
||||||
|
$quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', (string) $this->quickSearchField);
|
||||||
|
foreach ($quickSearchArr as $f) {
|
||||||
|
$f = trim((string) $f);
|
||||||
|
if ($f === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$f = str_contains($f, '.') ? substr($f, strrpos($f, '.') + 1) : $f;
|
||||||
|
if ($f !== '' && !in_array($f, $fields, true)) {
|
||||||
|
$fields[] = $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list($where, $alias, $limit, $order) = $this->queryBuilder();
|
||||||
|
$table = strtolower($this->model->getTable());
|
||||||
|
$mainShort = $alias[$table] ?? '';
|
||||||
|
if ($mainShort !== '') {
|
||||||
|
$where[] = [$mainShort . '.config_key', 'not in', $this->excludedConfigKeys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $this->model
|
||||||
|
->field($fields)
|
||||||
|
->alias($alias)
|
||||||
|
->where($where)
|
||||||
|
->order($order)
|
||||||
|
->paginate($limit);
|
||||||
|
|
||||||
|
return $this->success('', [
|
||||||
|
'list' => $res->items(),
|
||||||
|
'total' => $res->total(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
121
app/admin/controller/config/StreakWinReward.php
Normal file
121
app/admin/controller/config/StreakWinReward.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\config;
|
||||||
|
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\game\StreakWinReward as StreakWinRewardLib;
|
||||||
|
use support\think\Db;
|
||||||
|
use support\Response;
|
||||||
|
use Throwable;
|
||||||
|
use Webman\Http\Request as WebmanRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连胜奖励(game_config.streak_win_reward)
|
||||||
|
*/
|
||||||
|
class StreakWinReward extends Backend
|
||||||
|
{
|
||||||
|
protected bool $modelValidate = false;
|
||||||
|
|
||||||
|
protected array $noNeedPermission = ['index', 'save'];
|
||||||
|
|
||||||
|
private function hasNodePermission(WebmanRequest $request, string $action): bool
|
||||||
|
{
|
||||||
|
if (!$this->auth) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$controllerPath = get_controller_path($request);
|
||||||
|
if (!$controllerPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$paths = [];
|
||||||
|
$paths[] = $controllerPath . '/' . $action;
|
||||||
|
$parts = explode('/', $controllerPath);
|
||||||
|
foreach ($parts as &$part) {
|
||||||
|
if (str_contains($part, '_')) {
|
||||||
|
$part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$paths[] = implode('/', $parts) . '/' . $action;
|
||||||
|
foreach (array_values(array_unique($paths)) as $path) {
|
||||||
|
if ($this->auth->check($path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function initController(WebmanRequest $request): ?Response
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(WebmanRequest $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
if (!$this->hasNodePermission($request, 'index')) {
|
||||||
|
return $this->error(__('You have no permission'), [], 401);
|
||||||
|
}
|
||||||
|
if ($request->method() !== 'GET') {
|
||||||
|
return $this->error(__('Parameter error'));
|
||||||
|
}
|
||||||
|
$row = Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->find();
|
||||||
|
$rows = StreakWinRewardLib::parseFromConfigValue($row['config_value'] ?? null);
|
||||||
|
|
||||||
|
return $this->success('', [
|
||||||
|
'rows' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(WebmanRequest $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
if (!$this->hasNodePermission($request, 'save')) {
|
||||||
|
return $this->error(__('You have no permission'), [], 401);
|
||||||
|
}
|
||||||
|
if ($request->method() !== 'POST') {
|
||||||
|
return $this->error(__('Parameter error'));
|
||||||
|
}
|
||||||
|
$payload = $request->post('rows');
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return $this->error('参数错误');
|
||||||
|
}
|
||||||
|
$encoded = StreakWinRewardLib::encodeForDb($payload);
|
||||||
|
$now = time();
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$exists = Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->find();
|
||||||
|
if ($exists) {
|
||||||
|
Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->update([
|
||||||
|
'config_value' => $encoded,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Db::name('game_config')->insert([
|
||||||
|
'config_key' => StreakWinRewardLib::CONFIG_KEY,
|
||||||
|
'config_value' => $encoded,
|
||||||
|
'value_type' => 'json',
|
||||||
|
'remark' => '连胜奖励',
|
||||||
|
'create_time' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
|
||||||
|
return $this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
StreakWinRewardLib::clearCache();
|
||||||
|
|
||||||
|
return $this->success('保存成功');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,9 @@ class Live extends Backend
|
|||||||
return $this->success((string) $res['msg'], $res);
|
return $this->success((string) $res['msg'], $res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预约本期开奖号码(倒计时结束后自动开奖,不立即开奖)。
|
||||||
|
*/
|
||||||
public function draw(WebmanRequest $request): Response
|
public function draw(WebmanRequest $request): Response
|
||||||
{
|
{
|
||||||
$response = $this->initializeBackend($request);
|
$response = $this->initializeBackend($request);
|
||||||
@@ -88,10 +91,13 @@ class Live extends Backend
|
|||||||
$recordIdRaw = $request->post('record_id');
|
$recordIdRaw = $request->post('record_id');
|
||||||
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
||||||
$manualRaw = $request->post('manual_number');
|
$manualRaw = $request->post('manual_number');
|
||||||
$manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null;
|
if (!is_numeric((string) $manualRaw)) {
|
||||||
$res = GameLiveService::drawResult($recordId, $manualNumber);
|
return $this->error('请填写开奖号码');
|
||||||
|
}
|
||||||
|
$manualNumber = (int) $manualRaw;
|
||||||
|
$res = GameLiveService::scheduleDraw($recordId, $manualNumber);
|
||||||
if (!($res['ok'] ?? false)) {
|
if (!($res['ok'] ?? false)) {
|
||||||
return $this->error((string) ($res['msg'] ?? '开奖失败'));
|
return $this->error((string) ($res['msg'] ?? '预约失败'));
|
||||||
}
|
}
|
||||||
return $this->success((string) $res['msg'], $res);
|
return $this->success((string) $res['msg'], $res);
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/admin/controller/test/PushGamePeriod.php
Normal file
32
app/admin/controller/test/PushGamePeriod.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\test;
|
||||||
|
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\admin\PushChannelConfigHelper;
|
||||||
|
use support\Response;
|
||||||
|
use Webman\Http\Request as WebmanRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送测试:public-game-period(对局公共频道)
|
||||||
|
*/
|
||||||
|
class PushGamePeriod extends Backend
|
||||||
|
{
|
||||||
|
protected ?object $model = null;
|
||||||
|
|
||||||
|
public function pushConfig(WebmanRequest $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('', [
|
||||||
|
'url' => PushChannelConfigHelper::wsBaseUrl(),
|
||||||
|
'app_key' => PushChannelConfigHelper::appKey(),
|
||||||
|
'channel' => 'public-game-period',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/admin/controller/test/PushOperationNotice.php
Normal file
32
app/admin/controller/test/PushOperationNotice.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\test;
|
||||||
|
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\admin\PushChannelConfigHelper;
|
||||||
|
use support\Response;
|
||||||
|
use Webman\Http\Request as WebmanRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送测试:public-operation-notice(公告广播频道)
|
||||||
|
*/
|
||||||
|
class PushOperationNotice extends Backend
|
||||||
|
{
|
||||||
|
protected ?object $model = null;
|
||||||
|
|
||||||
|
public function pushConfig(WebmanRequest $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('', [
|
||||||
|
'url' => PushChannelConfigHelper::wsBaseUrl(),
|
||||||
|
'app_key' => PushChannelConfigHelper::appKey(),
|
||||||
|
'channel' => 'public-operation-notice',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/admin/controller/test/PushPrivateUser.php
Normal file
40
app/admin/controller/test/PushPrivateUser.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\test;
|
||||||
|
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\admin\PushChannelConfigHelper;
|
||||||
|
use support\Response;
|
||||||
|
use Webman\Http\Request as WebmanRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送测试:private-user-{uuid}(用户私有频道)
|
||||||
|
*/
|
||||||
|
class PushPrivateUser extends Backend
|
||||||
|
{
|
||||||
|
protected ?object $model = null;
|
||||||
|
|
||||||
|
public function pushConfig(WebmanRequest $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = trim((string) ($request->get('uuid') ?? $request->post('uuid') ?? ''));
|
||||||
|
if ($uuid === '') {
|
||||||
|
return $this->error(__('Parameter %s can not be empty', ['uuid']));
|
||||||
|
}
|
||||||
|
if (strlen($uuid) > 64 || !preg_match('/^[0-9a-zA-Z_-]+$/', $uuid)) {
|
||||||
|
return $this->error(__('Parameter error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('', [
|
||||||
|
'url' => PushChannelConfigHelper::wsBaseUrl(),
|
||||||
|
'app_key' => PushChannelConfigHelper::appKey(),
|
||||||
|
'channel' => 'private-user-' . $uuid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use app\common\model\BetOrder;
|
|||||||
use app\common\model\GameConfig;
|
use app\common\model\GameConfig;
|
||||||
use app\common\model\GameRecord;
|
use app\common\model\GameRecord;
|
||||||
use app\common\model\UserWalletRecord;
|
use app\common\model\UserWalletRecord;
|
||||||
|
use app\common\service\UserPushService;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
use Webman\Http\Request;
|
use Webman\Http\Request;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
@@ -137,7 +138,7 @@ class Game extends MobileBase
|
|||||||
* 提交下注:入参极简——period_no + numbers + bet_amount(整笔总金额) + idempotency_key。
|
* 提交下注:入参极简——period_no + numbers + bet_amount(整笔总金额) + idempotency_key。
|
||||||
*
|
*
|
||||||
* 下注判定:开奖号码 ∈ pick_numbers 即算中奖,赔付按整笔 total_amount × odds 计算
|
* 下注判定:开奖号码 ∈ pick_numbers 即算中奖,赔付按整笔 total_amount × odds 计算
|
||||||
* (odds 定义见 GameBetSettleService::BASE_ODDS 与 streak_at_bet)。
|
* (派彩 = 压注总额 × 连胜奖励表 odds_factor;streak_at_bet 为下注时快照)。
|
||||||
*/
|
*/
|
||||||
public function betPlace(Request $request): Response
|
public function betPlace(Request $request): Response
|
||||||
{
|
{
|
||||||
@@ -217,6 +218,14 @@ class Game extends MobileBase
|
|||||||
'update_time' => time(),
|
'update_time' => time(),
|
||||||
]);
|
]);
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
UserPushService::publish((int) $user->id, UserPushService::EVT_BET_ACCEPTED, [
|
||||||
|
'order_no' => $orderNo,
|
||||||
|
'period_no' => (string) $period->period_no,
|
||||||
|
'status' => 'accepted',
|
||||||
|
'balance_after' => $after,
|
||||||
|
'total_amount' => $totalAmount,
|
||||||
|
'current_streak' => (int) ($user->current_streak ?? 0),
|
||||||
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
|
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
|
||||||
|
|||||||
24
app/common/library/admin/PushChannelConfigHelper.php
Normal file
24
app/common/library/admin/PushChannelConfigHelper.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\admin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台推送测试页:读取 webman/push 配置(与 game/Live::pushConfig 口径一致)
|
||||||
|
*/
|
||||||
|
final class PushChannelConfigHelper
|
||||||
|
{
|
||||||
|
public static function wsBaseUrl(): string
|
||||||
|
{
|
||||||
|
$ws = (string) config('plugin.webman.push.app.websocket');
|
||||||
|
$ws = str_replace('websocket://', 'ws://', $ws);
|
||||||
|
|
||||||
|
return str_replace('0.0.0.0', '127.0.0.1', $ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function appKey(): string
|
||||||
|
{
|
||||||
|
return (string) config('plugin.webman.push.app.app_key');
|
||||||
|
}
|
||||||
|
}
|
||||||
205
app/common/library/game/StreakWinReward.php
Normal file
205
app/common/library/game/StreakWinReward.php
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\game;
|
||||||
|
|
||||||
|
use support\think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连胜奖励(game_config.streak_win_reward):按「连胜档位」1~10 配置赔率系数与是否大奖。
|
||||||
|
*
|
||||||
|
* 派彩:total_amount × odds_factor(与后台「连胜奖励」表一致,不再额外 ×33)。
|
||||||
|
*/
|
||||||
|
final class StreakWinReward
|
||||||
|
{
|
||||||
|
public const CONFIG_KEY = 'streak_win_reward';
|
||||||
|
|
||||||
|
/** @var list<array{streak: int, odds_factor: int, is_jackpot: bool}>|null */
|
||||||
|
private static ?array $cache = null;
|
||||||
|
|
||||||
|
public static function clearCache(): void
|
||||||
|
{
|
||||||
|
self::$cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
|
||||||
|
*/
|
||||||
|
public static function defaultRows(): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
for ($s = 1; $s <= 10; $s++) {
|
||||||
|
$out[] = [
|
||||||
|
'streak' => $s,
|
||||||
|
'odds_factor' => $s,
|
||||||
|
'is_jackpot' => $s === 10,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $raw
|
||||||
|
*
|
||||||
|
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
|
||||||
|
*/
|
||||||
|
public static function parseFromConfigValue($raw): array
|
||||||
|
{
|
||||||
|
if (!is_string($raw) || trim($raw) === '') {
|
||||||
|
return self::defaultRows();
|
||||||
|
}
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return self::defaultRows();
|
||||||
|
}
|
||||||
|
$list = $decoded['rows'] ?? $decoded;
|
||||||
|
if (!is_array($list)) {
|
||||||
|
return self::defaultRows();
|
||||||
|
}
|
||||||
|
$byStreak = [];
|
||||||
|
foreach ($list as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$streak = isset($row['streak']) && is_numeric($row['streak']) ? (int) $row['streak'] : 0;
|
||||||
|
if ($streak < 1 || $streak > 10) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$factor = isset($row['odds_factor']) && is_numeric($row['odds_factor']) ? (int) $row['odds_factor'] : $streak;
|
||||||
|
if ($factor < 1) {
|
||||||
|
$factor = 1;
|
||||||
|
}
|
||||||
|
$jack = !empty($row['is_jackpot']);
|
||||||
|
$byStreak[$streak] = [
|
||||||
|
'streak' => $streak,
|
||||||
|
'odds_factor' => $factor,
|
||||||
|
'is_jackpot' => $jack,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
for ($s = 1; $s <= 10; $s++) {
|
||||||
|
$out[] = $byStreak[$s] ?? [
|
||||||
|
'streak' => $s,
|
||||||
|
'odds_factor' => $s,
|
||||||
|
'is_jackpot' => $s === 10,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从库加载并缓存
|
||||||
|
*
|
||||||
|
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
|
||||||
|
*/
|
||||||
|
public static function loadRows(): array
|
||||||
|
{
|
||||||
|
if (self::$cache !== null) {
|
||||||
|
return self::$cache;
|
||||||
|
}
|
||||||
|
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY)->find();
|
||||||
|
self::$cache = self::parseFromConfigValue($row['config_value'] ?? null);
|
||||||
|
|
||||||
|
return self::$cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* streak_at_bet 为下注时快照(0 表示尚未连胜);档位取 min(streak_at_bet+1, 10)。
|
||||||
|
*/
|
||||||
|
public static function levelFromStreakAtBet(int $streakAtBet): int
|
||||||
|
{
|
||||||
|
$level = $streakAtBet + 1;
|
||||||
|
if ($level < 1) {
|
||||||
|
$level = 1;
|
||||||
|
}
|
||||||
|
if ($level > 10) {
|
||||||
|
$level = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{streak: int, odds_factor: int, is_jackpot: bool}
|
||||||
|
*/
|
||||||
|
public static function rowForStreakAtBet(int $streakAtBet): array
|
||||||
|
{
|
||||||
|
$level = self::levelFromStreakAtBet($streakAtBet);
|
||||||
|
foreach (self::loadRows() as $row) {
|
||||||
|
if ((int) ($row['streak'] ?? 0) === $level) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'streak' => $level,
|
||||||
|
'odds_factor' => $level,
|
||||||
|
'is_jackpot' => $level === 10,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isJackpotForStreakAtBet(int $streakAtBet): bool
|
||||||
|
{
|
||||||
|
return self::rowForStreakAtBet($streakAtBet)['is_jackpot'] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回该注单适用的「赔率乘数」字符串(= 配置档位的 odds_factor),供 bcmul(total_amount, ..., 4)。
|
||||||
|
*/
|
||||||
|
public static function totalOddsMultiplierForStreakAtBet(int $streakAtBet): string
|
||||||
|
{
|
||||||
|
$factor = (int) self::rowForStreakAtBet($streakAtBet)['odds_factor'];
|
||||||
|
if ($factor < 1) {
|
||||||
|
$factor = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bcadd((string) $factor, '0', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{streak: int, odds_factor: int, is_jackpot: bool}> $rows
|
||||||
|
*/
|
||||||
|
public static function encodeForDb(array $rows): string
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
for ($s = 1; $s <= 10; $s++) {
|
||||||
|
$found = null;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (!is_array($r)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$st = isset($r['streak']) && is_numeric($r['streak']) ? (int) $r['streak'] : 0;
|
||||||
|
if ($st === $s) {
|
||||||
|
$found = $r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($found === null) {
|
||||||
|
$normalized[] = [
|
||||||
|
'streak' => $s,
|
||||||
|
'odds_factor' => $s,
|
||||||
|
'is_jackpot' => $s === 10,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$f = isset($found['odds_factor']) && is_numeric($found['odds_factor']) ? (int) $found['odds_factor'] : $s;
|
||||||
|
if ($f < 1) {
|
||||||
|
$f = 1;
|
||||||
|
}
|
||||||
|
$normalized[] = [
|
||||||
|
'streak' => $s,
|
||||||
|
'odds_factor' => $f,
|
||||||
|
'is_jackpot' => !empty($found['is_jackpot']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$json = json_encode(['rows' => $normalized], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
return '{"rows":[]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ class GameRecord extends Model
|
|||||||
'draw_mode' => 'integer',
|
'draw_mode' => 'integer',
|
||||||
'preset_number' => 'integer',
|
'preset_number' => 'integer',
|
||||||
'result_number' => 'integer',
|
'result_number' => 'integer',
|
||||||
|
'ai_locked_number' => 'integer',
|
||||||
|
'pending_draw_number' => 'integer',
|
||||||
|
'payout_until' => 'integer',
|
||||||
'platform_profit_amount' => 'string',
|
'platform_profit_amount' => 'string',
|
||||||
'winner_user_count' => 'integer',
|
'winner_user_count' => 'integer',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,25 +4,27 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\game\StreakWinReward;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开奖后结算注单:写入 win_amount、status=已结算;中奖时入账并记 user_wallet_record(biz_type=payout)。
|
* 开奖后结算注单:写入 win_amount、status=已结算;中奖时入账并记 user_wallet_record(biz_type=payout)。
|
||||||
|
* 连胜赔率来自 game_config.streak_win_reward;结算后更新 user.current_streak(未中奖则连胜归 0)。
|
||||||
*/
|
*/
|
||||||
final class GameBetSettleService
|
final class GameBetSettleService
|
||||||
{
|
{
|
||||||
private const BASE_ODDS = 33;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
|
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
|
||||||
*
|
*
|
||||||
|
* @return array{jackpot_hits: list<array{user_id: int, period_no: string, total_win: string, result_number: int}>}
|
||||||
|
*
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public static function settleBetsForDraw(int $recordId, int $resultNumber): void
|
public static function settleBetsForDraw(int $recordId, int $resultNumber): array
|
||||||
{
|
{
|
||||||
if ($recordId <= 0 || $resultNumber < 1) {
|
if ($recordId <= 0 || $resultNumber < 1) {
|
||||||
return;
|
return ['jackpot_hits' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = time();
|
$now = time();
|
||||||
@@ -33,12 +35,29 @@ final class GameBetSettleService
|
|||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
|
/** @var array<int, array{period_no: string, total_win: string, balance_after: string, orders: list<array{order_no: string, win_amount: string, hit: bool}>}> */
|
||||||
|
$aggregateByUser = [];
|
||||||
|
|
||||||
|
/** @var array<int, array{streak_at: int, had_win: bool}> */
|
||||||
|
$userOutcome = [];
|
||||||
|
|
||||||
|
/** @var array<int, true> */
|
||||||
|
$jackpotNotify = [];
|
||||||
|
|
||||||
foreach ($bets as $bet) {
|
foreach ($bets as $bet) {
|
||||||
$betId = (int) ($bet['id'] ?? 0);
|
$betId = (int) ($bet['id'] ?? 0);
|
||||||
if ($betId <= 0) {
|
if ($betId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($bet['user_id'] ?? 0);
|
||||||
|
if ($userId > 0 && !isset($userOutcome[$userId])) {
|
||||||
|
$userOutcome[$userId] = [
|
||||||
|
'streak_at' => (int) ($bet['streak_at_bet'] ?? 0),
|
||||||
|
'had_win' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$win = self::computeWinAmount($bet, $resultNumber);
|
$win = self::computeWinAmount($bet, $resultNumber);
|
||||||
$jackpot = '0.0000';
|
$jackpot = '0.0000';
|
||||||
|
|
||||||
@@ -56,17 +75,111 @@ final class GameBetSettleService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 结算刚刚成功(status 1 → 2):把本单下注总额 1:1 累加到用户打码量
|
|
||||||
self::creditUserBetFlow($bet, $now);
|
self::creditUserBetFlow($bet, $now);
|
||||||
|
|
||||||
if (bccomp($win, '0', 4) <= 0) {
|
if ($userId > 0) {
|
||||||
|
if (bccomp($win, '0', 4) > 0) {
|
||||||
|
$userOutcome[$userId]['had_win'] = true;
|
||||||
|
}
|
||||||
|
if (bccomp($win, '0', 4) > 0 && StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0))) {
|
||||||
|
$jackpotNotify[$userId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::creditUserPayout($bet, $betId, $win, $now);
|
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||||||
|
if (bccomp($win, '0', 4) > 0) {
|
||||||
|
$paid = self::creditUserPayout($bet, $betId, $win, $now);
|
||||||
|
if ($paid !== null) {
|
||||||
|
$balanceAfter = $paid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$periodNo = (string) ($bet['period_no'] ?? '');
|
||||||
|
if (!isset($aggregateByUser[$userId])) {
|
||||||
|
$aggregateByUser[$userId] = [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'total_win' => '0.0000',
|
||||||
|
'balance_after' => $balanceAfter,
|
||||||
|
'orders' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$aggregateByUser[$userId]['total_win'] = bcadd($aggregateByUser[$userId]['total_win'], $win, 4);
|
||||||
|
$aggregateByUser[$userId]['balance_after'] = $balanceAfter;
|
||||||
|
$aggregateByUser[$userId]['orders'][] = [
|
||||||
|
'order_no' => (string) $betId,
|
||||||
|
'win_amount' => $win,
|
||||||
|
'hit' => bccomp($win, '0', 4) > 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($userOutcome as $userId => $info) {
|
||||||
|
$streakAt = (int) ($info['streak_at'] ?? 0);
|
||||||
|
$hadWin = (bool) ($info['had_win'] ?? false);
|
||||||
|
if ($hadWin) {
|
||||||
|
$next = $streakAt + 1;
|
||||||
|
if ($next > 10) {
|
||||||
|
$next = 10;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$next = 0;
|
||||||
|
}
|
||||||
|
Db::name('user')->where('id', $userId)->update([
|
||||||
|
'current_streak' => $next,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($aggregateByUser as $userId => $agg) {
|
||||||
|
$hitOrderCount = 0;
|
||||||
|
foreach ($agg['orders'] as $o) {
|
||||||
|
if (($o['hit'] ?? false) === true) {
|
||||||
|
$hitOrderCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UserPushService::publish((int) $userId, UserPushService::EVT_BET_SETTLED, [
|
||||||
|
'period_no' => $agg['period_no'],
|
||||||
|
'result_number' => $resultNumber,
|
||||||
|
'total_win_amount' => $agg['total_win'],
|
||||||
|
'order_count' => count($agg['orders']),
|
||||||
|
'hit_order_count' => $hitOrderCount,
|
||||||
|
'balance_after' => $agg['balance_after'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (bccomp($agg['total_win'], '0', 4) > 0) {
|
||||||
|
UserPushService::publish((int) $userId, UserPushService::EVT_WALLET_CHANGED, [
|
||||||
|
'reason' => 'payout',
|
||||||
|
'ref_type' => 'game_period',
|
||||||
|
'ref_id' => (string) $recordId,
|
||||||
|
'delta' => $agg['total_win'],
|
||||||
|
'balance_after' => $agg['balance_after'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$jackpotHits = [];
|
||||||
|
foreach ($jackpotNotify as $uid => $_) {
|
||||||
|
if (!isset($aggregateByUser[$uid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$agg = $aggregateByUser[$uid];
|
||||||
|
if (bccomp($agg['total_win'], '0', 4) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$jackpotHits[] = [
|
||||||
|
'user_id' => (int) $uid,
|
||||||
|
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||||||
|
'total_win' => (string) $agg['total_win'],
|
||||||
|
'result_number' => $resultNumber,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['jackpot_hits' => $jackpotHits];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。
|
* 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。
|
||||||
*/
|
*/
|
||||||
@@ -96,8 +209,9 @@ final class GameBetSettleService
|
|||||||
}
|
}
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
self::settleBetsForDraw($rid, $rn);
|
$out = self::settleBetsForDraw($rid, $rn);
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
JackpotPushService::publishHits($out['jackpot_hits'] ?? []);
|
||||||
$count++;
|
$count++;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
@@ -109,7 +223,7 @@ final class GameBetSettleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应付派彩:开奖号码 ∈ pick_numbers 即中奖;整笔 total_amount × (连胜+1) × 33(与 GameLiveService 一致)。
|
* 应付派彩:开奖号码 ∈ pick_numbers 即中奖;整笔 total_amount × odds_factor(odds_factor 来自连胜奖励表对应档位)。
|
||||||
*/
|
*/
|
||||||
public static function computeWinAmount(array $bet, int $resultNumber): string
|
public static function computeWinAmount(array $bet, int $resultNumber): string
|
||||||
{
|
{
|
||||||
@@ -126,7 +240,7 @@ final class GameBetSettleService
|
|||||||
}
|
}
|
||||||
$total = (string) ($bet['total_amount'] ?? '0');
|
$total = (string) ($bet['total_amount'] ?? '0');
|
||||||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||||||
$odds = (string) (($streak + 1) * self::BASE_ODDS);
|
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||||||
|
|
||||||
return bcmul($total, $odds, 4);
|
return bcmul($total, $odds, 4);
|
||||||
}
|
}
|
||||||
@@ -152,7 +266,6 @@ final class GameBetSettleService
|
|||||||
if (bccomp($flow, '0', 4) <= 0) {
|
if (bccomp($flow, '0', 4) <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 原子加法:避免读-改-写导致的并发覆盖;$flow 已由 bcadd 归一化为纯数字字符串,不存在 SQL 注入
|
|
||||||
Db::name('user')
|
Db::name('user')
|
||||||
->where('id', $userId)
|
->where('id', $userId)
|
||||||
->update([
|
->update([
|
||||||
@@ -161,21 +274,26 @@ final class GameBetSettleService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): void
|
/**
|
||||||
|
* @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null
|
||||||
|
*/
|
||||||
|
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string
|
||||||
{
|
{
|
||||||
$userId = (int) ($bet['user_id'] ?? 0);
|
$userId = (int) ($bet['user_id'] ?? 0);
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$idem = 'payout_bet_' . $betId;
|
$idem = 'payout_bet_' . $betId;
|
||||||
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
|
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
|
||||||
return;
|
$coin = Db::name('user')->where('id', $userId)->value('coin');
|
||||||
|
|
||||||
|
return (string) ($coin ?? '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Db::name('user')->where('id', $userId)->find();
|
$user = Db::name('user')->where('id', $userId)->find();
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$before = (string) ($user['coin'] ?? '0');
|
$before = (string) ($user['coin'] ?? '0');
|
||||||
@@ -201,5 +319,7 @@ final class GameBetSettleService
|
|||||||
'coin' => $after,
|
'coin' => $after,
|
||||||
'update_time' => $now,
|
'update_time' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return $after;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\game\StreakWinReward;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use Webman\Push\Api;
|
use Webman\Push\Api;
|
||||||
|
|
||||||
final class GameLiveService
|
final class GameLiveService
|
||||||
{
|
{
|
||||||
private const BASE_ODDS = 33;
|
|
||||||
private const CHANNEL = 'game-live';
|
private const CHANNEL = 'game-live';
|
||||||
private const EVENT = 'bet-updated';
|
private const EVENT = 'bet-updated';
|
||||||
|
|
||||||
|
/** 与《36字花-移动端接口设计草案》7.1 对齐:公共对局频道 */
|
||||||
|
private const CHANNEL_PUBLIC_GAME_PERIOD = 'public-game-period';
|
||||||
|
private const EVT_PERIOD_TICK = 'period.tick';
|
||||||
|
private const EVT_PERIOD_LOCKED = 'period.locked';
|
||||||
|
private const EVT_PERIOD_OPENED = 'period.opened';
|
||||||
|
private const EVT_PERIOD_PAYOUT = 'period.payout';
|
||||||
private const KEY_PERIOD_SECONDS = 'period_seconds';
|
private const KEY_PERIOD_SECONDS = 'period_seconds';
|
||||||
private const KEY_BET_SECONDS = 'bet_seconds';
|
private const KEY_BET_SECONDS = 'bet_seconds';
|
||||||
private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';
|
private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';
|
||||||
@@ -20,26 +27,21 @@ final class GameLiveService
|
|||||||
/** 开奖结果号码池:1 至此上限(与单注可选号码个数配置无关) */
|
/** 开奖结果号码池:1 至此上限(与单注可选号码个数配置无关) */
|
||||||
private const DRAW_NUMBER_MAX = 36;
|
private const DRAW_NUMBER_MAX = 36;
|
||||||
|
|
||||||
|
/** 开奖后派彩展示宽限期(秒),之后再创建下一期 */
|
||||||
|
private const PAYOUT_GRACE_SECONDS = 3;
|
||||||
|
|
||||||
public static function buildSnapshot(?int $recordId = null): array
|
public static function buildSnapshot(?int $recordId = null): array
|
||||||
{
|
{
|
||||||
$record = self::resolveRecord($recordId);
|
$record = self::resolveRecord($recordId);
|
||||||
if (!$record) {
|
if (!$record) {
|
||||||
return [
|
return self::emptySnapshotPayload();
|
||||||
'record' => null,
|
}
|
||||||
'bets' => [],
|
|
||||||
'candidate_numbers' => [],
|
$rid = (int) $record['id'];
|
||||||
'ai_default_number' => null,
|
self::ensureAiLocked($rid);
|
||||||
'calc_number' => null,
|
$record = self::reloadRecord($rid);
|
||||||
'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30),
|
if (!$record) {
|
||||||
'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20),
|
return self::emptySnapshotPayload();
|
||||||
'pick_max_number_count' => self::getPickMaxNumberCount(),
|
|
||||||
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
|
||||||
'remaining_seconds' => 0,
|
|
||||||
'bet_remaining_seconds' => 0,
|
|
||||||
'can_calculate' => false,
|
|
||||||
'can_draw' => false,
|
|
||||||
'server_time' => time(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||||
@@ -48,19 +50,22 @@ final class GameLiveService
|
|||||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
$remaining = max(0, $periodSeconds - $elapsed);
|
$remaining = max(0, $periodSeconds - $elapsed);
|
||||||
$betRemaining = max(0, $betSeconds - $elapsed);
|
$betRemaining = max(0, $betSeconds - $elapsed);
|
||||||
|
$status = (int) $record['status'];
|
||||||
|
|
||||||
|
$payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0;
|
||||||
|
$payoutRemaining = 0;
|
||||||
|
if ($status === 3 && $payoutUntil > 0) {
|
||||||
|
$payoutRemaining = max(0, $payoutUntil - time());
|
||||||
|
}
|
||||||
|
|
||||||
$bets = Db::name('bet_order')
|
$bets = Db::name('bet_order')
|
||||||
->where('period_id', (int) $record['id'])
|
->where('period_id', $rid)
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->limit(200)
|
->limit(200)
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$candidates = [];
|
$candidates = [];
|
||||||
$bestNumber = null;
|
|
||||||
$bestLoss = null;
|
|
||||||
$bestNumbers = [];
|
|
||||||
$status = (int) $record['status'];
|
|
||||||
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
|
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
|
||||||
if ($canCalculate) {
|
if ($canCalculate) {
|
||||||
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
|
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
|
||||||
@@ -69,18 +74,28 @@ final class GameLiveService
|
|||||||
'number' => $n,
|
'number' => $n,
|
||||||
'estimated_loss' => $loss,
|
'estimated_loss' => $loss,
|
||||||
];
|
];
|
||||||
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
|
|
||||||
$bestLoss = $loss;
|
|
||||||
$bestNumbers = [$n];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) {
|
|
||||||
$bestNumbers[] = $n;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$bestNumber = self::pickRandomNumber($bestNumbers);
|
|
||||||
|
$aiLocked = $record['ai_locked_number'] ?? null;
|
||||||
|
$aiDisplay = null;
|
||||||
|
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
|
||||||
|
$aiDisplay = (int) $aiLocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$pendingRaw = $record['pending_draw_number'] ?? null;
|
||||||
|
$pendingDraw = null;
|
||||||
|
if ($pendingRaw !== null && $pendingRaw !== '' && is_numeric((string) $pendingRaw)) {
|
||||||
|
$pd = (int) $pendingRaw;
|
||||||
|
if ($pd >= 1 && $pd <= self::DRAW_NUMBER_MAX) {
|
||||||
|
$pendingDraw = $pd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$canScheduleDraw = ($status === 0 || $status === 1)
|
||||||
|
&& $elapsed >= $betSeconds
|
||||||
|
&& $elapsed < $periodSeconds;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'record' => $record,
|
'record' => $record,
|
||||||
'bets' => array_map(static function (array $row): array {
|
'bets' => array_map(static function (array $row): array {
|
||||||
@@ -95,16 +110,47 @@ final class GameLiveService
|
|||||||
];
|
];
|
||||||
}, $bets),
|
}, $bets),
|
||||||
'candidate_numbers' => $candidates,
|
'candidate_numbers' => $candidates,
|
||||||
'ai_default_number' => $bestNumber,
|
'ai_default_number' => $aiDisplay,
|
||||||
'calc_number' => $bestNumber,
|
'calc_number' => $aiDisplay,
|
||||||
|
'pending_draw_number' => $pendingDraw,
|
||||||
'period_seconds' => $periodSeconds,
|
'period_seconds' => $periodSeconds,
|
||||||
'bet_seconds' => $betSeconds,
|
'bet_seconds' => $betSeconds,
|
||||||
'pick_max_number_count' => $pickMax,
|
'pick_max_number_count' => $pickMax,
|
||||||
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
||||||
'remaining_seconds' => $remaining,
|
'remaining_seconds' => $remaining,
|
||||||
'bet_remaining_seconds' => $betRemaining,
|
'bet_remaining_seconds' => $betRemaining,
|
||||||
|
'payout_remaining_seconds' => $payoutRemaining,
|
||||||
|
'is_payout_phase' => $status === 3,
|
||||||
'can_calculate' => $canCalculate,
|
'can_calculate' => $canCalculate,
|
||||||
'can_draw' => $canCalculate,
|
'can_draw' => $canScheduleDraw,
|
||||||
|
'can_schedule_draw' => $canScheduleDraw,
|
||||||
|
'server_time' => time(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function emptySnapshotPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'record' => null,
|
||||||
|
'bets' => [],
|
||||||
|
'candidate_numbers' => [],
|
||||||
|
'ai_default_number' => null,
|
||||||
|
'calc_number' => null,
|
||||||
|
'pending_draw_number' => null,
|
||||||
|
'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30),
|
||||||
|
'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20),
|
||||||
|
'pick_max_number_count' => self::getPickMaxNumberCount(),
|
||||||
|
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
||||||
|
'remaining_seconds' => 0,
|
||||||
|
'bet_remaining_seconds' => 0,
|
||||||
|
'payout_remaining_seconds' => 0,
|
||||||
|
'is_payout_phase' => false,
|
||||||
|
'can_calculate' => false,
|
||||||
|
'can_draw' => false,
|
||||||
|
'can_schedule_draw' => false,
|
||||||
'server_time' => time(),
|
'server_time' => time(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -124,12 +170,11 @@ final class GameLiveService
|
|||||||
if ($elapsed < $betSeconds) {
|
if ($elapsed < $betSeconds) {
|
||||||
return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算'];
|
return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算'];
|
||||||
}
|
}
|
||||||
if ((int) $record['status'] === 0) {
|
|
||||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
self::ensureAiLocked((int) $record['id']);
|
||||||
'status' => 1,
|
$record = self::reloadRecord((int) $record['id']);
|
||||||
'update_time' => time(),
|
if (!$record) {
|
||||||
]);
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
$record['status'] = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$pickMax = self::getPickMaxNumberCount();
|
$pickMax = self::getPickMaxNumberCount();
|
||||||
@@ -156,6 +201,12 @@ final class GameLiveService
|
|||||||
}
|
}
|
||||||
$bestNumber = self::pickRandomNumber($bestNumbers);
|
$bestNumber = self::pickRandomNumber($bestNumbers);
|
||||||
|
|
||||||
|
$aiLocked = $record['ai_locked_number'] ?? null;
|
||||||
|
$aiDisplay = null;
|
||||||
|
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
|
||||||
|
$aiDisplay = (int) $aiLocked;
|
||||||
|
}
|
||||||
|
|
||||||
$finalNumber = $manualNumber ?? $bestNumber;
|
$finalNumber = $manualNumber ?? $bestNumber;
|
||||||
$finalLoss = '0.0000';
|
$finalLoss = '0.0000';
|
||||||
if ($finalNumber !== null) {
|
if ($finalNumber !== null) {
|
||||||
@@ -171,47 +222,180 @@ final class GameLiveService
|
|||||||
'pick_max_number_count' => $pickMax,
|
'pick_max_number_count' => $pickMax,
|
||||||
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
||||||
'candidate_numbers' => $candidates,
|
'candidate_numbers' => $candidates,
|
||||||
'ai_default_number' => $bestNumber,
|
'ai_default_number' => $aiDisplay,
|
||||||
'final_number' => $finalNumber,
|
'final_number' => $finalNumber,
|
||||||
'final_estimated_loss' => $finalLoss,
|
'final_estimated_loss' => $finalLoss,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员预约本期开奖号码(倒计时结束后由 tick 自动开奖,不立即开奖)。
|
||||||
|
*/
|
||||||
|
public static function scheduleDraw(?int $recordId, int $manualNumber): array
|
||||||
|
{
|
||||||
|
if ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX) {
|
||||||
|
return ['ok' => false, 'msg' => '开奖号码超出允许范围'];
|
||||||
|
}
|
||||||
|
$record = self::resolveRecord($recordId);
|
||||||
|
if (!$record) {
|
||||||
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
|
}
|
||||||
|
if (!in_array((int) $record['status'], [0, 1], true)) {
|
||||||
|
return ['ok' => false, 'msg' => '当前对局状态不可预约开奖'];
|
||||||
|
}
|
||||||
|
$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' => '下注尚未结束,无法预约开奖'];
|
||||||
|
}
|
||||||
|
if ($elapsed >= $periodSeconds) {
|
||||||
|
return ['ok' => false, 'msg' => '本期倒计时已结束,请刷新页面'];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::ensureAiLocked((int) $record['id']);
|
||||||
|
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
||||||
|
'pending_draw_number' => $manualNumber,
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
self::publishSnapshot(null);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'msg' => '已预约本期开奖号码,倒计时结束后将使用该号码开奖',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 倒计时结束自动开奖(AI 或预约号码);派彩宽限期后由 finalizePayoutGrace 结单并开下一期。
|
||||||
|
*/
|
||||||
public static function drawResult(?int $recordId, ?int $manualNumber = null): array
|
public static function drawResult(?int $recordId, ?int $manualNumber = null): array
|
||||||
{
|
{
|
||||||
$calc = self::calculateResult($recordId, $manualNumber);
|
$record = self::resolveRecord($recordId);
|
||||||
if (!($calc['ok'] ?? false)) {
|
if (!$record) {
|
||||||
return $calc;
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
}
|
}
|
||||||
$record = $calc['record'];
|
if (!in_array((int) $record['status'], [0, 1], true)) {
|
||||||
$finalNumber = (int) $calc['final_number'];
|
return ['ok' => false, 'msg' => '当前对局状态不可开奖'];
|
||||||
|
}
|
||||||
|
$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' => '下注开放时长未结束,不可开奖'];
|
||||||
|
}
|
||||||
|
if ($elapsed < $periodSeconds) {
|
||||||
|
return ['ok' => false, 'msg' => '本期倒计时未结束,无法开奖'];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::ensureAiLocked((int) $record['id']);
|
||||||
|
$record = self::reloadRecord((int) $record['id']);
|
||||||
|
if (!$record) {
|
||||||
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$useManual = $manualNumber;
|
||||||
|
if ($useManual === null) {
|
||||||
|
$p = $record['pending_draw_number'] ?? null;
|
||||||
|
if ($p !== null && $p !== '' && is_numeric((string) $p)) {
|
||||||
|
$pn = (int) $p;
|
||||||
|
if ($pn >= 1 && $pn <= self::DRAW_NUMBER_MAX) {
|
||||||
|
$useManual = $pn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalNumber = null;
|
||||||
|
$drawMode = 0;
|
||||||
|
if ($useManual !== null && $useManual >= 1 && $useManual <= self::DRAW_NUMBER_MAX) {
|
||||||
|
$finalNumber = $useManual;
|
||||||
|
$drawMode = 1;
|
||||||
|
} else {
|
||||||
|
$al = $record['ai_locked_number'] ?? null;
|
||||||
|
if ($al !== null && $al !== '' && is_numeric((string) $al)) {
|
||||||
|
$finalNumber = (int) $al;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($finalNumber === null || $finalNumber < 1) {
|
||||||
|
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
|
||||||
|
$finalNumber = self::computeBestNumberFromBets($bets) ?? 1;
|
||||||
|
$drawMode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
|
||||||
|
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
|
||||||
$now = time();
|
$now = time();
|
||||||
|
$payoutUntil = $now + self::PAYOUT_GRACE_SECONDS;
|
||||||
|
|
||||||
|
$settleOut = ['jackpot_hits' => []];
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
||||||
'status' => 4,
|
'status' => 3,
|
||||||
'result_number' => $finalNumber,
|
'result_number' => $finalNumber,
|
||||||
'draw_mode' => $manualNumber === null ? 0 : 1,
|
'draw_mode' => $drawMode,
|
||||||
|
'pending_draw_number' => null,
|
||||||
|
'payout_until' => $payoutUntil,
|
||||||
'update_time' => $now,
|
'update_time' => $now,
|
||||||
]);
|
]);
|
||||||
GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
|
$settleOut = GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
|
||||||
GameRecordService::createNextRecordAfterDraw();
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
GameRecordStatService::refreshForRecordId((int) $record['id']);
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
return ['ok' => false, 'msg' => $e->getMessage()];
|
return ['ok' => false, 'msg' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
GameRecordStatService::refreshForRecordId((int) $record['id']);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
JackpotPushService::publishHits($settleOut['jackpot_hits'] ?? []);
|
||||||
|
|
||||||
|
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
||||||
|
self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil);
|
||||||
self::publishSnapshot(null);
|
self::publishSnapshot(null);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'msg' => '开奖完成',
|
'msg' => '开奖完成,派彩中',
|
||||||
'result_number' => $finalNumber,
|
'result_number' => $finalNumber,
|
||||||
'estimated_loss' => $calc['final_estimated_loss'],
|
'estimated_loss' => $finalLoss,
|
||||||
|
'payout_until' => $payoutUntil,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派彩宽限期结束:将本期置为已结束并创建下一期。
|
||||||
|
*/
|
||||||
|
public static function finalizePayoutGrace(): void
|
||||||
|
{
|
||||||
|
$row = Db::name('game_record')
|
||||||
|
->where('status', 3)
|
||||||
|
->where('payout_until', '>', 0)
|
||||||
|
->where('payout_until', '<=', time())
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if (!$row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$id = (int) $row['id'];
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
Db::name('game_record')->where('id', $id)->update([
|
||||||
|
'status' => 4,
|
||||||
|
'payout_until' => null,
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
GameRecordService::createNextRecordAfterDraw();
|
||||||
|
Db::commit();
|
||||||
|
} catch (Throwable) {
|
||||||
|
Db::rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GameRecordStatService::refreshForRecordId($id);
|
||||||
|
self::publishSnapshot(null);
|
||||||
|
}
|
||||||
|
|
||||||
public static function tickAutoDraw(): void
|
public static function tickAutoDraw(): void
|
||||||
{
|
{
|
||||||
$record = self::resolveRecord(null);
|
$record = self::resolveRecord(null);
|
||||||
@@ -221,13 +405,12 @@ final class GameLiveService
|
|||||||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
if ($elapsed >= $betSeconds && (int) $record['status'] === 0) {
|
self::ensureAiLocked((int) $record['id']);
|
||||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
$record = self::reloadRecord((int) $record['id']);
|
||||||
'status' => 1,
|
if (!$record) {
|
||||||
'update_time' => time(),
|
return;
|
||||||
]);
|
|
||||||
$record['status'] = 1;
|
|
||||||
}
|
}
|
||||||
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
if ($elapsed < $periodSeconds) {
|
if ($elapsed < $periodSeconds) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -238,16 +421,147 @@ final class GameLiveService
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$payload = self::buildSnapshot($recordId);
|
$payload = self::buildSnapshot($recordId);
|
||||||
$api = new Api(
|
$api = self::createPushApi();
|
||||||
|
$api->trigger(self::CHANNEL, self::EVENT, $payload);
|
||||||
|
self::publishPublicPeriodTick($payload, $api);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createPushApi(): Api
|
||||||
|
{
|
||||||
|
return new Api(
|
||||||
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
|
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
|
||||||
(string) config('plugin.webman.push.app.app_key'),
|
(string) config('plugin.webman.push.app.app_key'),
|
||||||
(string) config('plugin.webman.push.app.app_secret')
|
(string) config('plugin.webman.push.app.app_secret')
|
||||||
);
|
);
|
||||||
$api->trigger(self::CHANNEL, self::EVENT, $payload);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义)
|
||||||
|
*/
|
||||||
|
private static function publishPublicPeriodTick(array $snapshot, Api $api): void
|
||||||
|
{
|
||||||
|
$record = $snapshot['record'] ?? null;
|
||||||
|
$serverTime = (int) ($snapshot['server_time'] ?? time());
|
||||||
|
$remaining = (int) ($snapshot['remaining_seconds'] ?? 0);
|
||||||
|
$betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0);
|
||||||
|
$payoutRem = (int) ($snapshot['payout_remaining_seconds'] ?? 0);
|
||||||
|
$isPayout = !empty($snapshot['is_payout_phase']);
|
||||||
|
$periodNo = '';
|
||||||
|
$dbStatus = 0;
|
||||||
|
$resultNumber = null;
|
||||||
|
if (is_array($record)) {
|
||||||
|
$periodNo = (string) ($record['period_no'] ?? '');
|
||||||
|
$dbStatus = (int) ($record['status'] ?? 0);
|
||||||
|
$rn = $record['result_number'] ?? null;
|
||||||
|
$resultNumber = is_numeric((string) $rn) ? (int) $rn : null;
|
||||||
|
}
|
||||||
|
if ($record === null || $periodNo === '') {
|
||||||
|
$status = 'idle';
|
||||||
|
} else {
|
||||||
|
$status = self::mapPublicPeriodStatus($dbStatus, $betCloseIn);
|
||||||
|
}
|
||||||
|
$payload = [
|
||||||
|
'server_time' => $serverTime,
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'status' => $status,
|
||||||
|
'countdown' => $remaining,
|
||||||
|
'bet_close_in'=> $betCloseIn,
|
||||||
|
'payout_remaining_seconds' => $payoutRem,
|
||||||
|
'is_payout_phase' => $isPayout,
|
||||||
|
'payout_message' => $isPayout ? '派彩中,请稍候' : '',
|
||||||
|
];
|
||||||
|
if ($periodNo !== '' && $record !== null) {
|
||||||
|
$start = (int) ($record['period_start_at'] ?? 0);
|
||||||
|
$betSeconds = (int) ($snapshot['bet_seconds'] ?? 20);
|
||||||
|
$periodSeconds = (int) ($snapshot['period_seconds'] ?? 30);
|
||||||
|
if ($start > 0) {
|
||||||
|
$payload['lock_at'] = $start + $betSeconds;
|
||||||
|
$payload['open_at'] = $start + $periodSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($resultNumber !== null) {
|
||||||
|
$payload['result_number'] = $resultNumber;
|
||||||
|
}
|
||||||
|
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_TICK, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record game_record 行
|
||||||
|
*/
|
||||||
|
private static function publishPublicPeriodLocked(array $record): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$start = (int) ($record['period_start_at'] ?? 0);
|
||||||
|
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||||
|
$periodNo = (string) ($record['period_no'] ?? '');
|
||||||
|
$payload = [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'lock_at' => $start > 0 ? $start + $betSeconds : time(),
|
||||||
|
];
|
||||||
|
$api = self::createPushApi();
|
||||||
|
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_LOCKED, $payload);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'result_number' => $resultNumber,
|
||||||
|
'open_time' => $openTime,
|
||||||
|
];
|
||||||
|
$api = self::createPushApi();
|
||||||
|
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_OPENED, $payload);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派彩阶段开始(开奖后宽限期内推送)
|
||||||
|
*/
|
||||||
|
private static function publishPublicPeriodPayout(string $periodNo, int $resultNumber, int $payoutUntil): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'result_number' => $resultNumber,
|
||||||
|
'payout_until' => $payoutUntil,
|
||||||
|
'message' => '派彩中,请稍候',
|
||||||
|
];
|
||||||
|
$api = self::createPushApi();
|
||||||
|
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_PAYOUT, $payload);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished
|
||||||
|
*/
|
||||||
|
private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string
|
||||||
|
{
|
||||||
|
if ($dbStatus === 0) {
|
||||||
|
return $betCloseIn > 0 ? 'betting' : 'locked';
|
||||||
|
}
|
||||||
|
if ($dbStatus === 1) {
|
||||||
|
return 'locked';
|
||||||
|
}
|
||||||
|
if ($dbStatus === 4) {
|
||||||
|
return 'finished';
|
||||||
|
}
|
||||||
|
if ($dbStatus === 3) {
|
||||||
|
return 'payouting';
|
||||||
|
}
|
||||||
|
if ($dbStatus === 2) {
|
||||||
|
return 'settling';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'finished';
|
||||||
|
}
|
||||||
|
|
||||||
private static function resolveRecord(?int $recordId): ?array
|
private static function resolveRecord(?int $recordId): ?array
|
||||||
{
|
{
|
||||||
if ($recordId !== null && $recordId > 0) {
|
if ($recordId !== null && $recordId > 0) {
|
||||||
@@ -259,6 +573,84 @@ final class GameLiveService
|
|||||||
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
|
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function reloadRecord(int $id): ?array
|
||||||
|
{
|
||||||
|
$row = Db::name('game_record')->where('id', $id)->find();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封盘后计算并锁定 AI 号码(本期不变),并封盘(status 0→1)。
|
||||||
|
*/
|
||||||
|
private static function ensureAiLocked(int $recordId): void
|
||||||
|
{
|
||||||
|
$record = Db::name('game_record')->where('id', $recordId)->find();
|
||||||
|
if (!$record) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||||
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
|
if ($elapsed < $betSeconds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$st = (int) $record['status'];
|
||||||
|
if ($st !== 0 && $st !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $record['ai_locked_number'] ?? null;
|
||||||
|
if ($existing !== null && $existing !== '' && is_numeric((string) $existing) && (int) $existing > 0) {
|
||||||
|
if ($st === 0) {
|
||||||
|
Db::name('game_record')->where('id', $recordId)->update([
|
||||||
|
'status' => 1,
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
$record['status'] = 1;
|
||||||
|
self::publishPublicPeriodLocked($record);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray();
|
||||||
|
$best = self::computeBestNumberFromBets($bets);
|
||||||
|
if ($best === null || $best < 1) {
|
||||||
|
$best = 1;
|
||||||
|
}
|
||||||
|
$update = [
|
||||||
|
'ai_locked_number' => $best,
|
||||||
|
'update_time' => time(),
|
||||||
|
];
|
||||||
|
if ($st === 0) {
|
||||||
|
$update['status'] = 1;
|
||||||
|
}
|
||||||
|
Db::name('game_record')->where('id', $recordId)->update($update);
|
||||||
|
$record = array_merge($record, $update);
|
||||||
|
if ($st === 0) {
|
||||||
|
self::publishPublicPeriodLocked($record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $bets
|
||||||
|
*/
|
||||||
|
private static function computeBestNumberFromBets(array $bets): ?int
|
||||||
|
{
|
||||||
|
$bestLoss = null;
|
||||||
|
$bestNumbers = [];
|
||||||
|
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
|
||||||
|
$loss = self::estimateLossForNumber($bets, $n);
|
||||||
|
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
|
||||||
|
$bestLoss = $loss;
|
||||||
|
$bestNumbers = [$n];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) {
|
||||||
|
$bestNumbers[] = $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::pickRandomNumber($bestNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
private static function getConfigInt(string $key, int $default): int
|
private static function getConfigInt(string $key, int $default): int
|
||||||
{
|
{
|
||||||
$row = Db::name('game_config')->where('config_key', $key)->find();
|
$row = Db::name('game_config')->where('config_key', $key)->find();
|
||||||
@@ -304,7 +696,7 @@ final class GameLiveService
|
|||||||
}
|
}
|
||||||
$total = (string) ($bet['total_amount'] ?? '0');
|
$total = (string) ($bet['total_amount'] ?? '0');
|
||||||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||||||
$odds = (string) (($streak + 1) * self::BASE_ODDS);
|
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||||||
$orderPayout = bcmul($total, $odds, 4);
|
$orderPayout = bcmul($total, $odds, 4);
|
||||||
$payout = bcadd($payout, $orderPayout, 4);
|
$payout = bcadd($payout, $orderPayout, 4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\game\StreakWinReward;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,8 +12,6 @@ use support\think\Db;
|
|||||||
*/
|
*/
|
||||||
final class GameRecordStatService
|
final class GameRecordStatService
|
||||||
{
|
{
|
||||||
private const BASE_ODDS = 33;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据注单与开奖号码回写 game_record 统计字段(已结束对局)。
|
* 根据注单与开奖号码回写 game_record 统计字段(已结束对局)。
|
||||||
*/
|
*/
|
||||||
@@ -82,7 +81,7 @@ final class GameRecordStatService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与 GameLiveService::estimateLossForNumber 中派彩一致:命中号码时 total_amount × (streak+1) × 33。
|
* 与 GameLiveService::estimateLossForNumber 一致:命中号码时 total_amount × odds_factor。
|
||||||
*/
|
*/
|
||||||
private static function estimatePayoutForBet(array $bet, int $resultNumber): string
|
private static function estimatePayoutForBet(array $bet, int $resultNumber): string
|
||||||
{
|
{
|
||||||
@@ -99,7 +98,7 @@ final class GameRecordStatService
|
|||||||
}
|
}
|
||||||
$total = (string) ($bet['total_amount'] ?? '0');
|
$total = (string) ($bet['total_amount'] ?? '0');
|
||||||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||||||
$odds = (string) (($streak + 1) * self::BASE_ODDS);
|
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||||||
|
|
||||||
return bcmul($total, $odds, 4);
|
return bcmul($total, $odds, 4);
|
||||||
}
|
}
|
||||||
|
|||||||
72
app/common/service/JackpotPushService.php
Normal file
72
app/common/service/JackpotPushService.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
use Webman\Push\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大奖派彩:玩家私有频道 + 公共频道(对局频道 + 公告频道,便于大厅与公告测试页均能收到)
|
||||||
|
*/
|
||||||
|
final class JackpotPushService
|
||||||
|
{
|
||||||
|
private const CHANNEL_GAME_PERIOD = 'public-game-period';
|
||||||
|
|
||||||
|
private const CHANNEL_OPERATION_NOTICE = 'public-operation-notice';
|
||||||
|
|
||||||
|
private const EVT_JACKPOT_HIT = 'jackpot.hit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{user_id: int, period_no: string, total_win: string, result_number: int}> $hits
|
||||||
|
*/
|
||||||
|
public static function publishHits(array $hits): void
|
||||||
|
{
|
||||||
|
foreach ($hits as $h) {
|
||||||
|
$uid = (int) ($h['user_id'] ?? 0);
|
||||||
|
if ($uid <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$periodNo = (string) ($h['period_no'] ?? '');
|
||||||
|
$totalWin = (string) ($h['total_win'] ?? '0');
|
||||||
|
$rn = (int) ($h['result_number'] ?? 0);
|
||||||
|
UserPushService::publish($uid, UserPushService::EVT_JACKPOT_HIT, [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'total_win_amount' => $totalWin,
|
||||||
|
'result_number' => $rn,
|
||||||
|
'is_jackpot' => true,
|
||||||
|
]);
|
||||||
|
self::publishPublicChannels($periodNo, $uid, $totalWin, $rn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private static function triggerChannel(Api $api, string $channel, array $payload): void
|
||||||
|
{
|
||||||
|
$api->trigger($channel, self::EVT_JACKPOT_HIT, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function publishPublicChannels(string $periodNo, int $userId, string $totalWin, int $resultNumber): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$api = new Api(
|
||||||
|
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
|
||||||
|
(string) config('plugin.webman.push.app.app_key'),
|
||||||
|
(string) config('plugin.webman.push.app.app_secret')
|
||||||
|
);
|
||||||
|
$payload = [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'total_win_amount' => $totalWin,
|
||||||
|
'result_number' => $resultNumber,
|
||||||
|
'message' => '恭喜玩家命中大奖派彩',
|
||||||
|
];
|
||||||
|
self::triggerChannel($api, self::CHANNEL_GAME_PERIOD, $payload);
|
||||||
|
self::triggerChannel($api, self::CHANNEL_OPERATION_NOTICE, $payload);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/common/service/UserPushService.php
Normal file
64
app/common/service/UserPushService.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use support\think\Db;
|
||||||
|
use Throwable;
|
||||||
|
use Webman\Push\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户私有频道推送:private-user-{uuid}(与移动端接口设计草案 7.1 一致)
|
||||||
|
*/
|
||||||
|
final class UserPushService
|
||||||
|
{
|
||||||
|
public const EVT_BET_ACCEPTED = 'bet.accepted';
|
||||||
|
|
||||||
|
/** 单注开奖结果(含未中奖 win_amount=0) */
|
||||||
|
public const EVT_BET_SETTLED = 'bet.settled';
|
||||||
|
|
||||||
|
public const EVT_WALLET_CHANGED = 'wallet.changed';
|
||||||
|
|
||||||
|
/** 命中配置为「大奖」的连胜档派彩(私有频道) */
|
||||||
|
public const EVT_JACKPOT_HIT = 'jackpot.hit';
|
||||||
|
|
||||||
|
private static function channelName(string $uuid): string
|
||||||
|
{
|
||||||
|
return 'private-user-' . $uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createApi(): Api
|
||||||
|
{
|
||||||
|
return new Api(
|
||||||
|
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
|
||||||
|
(string) config('plugin.webman.push.app.app_key'),
|
||||||
|
(string) config('plugin.webman.push.app.app_secret')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function uuidForUserId(int $userId): ?string
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$u = Db::name('user')->where('id', $userId)->value('uuid');
|
||||||
|
|
||||||
|
return is_string($u) && $u !== '' ? $u : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function publish(int $userId, string $event, array $data): void
|
||||||
|
{
|
||||||
|
$uuid = self::uuidForUserId($userId);
|
||||||
|
if ($uuid === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
self::createApi()->trigger(self::channelName($uuid), $event, $data);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ class GameLiveTicker
|
|||||||
public function onWorkerStart(): void
|
public function onWorkerStart(): void
|
||||||
{
|
{
|
||||||
Timer::add(1, static function (): void {
|
Timer::add(1, static function (): void {
|
||||||
|
GameLiveService::finalizePayoutGrace();
|
||||||
GameLiveService::tickAutoDraw();
|
GameLiveService::tickAutoDraw();
|
||||||
GameLiveService::publishSnapshot(null);
|
GameLiveService::publishSnapshot(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,53 @@ import createAxios from '/@/utils/axios'
|
|||||||
|
|
||||||
export const url = '/admin/Dashboard/'
|
export const url = '/admin/Dashboard/'
|
||||||
|
|
||||||
|
export interface DashboardTrend {
|
||||||
|
days: string[]
|
||||||
|
new_users: number[]
|
||||||
|
deposit_amount: string[]
|
||||||
|
bet_amount: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardChannelShareItem {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成功充值金额按渠道(value 为两位小数字符串) */
|
||||||
|
export interface DashboardDepositAmountChannelItem {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRecentUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
create_time: number
|
||||||
|
channel_name: string
|
||||||
|
head_image: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
user_total: number
|
||||||
|
user_new_today: number
|
||||||
|
user_new_yesterday: number
|
||||||
|
user_new_growth_pct: number | null
|
||||||
|
deposit_today_amount: string
|
||||||
|
deposit_today_count: number
|
||||||
|
withdraw_pending: number
|
||||||
|
bet_today_amount: string
|
||||||
|
bet_today_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPayload {
|
||||||
|
remark: string
|
||||||
|
stats: DashboardStats
|
||||||
|
trend: DashboardTrend
|
||||||
|
channel_share: DashboardChannelShareItem[]
|
||||||
|
deposit_amount_channel_share: DashboardDepositAmountChannelItem[]
|
||||||
|
recent_users: DashboardRecentUser[]
|
||||||
|
}
|
||||||
|
|
||||||
export function index() {
|
export function index() {
|
||||||
return createAxios({
|
return createAxios({
|
||||||
url: url + 'index',
|
url: url + 'index',
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ export default {
|
|||||||
'/': ['./frontend/${lang}/index.ts'],
|
'/': ['./frontend/${lang}/index.ts'],
|
||||||
[adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'],
|
[adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'],
|
||||||
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],
|
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],
|
||||||
|
/** 推送测试三页:共享 test.push.* 文案(见 PushChannelTestPage.vue) */
|
||||||
|
[adminBaseRoutePath + '/test/pushGamePeriod']: ['./backend/${lang}/test/push.ts'],
|
||||||
|
[adminBaseRoutePath + '/test/pushOperationNotice']: ['./backend/${lang}/test/push.ts'],
|
||||||
|
[adminBaseRoutePath + '/test/pushPrivateUser']: ['./backend/${lang}/test/push.ts'],
|
||||||
}
|
}
|
||||||
|
|||||||
8
web/src/lang/backend/en/config/streakWinReward.ts
Normal file
8
web/src/lang/backend/en/config/streakWinReward.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
desc: 'Streak levels 1–10: payout = bet total × odds_factor. Jackpot rows trigger jackpot.hit on the user private channel, public-game-period, and public-operation-notice when won.',
|
||||||
|
btn_save: 'Save',
|
||||||
|
streak: 'Streak (rounds)',
|
||||||
|
odds_factor: 'Odds factor',
|
||||||
|
is_jackpot: 'Jackpot',
|
||||||
|
err_factor: 'Row {no}: odds factor must be ≥ 1',
|
||||||
|
}
|
||||||
@@ -36,4 +36,27 @@ export default {
|
|||||||
second: 'Second',
|
second: 'Second',
|
||||||
day: 'Day',
|
day: 'Day',
|
||||||
'Number of attachments Uploaded': 'Number of attachments upload',
|
'Number of attachments Uploaded': 'Number of attachments upload',
|
||||||
|
|
||||||
|
stat_user_total: 'Total users',
|
||||||
|
stat_new_today: 'New users today',
|
||||||
|
stat_deposit_today: 'Deposits today (success)',
|
||||||
|
stat_withdraw_pending: 'Withdrawals pending review',
|
||||||
|
stat_hint_pending: 'Pending',
|
||||||
|
chart_new_user_deposit: 'Last 7 days: new users & daily deposits',
|
||||||
|
chart_bet_7d: 'Last 7 days: bet amount',
|
||||||
|
chart_channel_users: 'Users by channel',
|
||||||
|
chart_deposit_status: 'Deposit order status',
|
||||||
|
chart_deposit_amount_channel: 'Deposit amount by channel',
|
||||||
|
recent_users: 'Recent sign-ups',
|
||||||
|
no_data: 'No data',
|
||||||
|
bet_today_line: "Today's bet volume",
|
||||||
|
orders_unit: ' orders',
|
||||||
|
deposit_orders_today: '{n} successful today',
|
||||||
|
series_new_users: 'New users',
|
||||||
|
series_deposit_amount: 'Deposit amount',
|
||||||
|
series_bet_amount: 'Bet amount',
|
||||||
|
load_failed: 'Failed to load statistics. Please try again later.',
|
||||||
|
seconds_ago: 's ago',
|
||||||
|
minutes_ago: 'm ago',
|
||||||
|
hours_ago: 'h ago',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: 'Listen to pushed bet stream in real time and show the AI default number (minimum estimated platform loss).',
|
tip: 'Realtime bets; after lock the AI default number is fixed for this round and used at countdown end (or your scheduled number). After draw, ~3s payout grace then next round.',
|
||||||
current_record: 'Current round',
|
current_record: 'Current round',
|
||||||
ai_default_number: 'AI default number',
|
ai_default_number: 'AI default number',
|
||||||
|
pending_draw: 'Scheduled draw number',
|
||||||
countdown: 'Countdown',
|
countdown: 'Countdown',
|
||||||
bet_countdown: 'Bet left',
|
bet_countdown: 'Bet left',
|
||||||
draw_countdown: 'Draw left',
|
draw_countdown: 'Draw left',
|
||||||
|
payout_countdown: 'Payout left',
|
||||||
|
payout_na: '—',
|
||||||
|
payout_phase: 'Payout in progress',
|
||||||
btn_calc: 'Calculate PnL',
|
btn_calc: 'Calculate PnL',
|
||||||
btn_draw: 'Draw now',
|
btn_draw: 'Schedule draw',
|
||||||
calc_result_number: 'Calculated number',
|
calc_result_number: 'Calculated number',
|
||||||
calc_estimated_loss: 'Estimated payout',
|
calc_estimated_loss: 'Estimated payout',
|
||||||
push_connected: 'Push connected, realtime updates running',
|
push_connected: 'Push connected, realtime updates running',
|
||||||
|
|||||||
12
web/src/lang/backend/en/test/push.ts
Normal file
12
web/src/lang/backend/en/test/push.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
connected: 'Push connected (subscribed)',
|
||||||
|
disconnected: 'Disconnected or idle',
|
||||||
|
user_uuid: 'User uuid',
|
||||||
|
user_uuid_placeholder: '10-char public id as in mobile profile',
|
||||||
|
btn_connect: 'Connect & subscribe',
|
||||||
|
btn_disconnect: 'Disconnect',
|
||||||
|
btn_clear: 'Clear log',
|
||||||
|
channel_label: 'Channel',
|
||||||
|
log_title: 'Event log',
|
||||||
|
log_empty: 'No messages yet. Ensure the push worker is running and the server publishes to this channel.',
|
||||||
|
}
|
||||||
3
web/src/lang/backend/en/test/pushGamePeriod.ts
Normal file
3
web/src/lang/backend/en/test/pushGamePeriod.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
tip: 'Subscribe to public-game-period (global period channel) for period.tick / period.locked / period.opened / period.payout / jackpot.hit. The server must publish to this channel.',
|
||||||
|
}
|
||||||
3
web/src/lang/backend/en/test/pushOperationNotice.ts
Normal file
3
web/src/lang/backend/en/test/pushOperationNotice.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
tip: 'Subscribe to public-operation-notice for broadcast notices such as notice.popout. The server must publish to this channel.',
|
||||||
|
}
|
||||||
3
web/src/lang/backend/en/test/pushPrivateUser.ts
Normal file
3
web/src/lang/backend/en/test/pushPrivateUser.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
tip: 'Subscribe to private-user-{uuid}: enter the player uuid; auth uses /plugin/webman/push/auth like the mobile client. For bet.accepted, wallet.changed, etc.',
|
||||||
|
}
|
||||||
8
web/src/lang/backend/zh-cn/config/streakWinReward.ts
Normal file
8
web/src/lang/backend/zh-cn/config/streakWinReward.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
desc: '1~10 档连胜:派彩 = 压注总额 × 赔率系数(odds_factor)。勾选「大奖」的档位在中奖时会对玩家私有频道、public-game-period 与 public-operation-notice 推送 jackpot.hit。',
|
||||||
|
btn_save: '保存',
|
||||||
|
streak: '连胜档(局)',
|
||||||
|
odds_factor: '赔率系数',
|
||||||
|
is_jackpot: '是否大奖',
|
||||||
|
err_factor: '第 {no} 行赔率系数须 ≥1',
|
||||||
|
}
|
||||||
@@ -36,4 +36,27 @@ export default {
|
|||||||
second: '秒',
|
second: '秒',
|
||||||
day: '天',
|
day: '天',
|
||||||
'Number of attachments Uploaded': '附件上传量',
|
'Number of attachments Uploaded': '附件上传量',
|
||||||
|
|
||||||
|
stat_user_total: '会员总数',
|
||||||
|
stat_new_today: '今日新增用户',
|
||||||
|
stat_deposit_today: '今日充值(成功)',
|
||||||
|
stat_withdraw_pending: '待审核提现',
|
||||||
|
stat_hint_pending: '待处理',
|
||||||
|
chart_new_user_deposit: '近7日新增用户与每日充值',
|
||||||
|
chart_bet_7d: '近7日投注金额',
|
||||||
|
chart_channel_users: '用户渠道分布',
|
||||||
|
chart_deposit_status: '充值订单状态分布',
|
||||||
|
chart_deposit_amount_channel: '充值金额渠道分布',
|
||||||
|
recent_users: '最新注册用户',
|
||||||
|
no_data: '暂无数据',
|
||||||
|
bet_today_line: '今日投注流水',
|
||||||
|
orders_unit: '笔',
|
||||||
|
deposit_orders_today: '今日成功 {n} 笔',
|
||||||
|
series_new_users: '新增用户',
|
||||||
|
series_deposit_amount: '充值金额',
|
||||||
|
series_bet_amount: '投注金额',
|
||||||
|
load_failed: '统计数据加载失败,请稍后重试',
|
||||||
|
seconds_ago: '秒前',
|
||||||
|
minutes_ago: '分钟前',
|
||||||
|
hours_ago: '小时前',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: '实时监听页面推送的压注记录,并展示AI默认最优开奖号码(平台预估亏损最少)',
|
tip: '实时监听压注记录;封盘后 AI 默认号码会锁定,本期倒计时结束按该号码(或您预约的号码)开奖;开奖后约 3 秒派彩再进入下一期。',
|
||||||
current_record: '当前对局',
|
current_record: '当前对局',
|
||||||
ai_default_number: 'AI默认开奖号码',
|
ai_default_number: 'AI默认开奖号码',
|
||||||
|
pending_draw: '已预约开奖号码',
|
||||||
countdown: '倒计时',
|
countdown: '倒计时',
|
||||||
bet_countdown: '下注剩余',
|
bet_countdown: '下注剩余',
|
||||||
draw_countdown: '开奖剩余',
|
draw_countdown: '开奖剩余',
|
||||||
|
payout_countdown: '派彩剩余',
|
||||||
|
payout_na: '—',
|
||||||
|
payout_phase: '派彩中,请稍候',
|
||||||
btn_calc: '计算法盈亏',
|
btn_calc: '计算法盈亏',
|
||||||
btn_draw: '开奖',
|
btn_draw: '预约开奖',
|
||||||
calc_result_number: '计算开奖号码',
|
calc_result_number: '计算开奖号码',
|
||||||
calc_estimated_loss: '计算预估赔付',
|
calc_estimated_loss: '计算预估赔付',
|
||||||
push_connected: '推送服务已连接,页面数据实时更新中',
|
push_connected: '推送服务已连接,页面数据实时更新中',
|
||||||
|
|||||||
12
web/src/lang/backend/zh-cn/test/push.ts
Normal file
12
web/src/lang/backend/zh-cn/test/push.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
connected: '推送服务已连接(已订阅频道)',
|
||||||
|
disconnected: '未连接或已断开',
|
||||||
|
user_uuid: '用户 uuid',
|
||||||
|
user_uuid_placeholder: '与移动端档案一致的 10 位对外标识',
|
||||||
|
btn_connect: '连接并订阅',
|
||||||
|
btn_disconnect: '断开',
|
||||||
|
btn_clear: '清空日志',
|
||||||
|
channel_label: '当前频道',
|
||||||
|
log_title: '事件日志',
|
||||||
|
log_empty: '暂无推送,请确认 push 进程已启动且服务端会向对应频道发消息。',
|
||||||
|
}
|
||||||
3
web/src/lang/backend/zh-cn/test/pushGamePeriod.ts
Normal file
3
web/src/lang/backend/zh-cn/test/pushGamePeriod.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
tip: '订阅文档中的「全局对局频道」public-game-period,用于验证 period.tick / period.locked / period.opened / period.payout / jackpot.hit 等公共事件(需服务端向该频道推送)。',
|
||||||
|
}
|
||||||
3
web/src/lang/backend/zh-cn/test/pushOperationNotice.ts
Normal file
3
web/src/lang/backend/zh-cn/test/pushOperationNotice.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
tip: '订阅「公告广播频道」public-operation-notice:全站公告 notice.popout 与本游戏 jackpot.hit(连胜大奖广播)均会发往此频道;需服务端推送。',
|
||||||
|
}
|
||||||
3
web/src/lang/backend/zh-cn/test/pushPrivateUser.ts
Normal file
3
web/src/lang/backend/zh-cn/test/pushPrivateUser.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
tip: '订阅「用户私有频道」private-user-{uuid}:请输入玩家 uuid,连接后将走 /plugin/webman/push/auth 鉴权(与移动端一致)。用于验证 bet.accepted、wallet.changed 等私有事件。',
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ const staticRoutes: Array<RouteRecordRaw> = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: pageTitle('home'),
|
title: pageTitle('home'),
|
||||||
},
|
},
|
||||||
|
redirect: adminBaseRoutePath,
|
||||||
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 管理员登录页 - 不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局
|
// 管理员登录页 - 不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局
|
||||||
|
|||||||
82
web/src/utils/backend/pushChannelTest.ts
Normal file
82
web/src/utils/backend/pushChannelTest.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 后台推送频道测试:加载官方 push.js 并订阅频道、监听文档约定事件
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOC_EVENTS = [
|
||||||
|
'period.tick',
|
||||||
|
'period.locked',
|
||||||
|
'period.opened',
|
||||||
|
'period.payout',
|
||||||
|
'jackpot.hit',
|
||||||
|
'bet.accepted',
|
||||||
|
'bet.settled',
|
||||||
|
'wallet.changed',
|
||||||
|
'notice.popout',
|
||||||
|
'withdraw.review_required',
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function loadPushJs(): Promise<void> {
|
||||||
|
if ((window as any).Push) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = '/plugin/webman/push/push.js'
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => reject(new Error('load push.js failed'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushTestLogLine = { t: number; event: string; payload: string }
|
||||||
|
|
||||||
|
export function startPushChannelListener(options: {
|
||||||
|
url: string
|
||||||
|
app_key: string
|
||||||
|
channel: string
|
||||||
|
/** 私有频道需走 /plugin/webman/push/auth */
|
||||||
|
usePrivateAuth: boolean
|
||||||
|
onLog: (line: PushTestLogLine) => void
|
||||||
|
onConnected: (ok: boolean) => void
|
||||||
|
}): { disconnect: () => void } {
|
||||||
|
const PushCtor = (window as any).Push
|
||||||
|
const cfg: anyObj = { url: options.url, app_key: options.app_key }
|
||||||
|
if (options.usePrivateAuth) {
|
||||||
|
cfg.auth = '/plugin/webman/push/auth'
|
||||||
|
}
|
||||||
|
const client = new PushCtor(cfg)
|
||||||
|
const ch = client.subscribe(options.channel)
|
||||||
|
|
||||||
|
const pushLog = (event: string, data: unknown) => {
|
||||||
|
let payload = ''
|
||||||
|
try {
|
||||||
|
payload = typeof data === 'string' ? data : JSON.stringify(data)
|
||||||
|
} catch {
|
||||||
|
payload = String(data)
|
||||||
|
}
|
||||||
|
options.onLog({ t: Date.now(), event, payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.on('pusher:subscription_succeeded', () => {
|
||||||
|
options.onConnected(true)
|
||||||
|
pushLog('pusher:subscription_succeeded', {})
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const ev of DOC_EVENTS) {
|
||||||
|
ch.on(ev, (data: unknown) => {
|
||||||
|
options.onConnected(true)
|
||||||
|
pushLog(ev, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
disconnect: () => {
|
||||||
|
try {
|
||||||
|
client.disconnect()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
options.onConnected(false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,32 +14,45 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" border stripe :data="items" row-key="_rowKey" max-height="720">
|
<el-table
|
||||||
<el-table-column prop="sort" :label="t('config.depositTier.sort')" width="100" align="center">
|
v-loading="loading"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
:data="items"
|
||||||
|
row-key="_rowKey"
|
||||||
|
max-height="720"
|
||||||
|
class="deposit-tier-table"
|
||||||
|
header-align="center"
|
||||||
|
>
|
||||||
|
<el-table-column prop="sort" :label="t('config.depositTier.sort')" width="100" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number v-model="row.sort" :min="0" :max="9999" :controls="false" style="width: 100%" />
|
<div class="cell-center">
|
||||||
|
<el-input-number v-model="row.sort" :min="0" :max="9999" :controls="false" style="width: 100%; max-width: 160px" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.status')" width="100" align="center">
|
<el-table-column :label="t('config.depositTier.status')" width="100" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<div class="cell-center">
|
||||||
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" />
|
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.title_col')" min-width="180">
|
<el-table-column :label="t('config.depositTier.title_col')" min-width="180" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.title" maxlength="64" :placeholder="t('config.depositTier.title_ph')" />
|
<el-input v-model="row.title" maxlength="64" :placeholder="t('config.depositTier.title_ph')" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.title_en_col')" min-width="180">
|
<el-table-column :label="t('config.depositTier.title_en_col')" min-width="180" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.title_en" maxlength="64" :placeholder="t('config.depositTier.title_en_ph')" />
|
<el-input v-model="row.title_en" maxlength="64" :placeholder="t('config.depositTier.title_en_ph')" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.amount')" min-width="140">
|
<el-table-column :label="t('config.depositTier.amount')" min-width="140" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.amount" :placeholder="t('config.depositTier.amount_ph')">
|
<el-input v-model="row.amount" :placeholder="t('config.depositTier.amount_ph')">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
@@ -49,7 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.bonus_amount')" min-width="140">
|
<el-table-column :label="t('config.depositTier.bonus_amount')" min-width="140" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.bonus_amount" :placeholder="t('config.depositTier.bonus_ph')">
|
<el-input v-model="row.bonus_amount" :placeholder="t('config.depositTier.bonus_ph')">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
@@ -59,25 +72,25 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.desc_col')" min-width="220">
|
<el-table-column :label="t('config.depositTier.desc_col')" min-width="220" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.desc" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_ph')" />
|
<el-input v-model="row.desc" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_ph')" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.desc_en_col')" min-width="220">
|
<el-table-column :label="t('config.depositTier.desc_en_col')" min-width="220" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.desc_en" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_en_ph')" />
|
<el-input v-model="row.desc_en" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_en_ph')" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.tier_id')" width="140">
|
<el-table-column :label="t('config.depositTier.tier_id')" width="140" align="center" header-align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-text class="tier-id" truncated>{{ row.id || t('config.depositTier.auto_id') }}</el-text>
|
<el-text class="tier-id" truncated>{{ row.id || t('config.depositTier.auto_id') }}</el-text>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column :label="t('config.depositTier.operate')" width="90" align="center" fixed="right">
|
<el-table-column :label="t('config.depositTier.operate')" width="90" align="center" header-align="center" fixed="right">
|
||||||
<template #default="{ $index }">
|
<template #default="{ $index }">
|
||||||
<el-button type="danger" link @click="onRemove($index)">
|
<el-button type="danger" link @click="onRemove($index)">
|
||||||
{{ t('config.depositTier.btn_remove') }}
|
{{ t('config.depositTier.btn_remove') }}
|
||||||
@@ -248,4 +261,18 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deposit-tier-table {
|
||||||
|
:deep(.el-table__header th),
|
||||||
|
:deep(.el-table__body td) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const baTable = new baTableClass(
|
|||||||
new baTableApi('/admin/config.GameConfig/'),
|
new baTableApi('/admin/config.GameConfig/'),
|
||||||
{
|
{
|
||||||
pk: 'id',
|
pk: 'id',
|
||||||
|
filter: { page: 1, limit: 50 },
|
||||||
column: [
|
column: [
|
||||||
{ type: 'selection', align: 'center', operator: false },
|
{ type: 'selection', align: 'center', operator: false },
|
||||||
{ label: t('config.gameConfig.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
|
{ label: t('config.gameConfig.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
|
||||||
|
|||||||
141
web/src/views/backend/config/streakWinReward/index.vue
Normal file
141
web/src/views/backend/config/streakWinReward/index.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="default-main ba-table-box streak-reward-page">
|
||||||
|
<el-alert type="info" :closable="false" show-icon>
|
||||||
|
{{ t('config.streakWinReward.desc') }}
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="success" :loading="saving" :disabled="loading" @click="onSave">
|
||||||
|
{{ t('config.streakWinReward.btn_save') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
:data="rows"
|
||||||
|
max-height="720"
|
||||||
|
class="streak-reward-table"
|
||||||
|
header-align="center"
|
||||||
|
>
|
||||||
|
<el-table-column prop="streak" :label="t('config.streakWinReward.streak')" width="110" align="center" header-align="center" />
|
||||||
|
<el-table-column :label="t('config.streakWinReward.odds_factor')" min-width="200" align="center" header-align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-center">
|
||||||
|
<el-input-number v-model="row.odds_factor" :min="1" :max="999" :step="1" :controls="true" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('config.streakWinReward.is_jackpot')" min-width="90" align="center" header-align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-center">
|
||||||
|
<el-switch v-model="row.is_jackpot" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import createAxios from '/@/utils/axios'
|
||||||
|
import { auth } from '/@/utils/common'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'config/streakWinReward',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
streak: number
|
||||||
|
odds_factor: number
|
||||||
|
is_jackpot: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const rows = ref<Row[]>([])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await createAxios({
|
||||||
|
url: '/admin/config.StreakWinReward/index',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
if (res.code === 1 && res.data && Array.isArray(res.data.rows)) {
|
||||||
|
rows.value = res.data.rows.map((r: Row) => ({
|
||||||
|
streak: typeof r.streak === 'number' ? r.streak : 1,
|
||||||
|
odds_factor: typeof r.odds_factor === 'number' ? r.odds_factor : 1,
|
||||||
|
is_jackpot: !!r.is_jackpot,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!auth('save')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (let i = 0; i < rows.value.length; i++) {
|
||||||
|
const row = rows.value[i]
|
||||||
|
if (row.odds_factor < 1) {
|
||||||
|
ElMessage.warning(t('config.streakWinReward.err_factor', { no: i + 1 }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await createAxios({
|
||||||
|
url: '/admin/config.StreakWinReward/save',
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
rows: rows.value.map((r) => ({
|
||||||
|
streak: r.streak,
|
||||||
|
odds_factor: r.odds_factor,
|
||||||
|
is_jackpot: r.is_jackpot,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
showSuccessMessage: true,
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.streak-reward-page {
|
||||||
|
.toolbar {
|
||||||
|
margin: 12px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.streak-reward-table {
|
||||||
|
:deep(.el-table__header th),
|
||||||
|
:deep(.el-table__body td) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,126 +1,84 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="default-main">
|
<div class="default-main dashboard-page" v-loading="loading">
|
||||||
<div class="banner">
|
|
||||||
<el-row :gutter="10">
|
|
||||||
<el-col :md="24" :lg="18">
|
|
||||||
<div class="welcome suspension">
|
|
||||||
<img class="welcome-img" :src="headerSvg" alt="" />
|
|
||||||
<div class="welcome-text">
|
|
||||||
<div class="welcome-title">{{ adminInfo.nickname + t('utils.comma') + getGreet() }}</div>
|
|
||||||
<div class="welcome-note">{{ state.remark }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
<el-col :lg="6" class="hidden-md-and-down">
|
|
||||||
<div class="working">
|
|
||||||
<img class="working-coffee" :src="coffeeSvg" alt="" />
|
|
||||||
<div class="working-text">
|
|
||||||
{{ t('dashboard.You have worked today') }}<span class="time">{{ state.workingTimeFormat }}</span>
|
|
||||||
</div>
|
|
||||||
<div @click="onChangeWorkState()" class="working-opt working-rest">
|
|
||||||
{{ state.pauseWork ? t('dashboard.Continue to work') : t('dashboard.have a bit of rest') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</div>
|
|
||||||
<div class="small-panel-box">
|
<div class="small-panel-box">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :sm="12" :lg="6">
|
<el-col :sm="12" :lg="6">
|
||||||
<div class="small-panel user-reg suspension">
|
<div class="small-panel user-reg suspension">
|
||||||
<div class="small-panel-title">{{ t('dashboard.Member registration') }}</div>
|
<div class="small-panel-title">{{ t('dashboard.stat_user_total') }}</div>
|
||||||
<div class="small-panel-content">
|
<div class="small-panel-content">
|
||||||
<div class="content-left">
|
<div class="content-left">
|
||||||
<Icon color="#8595F4" size="20" name="fa fa-line-chart" />
|
<Icon color="#8595F4" size="20" name="fa fa-users" />
|
||||||
<el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" />
|
<el-statistic :value="stats.user_total" :value-style="statisticValueStyle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="content-right">+14%</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :sm="12" :lg="6">
|
<el-col :sm="12" :lg="6">
|
||||||
<div class="small-panel file suspension">
|
<div class="small-panel file suspension">
|
||||||
<div class="small-panel-title">{{ t('dashboard.Number of attachments Uploaded') }}</div>
|
<div class="small-panel-title">{{ t('dashboard.stat_new_today') }}</div>
|
||||||
<div class="small-panel-content">
|
<div class="small-panel-content">
|
||||||
<div class="content-left">
|
<div class="content-left">
|
||||||
<Icon color="#AD85F4" size="20" name="fa fa-file-text" />
|
<Icon color="#AD85F4" size="20" name="fa fa-user-plus" />
|
||||||
<el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" />
|
<el-statistic :value="stats.user_new_today" :value-style="statisticValueStyle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="content-right">+50%</div>
|
<div class="content-right" :class="growthClass">{{ growthText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :sm="12" :lg="6">
|
<el-col :sm="12" :lg="6">
|
||||||
<div class="small-panel users suspension">
|
<div class="small-panel users suspension">
|
||||||
<div class="small-panel-title">{{ t('dashboard.Total number of members') }}</div>
|
<div class="small-panel-title">{{ t('dashboard.stat_deposit_today') }}</div>
|
||||||
<div class="small-panel-content">
|
<div class="small-panel-content">
|
||||||
<div class="content-left">
|
<div class="content-left">
|
||||||
<Icon color="#74A8B5" size="20" name="fa fa-users" />
|
<Icon color="#74A8B5" size="20" name="fa fa-credit-card" />
|
||||||
<el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" />
|
<el-statistic :value="depositTodayDisplay" :value-style="statisticValueStyle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="content-right">+28%</div>
|
<div class="content-right color-success">{{ depositCountText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :sm="12" :lg="6">
|
<el-col :sm="12" :lg="6">
|
||||||
<div class="small-panel addons suspension">
|
<div class="small-panel addons suspension">
|
||||||
<div class="small-panel-title">{{ t('dashboard.Number of installed plug-ins') }}</div>
|
<div class="small-panel-title">{{ t('dashboard.stat_withdraw_pending') }}</div>
|
||||||
<div class="small-panel-content">
|
<div class="small-panel-content">
|
||||||
<div class="content-left">
|
<div class="content-left">
|
||||||
<Icon color="#F48595" size="20" name="fa fa-object-group" />
|
<Icon color="#F48595" size="20" name="fa fa-clock-o" />
|
||||||
<el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" />
|
<el-statistic :value="stats.withdraw_pending" :value-style="statisticValueStyle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="content-right">+88%</div>
|
<div class="content-right color-warning">{{ t('dashboard.stat_hint_pending') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="growth-chart">
|
<div class="growth-chart">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
|
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
|
||||||
<el-card shadow="hover" :header="t('dashboard.Membership growth')">
|
<el-card shadow="hover" :header="t('dashboard.chart_new_user_deposit')">
|
||||||
<div class="user-growth-chart" :ref="chartRefs.set"></div>
|
<div class="user-growth-chart" :ref="chartRefs.set"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
|
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
|
||||||
<el-card shadow="hover" :header="t('dashboard.Annex growth')">
|
<el-card shadow="hover" :header="t('dashboard.chart_bet_7d')">
|
||||||
<div class="file-growth-chart" :ref="chartRefs.set"></div>
|
<div class="file-growth-chart" :ref="chartRefs.set"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="24" :md="24" :lg="6">
|
<el-col :xs="24" :sm="24" :md="24" :lg="6">
|
||||||
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.New member')">
|
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.recent_users')">
|
||||||
<div class="new-user-growth">
|
<div class="new-user-growth">
|
||||||
<el-scrollbar>
|
<el-empty v-if="recentUsers.length === 0" :description="t('dashboard.no_data')" />
|
||||||
<div class="new-user-item">
|
<el-scrollbar v-else>
|
||||||
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
|
<div v-for="u in recentUsers" :key="u.id" class="new-user-item">
|
||||||
|
<img
|
||||||
|
class="new-user-avatar"
|
||||||
|
:src="u.head_image ? fullUrl(u.head_image) : fullUrl('/static/images/avatar.png')"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
<div class="new-user-base">
|
<div class="new-user-base">
|
||||||
<div class="new-user-name">妙码生花</div>
|
<div class="new-user-name">{{ u.username || '-' }}</div>
|
||||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
<div class="new-user-time">{{ formatUserTime(u.create_time) }}</div>
|
||||||
</div>
|
<div v-if="u.channel_name" class="new-user-channel">{{ u.channel_name }}</div>
|
||||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
|
||||||
</div>
|
|
||||||
<div class="new-user-item">
|
|
||||||
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
|
|
||||||
<div class="new-user-base">
|
|
||||||
<div class="new-user-name">码上生花</div>
|
|
||||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
|
||||||
</div>
|
|
||||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
|
||||||
</div>
|
|
||||||
<div class="new-user-item">
|
|
||||||
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
|
|
||||||
<div class="new-user-base">
|
|
||||||
<div class="new-user-name">Admin</div>
|
|
||||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
|
||||||
</div>
|
|
||||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
|
||||||
</div>
|
|
||||||
<div class="new-user-item">
|
|
||||||
<img class="new-user-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
|
|
||||||
<div class="new-user-base">
|
|
||||||
<div class="new-user-name">纯属虚构</div>
|
|
||||||
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
|
||||||
</div>
|
</div>
|
||||||
@@ -134,12 +92,12 @@
|
|||||||
<div class="growth-chart">
|
<div class="growth-chart">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
|
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
|
||||||
<el-card shadow="hover" :header="t('dashboard.Member source')">
|
<el-card shadow="hover" :header="t('dashboard.chart_channel_users')">
|
||||||
<div class="user-source-chart" :ref="chartRefs.set"></div>
|
<div class="user-source-chart" :ref="chartRefs.set"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
|
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
|
||||||
<el-card shadow="hover" :header="t('dashboard.Member last name')">
|
<el-card shadow="hover" :header="t('dashboard.chart_deposit_amount_channel')">
|
||||||
<div class="user-surname-chart" :ref="chartRefs.set"></div>
|
<div class="user-surname-chart" :ref="chartRefs.set"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -149,350 +107,235 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEventListener, useTemplateRefsList, useTransition } from '@vueuse/core'
|
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { CSSProperties, nextTick, onActivated, onBeforeMount, onMounted, onUnmounted, reactive, toRefs, watch } from 'vue'
|
import type { EChartsType } from 'echarts/core'
|
||||||
|
import { CSSProperties, computed, nextTick, onActivated, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { index } from '/@/api/backend/dashboard'
|
import { index } from '/@/api/backend/dashboard'
|
||||||
import coffeeSvg from '/@/assets/dashboard/coffee.svg'
|
import type { DashboardDepositAmountChannelItem, DashboardPayload, DashboardRecentUser } from '/@/api/backend/dashboard'
|
||||||
import headerSvg from '/@/assets/dashboard/header-1.svg'
|
|
||||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
|
||||||
import { WORKING_TIME } from '/@/stores/constant/cacheKey'
|
|
||||||
import { useNavTabs } from '/@/stores/navTabs'
|
import { useNavTabs } from '/@/stores/navTabs'
|
||||||
import { fullUrl, getGreet } from '/@/utils/common'
|
import { fullUrl } from '/@/utils/common'
|
||||||
import { Local } from '/@/utils/storage'
|
|
||||||
let workTimer: number
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
})
|
})
|
||||||
|
|
||||||
const d = new Date()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const navTabs = useNavTabs()
|
const navTabs = useNavTabs()
|
||||||
const adminInfo = useAdminInfo()
|
|
||||||
const chartRefs = useTemplateRefsList<HTMLDivElement>()
|
const chartRefs = useTemplateRefsList<HTMLDivElement>()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
const state: {
|
const state: {
|
||||||
charts: any[]
|
charts: EChartsType[]
|
||||||
remark: string
|
|
||||||
workingTimeFormat: string
|
|
||||||
pauseWork: boolean
|
|
||||||
} = reactive({
|
} = reactive({
|
||||||
charts: [],
|
charts: [],
|
||||||
remark: 'dashboard.Loading',
|
|
||||||
workingTimeFormat: '',
|
|
||||||
pauseWork: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
const stats = reactive({
|
||||||
* 带有数字向上变化特效的数据
|
user_total: 0,
|
||||||
*/
|
user_new_today: 0,
|
||||||
const countUp = reactive({
|
user_new_yesterday: 0,
|
||||||
userRegNumber: 0,
|
user_new_growth_pct: null as number | null,
|
||||||
fileNumber: 0,
|
deposit_today_amount: '0.00',
|
||||||
usersNumber: 0,
|
deposit_today_count: 0,
|
||||||
addonsNumber: 0,
|
withdraw_pending: 0,
|
||||||
|
bet_today_amount: '0.00',
|
||||||
|
bet_today_count: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const trend = reactive({
|
||||||
|
days: [] as string[],
|
||||||
|
new_users: [] as number[],
|
||||||
|
deposit_amount: [] as string[],
|
||||||
|
bet_amount: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const channelShare = ref<{ name: string; value: number }[]>([])
|
||||||
|
const depositAmountChannelShare = ref<DashboardDepositAmountChannelItem[]>([])
|
||||||
|
const recentUsers = ref<DashboardRecentUser[]>([])
|
||||||
|
|
||||||
|
const depositTodayDisplay = computed(() => {
|
||||||
|
const n = parseFloat(stats.deposit_today_amount)
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
return stats.deposit_today_amount
|
||||||
|
}
|
||||||
|
return n.toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const growthText = computed(() => {
|
||||||
|
const p = stats.user_new_growth_pct
|
||||||
|
if (p === null || p === undefined) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
const sign = p > 0 ? '+' : ''
|
||||||
|
return sign + p.toFixed(1) + '%'
|
||||||
|
})
|
||||||
|
|
||||||
|
const growthClass = computed(() => {
|
||||||
|
const p = stats.user_new_growth_pct
|
||||||
|
if (p === null || p === undefined) {
|
||||||
|
return 'color-info'
|
||||||
|
}
|
||||||
|
if (p > 0) {
|
||||||
|
return 'color-success'
|
||||||
|
}
|
||||||
|
if (p < 0) {
|
||||||
|
return 'color-danger'
|
||||||
|
}
|
||||||
|
return 'color-info'
|
||||||
|
})
|
||||||
|
|
||||||
|
const depositCountText = computed(() => {
|
||||||
|
return t('dashboard.deposit_orders_today', { n: stats.deposit_today_count })
|
||||||
})
|
})
|
||||||
|
|
||||||
const countUpRefs = toRefs(countUp)
|
|
||||||
const userRegNumberOutput = useTransition(countUpRefs.userRegNumber, { duration: 1500 })
|
|
||||||
const fileNumberOutput = useTransition(countUpRefs.fileNumber, { duration: 1500 })
|
|
||||||
const usersNumberOutput = useTransition(countUpRefs.usersNumber, { duration: 1500 })
|
|
||||||
const addonsNumberOutput = useTransition(countUpRefs.addonsNumber, { duration: 1500 })
|
|
||||||
const statisticValueStyle: CSSProperties = {
|
const statisticValueStyle: CSSProperties = {
|
||||||
fontSize: '28px',
|
fontSize: '28px',
|
||||||
}
|
}
|
||||||
|
|
||||||
index().then((res) => {
|
const disposeCharts = () => {
|
||||||
state.remark = res.data.remark
|
for (const c of state.charts) {
|
||||||
})
|
c.dispose()
|
||||||
|
}
|
||||||
const initCountUp = () => {
|
state.charts = []
|
||||||
// 虚拟数据
|
|
||||||
countUpRefs.userRegNumber.value = 5456
|
|
||||||
countUpRefs.fileNumber.value = 1234
|
|
||||||
countUpRefs.usersNumber.value = 9486
|
|
||||||
countUpRefs.addonsNumber.value = 875
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initUserGrowthChart = () => {
|
const parseMoneySeries = (arr: string[]) => arr.map((s) => parseFloat(String(s)) || 0)
|
||||||
const userGrowthChart = echarts.init(chartRefs.value[0] as HTMLElement)
|
|
||||||
const option = {
|
const initTrendDepositChart = () => {
|
||||||
grid: {
|
const el = chartRefs.value[0] as HTMLElement
|
||||||
top: 40,
|
if (!el) {
|
||||||
right: 0,
|
return
|
||||||
bottom: 20,
|
}
|
||||||
left: 40,
|
const chart = echarts.init(el)
|
||||||
},
|
chart.setOption({
|
||||||
xAxis: {
|
color: ['#8595F4', '#67C23A'],
|
||||||
data: [
|
grid: { top: 48, right: 56, bottom: 24, left: 48 },
|
||||||
t('dashboard.Monday'),
|
tooltip: { trigger: 'axis' },
|
||||||
t('dashboard.Tuesday'),
|
|
||||||
t('dashboard.Wednesday'),
|
|
||||||
t('dashboard.Thursday'),
|
|
||||||
t('dashboard.Friday'),
|
|
||||||
t('dashboard.Saturday'),
|
|
||||||
t('dashboard.Sunday'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
yAxis: {},
|
|
||||||
legend: {
|
legend: {
|
||||||
data: [t('dashboard.Visits'), t('dashboard.Registration volume')],
|
data: [t('dashboard.series_new_users'), t('dashboard.series_deposit_amount')],
|
||||||
textStyle: {
|
textStyle: { color: '#73767a' },
|
||||||
color: '#73767a',
|
|
||||||
},
|
|
||||||
top: 0,
|
top: 0,
|
||||||
},
|
},
|
||||||
series: [
|
xAxis: { type: 'category', data: trend.days, boundaryGap: false },
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: t('dashboard.series_new_users'), splitLine: { lineStyle: { type: 'dashed' } } },
|
||||||
{
|
{
|
||||||
name: t('dashboard.Visits'),
|
type: 'value',
|
||||||
data: [100, 160, 280, 230, 190, 200, 480],
|
name: t('dashboard.series_deposit_amount'),
|
||||||
type: 'line',
|
position: 'right',
|
||||||
smooth: true,
|
|
||||||
areaStyle: {
|
|
||||||
color: '#8595F4',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t('dashboard.Registration volume'),
|
|
||||||
data: [45, 180, 146, 99, 210, 127, 288],
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
areaStyle: {
|
|
||||||
color: '#F48595',
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
userGrowthChart.setOption(option)
|
|
||||||
state.charts.push(userGrowthChart)
|
|
||||||
}
|
|
||||||
|
|
||||||
const initFileGrowthChart = () => {
|
|
||||||
const fileGrowthChart = echarts.init(chartRefs.value[1] as HTMLElement)
|
|
||||||
const option = {
|
|
||||||
grid: {
|
|
||||||
top: 30,
|
|
||||||
right: 0,
|
|
||||||
bottom: 20,
|
|
||||||
left: 0,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'item',
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
type: 'scroll',
|
|
||||||
bottom: 0,
|
|
||||||
data: (function () {
|
|
||||||
var list = []
|
|
||||||
for (var i = 1; i <= 28; i++) {
|
|
||||||
list.push(i + 2000 + '')
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
})(),
|
|
||||||
textStyle: {
|
|
||||||
color: '#73767a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
visualMap: {
|
|
||||||
top: 'middle',
|
|
||||||
right: 10,
|
|
||||||
color: ['red', 'yellow'],
|
|
||||||
calculable: true,
|
|
||||||
},
|
|
||||||
radar: {
|
|
||||||
indicator: [
|
|
||||||
{ name: t('dashboard.picture') },
|
|
||||||
{ name: t('dashboard.file') },
|
|
||||||
{ name: t('dashboard.table') },
|
|
||||||
{ name: t('dashboard.Compressed package') },
|
|
||||||
{ name: t('dashboard.other') },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
series: (function () {
|
|
||||||
var series = []
|
|
||||||
for (var i = 1; i <= 28; i++) {
|
|
||||||
series.push({
|
|
||||||
type: 'radar',
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: {
|
|
||||||
width: 1,
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
areaStyle: {
|
|
||||||
color: 'rgba(0,250,0,0.3)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
value: [(40 - i) * 10, (38 - i) * 4 + 60, i * 5 + 10, i * 9, (i * i) / 2],
|
|
||||||
name: i + 2000 + '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return series
|
|
||||||
})(),
|
|
||||||
}
|
|
||||||
fileGrowthChart.setOption(option)
|
|
||||||
state.charts.push(fileGrowthChart)
|
|
||||||
}
|
|
||||||
|
|
||||||
const initUserSourceChart = () => {
|
|
||||||
const UserSourceChart = echarts.init(chartRefs.value[2] as HTMLElement)
|
|
||||||
const pathSymbols = {
|
|
||||||
reindeer:
|
|
||||||
'path://M-22.788,24.521c2.08-0.986,3.611-3.905,4.984-5.892 c-2.686,2.782-5.047,5.884-9.102,7.312c-0.992,0.005-0.25-2.016,0.34-2.362l1.852-0.41c0.564-0.218,0.785-0.842,0.902-1.347 c2.133-0.727,4.91-4.129,6.031-6.194c1.748-0.7,4.443-0.679,5.734-2.293c1.176-1.468,0.393-3.992,1.215-6.557 c0.24-0.754,0.574-1.581,1.008-2.293c-0.611,0.011-1.348-0.061-1.959-0.608c-1.391-1.245-0.785-2.086-1.297-3.313 c1.684,0.744,2.5,2.584,4.426,2.586C-8.46,3.012-8.255,2.901-8.04,2.824c6.031-1.952,15.182-0.165,19.498-3.937 c1.15-3.933-1.24-9.846-1.229-9.938c0.008-0.062-1.314-0.004-1.803-0.258c-1.119-0.771-6.531-3.75-0.17-3.33 c0.314-0.045,0.943,0.259,1.439,0.435c-0.289-1.694-0.92-0.144-3.311-1.946c0,0-1.1-0.855-1.764-1.98 c-0.836-1.09-2.01-2.825-2.992-4.031c-1.523-2.476,1.367,0.709,1.816,1.108c1.768,1.704,1.844,3.281,3.232,3.983 c0.195,0.203,1.453,0.164,0.926-0.468c-0.525-0.632-1.367-1.278-1.775-2.341c-0.293-0.703-1.311-2.326-1.566-2.711 c-0.256-0.384-0.959-1.718-1.67-2.351c-1.047-1.187-0.268-0.902,0.521-0.07c0.789,0.834,1.537,1.821,1.672,2.023 c0.135,0.203,1.584,2.521,1.725,2.387c0.102-0.259-0.035-0.428-0.158-0.852c-0.125-0.423-0.912-2.032-0.961-2.083 c-0.357-0.852-0.566-1.908-0.598-3.333c0.4-2.375,0.648-2.486,0.549-0.705c0.014,1.143,0.031,2.215,0.602,3.247 c0.807,1.496,1.764,4.064,1.836,4.474c0.561,3.176,2.904,1.749,2.281-0.126c-0.068-0.446-0.109-2.014-0.287-2.862 c-0.18-0.849-0.219-1.688-0.113-3.056c0.066-1.389,0.232-2.055,0.277-2.299c0.285-1.023,0.4-1.088,0.408,0.135 c-0.059,0.399-0.131,1.687-0.125,2.655c0.064,0.642-0.043,1.768,0.172,2.486c0.654,1.928-0.027,3.496,1,3.514 c1.805-0.424,2.428-1.218,2.428-2.346c-0.086-0.704-0.121-0.843-0.031-1.193c0.221-0.568,0.359-0.67,0.312-0.076 c-0.055,0.287,0.031,0.533,0.082,0.794c0.264,1.197,0.912,0.114,1.283-0.782c0.15-0.238,0.539-2.154,0.545-2.522 c-0.023-0.617,0.285-0.645,0.309,0.01c0.064,0.422-0.248,2.646-0.205,2.334c-0.338,1.24-1.105,3.402-3.379,4.712 c-0.389,0.12-1.186,1.286-3.328,2.178c0,0,1.729,0.321,3.156,0.246c1.102-0.19,3.707-0.027,4.654,0.269 c1.752,0.494,1.531-0.053,4.084,0.164c2.26-0.4,2.154,2.391-1.496,3.68c-2.549,1.405-3.107,1.475-2.293,2.984 c3.484,7.906,2.865,13.183,2.193,16.466c2.41,0.271,5.732-0.62,7.301,0.725c0.506,0.333,0.648,1.866-0.457,2.86 c-4.105,2.745-9.283,7.022-13.904,7.662c-0.977-0.194,0.156-2.025,0.803-2.247l1.898-0.03c0.596-0.101,0.936-0.669,1.152-1.139 c3.16-0.404,5.045-3.775,8.246-4.818c-4.035-0.718-9.588,3.981-12.162,1.051c-5.043,1.423-11.449,1.84-15.895,1.111 c-3.105,2.687-7.934,4.021-12.115,5.866c-3.271,3.511-5.188,8.086-9.967,10.414c-0.986,0.119-0.48-1.974,0.066-2.385l1.795-0.618 C-22.995,25.682-22.849,25.035-22.788,24.521z',
|
|
||||||
plane: 'path://M1.112,32.559l2.998,1.205l-2.882,2.268l-2.215-0.012L1.112,32.559z M37.803,23.96 c0.158-0.838,0.5-1.509,0.961-1.904c-0.096-0.037-0.205-0.071-0.344-0.071c-0.777-0.005-2.068-0.009-3.047-0.009 c-0.633,0-1.217,0.066-1.754,0.18l2.199,1.804H37.803z M39.738,23.036c-0.111,0-0.377,0.325-0.537,0.924h1.076 C40.115,23.361,39.854,23.036,39.738,23.036z M39.934,39.867c-0.166,0-0.674,0.705-0.674,1.986s0.506,1.986,0.674,1.986 s0.672-0.705,0.672-1.986S40.102,39.867,39.934,39.867z M38.963,38.889c-0.098-0.038-0.209-0.07-0.348-0.073 c-0.082,0-0.174,0-0.268-0.001l-7.127,4.671c0.879,0.821,2.42,1.417,4.348,1.417c0.979,0,2.27-0.006,3.047-0.01 c0.139,0,0.25-0.034,0.348-0.072c-0.646-0.555-1.07-1.643-1.07-2.967C37.891,40.529,38.316,39.441,38.963,38.889z M32.713,23.96 l-12.37-10.116l-4.693-0.004c0,0,4,8.222,4.827,10.121H32.713z M59.311,32.374c-0.248,2.104-5.305,3.172-8.018,3.172H39.629 l-25.325,16.61L9.607,52.16c0,0,6.687-8.479,7.95-10.207c1.17-1.6,3.019-3.699,3.027-6.407h-2.138 c-5.839,0-13.816-3.789-18.472-5.583c-2.818-1.085-2.396-4.04-0.031-4.04h0.039l-3.299-11.371h3.617c0,0,4.352,5.696,5.846,7.5 c2,2.416,4.503,3.678,8.228,3.87h30.727c2.17,0,4.311,0.417,6.252,1.046c3.49,1.175,5.863,2.7,7.199,4.027 C59.145,31.584,59.352,32.025,59.311,32.374z M22.069,30.408c0-0.815-0.661-1.475-1.469-1.475c-0.812,0-1.471,0.66-1.471,1.475 s0.658,1.475,1.471,1.475C21.408,31.883,22.069,31.224,22.069,30.408z M27.06,30.408c0-0.815-0.656-1.478-1.466-1.478 c-0.812,0-1.471,0.662-1.471,1.478s0.658,1.477,1.471,1.477C26.404,31.885,27.06,31.224,27.06,30.408z M32.055,30.408 c0-0.815-0.66-1.475-1.469-1.475c-0.808,0-1.466,0.66-1.466,1.475s0.658,1.475,1.466,1.475 C31.398,31.883,32.055,31.224,32.055,30.408z M37.049,30.408c0-0.815-0.658-1.478-1.467-1.478c-0.812,0-1.469,0.662-1.469,1.478 s0.656,1.477,1.469,1.477C36.389,31.885,37.049,31.224,37.049,30.408z M42.039,30.408c0-0.815-0.656-1.478-1.465-1.478 c-0.811,0-1.469,0.662-1.469,1.478s0.658,1.477,1.469,1.477C41.383,31.885,42.039,31.224,42.039,30.408z M55.479,30.565 c-0.701-0.436-1.568-0.896-2.627-1.347c-0.613,0.289-1.551,0.476-2.73,0.476c-1.527,0-1.639,2.263,0.164,2.316 C52.389,32.074,54.627,31.373,55.479,30.565z',
|
|
||||||
rocket: 'path://M-244.396,44.399c0,0,0.47-2.931-2.427-6.512c2.819-8.221,3.21-15.709,3.21-15.709s5.795,1.383,5.795,7.325C-237.818,39.679-244.396,44.399-244.396,44.399z M-260.371,40.827c0,0-3.881-12.946-3.881-18.319c0-2.416,0.262-4.566,0.669-6.517h17.684c0.411,1.952,0.675,4.104,0.675,6.519c0,5.291-3.87,18.317-3.87,18.317H-260.371z M-254.745,18.951c-1.99,0-3.603,1.676-3.603,3.744c0,2.068,1.612,3.744,3.603,3.744c1.988,0,3.602-1.676,3.602-3.744S-252.757,18.951-254.745,18.951z M-255.521,2.228v-5.098h1.402v4.969c1.603,1.213,5.941,5.069,7.901,12.5h-17.05C-261.373,7.373-257.245,3.558-255.521,2.228zM-265.07,44.399c0,0-6.577-4.721-6.577-14.896c0-5.942,5.794-7.325,5.794-7.325s0.393,7.488,3.211,15.708C-265.539,41.469-265.07,44.399-265.07,44.399z M-252.36,45.15l-1.176-1.22L-254.789,48l-1.487-4.069l-1.019,2.116l-1.488-3.826h8.067L-252.36,45.15z',
|
|
||||||
train: 'path://M67.335,33.596L67.335,33.596c-0.002-1.39-1.153-3.183-3.328-4.218h-9.096v-2.07h5.371 c-4.939-2.07-11.199-4.141-14.89-4.141H19.72v12.421v5.176h38.373c4.033,0,8.457-1.035,9.142-5.176h-0.027 c0.076-0.367,0.129-0.751,0.129-1.165L67.335,33.596L67.335,33.596z M27.999,30.413h-3.105v-4.141h3.105V30.413z M35.245,30.413 h-3.104v-4.141h3.104V30.413z M42.491,30.413h-3.104v-4.141h3.104V30.413z M49.736,30.413h-3.104v-4.141h3.104V30.413z M14.544,40.764c1.143,0,2.07-0.927,2.07-2.07V35.59V25.237c0-1.145-0.928-2.07-2.07-2.07H-9.265c-1.143,0-2.068,0.926-2.068,2.07 v10.351v3.105c0,1.144,0.926,2.07,2.068,2.07H14.544L14.544,40.764z M8.333,26.272h3.105v4.141H8.333V26.272z M1.087,26.272h3.105 v4.141H1.087V26.272z M-6.159,26.272h3.105v4.141h-3.105V26.272z M-9.265,41.798h69.352v1.035H-9.265V41.798z',
|
|
||||||
}
|
|
||||||
const option = {
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'none',
|
|
||||||
},
|
|
||||||
formatter: function (params: any) {
|
|
||||||
return params[0].name + ': ' + params[0].value
|
|
||||||
},
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
data: [t('dashboard.Baidu'), t('dashboard.Direct access'), t('dashboard.take a plane'), t('dashboard.Take the high-speed railway')],
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: {
|
|
||||||
color: '#e54035',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
axisTick: { show: false },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { show: false },
|
|
||||||
},
|
},
|
||||||
color: ['#e54035'],
|
],
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'hill',
|
name: t('dashboard.series_new_users'),
|
||||||
type: 'pictorialBar',
|
type: 'line',
|
||||||
barCategoryGap: '-130%',
|
smooth: true,
|
||||||
symbol: 'path://M0,10 L10,10 C5.5,10 5.5,5 5,0 C4.5,5 4.5,10 0,10 z',
|
data: trend.new_users,
|
||||||
itemStyle: {
|
areaStyle: { color: 'rgba(133,149,244,0.15)' },
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
itemStyle: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: [123, 60, 25, 80],
|
|
||||||
z: 10,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'glyph',
|
name: t('dashboard.series_deposit_amount'),
|
||||||
type: 'pictorialBar',
|
type: 'line',
|
||||||
barGap: '-100%',
|
smooth: true,
|
||||||
symbolPosition: 'end',
|
yAxisIndex: 1,
|
||||||
symbolSize: 50,
|
data: parseMoneySeries(trend.deposit_amount),
|
||||||
symbolOffset: [0, '-120%'],
|
areaStyle: { color: 'rgba(103,194,58,0.12)' },
|
||||||
data: [
|
|
||||||
{
|
|
||||||
value: 123,
|
|
||||||
symbol: pathSymbols.reindeer,
|
|
||||||
symbolSize: [60, 60],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 60,
|
|
||||||
symbol: pathSymbols.rocket,
|
|
||||||
symbolSize: [50, 60],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 25,
|
|
||||||
symbol: pathSymbols.plane,
|
|
||||||
symbolSize: [65, 35],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 80,
|
|
||||||
symbol: pathSymbols.train,
|
|
||||||
symbolSize: [50, 30],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
})
|
||||||
],
|
state.charts.push(chart)
|
||||||
}
|
|
||||||
UserSourceChart.setOption(option)
|
|
||||||
state.charts.push(UserSourceChart)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initUserSurnameChart = () => {
|
const initBetTrendChart = () => {
|
||||||
const userSurnameChart = echarts.init(chartRefs.value[3] as HTMLElement)
|
const el = chartRefs.value[1] as HTMLElement
|
||||||
const data = genData(20)
|
if (!el) {
|
||||||
const option = {
|
return
|
||||||
tooltip: {
|
}
|
||||||
trigger: 'item',
|
const chart = echarts.init(el)
|
||||||
formatter: '{a} <br/>{b} : {c} ({d}%)',
|
chart.setOption({
|
||||||
},
|
color: ['#E6A23C'],
|
||||||
legend: {
|
grid: { top: 36, right: 16, bottom: 24, left: 48 },
|
||||||
type: 'scroll',
|
tooltip: { trigger: 'axis' },
|
||||||
orient: 'vertical',
|
xAxis: { type: 'category', data: trend.days },
|
||||||
right: 10,
|
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
||||||
top: 20,
|
|
||||||
bottom: 20,
|
|
||||||
data: data.legendData,
|
|
||||||
textStyle: {
|
|
||||||
color: '#73767a',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: t('dashboard.full name'),
|
name: t('dashboard.series_bet_amount'),
|
||||||
type: 'pie',
|
type: 'bar',
|
||||||
radius: '55%',
|
data: parseMoneySeries(trend.bet_amount),
|
||||||
center: ['40%', '50%'],
|
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||||
data: data.seriesData,
|
|
||||||
emphasis: {
|
|
||||||
itemStyle: {
|
|
||||||
shadowBlur: 10,
|
|
||||||
shadowOffsetX: 0,
|
|
||||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
state.charts.push(chart)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initChannelPie = () => {
|
||||||
|
const el = chartRefs.value[2] as HTMLElement
|
||||||
|
if (!el) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chart = echarts.init(el)
|
||||||
|
const data = channelShare.value.map((x) => ({ name: x.name, value: x.value }))
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { type: 'scroll', bottom: 0, textStyle: { color: '#73767a' } },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['36%', '62%'],
|
||||||
|
center: ['50%', '46%'],
|
||||||
|
data,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.15)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
|
||||||
function genData(count: any) {
|
|
||||||
// prettier-ignore
|
|
||||||
const nameList = [
|
|
||||||
'赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨', '朱', '秦', '尤', '许', '何', '吕', '施', '张', '孔', '曹', '严', '华', '金', '魏', '陶', '姜', '戚', '谢', '邹', '喻', '柏', '水', '窦', '章', '云', '苏', '潘', '葛', '奚', '范', '彭', '郎', '鲁', '韦', '昌', '马', '苗', '凤', '花', '方', '俞', '任', '袁', '柳', '酆', '鲍', '史', '唐', '费', '廉', '岑', '薛', '雷', '贺', '倪', '汤', '滕', '殷', '罗', '毕', '郝', '邬', '安', '常', '乐', '于', '时', '傅', '皮', '卞', '齐', '康', '伍', '余', '元', '卜', '顾', '孟', '平', '黄', '和', '穆', '萧', '尹', '姚', '邵', '湛', '汪', '祁', '毛', '禹', '狄', '米', '贝', '明', '臧', '计', '伏', '成', '戴', '谈', '宋', '茅', '庞', '熊', '纪', '舒', '屈', '项', '祝', '董', '梁', '杜', '阮', '蓝', '闵', '席', '季', '麻', '强', '贾', '路', '娄', '危'
|
|
||||||
];
|
|
||||||
const legendData = []
|
|
||||||
const seriesData = []
|
|
||||||
for (var i = 0; i < count; i++) {
|
|
||||||
var name = Math.random() > 0.85 ? makeWord(2, 1) + '·' + makeWord(2, 0) : makeWord(2, 1)
|
|
||||||
legendData.push(name)
|
|
||||||
seriesData.push({
|
|
||||||
name: name,
|
|
||||||
value: Math.round(Math.random() * 100000),
|
|
||||||
})
|
})
|
||||||
|
state.charts.push(chart)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initDepositAmountChannelPie = () => {
|
||||||
|
const el = chartRefs.value[3] as HTMLElement
|
||||||
|
if (!el) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
const chart = echarts.init(el)
|
||||||
|
const data = depositAmountChannelShare.value.map((x) => {
|
||||||
|
const v = parseFloat(String(x.value))
|
||||||
return {
|
return {
|
||||||
legendData: legendData,
|
name: x.name,
|
||||||
seriesData: seriesData,
|
value: Number.isFinite(v) ? v : 0,
|
||||||
}
|
}
|
||||||
function makeWord(max: any, min: any) {
|
})
|
||||||
const nameLen = Math.ceil(Math.random() * max + min)
|
chart.setOption({
|
||||||
const name = []
|
tooltip: {
|
||||||
for (var i = 0; i < nameLen; i++) {
|
trigger: 'item',
|
||||||
name.push(nameList[Math.round(Math.random() * nameList.length - 1)])
|
formatter: (p: { name?: string; value?: number; percent?: number }) => {
|
||||||
}
|
const name = p.name ?? ''
|
||||||
return name.join('')
|
const val = typeof p.value === 'number' && Number.isFinite(p.value) ? p.value : 0
|
||||||
}
|
const pct = typeof p.percent === 'number' && Number.isFinite(p.percent) ? p.percent : 0
|
||||||
}
|
return name + '<br/>' + val.toFixed(2) + ' (' + pct.toFixed(1) + '%)'
|
||||||
userSurnameChart.setOption(option)
|
},
|
||||||
state.charts.push(userSurnameChart)
|
},
|
||||||
|
legend: { type: 'scroll', bottom: 0, textStyle: { color: '#73767a' } },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['32%', '58%'],
|
||||||
|
center: ['50%', '46%'],
|
||||||
|
data,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.15)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
state.charts.push(chart)
|
||||||
}
|
}
|
||||||
|
|
||||||
const echartsResize = () => {
|
const echartsResize = () => {
|
||||||
@@ -503,95 +346,66 @@ const echartsResize = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChangeWorkState = () => {
|
const applyPayload = (payload: DashboardPayload) => {
|
||||||
const time = parseInt((new Date().getTime() / 1000).toString())
|
const s = payload.stats
|
||||||
const workingTime = Local.get(WORKING_TIME)
|
stats.user_total = s.user_total
|
||||||
if (state.pauseWork) {
|
stats.user_new_today = s.user_new_today
|
||||||
// 继续工作
|
stats.user_new_yesterday = s.user_new_yesterday
|
||||||
workingTime.pauseTime += time - workingTime.startPauseTime
|
stats.user_new_growth_pct = s.user_new_growth_pct
|
||||||
workingTime.startPauseTime = 0
|
stats.deposit_today_amount = s.deposit_today_amount
|
||||||
Local.set(WORKING_TIME, workingTime)
|
stats.deposit_today_count = s.deposit_today_count
|
||||||
state.pauseWork = false
|
stats.withdraw_pending = s.withdraw_pending
|
||||||
startWork()
|
stats.bet_today_amount = s.bet_today_amount
|
||||||
} else {
|
stats.bet_today_count = s.bet_today_count
|
||||||
// 暂停工作
|
|
||||||
workingTime.startPauseTime = time
|
trend.days = payload.trend.days
|
||||||
Local.set(WORKING_TIME, workingTime)
|
trend.new_users = payload.trend.new_users
|
||||||
clearInterval(workTimer)
|
trend.deposit_amount = payload.trend.deposit_amount
|
||||||
state.pauseWork = true
|
trend.bet_amount = payload.trend.bet_amount
|
||||||
|
|
||||||
|
channelShare.value = payload.channel_share
|
||||||
|
depositAmountChannelShare.value = payload.deposit_amount_channel_share ?? []
|
||||||
|
recentUsers.value = payload.recent_users
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await index()
|
||||||
|
const payload = res.data as unknown as DashboardPayload
|
||||||
|
applyPayload(payload)
|
||||||
|
await nextTick()
|
||||||
|
disposeCharts()
|
||||||
|
initTrendDepositChart()
|
||||||
|
initBetTrendChart()
|
||||||
|
initChannelPie()
|
||||||
|
initDepositAmountChannelPie()
|
||||||
|
echartsResize()
|
||||||
|
} catch (_e) {
|
||||||
|
// 统计数据加载失败时由 axios 拦截器提示;此处不重复展示
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startWork = () => {
|
function formatUserTime(ts: number): string {
|
||||||
const workingTime = Local.get(WORKING_TIME) || { date: '', startTime: 0, pauseTime: 0, startPauseTime: 0 }
|
if (!ts || ts <= 0) {
|
||||||
const currentDate = d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
|
return '—'
|
||||||
const time = parseInt((new Date().getTime() / 1000).toString())
|
|
||||||
|
|
||||||
if (workingTime.date != currentDate) {
|
|
||||||
workingTime.date = currentDate
|
|
||||||
workingTime.startTime = time
|
|
||||||
workingTime.pauseTime = workingTime.startPauseTime = 0
|
|
||||||
Local.set(WORKING_TIME, workingTime)
|
|
||||||
}
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
let startPauseTime = 0
|
const diff = now - ts
|
||||||
if (workingTime.startPauseTime <= 0) {
|
if (diff < 60) {
|
||||||
state.pauseWork = false
|
return diff + t('dashboard.seconds_ago')
|
||||||
startPauseTime = 0
|
|
||||||
} else {
|
|
||||||
state.pauseWork = true
|
|
||||||
startPauseTime = time - workingTime.startPauseTime // 已暂停时间
|
|
||||||
}
|
}
|
||||||
|
if (diff < 3600) {
|
||||||
let workingSeconds = time - workingTime.startTime - workingTime.pauseTime - startPauseTime
|
return Math.floor(diff / 60) + t('dashboard.minutes_ago')
|
||||||
|
|
||||||
state.workingTimeFormat = formatSeconds(workingSeconds)
|
|
||||||
if (!state.pauseWork) {
|
|
||||||
workTimer = window.setInterval(() => {
|
|
||||||
workingSeconds++
|
|
||||||
state.workingTimeFormat = formatSeconds(workingSeconds)
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
}
|
if (diff < 86400) {
|
||||||
|
return Math.floor(diff / 3600) + t('dashboard.hours_ago')
|
||||||
const formatSeconds = (seconds: number) => {
|
|
||||||
var secondTime = 0 // 秒
|
|
||||||
var minuteTime = 0 // 分
|
|
||||||
var hourTime = 0 // 小时
|
|
||||||
var dayTime = 0 // 天
|
|
||||||
var result = ''
|
|
||||||
|
|
||||||
if (seconds < 60) {
|
|
||||||
secondTime = seconds
|
|
||||||
} else {
|
|
||||||
// 获取分钟,除以60取整数,得到整数分钟
|
|
||||||
minuteTime = Math.floor(seconds / 60)
|
|
||||||
// 获取秒数,秒数取佘,得到整数秒数
|
|
||||||
secondTime = Math.floor(seconds % 60)
|
|
||||||
// 如果分钟大于60,将分钟转换成小时
|
|
||||||
if (minuteTime >= 60) {
|
|
||||||
// 获取小时,获取分钟除以60,得到整数小时
|
|
||||||
hourTime = Math.floor(minuteTime / 60)
|
|
||||||
// 获取小时后取佘的分,获取分钟除以60取佘的分
|
|
||||||
minuteTime = Math.floor(minuteTime % 60)
|
|
||||||
if (hourTime >= 24) {
|
|
||||||
// 获取天数, 获取小时除以24,得到整数天
|
|
||||||
dayTime = Math.floor(hourTime / 24)
|
|
||||||
// 获取小时后取余小时,获取分钟除以24取余的分;
|
|
||||||
hourTime = Math.floor(hourTime % 24)
|
|
||||||
}
|
}
|
||||||
}
|
const d = new Date(ts * 1000)
|
||||||
}
|
const pad = (n: number) => (n < 10 ? '0' + n : String(n))
|
||||||
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||||
result =
|
|
||||||
hourTime +
|
|
||||||
t('dashboard.hour') +
|
|
||||||
((minuteTime >= 10 ? minuteTime : '0' + minuteTime) + t('dashboard.minute')) +
|
|
||||||
((secondTime >= 10 ? secondTime : '0' + secondTime) + t('dashboard.second'))
|
|
||||||
if (dayTime > 0) {
|
|
||||||
result = dayTime + t('dashboard.day') + result
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
@@ -599,23 +413,12 @@ onActivated(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startWork()
|
loadDashboard()
|
||||||
initCountUp()
|
|
||||||
initUserGrowthChart()
|
|
||||||
initFileGrowthChart()
|
|
||||||
initUserSourceChart()
|
|
||||||
initUserSurnameChart()
|
|
||||||
useEventListener(window, 'resize', echartsResize)
|
useEventListener(window, 'resize', echartsResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeMount(() => {
|
|
||||||
for (const key in state.charts) {
|
|
||||||
state.charts[key].dispose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearInterval(workTimer)
|
disposeCharts()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -624,89 +427,12 @@ watch(
|
|||||||
echartsResize()
|
echartsResize()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.welcome {
|
|
||||||
background: #e1eaf9;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 15px 20px !important;
|
|
||||||
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
|
|
||||||
.welcome-img {
|
|
||||||
height: 100px;
|
|
||||||
margin-right: 10px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.welcome-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 30px;
|
|
||||||
color: var(--ba-color-primary-light);
|
|
||||||
}
|
|
||||||
.welcome-note {
|
|
||||||
padding-top: 6px;
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.working {
|
|
||||||
height: 130px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
&:hover {
|
|
||||||
.working-coffee {
|
|
||||||
-webkit-transform: translateY(-4px) scale(1.02);
|
|
||||||
-moz-transform: translateY(-4px) scale(1.02);
|
|
||||||
-ms-transform: translateY(-4px) scale(1.02);
|
|
||||||
-o-transform: translateY(-4px) scale(1.02);
|
|
||||||
transform: translateY(-4px) scale(1.02);
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.working-coffee {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
.working-text {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 15px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
.working-opt {
|
|
||||||
position: absolute;
|
|
||||||
top: -40px;
|
|
||||||
right: 10px;
|
|
||||||
background-color: rgba($color: #000000, $alpha: 0.3);
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
color: var(--ba-bg-color-overlay);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
z-index: 999;
|
|
||||||
&:active {
|
|
||||||
background-color: rgba($color: #000000, $alpha: 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:hover {
|
|
||||||
.working-opt {
|
|
||||||
opacity: 1;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
.working-done {
|
|
||||||
opacity: 1;
|
|
||||||
top: 50px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.small-panel-box {
|
.small-panel-box {
|
||||||
margin-top: 20px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.small-panel {
|
.small-panel {
|
||||||
background-color: #e9edf2;
|
background-color: #e9edf2;
|
||||||
@@ -731,8 +457,11 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.content-right {
|
.content-right {
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
max-width: 48%;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.color-success {
|
.color-success {
|
||||||
color: var(--el-color-success);
|
color: var(--el-color-success);
|
||||||
@@ -755,62 +484,62 @@ watch(
|
|||||||
.file-growth-chart {
|
.file-growth-chart {
|
||||||
height: 260px;
|
height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-source-chart,
|
||||||
|
.user-surname-chart {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
.new-user-growth {
|
.new-user-growth {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-source-chart,
|
|
||||||
.user-surname-chart {
|
|
||||||
height: 400px;
|
|
||||||
}
|
|
||||||
.new-user-item {
|
.new-user-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20px;
|
padding: 16px 20px;
|
||||||
margin: 10px 15px;
|
margin: 8px 12px;
|
||||||
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
|
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
|
||||||
background-color: var(--ba-bg-color-overlay);
|
background-color: var(--ba-bg-color-overlay);
|
||||||
.new-user-avatar {
|
.new-user-avatar {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.new-user-base {
|
.new-user-base {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
color: #2c3f5d;
|
color: #2c3f5d;
|
||||||
.new-user-name {
|
.new-user-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.new-user-time {
|
.new-user-time {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.new-user-channel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.new-user-arrow {
|
.new-user-arrow {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.new-user-card :deep(.el-card__body) {
|
.new-user-card :deep(.el-card__body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 425px) {
|
|
||||||
.welcome-img {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media screen and (max-width: 1200px) {
|
@media screen and (max-width: 1200px) {
|
||||||
.lg-mb-20 {
|
.lg-mb-20 {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html.dark {
|
html.dark {
|
||||||
.welcome {
|
|
||||||
background-color: var(--ba-bg-color-overlay);
|
|
||||||
}
|
|
||||||
.working-opt {
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
background-color: var(--ba-border-color);
|
|
||||||
}
|
|
||||||
.small-panel {
|
.small-panel {
|
||||||
background-color: var(--ba-bg-color-overlay);
|
background-color: var(--ba-bg-color-overlay);
|
||||||
.small-panel-content {
|
.small-panel-content {
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
<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-card shadow="never" class="mb-12">
|
<el-card shadow="never" class="mb-12">
|
||||||
|
<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 class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<div>{{ t('game.live.current_record') }}: {{ snapshot.record?.period_no || '-' }}</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>{{ 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>
|
||||||
<div>{{ t('game.live.countdown') }}: {{ countdownText }}</div>
|
<div>{{ t('game.live.countdown') }}: {{ countdownText }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@@ -15,7 +19,7 @@
|
|||||||
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
|
<el-button :loading="calcLoading" :disabled="!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_draw" @click="onDraw">
|
<el-button type="primary" :loading="drawLoading" :disabled="!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" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||||||
@@ -67,6 +71,7 @@ interface Snapshot {
|
|||||||
bets: anyObj[]
|
bets: anyObj[]
|
||||||
candidate_numbers: anyObj[]
|
candidate_numbers: anyObj[]
|
||||||
ai_default_number: number | null
|
ai_default_number: number | null
|
||||||
|
pending_draw_number: number | null
|
||||||
period_seconds?: number
|
period_seconds?: number
|
||||||
bet_seconds?: number
|
bet_seconds?: number
|
||||||
pick_max_number_count?: number
|
pick_max_number_count?: number
|
||||||
@@ -74,8 +79,12 @@ interface Snapshot {
|
|||||||
draw_number_max?: number
|
draw_number_max?: number
|
||||||
remaining_seconds?: number
|
remaining_seconds?: number
|
||||||
bet_remaining_seconds?: number
|
bet_remaining_seconds?: number
|
||||||
|
payout_remaining_seconds?: number
|
||||||
|
is_payout_phase?: boolean
|
||||||
can_calculate?: boolean
|
can_calculate?: boolean
|
||||||
can_draw?: boolean
|
can_draw?: boolean
|
||||||
|
can_schedule_draw?: boolean
|
||||||
|
server_time?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -87,14 +96,18 @@ const snapshot = reactive<Snapshot>({
|
|||||||
bets: [],
|
bets: [],
|
||||||
candidate_numbers: [],
|
candidate_numbers: [],
|
||||||
ai_default_number: null,
|
ai_default_number: null,
|
||||||
|
pending_draw_number: null,
|
||||||
period_seconds: 30,
|
period_seconds: 30,
|
||||||
bet_seconds: 20,
|
bet_seconds: 20,
|
||||||
pick_max_number_count: 10,
|
pick_max_number_count: 10,
|
||||||
draw_number_max: 36,
|
draw_number_max: 36,
|
||||||
remaining_seconds: 0,
|
remaining_seconds: 0,
|
||||||
bet_remaining_seconds: 0,
|
bet_remaining_seconds: 0,
|
||||||
|
payout_remaining_seconds: 0,
|
||||||
|
is_payout_phase: false,
|
||||||
can_calculate: false,
|
can_calculate: false,
|
||||||
can_draw: false,
|
can_draw: false,
|
||||||
|
can_schedule_draw: false,
|
||||||
})
|
})
|
||||||
const calcLoading = ref(false)
|
const calcLoading = ref(false)
|
||||||
const drawLoading = ref(false)
|
const drawLoading = ref(false)
|
||||||
@@ -102,6 +115,12 @@ 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')
|
||||||
|
|
||||||
|
/** 服务端 Unix 秒 − 本地 Unix 秒,用于派彩倒计时与服务器对齐 */
|
||||||
|
const serverSkewSeconds = ref(0)
|
||||||
|
/** 每秒递增,驱动派彩剩余秒本地刷新 */
|
||||||
|
const clockTick = ref(0)
|
||||||
|
let clockTimer: number | null = null
|
||||||
|
|
||||||
let pushClient: any = null
|
let pushClient: any = null
|
||||||
let pushChannel: any = null
|
let pushChannel: any = null
|
||||||
let pollTimer: number | null = null
|
let pollTimer: number | null = null
|
||||||
@@ -113,6 +132,45 @@ function formatPicks(v: unknown): string {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncServerClock(serverTime: unknown): void {
|
||||||
|
if (typeof serverTime === 'number' && Number.isFinite(serverTime)) {
|
||||||
|
serverSkewSeconds.value = serverTime - Math.floor(Date.now() / 1000)
|
||||||
|
snapshot.server_time = serverTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPayoutUntilUnix(rec: anyObj | null): number | null {
|
||||||
|
if (!rec) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const v = rec.payout_until
|
||||||
|
if (v === null || v === undefined || v === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if (typeof v === 'string' && /^\d+$/.test(v)) {
|
||||||
|
return parseInt(v, 10)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 派彩剩余秒:优先用 payout_until 与对时后的「服务器当前秒」计算,便于每秒递减 */
|
||||||
|
const payoutRemainingLive = computed(() => {
|
||||||
|
clockTick.value
|
||||||
|
if (!snapshot.is_payout_phase) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const until = readPayoutUntilUnix(snapshot.record)
|
||||||
|
if (until !== null) {
|
||||||
|
const serverNow = Math.floor(Date.now() / 1000) + serverSkewSeconds.value
|
||||||
|
const diff = until - serverNow
|
||||||
|
return diff > 0 ? diff : 0
|
||||||
|
}
|
||||||
|
return snapshot.payout_remaining_seconds ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
async function loadSnapshot() {
|
async function loadSnapshot() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -122,14 +180,20 @@ async function loadSnapshot() {
|
|||||||
snapshot.bets = res.data.bets || []
|
snapshot.bets = res.data.bets || []
|
||||||
snapshot.candidate_numbers = res.data.candidate_numbers || []
|
snapshot.candidate_numbers = res.data.candidate_numbers || []
|
||||||
snapshot.ai_default_number = res.data.ai_default_number
|
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.period_seconds = res.data.period_seconds ?? 30
|
||||||
snapshot.bet_seconds = res.data.bet_seconds ?? 20
|
snapshot.bet_seconds = res.data.bet_seconds ?? 20
|
||||||
snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10
|
snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10
|
||||||
snapshot.draw_number_max = res.data.draw_number_max ?? 36
|
snapshot.draw_number_max = res.data.draw_number_max ?? 36
|
||||||
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
|
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
|
||||||
snapshot.bet_remaining_seconds = res.data.bet_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_calculate = !!res.data.can_calculate
|
||||||
snapshot.can_draw = !!res.data.can_draw
|
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
|
||||||
}
|
}
|
||||||
@@ -172,14 +236,20 @@ async function initPush() {
|
|||||||
snapshot.bets = payload.bets || []
|
snapshot.bets = payload.bets || []
|
||||||
snapshot.candidate_numbers = payload.candidate_numbers || []
|
snapshot.candidate_numbers = payload.candidate_numbers || []
|
||||||
snapshot.ai_default_number = payload.ai_default_number ?? null
|
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.period_seconds = payload.period_seconds ?? 30
|
||||||
snapshot.bet_seconds = payload.bet_seconds ?? 20
|
snapshot.bet_seconds = payload.bet_seconds ?? 20
|
||||||
snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10
|
snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10
|
||||||
snapshot.draw_number_max = payload.draw_number_max ?? 36
|
snapshot.draw_number_max = payload.draw_number_max ?? 36
|
||||||
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
|
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
|
||||||
snapshot.bet_remaining_seconds = payload.bet_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_calculate = !!payload.can_calculate
|
||||||
snapshot.can_draw = !!payload.can_draw
|
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
|
||||||
@@ -244,12 +314,19 @@ async function onDraw() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const countdownText = computed(() => {
|
const countdownText = computed(() => {
|
||||||
const total = snapshot.remaining_seconds ?? 0
|
|
||||||
const bet = snapshot.bet_remaining_seconds ?? 0
|
const bet = snapshot.bet_remaining_seconds ?? 0
|
||||||
return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${total}s`
|
const draw = snapshot.remaining_seconds ?? 0
|
||||||
|
let payoutPart = t('game.live.payout_na')
|
||||||
|
if (snapshot.is_payout_phase && payoutRemainingLive.value !== null) {
|
||||||
|
payoutPart = `${payoutRemainingLive.value}s`
|
||||||
|
}
|
||||||
|
return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${draw}s / ${t('game.live.payout_countdown')} ${payoutPart}`
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
clockTimer = window.setInterval(() => {
|
||||||
|
clockTick.value++
|
||||||
|
}, 1000)
|
||||||
await loadSnapshot()
|
await loadSnapshot()
|
||||||
try {
|
try {
|
||||||
await initPush()
|
await initPush()
|
||||||
@@ -269,6 +346,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
stopPolling()
|
stopPolling()
|
||||||
stopPushWatchdog()
|
stopPushWatchdog()
|
||||||
|
if (clockTimer !== null) {
|
||||||
|
window.clearInterval(clockTimer)
|
||||||
|
clockTimer = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
|
|||||||
142
web/src/views/backend/test/components/PushChannelTestPage.vue
Normal file
142
web/src/views/backend/test/components/PushChannelTestPage.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="default-main">
|
||||||
|
<el-alert type="info" :title="tip" show-icon class="mb-12" />
|
||||||
|
<el-alert :type="connected ? 'success' : 'warning'" :title="connected ? t('test.push.connected') : t('test.push.disconnected')" show-icon class="mb-12" />
|
||||||
|
|
||||||
|
<el-card shadow="never" class="mb-12">
|
||||||
|
<el-form :inline="true" @submit.prevent>
|
||||||
|
<el-form-item v-if="useUuid" :label="t('test.push.user_uuid')">
|
||||||
|
<el-input v-model="userUuid" :placeholder="t('test.push.user_uuid_placeholder')" clearable style="width: 280px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="connect">{{ t('test.push.btn_connect') }}</el-button>
|
||||||
|
<el-button :disabled="!session" @click="disconnect">{{ t('test.push.btn_disconnect') }}</el-button>
|
||||||
|
<el-button @click="clearLogs">{{ t('test.push.btn_clear') }}</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="text-muted mb-8">{{ t('test.push.channel_label') }}: {{ channelName || '-' }}</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>{{ t('test.push.log_title') }}</template>
|
||||||
|
<el-scrollbar max-height="480">
|
||||||
|
<pre class="push-log">{{ logText }}</pre>
|
||||||
|
</el-scrollbar>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import createAxios from '/@/utils/axios'
|
||||||
|
import { loadPushJs, startPushChannelListener, type PushTestLogLine } from '/@/utils/backend/pushChannelTest'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 如 /admin/test.PushGamePeriod/pushConfig */
|
||||||
|
configApi: string
|
||||||
|
useUuid?: boolean
|
||||||
|
tip: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
useUuid: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const loading = ref(false)
|
||||||
|
const connected = ref(false)
|
||||||
|
const channelName = ref('')
|
||||||
|
const userUuid = ref('')
|
||||||
|
const logs = ref<PushTestLogLine[]>([])
|
||||||
|
const session = ref<{ disconnect: () => void } | null>(null)
|
||||||
|
|
||||||
|
const logText = computed(() => {
|
||||||
|
if (!logs.value.length) return t('test.push.log_empty')
|
||||||
|
return logs.value
|
||||||
|
.map((row) => {
|
||||||
|
const time = new Date(row.t).toLocaleString()
|
||||||
|
return `[${time}] ${row.event}\n${row.payload}`
|
||||||
|
})
|
||||||
|
.join('\n\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
function appendLog(line: PushTestLogLine) {
|
||||||
|
logs.value = [line, ...logs.value].slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogs() {
|
||||||
|
logs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (props.useUuid && !userUuid.value.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
disconnect()
|
||||||
|
try {
|
||||||
|
await loadPushJs()
|
||||||
|
const params: anyObj = {}
|
||||||
|
if (props.useUuid) {
|
||||||
|
params.uuid = userUuid.value.trim()
|
||||||
|
}
|
||||||
|
const res = await createAxios({
|
||||||
|
url: props.configApi,
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
showCodeMessage: true,
|
||||||
|
})
|
||||||
|
if (res.code !== 1 || !res.data) {
|
||||||
|
connected.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { url, app_key, channel } = res.data
|
||||||
|
channelName.value = typeof channel === 'string' ? channel : ''
|
||||||
|
try {
|
||||||
|
const handle = startPushChannelListener({
|
||||||
|
url,
|
||||||
|
app_key,
|
||||||
|
channel: channelName.value,
|
||||||
|
usePrivateAuth: !!props.useUuid,
|
||||||
|
onLog: appendLog,
|
||||||
|
onConnected: (ok) => {
|
||||||
|
connected.value = ok
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session.value = handle
|
||||||
|
} catch {
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (session.value) {
|
||||||
|
session.value.disconnect()
|
||||||
|
session.value = null
|
||||||
|
}
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.push-log {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.text-muted {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
web/src/views/backend/test/pushGamePeriod/index.vue
Normal file
10
web/src/views/backend/test/pushGamePeriod/index.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<PushChannelTestPage config-api="/admin/test.PushGamePeriod/pushConfig" :tip="t('test.pushGamePeriod.tip')" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import PushChannelTestPage from '../components/PushChannelTestPage.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
10
web/src/views/backend/test/pushOperationNotice/index.vue
Normal file
10
web/src/views/backend/test/pushOperationNotice/index.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<PushChannelTestPage config-api="/admin/test.PushOperationNotice/pushConfig" :tip="t('test.pushOperationNotice.tip')" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import PushChannelTestPage from '../components/PushChannelTestPage.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
14
web/src/views/backend/test/pushPrivateUser/index.vue
Normal file
14
web/src/views/backend/test/pushPrivateUser/index.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<PushChannelTestPage
|
||||||
|
config-api="/admin/test.PushPrivateUser/pushConfig"
|
||||||
|
use-uuid
|
||||||
|
:tip="t('test.pushPrivateUser.tip')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import PushChannelTestPage from '../components/PushChannelTestPage.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user