feat: 重构代码架构 + 新增报表/跟单/输赢统计功能
- 拆分 HomeController → TransactionController, ReportWebController, FollowPlanController - 新增 Service 层: TransactionService, ReportService, FollowPlanService - pk10.php JS 抽离为 4 个独立文件 (sound/race/bet/poll) - 前台新增报表查询页面 (/report) + 跟单计划页面 (/follow-plan) - 后台新增跟单计划管理 + 用户输赢明细统计 - 封盘状态显示倒计时 (x:xx) - 音效仅在开奖弹窗打开时播放 - 路由按模块分组整理 - autoload 支持 App\Services 命名空间
@@ -34,7 +34,7 @@ class AgentController extends AdminBaseController {
|
||||
// 创建代理:先创建用户,再创建代理记录
|
||||
$userId = (int)($data['user_id'] ?? 0);
|
||||
if (!$userId) { $this->json(['status'=>'error','message'=>'User ID required']); return; }
|
||||
$code = strtoupper(substr(md5(uniqid()), 0, 8));
|
||||
$code = strtoupper(bin2hex(random_bytes(4)));
|
||||
$this->db->insert('agents', [
|
||||
'user_id' => $userId,
|
||||
'parent_id' => !empty($data['parent_id']) ? (int)$data['parent_id'] : null,
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
<?php
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Core\AdminBaseController;
|
||||
use Db\Database;
|
||||
|
||||
class BotController extends AdminBaseController
|
||||
{
|
||||
private Database $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->checkLogin();
|
||||
$this->checkAdmin();
|
||||
$this->db = new Database();
|
||||
}
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
$stats = [
|
||||
'bot_count' => 0,
|
||||
'group_count' => 0,
|
||||
'wallet_count' => 0,
|
||||
'enabled_group_count' => 0,
|
||||
'member_count' => 0,
|
||||
'shill_count' => 0,
|
||||
'push_count' => 0,
|
||||
'push_success_count' => 0,
|
||||
'countdown_group_count' => 0,
|
||||
'animation_group_count' => 0,
|
||||
'api_request_count' => 0,
|
||||
'api_signature_ok_count' => 0,
|
||||
];
|
||||
|
||||
$bots = [];
|
||||
$groups = [];
|
||||
$wallets = [];
|
||||
$rules = [];
|
||||
$games = [];
|
||||
$users = [];
|
||||
$members = [];
|
||||
$shills = [];
|
||||
$recentOrders = [];
|
||||
$pushLogs = [];
|
||||
$apiRequestLogs = [];
|
||||
$pushSummary = [
|
||||
'countdown' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0],
|
||||
'bet_success' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0],
|
||||
'draw' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0],
|
||||
'credit' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0],
|
||||
'debit' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0],
|
||||
'system' => ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0],
|
||||
];
|
||||
|
||||
try {
|
||||
$stats['bot_count'] = (int)($this->db->count('bot_instances') ?: 0);
|
||||
$stats['group_count'] = (int)($this->db->count('bot_groups') ?: 0);
|
||||
$stats['wallet_count'] = (int)($this->db->count('bot_group_wallets', ['status' => 1]) ?: 0);
|
||||
$stats['enabled_group_count'] = (int)($this->db->count('bot_groups', ['status' => 1, 'bet_enabled' => 1]) ?: 0);
|
||||
$stats['member_count'] = (int)($this->db->count('bot_group_members') ?: 0);
|
||||
$stats['shill_count'] = (int)($this->db->count('bot_shills', ['enabled' => 1]) ?: 0);
|
||||
$stats['push_count'] = (int)($this->db->count('bot_push_logs') ?: 0);
|
||||
$stats['push_success_count'] = (int)($this->db->count('bot_push_logs', ['status' => 'success']) ?: 0);
|
||||
$stats['countdown_group_count'] = (int)($this->db->count('bot_groups', ['remind_close_countdown' => 1]) ?: 0);
|
||||
$stats['animation_group_count'] = (int)($this->db->count('bot_groups', ['animation_enabled' => 1]) ?: 0);
|
||||
$stats['api_request_count'] = (int)($this->db->count('bot_api_request_logs') ?: 0);
|
||||
$stats['api_signature_ok_count'] = (int)($this->db->count('bot_api_request_logs', ['signature_ok' => 1]) ?: 0);
|
||||
|
||||
$bots = $this->db->select('bot_instances', '*', ['ORDER' => ['id' => 'DESC']]) ?: [];
|
||||
foreach ($bots as &$bot) {
|
||||
$botId = (int)$bot['id'];
|
||||
$bot['group_count'] = (int)($this->db->count('bot_groups', ['bot_id' => $botId]) ?: 0);
|
||||
$bot['active_group_count'] = (int)($this->db->count('bot_groups', [
|
||||
'bot_id' => $botId,
|
||||
'status' => 1,
|
||||
'bet_enabled' => 1,
|
||||
]) ?: 0);
|
||||
}
|
||||
unset($bot);
|
||||
|
||||
$groups = $this->db->select('bot_groups', [
|
||||
'[>]bot_instances' => ['bot_id' => 'id'],
|
||||
'[>]games' => ['game_id' => 'id'],
|
||||
], [
|
||||
'bot_groups.id',
|
||||
'bot_groups.bot_id',
|
||||
'bot_groups.game_id',
|
||||
'bot_groups.bet_format_rule_id',
|
||||
'bot_groups.tg_group_id',
|
||||
'bot_groups.group_name',
|
||||
'bot_groups.group_type',
|
||||
'bot_groups.status',
|
||||
'bot_groups.bet_enabled',
|
||||
'bot_groups.remind_bet_success',
|
||||
'bot_groups.remind_draw_result',
|
||||
'bot_groups.remind_close_countdown',
|
||||
'bot_groups.animation_enabled',
|
||||
'bot_groups.countdown_config',
|
||||
'bot_groups.updated_at',
|
||||
'bot_instances.name(bot_name)',
|
||||
'games.name(game_name)',
|
||||
], [
|
||||
'ORDER' => ['bot_groups.id' => 'DESC'],
|
||||
]) ?: [];
|
||||
|
||||
$wallets = $this->db->select('bot_group_wallets', [
|
||||
'[>]bot_groups' => ['group_id' => 'id'],
|
||||
'[>]users' => ['platform_user_id' => 'id'],
|
||||
], [
|
||||
'bot_group_wallets.id',
|
||||
'bot_group_wallets.group_id',
|
||||
'bot_group_wallets.platform_user_id',
|
||||
'bot_group_wallets.wallet_mode',
|
||||
'bot_group_wallets.status',
|
||||
'bot_group_wallets.updated_at',
|
||||
'bot_groups.group_name',
|
||||
'bot_groups.tg_group_id',
|
||||
'users.id(platform_user_id)',
|
||||
'users.username(platform_username)',
|
||||
'users.balance(platform_balance)',
|
||||
], [
|
||||
'ORDER' => ['bot_group_wallets.id' => 'DESC'],
|
||||
]) ?: [];
|
||||
|
||||
$rules = $this->db->select('bot_bet_format_rules', ['id', 'rule_code', 'name', 'status'], [
|
||||
'status' => 1,
|
||||
'ORDER' => ['id' => 'DESC'],
|
||||
]) ?: [];
|
||||
|
||||
$games = $this->db->select('games', ['id', 'name', 'type', 'status'], [
|
||||
'status' => 1,
|
||||
'ORDER' => ['sort_order' => 'ASC', 'id' => 'DESC'],
|
||||
]) ?: [];
|
||||
|
||||
$users = $this->db->select('users', ['id', 'username', 'balance', 'status', 'role'], [
|
||||
'role[!]' => 'admin',
|
||||
'ORDER' => ['id' => 'DESC'],
|
||||
'LIMIT' => 200,
|
||||
]) ?: [];
|
||||
|
||||
$members = $this->db->select('bot_group_members', [
|
||||
'[>]bot_groups' => ['group_id' => 'id'],
|
||||
'[>]users' => ['platform_user_id' => 'id'],
|
||||
], [
|
||||
'bot_group_members.id',
|
||||
'bot_group_members.group_id',
|
||||
'bot_group_members.tg_user_id',
|
||||
'bot_group_members.tg_username',
|
||||
'bot_group_members.tg_nickname',
|
||||
'bot_group_members.platform_user_id',
|
||||
'bot_group_members.role',
|
||||
'bot_group_members.bet_enabled',
|
||||
'bot_group_members.last_seen_at',
|
||||
'bot_group_members.updated_at',
|
||||
'bot_groups.group_name',
|
||||
'bot_groups.tg_group_id',
|
||||
'users.username(platform_username)',
|
||||
], [
|
||||
'ORDER' => ['bot_group_members.id' => 'DESC'],
|
||||
'LIMIT' => 200,
|
||||
]) ?: [];
|
||||
|
||||
$shills = $this->db->select('bot_shills', [
|
||||
'[>]bot_groups' => ['group_id' => 'id'],
|
||||
'[>]bot_group_members' => [
|
||||
'bot_shills.group_id' => 'group_id',
|
||||
'bot_shills.tg_user_id' => 'tg_user_id',
|
||||
],
|
||||
], [
|
||||
'bot_shills.id',
|
||||
'bot_shills.group_id',
|
||||
'bot_shills.tg_user_id',
|
||||
'bot_shills.note',
|
||||
'bot_shills.enabled',
|
||||
'bot_shills.updated_at',
|
||||
'bot_groups.group_name',
|
||||
'bot_groups.tg_group_id',
|
||||
'bot_group_members.tg_username',
|
||||
'bot_group_members.tg_nickname',
|
||||
], [
|
||||
'ORDER' => ['bot_shills.id' => 'DESC'],
|
||||
'LIMIT' => 200,
|
||||
]) ?: [];
|
||||
|
||||
$recentOrders = $this->db->select('bot_bet_orders', [
|
||||
'[>]bot_groups' => ['group_id' => 'id'],
|
||||
], [
|
||||
'bot_bet_orders.id(order_id)',
|
||||
'bot_bet_orders.period_number',
|
||||
'bot_bet_orders.bet_amount_total',
|
||||
'bot_bet_orders.accepted_bet_count',
|
||||
'bot_bet_orders.sync_status',
|
||||
'bot_bet_orders.sync_error',
|
||||
'bot_bet_orders.is_shill',
|
||||
'bot_bet_orders.created_at',
|
||||
'bot_bet_orders.tg_username',
|
||||
'bot_groups.group_name',
|
||||
'bot_groups.tg_group_id',
|
||||
], [
|
||||
'ORDER' => ['bot_bet_orders.id' => 'DESC'],
|
||||
'LIMIT' => 10,
|
||||
]) ?: [];
|
||||
|
||||
$pushLogs = $this->db->select('bot_push_logs', [
|
||||
'[>]bot_groups' => ['group_id' => 'id'],
|
||||
], [
|
||||
'bot_push_logs.id',
|
||||
'bot_push_logs.group_id',
|
||||
'bot_push_logs.period_number',
|
||||
'bot_push_logs.push_type',
|
||||
'bot_push_logs.payload_json',
|
||||
'bot_push_logs.tg_message_id',
|
||||
'bot_push_logs.status',
|
||||
'bot_push_logs.error_message',
|
||||
'bot_push_logs.created_at',
|
||||
'bot_groups.group_name',
|
||||
'bot_groups.tg_group_id',
|
||||
], [
|
||||
'ORDER' => ['bot_push_logs.id' => 'DESC'],
|
||||
'LIMIT' => 20,
|
||||
]) ?: [];
|
||||
|
||||
foreach ($pushLogs as &$pushLog) {
|
||||
$payload = [];
|
||||
if (!empty($pushLog['payload_json'])) {
|
||||
$decodedPayload = json_decode((string)$pushLog['payload_json'], true);
|
||||
if (is_array($decodedPayload)) {
|
||||
$payload = $decodedPayload;
|
||||
}
|
||||
}
|
||||
|
||||
$pushLog['payload'] = $payload;
|
||||
$pushType = (string)($pushLog['push_type'] ?? 'system');
|
||||
$status = (string)($pushLog['status'] ?? 'pending');
|
||||
if (!isset($pushSummary[$pushType])) {
|
||||
$pushSummary[$pushType] = ['total' => 0, 'success' => 0, 'failed' => 0, 'pending' => 0, 'skipped' => 0];
|
||||
}
|
||||
$pushSummary[$pushType]['total']++;
|
||||
if (isset($pushSummary[$pushType][$status])) {
|
||||
$pushSummary[$pushType][$status]++;
|
||||
}
|
||||
}
|
||||
unset($pushLog);
|
||||
|
||||
$apiRequestLogs = $this->db->select('bot_api_request_logs', [
|
||||
'[>]bot_instances' => ['bot_id' => 'id'],
|
||||
'[>]bot_groups' => ['group_id' => 'id'],
|
||||
], [
|
||||
'bot_api_request_logs.id',
|
||||
'bot_api_request_logs.request_uri',
|
||||
'bot_api_request_logs.http_method',
|
||||
'bot_api_request_logs.idempotency_key',
|
||||
'bot_api_request_logs.response_code',
|
||||
'bot_api_request_logs.client_ip',
|
||||
'bot_api_request_logs.signature_ok',
|
||||
'bot_api_request_logs.created_at',
|
||||
'bot_instances.name(bot_name)',
|
||||
'bot_groups.group_name',
|
||||
'bot_groups.tg_group_id',
|
||||
], [
|
||||
'ORDER' => ['bot_api_request_logs.id' => 'DESC'],
|
||||
'LIMIT' => 20,
|
||||
]) ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
$this->render('Admin/bots.php', compact(
|
||||
'stats', 'bots', 'groups', 'wallets', 'rules', 'games', 'users', 'members', 'shills', 'recentOrders', 'pushLogs', 'pushSummary', 'apiRequestLogs'
|
||||
) + ['title' => 'Bot 管理']);
|
||||
}
|
||||
|
||||
public function saveBot(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$name = trim((string)($payload['name'] ?? ''));
|
||||
$botToken = trim((string)($payload['bot_token'] ?? ''));
|
||||
$botUsername = $this->normalizeNullableString($payload['bot_username'] ?? null);
|
||||
$runMode = $this->normalizeRunMode((string)($payload['run_mode'] ?? 'polling'));
|
||||
$webhookUrl = $this->normalizeNullableString($payload['webhook_url'] ?? null);
|
||||
$remark = $this->normalizeNullableString($payload['remark'] ?? null);
|
||||
|
||||
if ($name === '' || $botToken === '') {
|
||||
$this->json(['success' => false, 'message' => 'Bot 名称和 Token 必填']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$id = $this->db->insert('bot_instances', [
|
||||
'name' => $name,
|
||||
'bot_token' => $botToken,
|
||||
'bot_username' => $botUsername,
|
||||
'bot_key' => bin2hex(random_bytes(8)),
|
||||
'bot_secret' => bin2hex(random_bytes(16)),
|
||||
'webhook_url' => $webhookUrl,
|
||||
'run_mode' => $runMode,
|
||||
'status' => 1,
|
||||
'remark' => $remark,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$this->json(['success' => true, 'message' => 'Bot 实例创建成功', 'data' => ['id' => (int)$id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => 'Bot 实例创建失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateBot(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$name = trim((string)($payload['name'] ?? ''));
|
||||
$botToken = trim((string)($payload['bot_token'] ?? ''));
|
||||
$botUsername = $this->normalizeNullableString($payload['bot_username'] ?? null);
|
||||
$runMode = $this->normalizeRunMode((string)($payload['run_mode'] ?? 'polling'));
|
||||
$webhookUrl = $this->normalizeNullableString($payload['webhook_url'] ?? null);
|
||||
$remark = $this->normalizeNullableString($payload['remark'] ?? null);
|
||||
|
||||
if ($id <= 0 || $name === '' || $botToken === '') {
|
||||
$this->json(['success' => false, 'message' => 'Bot ID、名称和 Token 必填']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$bot = $this->db->get('bot_instances', ['id'], ['id' => $id]);
|
||||
if (!$bot) {
|
||||
$this->json(['success' => false, 'message' => 'Bot 实例不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('bot_instances', [
|
||||
'name' => $name,
|
||||
'bot_token' => $botToken,
|
||||
'bot_username' => $botUsername,
|
||||
'webhook_url' => $webhookUrl,
|
||||
'run_mode' => $runMode,
|
||||
'remark' => $remark,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$this->json(['success' => true, 'message' => 'Bot 实例更新成功', 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => 'Bot 实例更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleBot(): void
|
||||
{
|
||||
$this->toggleRecord('bot_instances', 'Bot 参数无效', 'Bot 实例不存在', 'Bot %s已%s');
|
||||
}
|
||||
|
||||
public function saveGroup(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$botId = (int)($payload['bot_id'] ?? 0);
|
||||
$gameId = (int)($payload['game_id'] ?? 0);
|
||||
$tgGroupId = trim((string)($payload['tg_group_id'] ?? ''));
|
||||
$groupName = trim((string)($payload['group_name'] ?? ''));
|
||||
$groupType = $this->normalizeGroupType((string)($payload['group_type'] ?? 'group'));
|
||||
$ruleId = (int)($payload['bet_format_rule_id'] ?? 0);
|
||||
|
||||
if ($botId <= 0 || $gameId <= 0 || $tgGroupId === '' || $groupName === '') {
|
||||
$this->json(['success' => false, 'message' => 'Bot、游戏、群ID、群名称均必填']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$this->exists('bot_instances', $botId) || !$this->exists('games', $gameId)) {
|
||||
$this->json(['success' => false, 'message' => 'Bot 或游戏不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->db->insert('bot_groups', [
|
||||
'bot_id' => $botId,
|
||||
'tg_group_id' => $tgGroupId,
|
||||
'group_name' => $groupName,
|
||||
'group_type' => $groupType,
|
||||
'game_id' => $gameId,
|
||||
'bet_format_rule_id' => $ruleId > 0 ? $ruleId : null,
|
||||
'remind_bet_success' => !empty($payload['remind_bet_success']) ? 1 : 0,
|
||||
'remind_draw_result' => !empty($payload['remind_draw_result']) ? 1 : 0,
|
||||
'remind_close_countdown' => !empty($payload['remind_close_countdown']) ? 1 : 0,
|
||||
'countdown_config' => !empty($payload['countdown_config']) ? $this->normalizeCountdownConfig((string)$payload['countdown_config']) : null,
|
||||
'animation_enabled' => !empty($payload['animation_enabled']) ? 1 : 0,
|
||||
'bet_enabled' => !empty($payload['bet_enabled']) ? 1 : 0,
|
||||
'status' => 1,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$this->json(['success' => true, 'message' => '群配置创建成功', 'data' => ['id' => (int)$id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '群配置创建失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateGroup(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$botId = (int)($payload['bot_id'] ?? 0);
|
||||
$gameId = (int)($payload['game_id'] ?? 0);
|
||||
$tgGroupId = trim((string)($payload['tg_group_id'] ?? ''));
|
||||
$groupName = trim((string)($payload['group_name'] ?? ''));
|
||||
$groupType = $this->normalizeGroupType((string)($payload['group_type'] ?? 'group'));
|
||||
$ruleId = (int)($payload['bet_format_rule_id'] ?? 0);
|
||||
|
||||
if ($id <= 0 || $botId <= 0 || $gameId <= 0 || $tgGroupId === '' || $groupName === '') {
|
||||
$this->json(['success' => false, 'message' => '群配置参数不完整']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$this->exists('bot_groups', $id)) {
|
||||
$this->json(['success' => false, 'message' => '群配置不存在']);
|
||||
return;
|
||||
}
|
||||
if (!$this->exists('bot_instances', $botId) || !$this->exists('games', $gameId)) {
|
||||
$this->json(['success' => false, 'message' => 'Bot 或游戏不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('bot_groups', [
|
||||
'bot_id' => $botId,
|
||||
'tg_group_id' => $tgGroupId,
|
||||
'group_name' => $groupName,
|
||||
'group_type' => $groupType,
|
||||
'game_id' => $gameId,
|
||||
'bet_format_rule_id' => $ruleId > 0 ? $ruleId : null,
|
||||
'remind_bet_success' => !empty($payload['remind_bet_success']) ? 1 : 0,
|
||||
'remind_draw_result' => !empty($payload['remind_draw_result']) ? 1 : 0,
|
||||
'remind_close_countdown' => !empty($payload['remind_close_countdown']) ? 1 : 0,
|
||||
'countdown_config' => !empty($payload['countdown_config']) ? $this->normalizeCountdownConfig((string)$payload['countdown_config']) : null,
|
||||
'animation_enabled' => !empty($payload['animation_enabled']) ? 1 : 0,
|
||||
'bet_enabled' => !empty($payload['bet_enabled']) ? 1 : 0,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$this->json(['success' => true, 'message' => '群配置更新成功', 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '群配置更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleGroup(): void
|
||||
{
|
||||
$this->toggleRecord('bot_groups', '群配置参数无效', '群配置不存在', '群 %s已%s', 'group_name');
|
||||
}
|
||||
|
||||
public function saveWallet(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$groupId = (int)($payload['group_id'] ?? 0);
|
||||
$platformUserId = (int)($payload['platform_user_id'] ?? 0);
|
||||
$walletMode = $this->normalizeWalletMode((string)($payload['wallet_mode'] ?? 'master_pool'));
|
||||
|
||||
if ($groupId <= 0 || $platformUserId <= 0) {
|
||||
$this->json(['success' => false, 'message' => '群配置和总账号必填']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->db->get('users', ['id', 'username'], ['id' => $platformUserId]);
|
||||
if (!$this->exists('bot_groups', $groupId) || !$user) {
|
||||
$this->json(['success' => false, 'message' => '群配置或网站账号不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = $this->db->get('bot_group_wallets', ['id'], ['group_id' => $groupId]);
|
||||
$data = [
|
||||
'platform_user_id' => $platformUserId,
|
||||
'platform_username_snapshot' => $user['username'],
|
||||
'wallet_mode' => $walletMode,
|
||||
'status' => 1,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
$this->db->update('bot_group_wallets', $data, ['group_id' => $groupId]);
|
||||
$id = (int)$exists['id'];
|
||||
$message = '群总账号绑定已更新';
|
||||
} else {
|
||||
$data['group_id'] = $groupId;
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$id = (int)$this->db->insert('bot_group_wallets', $data);
|
||||
$message = '群总账号绑定成功';
|
||||
}
|
||||
|
||||
$this->json(['success' => true, 'message' => $message, 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '群总账号绑定失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateWallet(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$groupId = (int)($payload['group_id'] ?? 0);
|
||||
$platformUserId = (int)($payload['platform_user_id'] ?? 0);
|
||||
$walletMode = $this->normalizeWalletMode((string)($payload['wallet_mode'] ?? 'master_pool'));
|
||||
|
||||
if ($id <= 0 || $groupId <= 0 || $platformUserId <= 0) {
|
||||
$this->json(['success' => false, 'message' => '钱包绑定参数不完整']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$wallet = $this->db->get('bot_group_wallets', ['id'], ['id' => $id]);
|
||||
$user = $this->db->get('users', ['id', 'username'], ['id' => $platformUserId]);
|
||||
if (!$wallet) {
|
||||
$this->json(['success' => false, 'message' => '钱包绑定不存在']);
|
||||
return;
|
||||
}
|
||||
if (!$this->exists('bot_groups', $groupId) || !$user) {
|
||||
$this->json(['success' => false, 'message' => '群配置或网站账号不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$duplicate = $this->db->get('bot_group_wallets', ['id'], [
|
||||
'group_id' => $groupId,
|
||||
'id[!]' => $id,
|
||||
]);
|
||||
if ($duplicate) {
|
||||
$this->json(['success' => false, 'message' => '该群已绑定其他总账号']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('bot_group_wallets', [
|
||||
'group_id' => $groupId,
|
||||
'platform_user_id' => $platformUserId,
|
||||
'platform_username_snapshot' => $user['username'],
|
||||
'wallet_mode' => $walletMode,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$this->json(['success' => true, 'message' => '群总账号绑定更新成功', 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '群总账号绑定更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleWallet(): void
|
||||
{
|
||||
$this->toggleRecord('bot_group_wallets', '钱包绑定参数无效', '钱包绑定不存在', '群总账号绑定已%s', 'id', false);
|
||||
}
|
||||
|
||||
public function saveMember(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$groupId = (int)($payload['group_id'] ?? 0);
|
||||
$tgUserId = trim((string)($payload['tg_user_id'] ?? ''));
|
||||
$tgUsername = $this->normalizeNullableString($payload['tg_username'] ?? null);
|
||||
$tgNickname = $this->normalizeNullableString($payload['tg_nickname'] ?? null);
|
||||
$platformUserId = (int)($payload['platform_user_id'] ?? 0);
|
||||
$role = $this->normalizeMemberRole((string)($payload['role'] ?? 'member'));
|
||||
$betEnabled = !empty($payload['bet_enabled']) ? 1 : 0;
|
||||
|
||||
if ($groupId <= 0 || $tgUserId === '') {
|
||||
$this->json(['success' => false, 'message' => '群配置与 TG 用户 ID 必填']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$this->exists('bot_groups', $groupId)) {
|
||||
$this->json(['success' => false, 'message' => '群配置不存在']);
|
||||
return;
|
||||
}
|
||||
if ($platformUserId > 0 && !$this->exists('users', $platformUserId)) {
|
||||
$this->json(['success' => false, 'message' => '绑定网站账号不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int)$this->db->insert('bot_group_members', [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
'tg_username' => $tgUsername,
|
||||
'tg_nickname' => $tgNickname,
|
||||
'platform_user_id' => $platformUserId > 0 ? $platformUserId : null,
|
||||
'role' => $role,
|
||||
'bet_enabled' => $betEnabled,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$this->syncShillRecord($groupId, $tgUserId, $role === 'shill', $this->normalizeNullableString($payload['shill_note'] ?? null));
|
||||
$this->json(['success' => true, 'message' => '群成员映射创建成功', 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '群成员映射创建失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateMember(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$groupId = (int)($payload['group_id'] ?? 0);
|
||||
$tgUserId = trim((string)($payload['tg_user_id'] ?? ''));
|
||||
$tgUsername = $this->normalizeNullableString($payload['tg_username'] ?? null);
|
||||
$tgNickname = $this->normalizeNullableString($payload['tg_nickname'] ?? null);
|
||||
$platformUserId = (int)($payload['platform_user_id'] ?? 0);
|
||||
$role = $this->normalizeMemberRole((string)($payload['role'] ?? 'member'));
|
||||
$betEnabled = !empty($payload['bet_enabled']) ? 1 : 0;
|
||||
|
||||
if ($id <= 0 || $groupId <= 0 || $tgUserId === '') {
|
||||
$this->json(['success' => false, 'message' => '成员参数不完整']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$this->exists('bot_group_members', $id)) {
|
||||
$this->json(['success' => false, 'message' => '群成员映射不存在']);
|
||||
return;
|
||||
}
|
||||
if (!$this->exists('bot_groups', $groupId)) {
|
||||
$this->json(['success' => false, 'message' => '群配置不存在']);
|
||||
return;
|
||||
}
|
||||
if ($platformUserId > 0 && !$this->exists('users', $platformUserId)) {
|
||||
$this->json(['success' => false, 'message' => '绑定网站账号不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$duplicate = $this->db->get('bot_group_members', ['id'], [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
'id[!]' => $id,
|
||||
]);
|
||||
if ($duplicate) {
|
||||
$this->json(['success' => false, 'message' => '该 TG 用户已存在于当前群']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('bot_group_members', [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
'tg_username' => $tgUsername,
|
||||
'tg_nickname' => $tgNickname,
|
||||
'platform_user_id' => $platformUserId > 0 ? $platformUserId : null,
|
||||
'role' => $role,
|
||||
'bet_enabled' => $betEnabled,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$this->syncShillRecord($groupId, $tgUserId, $role === 'shill', $this->normalizeNullableString($payload['shill_note'] ?? null));
|
||||
$this->json(['success' => true, 'message' => '群成员映射更新成功', 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '群成员映射更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleMemberBet(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$betEnabled = isset($payload['bet_enabled']) ? (int)$payload['bet_enabled'] : null;
|
||||
|
||||
if ($id <= 0 || !in_array($betEnabled, [0, 1], true)) {
|
||||
$this->json(['success' => false, 'message' => '成员参数无效']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$member = $this->db->get('bot_group_members', ['id', 'tg_user_id'], ['id' => $id]);
|
||||
if (!$member) {
|
||||
$this->json(['success' => false, 'message' => '群成员映射不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('bot_group_members', [
|
||||
'bet_enabled' => $betEnabled,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$this->json([
|
||||
'success' => true,
|
||||
'message' => sprintf('成员 %s 下注权限已%s', (string)$member['tg_user_id'], $betEnabled === 1 ? '开启' : '关闭'),
|
||||
'data' => ['id' => $id, 'bet_enabled' => $betEnabled],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '成员下注权限更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveShill(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$groupId = (int)($payload['group_id'] ?? 0);
|
||||
$tgUserId = trim((string)($payload['tg_user_id'] ?? ''));
|
||||
$note = $this->normalizeNullableString($payload['note'] ?? null);
|
||||
|
||||
if ($groupId <= 0 || $tgUserId === '') {
|
||||
$this->json(['success' => false, 'message' => '群配置与 TG 用户 ID 必填']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$this->exists('bot_groups', $groupId)) {
|
||||
$this->json(['success' => false, 'message' => '群配置不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$member = $this->db->get('bot_group_members', ['id'], [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
]);
|
||||
if ($member) {
|
||||
$this->db->update('bot_group_members', [
|
||||
'role' => 'shill',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => (int)$member['id']]);
|
||||
}
|
||||
|
||||
$existing = $this->db->get('bot_shills', ['id'], [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
]);
|
||||
if ($existing) {
|
||||
$this->db->update('bot_shills', [
|
||||
'note' => $note,
|
||||
'enabled' => 1,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => (int)$existing['id']]);
|
||||
$id = (int)$existing['id'];
|
||||
$message = '托号已更新';
|
||||
} else {
|
||||
$id = (int)$this->db->insert('bot_shills', [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
'note' => $note,
|
||||
'enabled' => 1,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$message = '托号创建成功';
|
||||
}
|
||||
|
||||
$this->json(['success' => true, 'message' => $message, 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '托号创建失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateShill(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$groupId = (int)($payload['group_id'] ?? 0);
|
||||
$tgUserId = trim((string)($payload['tg_user_id'] ?? ''));
|
||||
$note = $this->normalizeNullableString($payload['note'] ?? null);
|
||||
|
||||
if ($id <= 0 || $groupId <= 0 || $tgUserId === '') {
|
||||
$this->json(['success' => false, 'message' => '托号参数不完整']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$shill = $this->db->get('bot_shills', ['id'], ['id' => $id]);
|
||||
if (!$shill) {
|
||||
$this->json(['success' => false, 'message' => '托号记录不存在']);
|
||||
return;
|
||||
}
|
||||
if (!$this->exists('bot_groups', $groupId)) {
|
||||
$this->json(['success' => false, 'message' => '群配置不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$duplicate = $this->db->get('bot_shills', ['id'], [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
'id[!]' => $id,
|
||||
]);
|
||||
if ($duplicate) {
|
||||
$this->json(['success' => false, 'message' => '该托号已存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('bot_shills', [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
'note' => $note,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$member = $this->db->get('bot_group_members', ['id'], [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
]);
|
||||
if ($member) {
|
||||
$this->db->update('bot_group_members', [
|
||||
'role' => 'shill',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => (int)$member['id']]);
|
||||
}
|
||||
|
||||
$this->json(['success' => true, 'message' => '托号更新成功', 'data' => ['id' => $id]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '托号更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleShill(): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$enabled = isset($payload['enabled']) ? (int)$payload['enabled'] : null;
|
||||
|
||||
if ($id <= 0 || !in_array($enabled, [0, 1], true)) {
|
||||
$this->json(['success' => false, 'message' => '托号参数无效']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$shill = $this->db->get('bot_shills', ['id', 'group_id', 'tg_user_id'], ['id' => $id]);
|
||||
if (!$shill) {
|
||||
$this->json(['success' => false, 'message' => '托号记录不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update('bot_shills', [
|
||||
'enabled' => $enabled,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$member = $this->db->get('bot_group_members', ['id'], [
|
||||
'group_id' => (int)$shill['group_id'],
|
||||
'tg_user_id' => (string)$shill['tg_user_id'],
|
||||
]);
|
||||
if ($member) {
|
||||
$this->db->update('bot_group_members', [
|
||||
'role' => $enabled === 1 ? 'shill' : 'member',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => (int)$member['id']]);
|
||||
}
|
||||
|
||||
$this->json([
|
||||
'success' => true,
|
||||
'message' => sprintf('托号 %s 已%s统计排除', (string)$shill['tg_user_id'], $enabled === 1 ? '加入' : '移出'),
|
||||
'data' => ['id' => $id, 'enabled' => $enabled],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '托号状态更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getRequestData(): array
|
||||
{
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (is_array($data) && !empty($data)) {
|
||||
return $data;
|
||||
}
|
||||
return $_POST;
|
||||
}
|
||||
|
||||
private function normalizeCountdownConfig(string $raw): string
|
||||
{
|
||||
$items = array_filter(array_map('trim', explode(',', $raw)), static function ($item) {
|
||||
return $item !== '' && ctype_digit($item);
|
||||
});
|
||||
$numbers = array_map('intval', $items);
|
||||
rsort($numbers);
|
||||
return json_encode(array_values(array_unique($numbers)), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function normalizeNullableString($value): ?string
|
||||
{
|
||||
$text = trim((string)$value);
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function normalizeRunMode(string $runMode): string
|
||||
{
|
||||
return in_array($runMode, ['polling', 'webhook'], true) ? $runMode : 'polling';
|
||||
}
|
||||
|
||||
private function normalizeGroupType(string $groupType): string
|
||||
{
|
||||
return in_array($groupType, ['group', 'supergroup', 'channel'], true) ? $groupType : 'group';
|
||||
}
|
||||
|
||||
private function normalizeWalletMode(string $walletMode): string
|
||||
{
|
||||
return in_array($walletMode, ['master_pool', 'per_member'], true) ? $walletMode : 'master_pool';
|
||||
}
|
||||
|
||||
private function normalizeMemberRole(string $role): string
|
||||
{
|
||||
return in_array($role, ['member', 'admin', 'shill'], true) ? $role : 'member';
|
||||
}
|
||||
|
||||
private function exists(string $table, int $id): bool
|
||||
{
|
||||
return $id > 0 && $this->db->has($table, ['id' => $id]);
|
||||
}
|
||||
|
||||
private function syncShillRecord(int $groupId, string $tgUserId, bool $enabled, ?string $note = null): void
|
||||
{
|
||||
$existing = $this->db->get('bot_shills', ['id'], [
|
||||
'group_id' => $groupId,
|
||||
'tg_user_id' => $tgUserId,
|
||||
]);
|
||||
|
||||
if ($enabled) {
|
||||
$data = [
|
||||
'note' => $note,
|
||||
'enabled' => 1,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
if ($existing) {
|
||||
$this->db->update('bot_shills', $data, ['id' => (int)$existing['id']]);
|
||||
} else {
|
||||
$data['group_id'] = $groupId;
|
||||
$data['tg_user_id'] = $tgUserId;
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$this->db->insert('bot_shills', $data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$this->db->update('bot_shills', [
|
||||
'enabled' => 0,
|
||||
'note' => $note,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => (int)$existing['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
private function toggleRecord(string $table, string $invalidMessage, string $notFoundMessage, string $messageTemplate, string $nameField = 'name', bool $withName = true): void
|
||||
{
|
||||
$payload = $this->getRequestData();
|
||||
$id = (int)($payload['id'] ?? 0);
|
||||
$status = isset($payload['status']) ? (int)$payload['status'] : null;
|
||||
|
||||
if ($id <= 0 || !in_array($status, [0, 1], true)) {
|
||||
$this->json(['success' => false, 'message' => $invalidMessage]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$row = $this->db->get($table, ['id', $nameField], ['id' => $id]);
|
||||
if (!$row) {
|
||||
$this->json(['success' => false, 'message' => $notFoundMessage]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->update($table, [
|
||||
'status' => $status,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
], ['id' => $id]);
|
||||
|
||||
$message = $withName
|
||||
? sprintf($messageTemplate, (string)($row[$nameField] ?? $id), $status === 1 ? '启用' : '停用')
|
||||
: sprintf($messageTemplate, $status === 1 ? '启用' : '停用');
|
||||
$this->json(['success' => true, 'message' => $message, 'data' => ['id' => $id, 'status' => $status]]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->json(['success' => false, 'message' => '状态更新失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function json(array $data): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Core\AdminBaseController;
|
||||
use App\Services\FollowPlanService;
|
||||
use Db\Database;
|
||||
|
||||
class FollowPlanController extends AdminBaseController {
|
||||
private $db;
|
||||
|
||||
public function __construct(Database $db) {
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public function index() {
|
||||
$this->checkLogin(); $this->checkAdmin();
|
||||
$service = new FollowPlanService($this->db);
|
||||
$plans = $service->getAllPlans();
|
||||
$games = $this->db->select('games', ['id', 'name', 'code'], ['status' => 1]);
|
||||
$this->render('Admin/follow_plans.php', compact('plans', 'games'));
|
||||
}
|
||||
|
||||
public function save() {
|
||||
$this->checkLogin(); $this->checkAdmin();
|
||||
header('Content-Type: application/json');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$data['created_by'] = $_SESSION['admin_id'] ?? 0;
|
||||
$service = new FollowPlanService($this->db);
|
||||
echo json_encode($service->savePlan($data));
|
||||
}
|
||||
|
||||
public function delete() {
|
||||
$this->checkLogin(); $this->checkAdmin();
|
||||
header('Content-Type: application/json');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$id = intval($data['id'] ?? 0);
|
||||
if ($id <= 0) { echo json_encode(['success' => false, 'message' => 'ID invalid']); return; }
|
||||
$service = new FollowPlanService($this->db);
|
||||
echo json_encode($service->deletePlan($id));
|
||||
}
|
||||
|
||||
public function toggle() {
|
||||
$this->checkLogin(); $this->checkAdmin();
|
||||
header('Content-Type: application/json');
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: $_POST;
|
||||
$id = intval($data['id'] ?? 0);
|
||||
$plan = $this->db->get('follow_plans', '*', ['id' => $id]);
|
||||
if (!$plan) { echo json_encode(['success' => false, 'message' => 'Not found']); return; }
|
||||
$newStatus = $plan['status'] ? 0 : 1;
|
||||
$this->db->update('follow_plans', ['status' => $newStatus], ['id' => $id]);
|
||||
echo json_encode(['success' => true, 'status' => $newStatus]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Core\AdminBaseController;
|
||||
use Db\Database;
|
||||
|
||||
class FundRequestController extends AdminBaseController {
|
||||
|
||||
public function __construct() {
|
||||
$this->checkLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* 充提审核列表页
|
||||
*/
|
||||
public function index() {
|
||||
$db = new Database();
|
||||
try {
|
||||
// 查询充提申请列表,JOIN users 获取用户名
|
||||
$requests = $db->select('fund_requests', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'fund_requests.id',
|
||||
'fund_requests.user_id',
|
||||
'users.username',
|
||||
'users.usdt_address',
|
||||
'fund_requests.type',
|
||||
'fund_requests.amount',
|
||||
'fund_requests.status',
|
||||
'fund_requests.remark',
|
||||
'fund_requests.admin_remark',
|
||||
'fund_requests.operator_id',
|
||||
'fund_requests.created_at',
|
||||
'fund_requests.processed_at'
|
||||
], [
|
||||
'ORDER' => ['fund_requests.id' => 'DESC'],
|
||||
'LIMIT' => 200
|
||||
]);
|
||||
|
||||
if (!is_array($requests)) {
|
||||
$requests = [];
|
||||
}
|
||||
|
||||
// 统计各状态数量
|
||||
$pendingCount = $db->count('fund_requests', ['status' => 'pending']);
|
||||
$todayDeposit = $db->count('fund_requests', [
|
||||
'type' => 'deposit',
|
||||
'created_at[>=]' => date('Y-m-d 00:00:00')
|
||||
]);
|
||||
$todayWithdraw = $db->count('fund_requests', [
|
||||
'type' => 'withdraw',
|
||||
'created_at[>=]' => date('Y-m-d 00:00:00')
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$requests = [];
|
||||
$pendingCount = 0;
|
||||
$todayDeposit = 0;
|
||||
$todayWithdraw = 0;
|
||||
}
|
||||
|
||||
$this->render('Admin/fund_requests.php', [
|
||||
'requests' => $requests,
|
||||
'pendingCount' => $pendingCount,
|
||||
'todayDeposit' => $todayDeposit,
|
||||
'todayWithdraw' => $todayWithdraw,
|
||||
'title' => '充提审核'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批通过
|
||||
*/
|
||||
public function approve() {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$rawData = file_get_contents('php://input');
|
||||
$data = json_decode($rawData, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
echo json_encode(['success' => false, 'message' => '数据格式错误']);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = isset($data['id']) ? (int)$data['id'] : 0;
|
||||
$adminRemark = trim($data['admin_remark'] ?? '');
|
||||
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['success' => false, 'message' => '无效的申请ID']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = new Database();
|
||||
|
||||
try {
|
||||
// 查询申请记录
|
||||
$request = $db->get('fund_requests', '*', ['id' => $id]);
|
||||
if (!$request) {
|
||||
echo json_encode(['success' => false, 'message' => '申请记录不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request['status'] !== 'pending') {
|
||||
echo json_encode(['success' => false, 'message' => '该申请已处理,状态:' . $request['status']]);
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = (int)$request['user_id'];
|
||||
$amount = floatval($request['amount']);
|
||||
$type = $request['type'];
|
||||
|
||||
// 获取用户信息
|
||||
$user = $db->get('users', ['id', 'balance'], ['id' => $userId]);
|
||||
if (!$user) {
|
||||
echo json_encode(['success' => false, 'message' => '用户不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
$balanceBefore = floatval($user['balance']);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$operatorId = $_SESSION['user_id'] ?? 0;
|
||||
|
||||
// 开启事务
|
||||
$db->medoo->pdo->beginTransaction();
|
||||
|
||||
if ($type === 'deposit') {
|
||||
// 充值:给用户加款
|
||||
$balanceAfter = $balanceBefore + $amount;
|
||||
|
||||
$db->update('users', [
|
||||
'balance' => $balanceAfter,
|
||||
'updated_at' => $now
|
||||
], ['id' => $userId]);
|
||||
|
||||
$db->insert('transactions', [
|
||||
'user_id' => $userId,
|
||||
'type' => 'deposit',
|
||||
'amount' => $amount,
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => $balanceAfter,
|
||||
'description' => '充值申请审核通过 #' . $id,
|
||||
'created_at' => $now
|
||||
]);
|
||||
} else {
|
||||
// 提现:余额已在申请时预扣,确认放款
|
||||
$balanceAfter = $balanceBefore; // 余额不变,已预扣
|
||||
|
||||
$db->insert('transactions', [
|
||||
'user_id' => $userId,
|
||||
'type' => 'withdraw',
|
||||
'amount' => -$amount,
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => $balanceAfter,
|
||||
'description' => '提现申请审核通过 #' . $id,
|
||||
'created_at' => $now
|
||||
]);
|
||||
}
|
||||
|
||||
// 更新申请状态
|
||||
$db->update('fund_requests', [
|
||||
'status' => 'approved',
|
||||
'admin_remark' => $adminRemark,
|
||||
'operator_id' => $operatorId,
|
||||
'processed_at' => $now
|
||||
], ['id' => $id]);
|
||||
|
||||
$db->medoo->pdo->commit();
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '审批通过']);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if ($db->medoo->pdo->inTransaction()) {
|
||||
$db->medoo->pdo->rollBack();
|
||||
}
|
||||
echo json_encode(['success' => false, 'message' => '操作失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝申请
|
||||
*/
|
||||
public function reject() {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$rawData = file_get_contents('php://input');
|
||||
$data = json_decode($rawData, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
echo json_encode(['success' => false, 'message' => '数据格式错误']);
|
||||
return;
|
||||
}
|
||||
|
||||
$id = isset($data['id']) ? (int)$data['id'] : 0;
|
||||
$adminRemark = trim($data['admin_remark'] ?? '');
|
||||
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['success' => false, 'message' => '无效的申请ID']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = new Database();
|
||||
|
||||
try {
|
||||
// 查询申请记录
|
||||
$request = $db->get('fund_requests', '*', ['id' => $id]);
|
||||
if (!$request) {
|
||||
echo json_encode(['success' => false, 'message' => '申请记录不存在']);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request['status'] !== 'pending') {
|
||||
echo json_encode(['success' => false, 'message' => '该申请已处理,状态:' . $request['status']]);
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = (int)$request['user_id'];
|
||||
$amount = floatval($request['amount']);
|
||||
$type = $request['type'];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$operatorId = $_SESSION['user_id'] ?? 0;
|
||||
|
||||
// 开启事务
|
||||
$db->medoo->pdo->beginTransaction();
|
||||
|
||||
if ($type === 'withdraw') {
|
||||
// 提现拒绝:退还预扣余额
|
||||
$user = $db->get('users', ['id', 'balance'], ['id' => $userId]);
|
||||
if ($user) {
|
||||
$balanceBefore = floatval($user['balance']);
|
||||
$balanceAfter = $balanceBefore + $amount;
|
||||
|
||||
$db->update('users', [
|
||||
'balance' => $balanceAfter,
|
||||
'updated_at' => $now
|
||||
], ['id' => $userId]);
|
||||
|
||||
$db->insert('transactions', [
|
||||
'user_id' => $userId,
|
||||
'type' => 'refund',
|
||||
'amount' => $amount,
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => $balanceAfter,
|
||||
'description' => '提现申请被拒绝,退还预扣金额 #' . $id,
|
||||
'created_at' => $now
|
||||
]);
|
||||
}
|
||||
}
|
||||
// 充值拒绝:无需退款
|
||||
|
||||
// 更新申请状态
|
||||
$db->update('fund_requests', [
|
||||
'status' => 'rejected',
|
||||
'admin_remark' => $adminRemark,
|
||||
'operator_id' => $operatorId,
|
||||
'processed_at' => $now
|
||||
], ['id' => $id]);
|
||||
|
||||
$db->medoo->pdo->commit();
|
||||
|
||||
echo json_encode(['success' => true, 'message' => '已拒绝']);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if ($db->medoo->pdo->inTransaction()) {
|
||||
$db->medoo->pdo->rollBack();
|
||||
}
|
||||
echo json_encode(['success' => false, 'message' => '操作失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待处理数量(轮询接口)
|
||||
*/
|
||||
public function pendingCount() {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$db = new Database();
|
||||
try {
|
||||
$count = $db->count('fund_requests', ['status' => 'pending']);
|
||||
echo json_encode(['count' => (int)$count]);
|
||||
} catch (\Throwable $e) {
|
||||
echo json_encode(['count' => 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,12 +61,23 @@ class PK10PeriodController extends AdminBaseController {
|
||||
}
|
||||
|
||||
$periodNumber = PK10Algorithm::generatePeriodNumber($gameId);
|
||||
$createdAt = date('Y-m-d H:i:s');
|
||||
$this->db->insert('periods', [
|
||||
'game_id' => $gameId,
|
||||
'period_number' => $periodNumber,
|
||||
'status' => 'pending',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'start_time' => $createdAt,
|
||||
'created_at' => $createdAt,
|
||||
]);
|
||||
$periodId = (int)$this->db->id();
|
||||
|
||||
if ($periodId > 0) {
|
||||
$this->queueCountdownPushes($gameId, [
|
||||
'id' => $periodId,
|
||||
'period_number' => $periodNumber,
|
||||
'created_at' => $createdAt,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->json(['status' => 'success', 'message' => 'New period started', 'period_number' => $periodNumber]);
|
||||
}
|
||||
@@ -83,6 +94,14 @@ class PK10PeriodController extends AdminBaseController {
|
||||
}
|
||||
|
||||
$this->db->update('periods', ['status' => 'locked'], ['id' => $periodId]);
|
||||
$this->queueBotPushByGame($period['game_id'], 'countdown', [
|
||||
'event' => 'period_locked',
|
||||
'period_id' => (int)$period['id'],
|
||||
'period_number' => (string)($period['period_number'] ?? ''),
|
||||
'message' => 'Period locked',
|
||||
], [
|
||||
'remind_close_countdown' => 1,
|
||||
]);
|
||||
$this->json(['status' => 'success', 'message' => 'Period locked']);
|
||||
}
|
||||
|
||||
@@ -122,7 +141,8 @@ class PK10PeriodController extends AdminBaseController {
|
||||
]);
|
||||
|
||||
if (!empty($waterConfig) && !empty($bets)) {
|
||||
$result = PK10Algorithm::generateControlledResult($bets, $waterConfig);
|
||||
$targetProfitRate = floatval(\App\Core\SettingsHelper::get('target_profit_rate'));
|
||||
$result = PK10Algorithm::generateControlledResult($bets, $waterConfig, 100, $targetProfitRate);
|
||||
} else {
|
||||
$result = PK10Algorithm::generateResult();
|
||||
}
|
||||
@@ -150,6 +170,17 @@ class PK10PeriodController extends AdminBaseController {
|
||||
'rank_10' => $result[9], 'champion_sum' => $sum,
|
||||
]);
|
||||
|
||||
$this->queueBotPushByGame($period['game_id'], 'draw', [
|
||||
'event' => 'period_drawn',
|
||||
'period_id' => (int)$period['id'],
|
||||
'period_number' => (string)($period['period_number'] ?? ''),
|
||||
'result' => $result,
|
||||
'champion_sum' => $sum,
|
||||
'manual' => !empty($manual),
|
||||
], [
|
||||
'remind_draw_result' => 1,
|
||||
], (string)($period['period_number'] ?? ''));
|
||||
|
||||
$this->db->medoo->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->medoo->pdo->rollBack();
|
||||
@@ -180,6 +211,15 @@ class PK10PeriodController extends AdminBaseController {
|
||||
$bets = $this->db->select('bets', '*', ['period_id' => $periodId, 'status' => 'pending']);
|
||||
if (empty($bets)) {
|
||||
$this->db->update('periods', ['status' => 'settled'], ['id' => $periodId]);
|
||||
$this->queueBotPushByGame($period['game_id'], 'system', [
|
||||
'event' => 'period_settled',
|
||||
'period_id' => (int)$period['id'],
|
||||
'period_number' => (string)($period['period_number'] ?? ''),
|
||||
'wins' => 0,
|
||||
'losses' => 0,
|
||||
'total_payout' => 0,
|
||||
'message' => 'No bets to settle',
|
||||
], [], (string)($period['period_number'] ?? ''));
|
||||
$this->json(['status' => 'success', 'message' => 'No bets to settle', 'wins' => 0, 'losses' => 0]);
|
||||
return;
|
||||
}
|
||||
@@ -230,6 +270,14 @@ class PK10PeriodController extends AdminBaseController {
|
||||
$this->settleAgentCommissions($bets, $periodId);
|
||||
|
||||
$this->db->update('periods', ['status' => 'settled'], ['id' => $periodId]);
|
||||
$this->queueBotPushByGame($period['game_id'], 'system', [
|
||||
'event' => 'period_settled',
|
||||
'period_id' => (int)$period['id'],
|
||||
'period_number' => (string)($period['period_number'] ?? ''),
|
||||
'wins' => $wins,
|
||||
'losses' => $losses,
|
||||
'total_payout' => (float)$totalPayout,
|
||||
], [], (string)($period['period_number'] ?? ''));
|
||||
$this->db->medoo->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->medoo->pdo->rollBack();
|
||||
@@ -298,6 +346,90 @@ class PK10PeriodController extends AdminBaseController {
|
||||
return $game ? (int)$game : 0;
|
||||
}
|
||||
|
||||
private function queueCountdownPushes(int $gameId, array $period): void {
|
||||
if ($gameId <= 0 || empty($period['id']) || empty($period['period_number'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$groups = $this->db->select('bot_groups', ['id', 'countdown_config'], [
|
||||
'game_id' => $gameId,
|
||||
'status' => 1,
|
||||
'remind_close_countdown' => 1,
|
||||
]) ?: [];
|
||||
if (empty($groups)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$game = $this->db->get('games', ['period_duration', 'lock_before_end'], ['id' => $gameId]);
|
||||
$periodDuration = (int)($game['period_duration'] ?? 300);
|
||||
$lockBeforeEnd = max(0, (int)($game['lock_before_end'] ?? 30));
|
||||
$periodCreatedAt = strtotime((string)($period['created_at'] ?? ''));
|
||||
if ($periodCreatedAt <= 0) {
|
||||
$periodCreatedAt = time();
|
||||
}
|
||||
$closeAtTs = $periodCreatedAt + max(0, $periodDuration - $lockBeforeEnd);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($groups as $group) {
|
||||
$decoded = json_decode((string)($group['countdown_config'] ?? ''), true);
|
||||
if (!is_array($decoded) || empty($decoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$secondsList = array_values(array_unique(array_filter(array_map('intval', $decoded), static function (int $seconds) use ($periodDuration, $lockBeforeEnd) {
|
||||
return $seconds > 0 && $seconds <= max(0, $periodDuration - $lockBeforeEnd);
|
||||
})));
|
||||
rsort($secondsList);
|
||||
|
||||
foreach ($secondsList as $seconds) {
|
||||
$dispatchAtTs = $closeAtTs - $seconds;
|
||||
if ($dispatchAtTs <= $periodCreatedAt) {
|
||||
continue;
|
||||
}
|
||||
$payload = [
|
||||
'event' => 'countdown_tick',
|
||||
'period_id' => (int)$period['id'],
|
||||
'period_number' => (string)$period['period_number'],
|
||||
'countdown_seconds' => $seconds,
|
||||
'dispatch_at' => date('Y-m-d H:i:s', $dispatchAtTs),
|
||||
'message' => sprintf('Bet closes in %d seconds', $seconds),
|
||||
];
|
||||
|
||||
$this->db->insert('bot_push_logs', [
|
||||
'group_id' => (int)$group['id'],
|
||||
'period_number' => (string)$period['period_number'],
|
||||
'push_type' => 'countdown',
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'status' => 'pending',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function queueBotPushByGame(int $gameId, string $pushType, array $payload, array $groupFilters = [], ?string $periodNumber = null): void {
|
||||
if ($gameId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$where = array_merge(['game_id' => $gameId, 'status' => 1], $groupFilters);
|
||||
$groups = $this->db->select('bot_groups', ['id'], $where) ?: [];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$this->db->insert('bot_push_logs', [
|
||||
'group_id' => (int)$group['id'],
|
||||
'period_number' => $periodNumber !== null ? $periodNumber : ((string)($payload['period_number'] ?? '') ?: null),
|
||||
'push_type' => $pushType,
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'status' => 'pending',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function json(array $data) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
|
||||
@@ -12,94 +12,132 @@ class ReportController extends AdminBaseController {
|
||||
$this->checkLogin(); $this->checkAdmin();
|
||||
$dateFrom = $_GET['from'] ?? date('Y-m-d', strtotime('-7 days'));
|
||||
$dateTo = $_GET['to'] ?? date('Y-m-d');
|
||||
$range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'];
|
||||
|
||||
// 总投注(排除虚拟)
|
||||
$totalBet = $this->db->sum('bets', 'amount', [
|
||||
'is_virtual' => 0, 'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
$reportService = new \App\Services\ReportService($this->db);
|
||||
|
||||
// 总派奖
|
||||
$totalWin = $this->db->sum('bets', 'win_amount', [
|
||||
'is_virtual' => 0, 'status' => 'win', 'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
// 总体统计
|
||||
$overview = $reportService->getOverviewStats($dateFrom, $dateTo);
|
||||
extract($overview); // totalBet, totalWin, profit, profitRate, totalDeposit, totalWithdraw, totalCommission, totalRebate, totalUsers, newUsers, activeUsers
|
||||
|
||||
// 平台利润
|
||||
$profit = $totalBet - $totalWin;
|
||||
// 日明细统计
|
||||
$dailyStats = $reportService->getDailyStats($dateFrom, $dateTo);
|
||||
|
||||
// 总充值
|
||||
$totalDeposit = $this->db->sum('transactions', 'amount', [
|
||||
'is_virtual' => 0, 'type[~]' => '%deposit%', 'amount[>]' => 0,
|
||||
'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 总提现
|
||||
$totalWithdraw = abs($this->db->sum('transactions', 'amount', [
|
||||
'is_virtual' => 0, 'type[~]' => '%withdraw%', 'amount[<]' => 0,
|
||||
'created_at[<>]' => $range
|
||||
]) ?: 0);
|
||||
|
||||
// 代理佣金
|
||||
$totalCommission = $this->db->sum('agent_commissions', 'commission', [
|
||||
'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 反水
|
||||
$totalRebate = $this->db->sum('agent_commissions', 'commission', [
|
||||
'type' => 'rebate', 'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 用户统计
|
||||
$totalUsers = $this->db->count('users', ['is_virtual' => 0]);
|
||||
$newUsers = $this->db->count('users', [
|
||||
'is_virtual' => 0, 'created_at[<>]' => $range
|
||||
]);
|
||||
|
||||
// 每日明细
|
||||
$dailyStats = [];
|
||||
$start = new \DateTime($dateFrom);
|
||||
$end = new \DateTime($dateTo);
|
||||
$end->modify('+1 day');
|
||||
$interval = new \DateInterval('P1D');
|
||||
$period = new \DatePeriod($start, $interval, $end);
|
||||
foreach ($period as $day) {
|
||||
$d = $day->format('Y-m-d');
|
||||
$dr = [$d . ' 00:00:00', $d . ' 23:59:59'];
|
||||
$dailyStats[] = [
|
||||
'date' => $d,
|
||||
'bets' => $this->db->sum('bets', 'amount', ['is_virtual'=>0,'created_at[<>]'=>$dr]) ?: 0,
|
||||
'wins' => $this->db->sum('bets', 'win_amount', ['is_virtual'=>0,'status'=>'win','created_at[<>]'=>$dr]) ?: 0,
|
||||
'deposits' => $this->db->sum('transactions', 'amount', ['is_virtual'=>0,'type[~]'=>'%deposit%','amount[>]'=>0,'created_at[<>]'=>$dr]) ?: 0,
|
||||
'withdraws' => abs($this->db->sum('transactions', 'amount', ['is_virtual'=>0,'type[~]'=>'%withdraw%','amount[<]'=>0,'created_at[<>]'=>$dr]) ?: 0),
|
||||
];
|
||||
}
|
||||
// 用户排行(投注前20)
|
||||
$topUsers = $reportService->getTopUsers($dateFrom, $dateTo, 20);
|
||||
|
||||
// 代理报表
|
||||
$agentStats = [];
|
||||
$agents = $this->db->select('agents', '*', ['status' => 1]);
|
||||
foreach ($agents as $ag) {
|
||||
$playerIds = $this->db->select('users', 'id', ['agent_id' => $ag['id'], 'is_virtual' => 0]);
|
||||
$agBet = 0; $agWin = 0;
|
||||
if (!empty($playerIds)) {
|
||||
$agBet = $this->db->sum('bets', 'amount', ['user_id' => $playerIds, 'is_virtual'=>0, 'created_at[<>]'=>$range]) ?: 0;
|
||||
$agWin = $this->db->sum('bets', 'win_amount', ['user_id' => $playerIds, 'is_virtual'=>0, 'status'=>'win', 'created_at[<>]'=>$range]) ?: 0;
|
||||
}
|
||||
$agComm = $this->db->sum('agent_commissions', 'commission', ['agent_id'=>$ag['id'],'created_at[<>]'=>$range]) ?: 0;
|
||||
$agentStats[] = [
|
||||
'agent' => $ag,
|
||||
'user' => $this->db->get('users', ['username'], ['id' => $ag['user_id']]),
|
||||
'players' => count($playerIds),
|
||||
'bets' => $agBet, 'wins' => $agWin, 'commission' => $agComm,
|
||||
'profit' => $agBet - $agWin - $agComm,
|
||||
];
|
||||
}
|
||||
$agentStats = $reportService->getAgentStats($dateFrom, $dateTo);
|
||||
|
||||
// 用户输赢明细
|
||||
$winLossData = $reportService->getUserWinLoss($dateFrom, $dateTo);
|
||||
$userSummary = $winLossData['summary'];
|
||||
|
||||
$this->render('Admin/reports.php', compact(
|
||||
'dateFrom','dateTo','totalBet','totalWin','profit',
|
||||
'dateFrom','dateTo','totalBet','totalWin','profit','profitRate',
|
||||
'totalDeposit','totalWithdraw','totalCommission','totalRebate',
|
||||
'totalUsers','newUsers','dailyStats','agentStats'
|
||||
'totalUsers','newUsers','activeUsers','dailyStats','topUsers','agentStats',
|
||||
'userSummary'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 导出
|
||||
*/
|
||||
public function export() {
|
||||
$this->checkLogin(); $this->checkAdmin();
|
||||
$dateFrom = $_GET['from'] ?? date('Y-m-d', strtotime('-7 days'));
|
||||
$dateTo = $_GET['to'] ?? date('Y-m-d');
|
||||
|
||||
$reportService = new \App\Services\ReportService($this->db);
|
||||
|
||||
// 通过 Service 获取数据
|
||||
$dailyStats = $reportService->getDailyStats($dateFrom, $dateTo);
|
||||
$topUsers = $reportService->getTopUsers($dateFrom, $dateTo, 0); // 不限条数
|
||||
$winLossData = $reportService->getUserWinLoss($dateFrom, $dateTo);
|
||||
$userSummary = $winLossData['summary'];
|
||||
$userWinLossRaw = $winLossData['raw'];
|
||||
|
||||
// 输出 CSV
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="report_' . $dateFrom . '_' . $dateTo . '.csv"');
|
||||
// BOM 头(Excel 兼容中文)
|
||||
echo "\xEF\xBB\xBF";
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// 日明细
|
||||
fputcsv($out, ['=== 日明细报表 ===']);
|
||||
fputcsv($out, ['日期', '投注额', '派彩额', '利润', '投注笔数', '中奖笔数', '胜率']);
|
||||
foreach ($dailyStats as $day) {
|
||||
$totalBet = floatval($day['total_bet'] ?? 0);
|
||||
$totalWin = floatval($day['total_win'] ?? 0);
|
||||
$betCount = intval($day['bet_count'] ?? 0);
|
||||
$winCount = intval($day['win_count'] ?? 0);
|
||||
$winRate = $betCount > 0 ? round($winCount / $betCount * 100, 1) . '%' : '0%';
|
||||
fputcsv($out, [
|
||||
$day['date'],
|
||||
number_format($totalBet, 2, '.', ''),
|
||||
number_format($totalWin, 2, '.', ''),
|
||||
number_format($totalBet - $totalWin, 2, '.', ''),
|
||||
$betCount,
|
||||
$winCount,
|
||||
$winRate
|
||||
]);
|
||||
}
|
||||
|
||||
fputcsv($out, []);
|
||||
fputcsv($out, ['=== 用户投注排行 ===']);
|
||||
fputcsv($out, ['排名', '用户名', '投注额', '派彩额', '平台盈亏', '投注笔数']);
|
||||
$rank = 1;
|
||||
foreach ($topUsers as $user) {
|
||||
fputcsv($out, [
|
||||
$rank++,
|
||||
$user['username'],
|
||||
number_format(floatval($user['total_bet']), 2, '.', ''),
|
||||
number_format(floatval($user['total_win']), 2, '.', ''),
|
||||
number_format(floatval($user['profit']), 2, '.', ''),
|
||||
intval($user['bet_count'])
|
||||
]);
|
||||
}
|
||||
|
||||
// 用户输赢明细
|
||||
fputcsv($out, []);
|
||||
fputcsv($out, ['=== 用户输赢明细 ===']);
|
||||
fputcsv($out, ['用户名', '当前余额', '总投注', '总派彩', '平台盈亏(投注-派彩)', '投注笔数', '中奖笔数', '胜率']);
|
||||
foreach ($userSummary as $ud) {
|
||||
$tb = $ud['total_bet'];
|
||||
$tw = $ud['total_win'];
|
||||
$bc = $ud['bet_count'];
|
||||
$wc = $ud['win_count'];
|
||||
$wr = $bc > 0 ? round($wc / $bc * 100, 1) . '%' : '0%';
|
||||
fputcsv($out, [
|
||||
$ud['username'],
|
||||
number_format($ud['balance'], 2, '.', ''),
|
||||
number_format($tb, 2, '.', ''),
|
||||
number_format($tw, 2, '.', ''),
|
||||
number_format($tb - $tw, 2, '.', ''),
|
||||
$bc, $wc, $wr
|
||||
]);
|
||||
}
|
||||
|
||||
// 用户日输赢明细
|
||||
fputcsv($out, []);
|
||||
fputcsv($out, ['=== 用户日输赢明细 ===']);
|
||||
fputcsv($out, ['用户名', '日期', '投注额', '派彩额', '平台盈亏', '笔数']);
|
||||
foreach ($userWinLossRaw as $dd) {
|
||||
$tb = floatval($dd['total_bet']);
|
||||
$tw = floatval($dd['total_win']);
|
||||
fputcsv($out, [
|
||||
$dd['username'], $dd['date'],
|
||||
number_format($tb, 2, '.', ''),
|
||||
number_format($tw, 2, '.', ''),
|
||||
number_format($tb - $tw, 2, '.', ''),
|
||||
intval($dd['bet_count'])
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function json($data) { header('Content-Type: application/json'); echo json_encode($data); }
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ class SettingsController extends AdminBaseController {
|
||||
'site_logo', 'site_favicon', 'site_copyright',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass',
|
||||
'smtp_from', 'smtp_from_name', 'smtp_encryption',
|
||||
'customer_service_url',
|
||||
];
|
||||
|
||||
foreach ($allowedKeys as $key) {
|
||||
|
||||
@@ -13,7 +13,8 @@ class WaterController extends AdminBaseController {
|
||||
$gameId = $this->getGameId();
|
||||
$configs = $this->db->select('water_control', '*', ['game_id' => $gameId]);
|
||||
$limits = $this->db->select('bet_limits', '*', ['game_id' => $gameId]);
|
||||
$this->render('Admin/water_control.php', compact('configs', 'limits', 'gameId'));
|
||||
$targetProfitRate = \App\Core\SettingsHelper::get('target_profit_rate');
|
||||
$this->render('Admin/water_control.php', compact('configs', 'limits', 'gameId', 'targetProfitRate'));
|
||||
}
|
||||
|
||||
public function updateWater() {
|
||||
@@ -61,6 +62,26 @@ class WaterController extends AdminBaseController {
|
||||
$this->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
public function updateProfitRate() {
|
||||
$this->checkLogin(); $this->checkAdmin();
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$rate = isset($data['target_profit_rate']) ? floatval($data['target_profit_rate']) : 15;
|
||||
if ($rate < 0 || $rate > 100) {
|
||||
$this->json(['status' => 'error', 'message' => '盈利率必须在0-100之间']);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $this->db;
|
||||
$existing = $db->get('system_settings', 'id', ['setting_key' => 'target_profit_rate']);
|
||||
if ($existing) {
|
||||
$db->update('system_settings', ['setting_value' => (string)$rate], ['id' => $existing]);
|
||||
} else {
|
||||
$db->insert('system_settings', ['setting_key' => 'target_profit_rate', 'setting_value' => (string)$rate]);
|
||||
}
|
||||
\App\Core\SettingsHelper::clearCache();
|
||||
$this->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
private function getGameId(): int {
|
||||
if (!empty($_GET['game_id'])) return (int)$_GET['game_id'];
|
||||
return (int)($this->db->get('games', 'id', ['code' => 'pk10']) ?: 0);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
namespace App\Controllers\Web;
|
||||
|
||||
use App\Core\WebBaseController;
|
||||
use App\Core\I18n;
|
||||
use App\Services\FollowPlanService;
|
||||
use Db\Database;
|
||||
|
||||
class FollowPlanController extends WebBaseController {
|
||||
|
||||
public function followPlan() {
|
||||
$this->checkWebLogin();
|
||||
I18n::init();
|
||||
$db = new Database();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$user = $db->get('users', ['id','username','balance'], ['id' => $userId]);
|
||||
|
||||
$service = new FollowPlanService($db);
|
||||
$game = $db->get('games', '*', ['code' => 'pk10', 'status' => 1]);
|
||||
$gameId = $game['id'] ?? 0;
|
||||
|
||||
$plans = $service->getActivePlans($gameId);
|
||||
$userFollows = $service->getUserFollowStatus($userId);
|
||||
$summary = $service->getUserSummary($userId);
|
||||
|
||||
extract(compact('user', 'plans', 'userFollows', 'summary', 'game'));
|
||||
include __DIR__ . '/../../Views/Web/follow_plan.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换跟单状态 API
|
||||
*/
|
||||
public function toggleFollow() {
|
||||
$this->checkWebLogin();
|
||||
header('Content-Type: application/json');
|
||||
$db = new Database();
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$planId = intval($data['plan_id'] ?? 0);
|
||||
|
||||
$service = new FollowPlanService($db);
|
||||
$result = $service->toggleFollow($this->getCurrentUserId(), $planId);
|
||||
echo json_encode($result);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,26 @@ class HomeController extends WebBaseController {
|
||||
$user = $db->get('users', ['id','username','balance','lang'], ['id' => $userId]);
|
||||
$game = $db->get('games', '*', ['code' => 'pk10', 'status' => 1]);
|
||||
$isAgent = !!$db->get('agents', 'id', ['user_id' => $userId, 'status' => 1]);
|
||||
// 加载赔率(优先代理专属赔率,否则用游戏默认赔率)
|
||||
$gameId = $game['id'] ?? 0;
|
||||
$oddsRaw = $db->select('game_odds', '*', ['game_id' => $gameId]);
|
||||
$oddsMap = [];
|
||||
foreach ($oddsRaw as $o) {
|
||||
$oddsMap[$o['type'] . '_' . $o['target']] = (float)$o['odds'];
|
||||
}
|
||||
// 代理专属赔率覆盖
|
||||
$agentId = $db->get('agents', 'id', ['user_id' => $userId, 'status' => 1]);
|
||||
if ($agentId) {
|
||||
$agentOdds = $db->select('agent_odds', '*', ['agent_id' => $agentId, 'game_id' => $gameId]);
|
||||
foreach ($agentOdds as $ao) {
|
||||
$oddsMap[$ao['bet_type'] . '_' . $ao['bet_target']] = (float)$ao['odds'];
|
||||
}
|
||||
}
|
||||
extract([
|
||||
'user' => $user ?: ['id' => $userId, 'username' => $this->getCurrentUsername(), 'balance' => 0],
|
||||
'game' => $game,
|
||||
'isAgent' => $isAgent,
|
||||
'isAgent' => !!$agentId,
|
||||
'oddsMap' => $oddsMap,
|
||||
]);
|
||||
include __DIR__ . '/../../Views/Web/pk10.php';
|
||||
}
|
||||
@@ -56,7 +72,13 @@ class HomeController extends WebBaseController {
|
||||
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 20
|
||||
]);
|
||||
|
||||
extract(compact('user', 'transactions', 'bets'));
|
||||
// 充提记录
|
||||
$fundRequests = $db->select('fund_requests', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 20
|
||||
]);
|
||||
|
||||
extract(compact('user', 'transactions', 'bets', 'fundRequests'));
|
||||
include __DIR__ . '/../../Views/Web/profile.php';
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class PeriodController extends WebBaseController {
|
||||
'game_id' => $gameId,
|
||||
'status' => ['drawn', 'settled'],
|
||||
'ORDER' => ['id' => 'DESC'],
|
||||
'LIMIT' => 10
|
||||
'LIMIT' => 30
|
||||
]);
|
||||
foreach ($recentPeriods as $s) {
|
||||
$pk = $getResult($s);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
namespace App\Controllers\Web;
|
||||
|
||||
use App\Core\WebBaseController;
|
||||
use App\Core\I18n;
|
||||
use App\Services\ReportService;
|
||||
use Db\Database;
|
||||
|
||||
class ReportWebController extends WebBaseController {
|
||||
|
||||
/**
|
||||
* 前台报表查询页面
|
||||
*/
|
||||
public function report() {
|
||||
$this->checkWebLogin();
|
||||
I18n::init();
|
||||
$db = new Database();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$user = $db->get('users', ['id','username','balance'], ['id' => $userId]);
|
||||
extract(compact('user'));
|
||||
include __DIR__ . '/../../Views/Web/report.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台报表查询 API
|
||||
*/
|
||||
public function userReportApi() {
|
||||
$this->checkWebLogin();
|
||||
header('Content-Type: application/json');
|
||||
$service = new ReportService(new Database());
|
||||
$dateFrom = $_GET['from'] ?? date('Y-m-d');
|
||||
$dateTo = $_GET['to'] ?? date('Y-m-d');
|
||||
$data = $service->getUserReport($this->getCurrentUserId(), $dateFrom, $dateTo);
|
||||
echo json_encode(['success' => true, 'data' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
namespace App\Controllers\Web;
|
||||
|
||||
use App\Core\WebBaseController;
|
||||
use App\Services\TransactionService;
|
||||
use Db\Database;
|
||||
|
||||
class TransactionController extends WebBaseController {
|
||||
|
||||
public function fundRequest() {
|
||||
$this->checkWebLogin();
|
||||
header('Content-Type: application/json');
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$service = new TransactionService(new Database());
|
||||
$result = $service->fundRequest(
|
||||
$this->getCurrentUserId(),
|
||||
$data['type'] ?? '',
|
||||
floatval($data['amount'] ?? 0),
|
||||
$data['remark'] ?? ''
|
||||
);
|
||||
echo json_encode($result);
|
||||
}
|
||||
|
||||
public function transfer() {
|
||||
$this->checkWebLogin();
|
||||
header('Content-Type: application/json');
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$service = new TransactionService(new Database());
|
||||
$result = $service->transfer(
|
||||
$this->getCurrentUserId(),
|
||||
trim($data['to_username'] ?? ''),
|
||||
floatval($data['amount'] ?? 0)
|
||||
);
|
||||
echo json_encode($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
namespace App\Core;
|
||||
|
||||
use Db\Database;
|
||||
|
||||
class BotApiBaseController extends BaseController
|
||||
{
|
||||
protected ?Database $db = null;
|
||||
protected ?array $botInstance = null;
|
||||
protected ?array $groupConfig = null;
|
||||
protected array $requestJson = [];
|
||||
protected string $rawRequestBody = '';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = new Database();
|
||||
$this->rawRequestBody = file_get_contents('php://input') ?: '';
|
||||
$this->requestJson = $this->getJsonInput();
|
||||
}
|
||||
|
||||
protected function getJsonInput(): array
|
||||
{
|
||||
$raw = $this->rawRequestBody;
|
||||
if (!$raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
protected function jsonResponse(bool $success, string $message = '', array $data = [], int $statusCode = 200): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'success' => $success,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function auditAndRespond(bool $success, string $message = '', array $data = [], int $statusCode = 200, ?int $groupId = null, ?string $idempotencyKey = null): void
|
||||
{
|
||||
$payload = [
|
||||
'success' => $success,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
];
|
||||
$responseBody = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
$this->logApiRequest(
|
||||
(int)($this->botInstance['id'] ?? 0) ?: null,
|
||||
$groupId,
|
||||
$idempotencyKey,
|
||||
$statusCode,
|
||||
$this->botInstance !== null,
|
||||
$this->rawRequestBody,
|
||||
$responseBody
|
||||
);
|
||||
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo $responseBody;
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function getHeader(string $name): string
|
||||
{
|
||||
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
||||
return trim($_SERVER[$key] ?? '');
|
||||
}
|
||||
|
||||
protected function authenticateBotRequest(): void
|
||||
{
|
||||
$botKey = $this->getHeader('X-Bot-Key');
|
||||
$timestamp = $this->getHeader('X-Timestamp');
|
||||
$signature = $this->getHeader('X-Signature');
|
||||
|
||||
if ($botKey === '' || $timestamp === '' || $signature === '') {
|
||||
$this->jsonResponse(false, 'Missing bot auth headers', [], 401);
|
||||
}
|
||||
|
||||
if (!ctype_digit($timestamp)) {
|
||||
$this->jsonResponse(false, 'Invalid timestamp', [], 401);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if (abs($now - (int)$timestamp) > 300) {
|
||||
$this->jsonResponse(false, 'Timestamp expired', [], 401);
|
||||
}
|
||||
|
||||
$bot = $this->db->get('bot_instances', '*', [
|
||||
'bot_key' => $botKey,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
if (!$bot) {
|
||||
$this->jsonResponse(false, 'Bot auth failed', [], 401);
|
||||
}
|
||||
|
||||
$rawBody = $this->rawRequestBody;
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$signPayload = $timestamp . "\n" . $method . "\n" . $path . "\n" . $rawBody;
|
||||
$expected = hash_hmac('sha256', $signPayload, $bot['bot_secret']);
|
||||
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
$this->logApiRequest((int)$bot['id'], null, null, 0, false, $rawBody, '');
|
||||
$this->jsonResponse(false, 'Signature verify failed', [], 401);
|
||||
}
|
||||
|
||||
$this->botInstance = $bot;
|
||||
}
|
||||
|
||||
protected function requireGroupByTelegramId(string $tgGroupId): array
|
||||
{
|
||||
$group = $this->db->get('bot_groups', '*', [
|
||||
'tg_group_id' => $tgGroupId,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
if (!$group) {
|
||||
$this->jsonResponse(false, 'Group config not found', [], 404);
|
||||
}
|
||||
|
||||
if ((int)$group['bot_id'] !== (int)($this->botInstance['id'] ?? 0)) {
|
||||
$this->jsonResponse(false, 'Group does not belong to this bot', [], 403);
|
||||
}
|
||||
|
||||
$this->groupConfig = $group;
|
||||
return $group;
|
||||
}
|
||||
|
||||
protected function getGroupWallet(int $groupId): array
|
||||
{
|
||||
$wallet = $this->db->get('bot_group_wallets', '*', [
|
||||
'group_id' => $groupId,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
if (!$wallet) {
|
||||
$this->jsonResponse(false, 'Group wallet not configured', [], 422);
|
||||
}
|
||||
|
||||
return $wallet;
|
||||
}
|
||||
|
||||
protected function buildIdempotencyKey(string $fallbackPrefix = 'bot'): string
|
||||
{
|
||||
$key = trim((string)($this->requestJson['idempotency_key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$groupId = (string)($this->groupConfig['tg_group_id'] ?? 'unknown');
|
||||
$messageId = (string)($this->requestJson['bet_context']['message_id'] ?? $this->requestJson['message_id'] ?? uniqid());
|
||||
return $fallbackPrefix . ':' . $groupId . ':' . $messageId;
|
||||
}
|
||||
|
||||
protected function logApiRequest(?int $botId, ?int $groupId, ?string $idempotencyKey, int $responseCode, bool $signatureOk, string $requestBody, string $responseBody): void
|
||||
{
|
||||
try {
|
||||
$this->db->insert('bot_api_request_logs', [
|
||||
'bot_id' => $botId,
|
||||
'group_id' => $groupId,
|
||||
'request_uri' => parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/',
|
||||
'http_method' => strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'request_body' => $requestBody,
|
||||
'response_body' => $responseBody,
|
||||
'response_code' => $responseCode,
|
||||
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'signature_ok' => $signatureOk ? 1 : 0,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 审计失败不阻断主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,28 +14,58 @@ class PK10Algorithm implements GameAlgorithmInterface {
|
||||
|
||||
/**
|
||||
* 带放水机制的开奖结果生成
|
||||
*
|
||||
* 算法策略:
|
||||
* - targetProfitRate > 0 时:选择利润最接近「总投注额 × 目标盈利率」的结果
|
||||
* - targetProfitRate == 0 时:选择平台利润最高的结果(原逻辑)
|
||||
*
|
||||
* @param array $bets 当期所有投注 [{bet_type, bet_target, amount, odds}, ...]
|
||||
* @param array $waterConfig [{bet_type => win_rate_pct}, ...]
|
||||
* 当前版本中 waterConfig 的 win_rate_pct 未直接参与结果选择,
|
||||
* 因为开奖结果是全局排列,无法单独控制某个投注类型的胜率。
|
||||
* 保留此参数供未来扩展(如按类型加权评分、分类型概率偏移等)。
|
||||
* @param int $attempts 最大尝试次数
|
||||
* @param float $targetProfitRate 目标盈利率(百分比,如 5.0 表示 5%)
|
||||
*/
|
||||
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100): array {
|
||||
if (empty($bets) || empty($waterConfig)) {
|
||||
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100, float $targetProfitRate = 0): array {
|
||||
if (empty($bets)) {
|
||||
return self::generateResult();
|
||||
}
|
||||
|
||||
// 计算目标利润(仅当 targetProfitRate > 0 时生效)
|
||||
$targetProfit = 0;
|
||||
if ($targetProfitRate > 0) {
|
||||
$totalBet = 0;
|
||||
foreach ($bets as $bet) {
|
||||
$totalBet += (float)$bet['amount'];
|
||||
}
|
||||
$targetProfit = $totalBet * ($targetProfitRate / 100);
|
||||
}
|
||||
|
||||
$bestResult = null;
|
||||
$bestProfit = PHP_INT_MIN;
|
||||
$bestDistance = PHP_FLOAT_MAX;
|
||||
|
||||
for ($i = 0; $i < $attempts; $i++) {
|
||||
$result = self::generateResult();
|
||||
$profit = self::calculatePlatformProfit($result, $bets);
|
||||
|
||||
// 选择平台利润最高的结果
|
||||
if ($targetProfitRate > 0) {
|
||||
// 目标盈利率模式:选择利润最接近目标值的结果
|
||||
$distance = abs($profit - $targetProfit);
|
||||
if ($distance < $bestDistance) {
|
||||
$bestDistance = $distance;
|
||||
$bestProfit = $profit;
|
||||
$bestResult = $result;
|
||||
}
|
||||
} else {
|
||||
// 原逻辑:选择平台利润最高的结果
|
||||
if ($profit > $bestProfit) {
|
||||
$bestProfit = $profit;
|
||||
$bestResult = $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $bestResult;
|
||||
}
|
||||
|
||||
@@ -38,21 +38,21 @@ class PluginManager {
|
||||
|
||||
$rootDir = realpath(__DIR__ . '/../../');
|
||||
$logDir = $rootDir . '/Storage/log';
|
||||
|
||||
// 确保日志目录(如不存在则创建)
|
||||
if (!is_dir($logDir)) {
|
||||
if (!mkdir($logDir, 0755, true) && !is_dir($logDir)) {
|
||||
throw new \RuntimeException("无法创建日志目录: $logDir ,请检查权限");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查目录可写性
|
||||
if (!is_writable($logDir)) {
|
||||
throw new \RuntimeException("日志目录不可写: $logDir ,请检查权限");
|
||||
}
|
||||
|
||||
$this->logFile = $logDir . '/plugin_manager.log';
|
||||
|
||||
// 尝试准备日志目录;失败时降级到 PHP error_log,不能阻断主流程
|
||||
if (!is_dir($logDir) && !@mkdir($logDir, 0755, true) && !is_dir($logDir)) {
|
||||
$this->logFile = null;
|
||||
error_log("PluginManager log directory create failed: $logDir");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->canWriteLogFile()) {
|
||||
$this->logFile = null;
|
||||
error_log("PluginManager log path not writable: $logDir");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查日志文件大小并自动清理(大于2MB时)
|
||||
$this->cleanupLogFile(2); // 传入最大允许的MB数
|
||||
}
|
||||
@@ -62,8 +62,7 @@ class PluginManager {
|
||||
* @param int $maxSizeMB 最大允许的文件大小(MB)
|
||||
*/
|
||||
private function cleanupLogFile(int $maxSizeMB) {
|
||||
// 检查文件是否存在
|
||||
if (!file_exists($this->logFile)) {
|
||||
if (!$this->canWriteLogFile() || !file_exists($this->logFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,23 +70,39 @@ class PluginManager {
|
||||
$maxSizeBytes = $maxSizeMB * 1024 * 1024;
|
||||
|
||||
// 获取当前文件大小
|
||||
$currentSize = filesize($this->logFile);
|
||||
$currentSize = @filesize($this->logFile);
|
||||
if ($currentSize === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果文件大小超过限制,清空文件
|
||||
if ($currentSize > $maxSizeBytes) {
|
||||
// 先备份当前日志内容(可选)
|
||||
$backupFile = $this->logFile . '.bak_' . date('YmdHis');
|
||||
copy($this->logFile, $backupFile);
|
||||
@copy($this->logFile, $backupFile);
|
||||
|
||||
// 清空日志文件
|
||||
file_put_contents($this->logFile, '');
|
||||
@file_put_contents($this->logFile, '');
|
||||
|
||||
// 记录清理日志
|
||||
$message = "[" . date('Y-m-d H:i:s') . "] 日志文件超过{$maxSizeMB}MB,已自动清理\n";
|
||||
file_put_contents($this->logFile, $message, FILE_APPEND);
|
||||
@file_put_contents($this->logFile, $message, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
private function canWriteLogFile(): bool {
|
||||
if (empty($this->logFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$logDir = dirname($this->logFile);
|
||||
if (!is_dir($logDir) || !is_writable($logDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !file_exists($this->logFile) || is_writable($this->logFile);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 设置系统核心路由
|
||||
@@ -121,13 +136,12 @@ class PluginManager {
|
||||
protected function log(string $msg, string $level = 'INFO'): void {
|
||||
$date = date('Y-m-d H:i:s');
|
||||
$logMsg = "[$date] [$level] $msg\n";
|
||||
$logDir = dirname($this->logFile);
|
||||
|
||||
try {
|
||||
if (is_dir($logDir) && is_writable($logDir)) {
|
||||
file_put_contents($this->logFile, $logMsg, FILE_APPEND);
|
||||
if ($this->canWriteLogFile()) {
|
||||
@file_put_contents($this->logFile, $logMsg, FILE_APPEND);
|
||||
} else {
|
||||
error_log("PluginManager log directory not writable: $logDir");
|
||||
error_log("PluginManager: " . trim($logMsg));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Failed to write plugin log: " . $e->getMessage());
|
||||
|
||||
@@ -60,12 +60,14 @@ class SettingsHelper {
|
||||
*/
|
||||
private static function getDefaults() {
|
||||
return [
|
||||
'site_title' => 'PK10 Speed Racing',
|
||||
'site_description' => 'PK10 Speed Racing - Online Betting Platform',
|
||||
'site_keywords' => 'pk10, speed racing, betting, online game',
|
||||
'site_title' => 'F1 Racing',
|
||||
'site_description' => 'F1 Racing - Online Betting Platform',
|
||||
'site_keywords' => 'f1, racing, betting, online game',
|
||||
'site_logo' => '/Static/images/logo.png',
|
||||
'site_favicon' => '/Static/css/favicon.ico',
|
||||
'site_copyright' => '© 2025 PK10 Racing. All rights reserved.'
|
||||
'site_copyright' => '© 2025 F1 Racing. All rights reserved.',
|
||||
'target_profit_rate' => '15', // 目标盈利率,百分比,如15表示15%
|
||||
'customer_service_url' => '', // 客服链接(充值时跳转)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use Db\Database;
|
||||
|
||||
class FollowPlanService {
|
||||
private $db;
|
||||
|
||||
public function __construct(Database $db) {
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的跟单计划(前台展示)
|
||||
*/
|
||||
public function getActivePlans(int $gameId = 0): array
|
||||
{
|
||||
$where = ['status' => 1];
|
||||
if ($gameId > 0) $where['game_id'] = $gameId;
|
||||
$where['ORDER'] = ['id' => 'ASC'];
|
||||
|
||||
$plans = $this->db->select('follow_plans', '*', $where);
|
||||
foreach ($plans as &$plan) {
|
||||
$stats = $this->getPlanStats($plan['id']);
|
||||
$plan['total_records'] = $stats['total'];
|
||||
$plan['win_count'] = $stats['wins'];
|
||||
$plan['win_rate'] = $stats['total'] > 0 ? round($stats['wins'] / $stats['total'] * 100, 1) : 0;
|
||||
// 最近 10 期记录
|
||||
$plan['recent'] = $this->db->select('follow_plan_records', '*', [
|
||||
'plan_id' => $plan['id'],
|
||||
'is_win[!]' => null,
|
||||
'ORDER' => ['id' => 'DESC'],
|
||||
'LIMIT' => 10
|
||||
]);
|
||||
}
|
||||
unset($plan);
|
||||
return $plans;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划统计
|
||||
*/
|
||||
public function getPlanStats(int $planId): array
|
||||
{
|
||||
$total = $this->db->count('follow_plan_records', [
|
||||
'plan_id' => $planId,
|
||||
'is_win[!]' => null
|
||||
]);
|
||||
$wins = $this->db->count('follow_plan_records', [
|
||||
'plan_id' => $planId,
|
||||
'is_win' => 1
|
||||
]);
|
||||
return ['total' => $total, 'wins' => $wins];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的跟单状态
|
||||
*/
|
||||
public function getUserFollowStatus(int $userId): array
|
||||
{
|
||||
$rows = $this->db->select('user_follow_plans', '*', [
|
||||
'user_id' => $userId,
|
||||
'is_active' => 1
|
||||
]);
|
||||
$result = [];
|
||||
foreach ($rows as $r) {
|
||||
$result[$r['plan_id']] = $r;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户开始/停止跟单
|
||||
*/
|
||||
public function toggleFollow(int $userId, int $planId): array
|
||||
{
|
||||
$existing = $this->db->get('user_follow_plans', '*', [
|
||||
'user_id' => $userId,
|
||||
'plan_id' => $planId
|
||||
]);
|
||||
|
||||
if ($existing) {
|
||||
$newStatus = $existing['is_active'] ? 0 : 1;
|
||||
$this->db->update('user_follow_plans', ['is_active' => $newStatus], [
|
||||
'id' => $existing['id']
|
||||
]);
|
||||
return ['success' => true, 'active' => $newStatus];
|
||||
}
|
||||
|
||||
// 新建
|
||||
$plan = $this->db->get('follow_plans', '*', ['id' => $planId, 'status' => 1]);
|
||||
if (!$plan) {
|
||||
return ['success' => false, 'message' => '计划不存在或已禁用'];
|
||||
}
|
||||
|
||||
$this->db->insert('user_follow_plans', [
|
||||
'user_id' => $userId,
|
||||
'plan_id' => $planId,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
return ['success' => true, 'active' => 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户跟单汇总(总胜率、总盈亏)
|
||||
*/
|
||||
public function getUserSummary(int $userId): array
|
||||
{
|
||||
$pdo = $this->db->medoo->pdo;
|
||||
$totalWinRate = 0;
|
||||
$totalProfit = 0;
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT SUM(total_bets) as bets, SUM(total_wins) as wins, SUM(total_profit) as profit
|
||||
FROM user_follow_plans WHERE user_id = ?
|
||||
");
|
||||
$stmt->execute([$userId]);
|
||||
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
$bets = intval($row['bets'] ?? 0);
|
||||
$wins = intval($row['wins'] ?? 0);
|
||||
$totalWinRate = $bets > 0 ? round($wins / $bets * 100, 1) : 0;
|
||||
$totalProfit = floatval($row['profit'] ?? 0);
|
||||
} catch (\Exception $e) {}
|
||||
|
||||
return ['win_rate' => $totalWinRate, 'profit' => $totalProfit];
|
||||
}
|
||||
|
||||
// ===== 后台管理 =====
|
||||
|
||||
/**
|
||||
* 获取所有计划(后台)
|
||||
*/
|
||||
public function getAllPlans(): array
|
||||
{
|
||||
$plans = $this->db->select('follow_plans', '*', ['ORDER' => ['id' => 'DESC']]);
|
||||
foreach ($plans as &$plan) {
|
||||
$stats = $this->getPlanStats($plan['id']);
|
||||
$plan['total_records'] = $stats['total'];
|
||||
$plan['win_count'] = $stats['wins'];
|
||||
$plan['win_rate'] = $stats['total'] > 0 ? round($stats['wins'] / $stats['total'] * 100, 1) : 0;
|
||||
$plan['follower_count'] = $this->db->count('user_follow_plans', [
|
||||
'plan_id' => $plan['id'], 'is_active' => 1
|
||||
]);
|
||||
}
|
||||
unset($plan);
|
||||
return $plans;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建/更新计划
|
||||
*/
|
||||
public function savePlan(array $data): array
|
||||
{
|
||||
$fields = [
|
||||
'game_id' => intval($data['game_id'] ?? 1),
|
||||
'name' => trim($data['name'] ?? ''),
|
||||
'plan_type' => trim($data['plan_type'] ?? 'bs'),
|
||||
'target_rank' => intval($data['target_rank'] ?? 1),
|
||||
'strategy' => trim($data['strategy'] ?? 'follow'),
|
||||
'bet_amount' => floatval($data['bet_amount'] ?? 100),
|
||||
'status' => intval($data['status'] ?? 1),
|
||||
];
|
||||
|
||||
if (empty($fields['name'])) {
|
||||
return ['success' => false, 'message' => '计划名称不能为空'];
|
||||
}
|
||||
|
||||
$id = intval($data['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$this->db->update('follow_plans', $fields, ['id' => $id]);
|
||||
} else {
|
||||
$fields['created_by'] = intval($data['created_by'] ?? 0);
|
||||
$this->db->insert('follow_plans', $fields);
|
||||
$id = intval($this->db->id());
|
||||
}
|
||||
|
||||
return ['success' => true, 'id' => $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除计划
|
||||
*/
|
||||
public function deletePlan(int $planId): array
|
||||
{
|
||||
$this->db->delete('follow_plan_records', ['plan_id' => $planId]);
|
||||
$this->db->delete('user_follow_plans', ['plan_id' => $planId]);
|
||||
$this->db->delete('follow_plans', ['id' => $planId]);
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加计划推荐记录(由 cron 或后台手动触发)
|
||||
*/
|
||||
public function addRecord(int $planId, int $periodId, string $periodNumber, string $recommendValue): array
|
||||
{
|
||||
$exists = $this->db->get('follow_plan_records', 'id', [
|
||||
'plan_id' => $planId, 'period_id' => $periodId
|
||||
]);
|
||||
if ($exists) {
|
||||
return ['success' => false, 'message' => '该期已有推荐记录'];
|
||||
}
|
||||
|
||||
$this->db->insert('follow_plan_records', [
|
||||
'plan_id' => $planId,
|
||||
'period_id' => $periodId,
|
||||
'period_number' => $periodNumber,
|
||||
'recommend_value' => $recommendValue,
|
||||
]);
|
||||
return ['success' => true, 'id' => intval($this->db->id())];
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算推荐记录(开奖后调用)
|
||||
*/
|
||||
public function settleRecord(int $recordId, string $resultValue, bool $isWin): void
|
||||
{
|
||||
$this->db->update('follow_plan_records', [
|
||||
'result_value' => $resultValue,
|
||||
'is_win' => $isWin ? 1 : 0,
|
||||
], ['id' => $recordId]);
|
||||
|
||||
// 更新所有跟了这个计划的用户统计
|
||||
$record = $this->db->get('follow_plan_records', '*', ['id' => $recordId]);
|
||||
if (!$record) return;
|
||||
|
||||
$plan = $this->db->get('follow_plans', '*', ['id' => $record['plan_id']]);
|
||||
if (!$plan) return;
|
||||
|
||||
$betAmount = floatval($plan['bet_amount']);
|
||||
$profit = $isWin ? $betAmount * 0.95 : -$betAmount; // 简化盈亏计算
|
||||
|
||||
$followers = $this->db->select('user_follow_plans', '*', [
|
||||
'plan_id' => $record['plan_id'], 'is_active' => 1
|
||||
]);
|
||||
|
||||
foreach ($followers as $f) {
|
||||
$this->db->update('user_follow_plans', [
|
||||
'total_bets[+]' => 1,
|
||||
'total_wins[+]' => $isWin ? 1 : 0,
|
||||
'total_profit[+]' => $profit,
|
||||
], ['id' => $f['id']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use Db\Database;
|
||||
|
||||
class ReportService {
|
||||
private $db;
|
||||
|
||||
public function __construct(Database $db) {
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台用户报表:按投注类型分组统计
|
||||
*
|
||||
* @param int $userId 用户ID
|
||||
* @param string $dateFrom 开始日期 (Y-m-d)
|
||||
* @param string $dateTo 结束日期 (Y-m-d)
|
||||
* @return array
|
||||
*/
|
||||
public function getUserReport(int $userId, string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$rangeFrom = $dateFrom . ' 00:00:00';
|
||||
$rangeTo = $dateTo . ' 23:59:59';
|
||||
|
||||
$pdo = $this->db->medoo->pdo;
|
||||
try {
|
||||
// 投注统计(有效流水 = 已结算投注额,排除 pending)
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
bet_type as type,
|
||||
COUNT(*) as bet_count,
|
||||
SUM(amount) as amount,
|
||||
SUM(CASE WHEN status IN ('win','lose') THEN amount ELSE 0 END) as effective_flow,
|
||||
SUM(CASE
|
||||
WHEN status='win' THEN win_amount
|
||||
WHEN status='lose' THEN -amount
|
||||
ELSE 0
|
||||
END) as win_loss
|
||||
FROM bets
|
||||
WHERE user_id = ? AND created_at BETWEEN ? AND ?
|
||||
GROUP BY bet_type
|
||||
ORDER BY bet_type
|
||||
");
|
||||
$stmt->execute([$userId, $rangeFrom, $rangeTo]);
|
||||
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// 退水(代理返佣给该用户的金额)
|
||||
$rebateTotal = 0;
|
||||
try {
|
||||
$stmt2 = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(commission), 0) as total
|
||||
FROM agent_commissions
|
||||
WHERE from_user_id = ? AND type = 'rebate' AND created_at BETWEEN ? AND ?
|
||||
");
|
||||
$stmt2->execute([$userId, $rangeFrom, $rangeTo]);
|
||||
$rebateTotal = floatval($stmt2->fetch(\PDO::FETCH_ASSOC)['total'] ?? 0);
|
||||
} catch (\Exception $e) {
|
||||
$rebateTotal = 0;
|
||||
}
|
||||
|
||||
// 将退水按比例分配到各类型(或全部放在第一行)
|
||||
$totalBet = array_sum(array_column($rows, 'amount'));
|
||||
foreach ($rows as &$row) {
|
||||
$row['amount'] = floatval($row['amount']);
|
||||
$row['effective_flow'] = floatval($row['effective_flow']);
|
||||
$row['win_loss'] = floatval($row['win_loss']);
|
||||
// 按投注额占比分配退水
|
||||
$ratio = $totalBet > 0 ? $row['amount'] / $totalBet : 0;
|
||||
$row['rebate'] = round($rebateTotal * $ratio, 2);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台总体统计
|
||||
*
|
||||
* @param string $dateFrom 开始日期 (Y-m-d)
|
||||
* @param string $dateTo 结束日期 (Y-m-d)
|
||||
* @return array
|
||||
*/
|
||||
public function getOverviewStats(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'];
|
||||
$pdo = $this->db->medoo->pdo;
|
||||
|
||||
// 总投注(排除虚拟)
|
||||
$totalBet = $this->db->sum('bets', 'amount', [
|
||||
'is_virtual' => 0, 'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 总派奖
|
||||
$totalWin = $this->db->sum('bets', 'win_amount', [
|
||||
'is_virtual' => 0, 'status' => 'win', 'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 平台利润
|
||||
$profit = $totalBet - $totalWin;
|
||||
$profitRate = $totalBet > 0 ? round($profit / $totalBet * 100, 2) : 0;
|
||||
|
||||
// 总充值
|
||||
$totalDeposit = $this->db->sum('transactions', 'amount', [
|
||||
'is_virtual' => 0, 'type[~]' => '%deposit%', 'amount[>]' => 0,
|
||||
'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 总提现
|
||||
$totalWithdraw = abs($this->db->sum('transactions', 'amount', [
|
||||
'is_virtual' => 0, 'type[~]' => '%withdraw%', 'amount[<]' => 0,
|
||||
'created_at[<>]' => $range
|
||||
]) ?: 0);
|
||||
|
||||
// 代理佣金
|
||||
$totalCommission = $this->db->sum('agent_commissions', 'commission', [
|
||||
'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 反水
|
||||
$totalRebate = $this->db->sum('agent_commissions', 'commission', [
|
||||
'type' => 'rebate', 'created_at[<>]' => $range
|
||||
]) ?: 0;
|
||||
|
||||
// 用户统计
|
||||
$totalUsers = $this->db->count('users', ['is_virtual' => 0]);
|
||||
$newUsers = $this->db->count('users', [
|
||||
'is_virtual' => 0, 'created_at[<>]' => $range
|
||||
]);
|
||||
|
||||
// 活跃用户数(有投注记录的非虚拟用户)
|
||||
$activeUsers = 0;
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT COUNT(DISTINCT user_id) as cnt FROM bets WHERE is_virtual = 0 AND created_at BETWEEN ? AND ?");
|
||||
$stmt->execute([$range[0], $range[1]]);
|
||||
$activeUsers = (int)($stmt->fetch(\PDO::FETCH_ASSOC)['cnt'] ?? 0);
|
||||
} catch (\Exception $e) {
|
||||
$activeUsers = 0;
|
||||
}
|
||||
|
||||
return compact(
|
||||
'totalBet', 'totalWin', 'profit', 'profitRate',
|
||||
'totalDeposit', 'totalWithdraw', 'totalCommission', 'totalRebate',
|
||||
'totalUsers', 'newUsers', 'activeUsers'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日明细统计
|
||||
*
|
||||
* @param string $dateFrom 开始日期 (Y-m-d)
|
||||
* @param string $dateTo 结束日期 (Y-m-d)
|
||||
* @return array
|
||||
*/
|
||||
public function getDailyStats(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'];
|
||||
$pdo = $this->db->medoo->pdo;
|
||||
|
||||
$dailyStats = [];
|
||||
try {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
SUM(amount) as total_bet,
|
||||
SUM(CASE WHEN status='win' THEN win_amount ELSE 0 END) as total_win,
|
||||
COUNT(*) as bet_count,
|
||||
SUM(CASE WHEN status='win' THEN 1 ELSE 0 END) as win_count
|
||||
FROM bets
|
||||
WHERE is_virtual = 0 AND created_at BETWEEN ? AND ?
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
");
|
||||
$stmt->execute([$range[0], $range[1]]);
|
||||
$dailyStats = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 补充每日充提数据
|
||||
foreach ($dailyStats as &$day) {
|
||||
$dr = [$day['date'] . ' 00:00:00', $day['date'] . ' 23:59:59'];
|
||||
$day['deposits'] = $this->db->sum('transactions', 'amount', [
|
||||
'is_virtual' => 0, 'type[~]' => '%deposit%', 'amount[>]' => 0,
|
||||
'created_at[<>]' => $dr
|
||||
]) ?: 0;
|
||||
$day['withdraws'] = abs($this->db->sum('transactions', 'amount', [
|
||||
'is_virtual' => 0, 'type[~]' => '%withdraw%', 'amount[<]' => 0,
|
||||
'created_at[<>]' => $dr
|
||||
]) ?: 0);
|
||||
$day['total_bet'] = floatval($day['total_bet'] ?? 0);
|
||||
$day['total_win'] = floatval($day['total_win'] ?? 0);
|
||||
$day['bet_count'] = intval($day['bet_count'] ?? 0);
|
||||
$day['win_count'] = intval($day['win_count'] ?? 0);
|
||||
$day['profit'] = $day['total_bet'] - $day['total_win'];
|
||||
$day['win_rate'] = $day['bet_count'] > 0 ? round($day['win_count'] / $day['bet_count'] * 100, 1) : 0;
|
||||
}
|
||||
unset($day);
|
||||
|
||||
return $dailyStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户输赢明细(含日维度分组 + 用户汇总)
|
||||
*
|
||||
* @param string $dateFrom 开始日期 (Y-m-d)
|
||||
* @param string $dateTo 结束日期 (Y-m-d)
|
||||
* @return array ['raw' => 原始行, 'summary' => 按用户汇总]
|
||||
*/
|
||||
public function getUserWinLoss(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'];
|
||||
$pdo = $this->db->medoo->pdo;
|
||||
|
||||
$userWinLoss = [];
|
||||
try {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT u.id as user_id, u.username, u.balance,
|
||||
DATE(b.created_at) as date,
|
||||
SUM(b.amount) as total_bet,
|
||||
SUM(CASE WHEN b.status='win' THEN b.win_amount ELSE 0 END) as total_win,
|
||||
COUNT(*) as bet_count,
|
||||
SUM(CASE WHEN b.status='win' THEN 1 ELSE 0 END) as win_count
|
||||
FROM bets b
|
||||
JOIN users u ON b.user_id = u.id
|
||||
WHERE b.is_virtual = 0 AND b.created_at BETWEEN ? AND ?
|
||||
GROUP BY b.user_id, DATE(b.created_at)
|
||||
ORDER BY u.username, date DESC
|
||||
");
|
||||
$stmt->execute([$range[0], $range[1]]);
|
||||
$userWinLoss = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
} catch (\Exception $e) {
|
||||
return ['raw' => [], 'summary' => []];
|
||||
}
|
||||
|
||||
// 按用户汇总
|
||||
$userSummary = [];
|
||||
foreach ($userWinLoss as $row) {
|
||||
$uid = $row['user_id'];
|
||||
if (!isset($userSummary[$uid])) {
|
||||
$userSummary[$uid] = [
|
||||
'username' => $row['username'],
|
||||
'balance' => floatval($row['balance']),
|
||||
'total_bet' => 0, 'total_win' => 0,
|
||||
'bet_count' => 0, 'win_count' => 0,
|
||||
'days' => []
|
||||
];
|
||||
}
|
||||
$bet = floatval($row['total_bet']);
|
||||
$win = floatval($row['total_win']);
|
||||
$userSummary[$uid]['total_bet'] += $bet;
|
||||
$userSummary[$uid]['total_win'] += $win;
|
||||
$userSummary[$uid]['bet_count'] += intval($row['bet_count']);
|
||||
$userSummary[$uid]['win_count'] += intval($row['win_count']);
|
||||
$userSummary[$uid]['days'][] = [
|
||||
'date' => $row['date'],
|
||||
'bet' => $bet, 'win' => $win,
|
||||
'profit' => $bet - $win,
|
||||
'count' => intval($row['bet_count']),
|
||||
];
|
||||
}
|
||||
// 排序:按总投注额降序
|
||||
uasort($userSummary, function($a, $b) { return $b['total_bet'] <=> $a['total_bet']; });
|
||||
|
||||
return ['raw' => $userWinLoss, 'summary' => $userSummary];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户排行(投注额 TOP N)
|
||||
*
|
||||
* @param string $dateFrom 开始日期 (Y-m-d)
|
||||
* @param string $dateTo 结束日期 (Y-m-d)
|
||||
* @param int $limit 条数限制,0=不限
|
||||
* @return array
|
||||
*/
|
||||
public function getTopUsers(string $dateFrom, string $dateTo, int $limit = 20): array
|
||||
{
|
||||
$range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'];
|
||||
$pdo = $this->db->medoo->pdo;
|
||||
|
||||
$sql = "
|
||||
SELECT u.username,
|
||||
SUM(b.amount) as total_bet,
|
||||
SUM(CASE WHEN b.status='win' THEN b.win_amount ELSE 0 END) as total_win,
|
||||
SUM(b.amount) - SUM(CASE WHEN b.status='win' THEN b.win_amount ELSE 0 END) as profit,
|
||||
COUNT(*) as bet_count
|
||||
FROM bets b
|
||||
JOIN users u ON b.user_id = u.id
|
||||
WHERE b.is_virtual = 0 AND b.created_at BETWEEN ? AND ?
|
||||
GROUP BY b.user_id
|
||||
ORDER BY total_bet DESC
|
||||
";
|
||||
if ($limit > 0) {
|
||||
$sql .= " LIMIT " . intval($limit);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$range[0], $range[1]]);
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理报表
|
||||
*
|
||||
* @param string $dateFrom 开始日期 (Y-m-d)
|
||||
* @param string $dateTo 结束日期 (Y-m-d)
|
||||
* @return array
|
||||
*/
|
||||
public function getAgentStats(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$range = [$dateFrom . ' 00:00:00', $dateTo . ' 23:59:59'];
|
||||
|
||||
$agentStats = [];
|
||||
$agents = $this->db->select('agents', '*', ['status' => 1]);
|
||||
foreach ($agents as $ag) {
|
||||
$playerIds = $this->db->select('users', 'id', ['agent_id' => $ag['id'], 'is_virtual' => 0]);
|
||||
$agBet = 0;
|
||||
$agWin = 0;
|
||||
if (!empty($playerIds)) {
|
||||
$agBet = $this->db->sum('bets', 'amount', ['user_id' => $playerIds, 'is_virtual' => 0, 'created_at[<>]' => $range]) ?: 0;
|
||||
$agWin = $this->db->sum('bets', 'win_amount', ['user_id' => $playerIds, 'is_virtual' => 0, 'status' => 'win', 'created_at[<>]' => $range]) ?: 0;
|
||||
}
|
||||
$agComm = $this->db->sum('agent_commissions', 'commission', ['agent_id' => $ag['id'], 'created_at[<>]' => $range]) ?: 0;
|
||||
$agentStats[] = [
|
||||
'agent' => $ag,
|
||||
'user' => $this->db->get('users', ['username'], ['id' => $ag['user_id']]),
|
||||
'players' => count($playerIds),
|
||||
'bets' => $agBet,
|
||||
'wins' => $agWin,
|
||||
'commission' => $agComm,
|
||||
'profit' => $agBet - $agWin - $agComm,
|
||||
];
|
||||
}
|
||||
|
||||
return $agentStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use Db\Database;
|
||||
|
||||
class TransactionService {
|
||||
private $db;
|
||||
|
||||
public function __construct(Database $db) {
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户间转账
|
||||
*
|
||||
* @param int $fromUserId 转出用户ID
|
||||
* @param string $toUsername 收款用户名
|
||||
* @param float $amount 转账金额
|
||||
* @return array ['success' => bool, 'message' => string]
|
||||
*/
|
||||
public function transfer(int $fromUserId, string $toUsername, float $amount): array
|
||||
{
|
||||
$toUsername = trim($toUsername);
|
||||
|
||||
if ($toUsername === '') {
|
||||
return ['success' => false, 'message' => '请输入对方用户名'];
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return ['success' => false, 'message' => '金额无效'];
|
||||
}
|
||||
|
||||
$fromUser = $this->db->get('users', '*', ['id' => $fromUserId]);
|
||||
if (!$fromUser) {
|
||||
return ['success' => false, 'message' => '用户不存在'];
|
||||
}
|
||||
if ($fromUser['balance'] < $amount) {
|
||||
return ['success' => false, 'message' => '余额不足'];
|
||||
}
|
||||
|
||||
// 查找收款用户
|
||||
$toUser = $this->db->get('users', '*', ['username' => $toUsername]);
|
||||
if (!$toUser) {
|
||||
return ['success' => false, 'message' => '收款用户不存在'];
|
||||
}
|
||||
if ($toUser['id'] === $fromUser['id']) {
|
||||
return ['success' => false, 'message' => '不能转给自己'];
|
||||
}
|
||||
|
||||
$this->db->medoo->pdo->beginTransaction();
|
||||
try {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$fromBalanceBefore = floatval($fromUser['balance']);
|
||||
$toBalanceBefore = floatval($toUser['balance']);
|
||||
$fromBalanceAfter = $fromBalanceBefore - $amount;
|
||||
$toBalanceAfter = $toBalanceBefore + $amount;
|
||||
|
||||
// 扣款
|
||||
$this->db->update('users', ['balance' => $fromBalanceAfter, 'updated_at' => $now], ['id' => $fromUser['id']]);
|
||||
// 加款
|
||||
$this->db->update('users', ['balance' => $toBalanceAfter, 'updated_at' => $now], ['id' => $toUser['id']]);
|
||||
|
||||
// 转出流水
|
||||
$this->db->insert('transactions', [
|
||||
'user_id' => $fromUser['id'],
|
||||
'type' => 'transfer_out',
|
||||
'amount' => -$amount,
|
||||
'balance_before' => $fromBalanceBefore,
|
||||
'balance_after' => $fromBalanceAfter,
|
||||
'description' => '转账给 ' . $toUser['username'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
// 转入流水
|
||||
$this->db->insert('transactions', [
|
||||
'user_id' => $toUser['id'],
|
||||
'type' => 'transfer_in',
|
||||
'amount' => $amount,
|
||||
'balance_before' => $toBalanceBefore,
|
||||
'balance_after' => $toBalanceAfter,
|
||||
'description' => '收到 ' . $fromUser['username'] . ' 的转账',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$this->db->medoo->pdo->commit();
|
||||
return ['success' => true, 'message' => '转账成功'];
|
||||
} catch (\Exception $e) {
|
||||
$this->db->medoo->pdo->rollBack();
|
||||
return ['success' => false, 'message' => '系统错误'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 充提申请
|
||||
*
|
||||
* @param int $userId 用户ID
|
||||
* @param string $type 类型: deposit|withdraw
|
||||
* @param float $amount 金额
|
||||
* @param string $remark 备注
|
||||
* @return array ['success' => bool, 'message' => string]
|
||||
*/
|
||||
public function fundRequest(int $userId, string $type, float $amount, string $remark = ''): array
|
||||
{
|
||||
if (!in_array($type, ['deposit', 'withdraw'])) {
|
||||
return ['success' => false, 'message' => '无效类型'];
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
return ['success' => false, 'message' => '金额无效'];
|
||||
}
|
||||
|
||||
$user = $this->db->get('users', '*', ['id' => $userId]);
|
||||
if (!$user) {
|
||||
return ['success' => false, 'message' => '用户不存在'];
|
||||
}
|
||||
|
||||
if ($type === 'withdraw') {
|
||||
if ($user['balance'] < $amount) {
|
||||
return ['success' => false, 'message' => '余额不足'];
|
||||
}
|
||||
$this->db->medoo->pdo->beginTransaction();
|
||||
try {
|
||||
$this->db->update('users', ['balance[-]' => $amount], ['id' => $user['id']]);
|
||||
$this->db->insert('fund_requests', [
|
||||
'user_id' => $user['id'],
|
||||
'type' => 'withdraw',
|
||||
'amount' => $amount,
|
||||
'status' => 'pending',
|
||||
'remark' => $remark,
|
||||
]);
|
||||
$this->db->medoo->pdo->commit();
|
||||
return ['success' => true, 'message' => '提现申请已提交'];
|
||||
} catch (\Exception $e) {
|
||||
$this->db->medoo->pdo->rollBack();
|
||||
return ['success' => false, 'message' => '系统错误'];
|
||||
}
|
||||
}
|
||||
|
||||
// deposit - 记录申请
|
||||
$this->db->insert('fund_requests', [
|
||||
'user_id' => $user['id'],
|
||||
'type' => 'deposit',
|
||||
'amount' => $amount,
|
||||
'status' => 'pending',
|
||||
'remark' => $remark,
|
||||
]);
|
||||
return ['success' => true, 'message' => '充值申请已提交'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<i class="fas fa-robot text-primary mr-3"></i>
|
||||
Bot 管理
|
||||
</h1>
|
||||
|
||||
<?php
|
||||
$botOptions = [];
|
||||
foreach (($bots ?? []) as $botItem) {
|
||||
$botOptions[] = [
|
||||
'id' => (int)($botItem['id'] ?? 0),
|
||||
'name' => (string)($botItem['name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$gameOptions = [];
|
||||
foreach (($games ?? []) as $gameItem) {
|
||||
$gameOptions[] = [
|
||||
'id' => (int)($gameItem['id'] ?? 0),
|
||||
'name' => (string)($gameItem['name'] ?? ''),
|
||||
'type' => (string)($gameItem['type'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$ruleOptions = [];
|
||||
foreach (($rules ?? []) as $ruleItem) {
|
||||
$ruleOptions[] = [
|
||||
'id' => (int)($ruleItem['id'] ?? 0),
|
||||
'name' => (string)($ruleItem['name'] ?? ''),
|
||||
'rule_code' => (string)($ruleItem['rule_code'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$groupOptions = [];
|
||||
foreach (($groups ?? []) as $groupItem) {
|
||||
$countdown = '';
|
||||
if (!empty($groupItem['countdown_config'])) {
|
||||
$decodedCountdown = json_decode((string)$groupItem['countdown_config'], true);
|
||||
if (is_array($decodedCountdown)) {
|
||||
$countdown = implode(',', array_map('intval', $decodedCountdown));
|
||||
}
|
||||
}
|
||||
|
||||
$groupOptions[] = [
|
||||
'id' => (int)($groupItem['id'] ?? 0),
|
||||
'bot_id' => (int)($groupItem['bot_id'] ?? 0),
|
||||
'game_id' => (int)($groupItem['game_id'] ?? 0),
|
||||
'bet_format_rule_id' => (int)($groupItem['bet_format_rule_id'] ?? 0),
|
||||
'tg_group_id' => (string)($groupItem['tg_group_id'] ?? ''),
|
||||
'group_name' => (string)($groupItem['group_name'] ?? ''),
|
||||
'group_type' => (string)($groupItem['group_type'] ?? 'group'),
|
||||
'countdown_config' => $countdown,
|
||||
];
|
||||
}
|
||||
|
||||
$userOptions = [];
|
||||
foreach (($users ?? []) as $userItem) {
|
||||
$userOptions[] = [
|
||||
'id' => (int)($userItem['id'] ?? 0),
|
||||
'username' => (string)($userItem['username'] ?? ''),
|
||||
'balance' => number_format((float)($userItem['balance'] ?? 0), 2, '.', ''),
|
||||
];
|
||||
}
|
||||
?>
|
||||
|
||||
<div id="botAdminAlert" class="hidden mb-6 rounded-lg border px-4 py-3 text-sm"></div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">Bot 实例数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['bot_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center"><i class="fas fa-robot text-primary"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">群配置数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['group_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center"><i class="fas fa-users text-success"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">已绑总账号</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['wallet_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-warning/10 flex items-center justify-center"><i class="fas fa-wallet text-warning"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">启用群数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['enabled_group_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center"><i class="fas fa-signal text-danger"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">成员映射数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['member_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center"><i class="fas fa-user-tag text-indigo-600"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">托号数量</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['shill_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-rose-100 flex items-center justify-center"><i class="fas fa-user-secret text-rose-600"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">推送日志数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['push_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-cyan-100 flex items-center justify-center"><i class="fas fa-paper-plane text-cyan-600"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">推送成功数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['push_success_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-emerald-100 flex items-center justify-center"><i class="fas fa-circle-check text-emerald-600"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">倒计时群数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['countdown_group_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center"><i class="fas fa-hourglass-half text-amber-600"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">动画群数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['animation_group_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-fuchsia-100 flex items-center justify-center"><i class="fas fa-image text-fuchsia-600"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">API 请求数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['api_request_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center"><i class="fas fa-arrow-right-arrow-left text-slate-600"></i></div></div></div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift"><div class="flex items-center justify-between"><div><p class="text-gray-500 text-sm">签名通过数</p><h3 class="text-2xl font-bold mt-1"><?= (int)($stats['api_signature_ok_count'] ?? 0) ?></h3></div><div class="w-10 h-10 rounded-full bg-lime-100 flex items-center justify-center"><i class="fas fa-shield-halved text-lime-600"></i></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6 mb-6">
|
||||
<div class="xl:col-span-2 bg-white rounded-xl shadow-md p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-800">Bot 实例总览</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">现在已支持新增、编辑、启停 Bot 实例。</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs bg-primary/10 text-primary"><i class="fas fa-hammer mr-1"></i> Phase 6 进阶</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white rounded-xl overflow-hidden">
|
||||
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">实例</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">运行模式</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">群数</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">更新时间</th><th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th></tr></thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<?php if (!empty($bots)): foreach ($bots as $bot): ?>
|
||||
<?php $enabled = (int)($bot['status'] ?? 0) === 1; ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-4 align-top"><div class="space-y-1"><div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars((string)($bot['name'] ?? '未命名 Bot')) ?></div><div class="text-xs text-gray-500">Key:<?= htmlspecialchars((string)($bot['bot_key'] ?? '-')) ?></div><div class="text-xs text-gray-400">@<?= htmlspecialchars((string)($bot['bot_username'] ?? 'unknown')) ?></div></div></td>
|
||||
<td class="px-4 py-4 align-top text-sm text-gray-600"><?= htmlspecialchars((string)($bot['run_mode'] ?? 'polling')) ?></td>
|
||||
<td class="px-4 py-4 align-top"><div class="text-sm text-gray-700">总群数:<?= (int)($bot['group_count'] ?? 0) ?></div><div class="text-xs text-gray-500 mt-1">启用下注:<?= (int)($bot['active_group_count'] ?? 0) ?></div></td>
|
||||
<td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700' ?>"><span class="w-2 h-2 rounded-full mr-1 <?= $enabled ? 'bg-green-500' : 'bg-gray-400' ?>"></span><?= $enabled ? '启用' : '停用' ?></span></td>
|
||||
<td class="px-4 py-4 align-top text-sm text-gray-500"><?= htmlspecialchars((string)($bot['updated_at'] ?? ($bot['created_at'] ?? '-'))) ?></td>
|
||||
<td class="px-4 py-4 align-top text-right"><div class="flex items-center justify-end gap-2"><button type="button" class="edit-bot-btn inline-flex items-center px-3 py-1.5 rounded-lg border border-primary/20 text-primary text-xs hover:bg-primary/5" data-id="<?= (int)$bot['id'] ?>" data-name="<?= htmlspecialchars((string)($bot['name'] ?? ''), ENT_QUOTES) ?>" data-token="<?= htmlspecialchars((string)($bot['bot_token'] ?? ''), ENT_QUOTES) ?>" data-username="<?= htmlspecialchars((string)($bot['bot_username'] ?? ''), ENT_QUOTES) ?>" data-run-mode="<?= htmlspecialchars((string)($bot['run_mode'] ?? 'polling'), ENT_QUOTES) ?>" data-webhook-url="<?= htmlspecialchars((string)($bot['webhook_url'] ?? ''), ENT_QUOTES) ?>" data-remark="<?= htmlspecialchars((string)($bot['remark'] ?? ''), ENT_QUOTES) ?>"><i class="fas fa-pen mr-1"></i>编辑</button><button type="button" class="toggle-btn inline-flex items-center px-3 py-1.5 rounded-lg text-xs <?= $enabled ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-green-50 text-green-600 border border-green-200' ?>" data-url="/admin/bots/toggle" data-id="<?= (int)$bot['id'] ?>" data-status="<?= $enabled ? 0 : 1 ?>"><?= $enabled ? '停用' : '启用' ?></button></div></td>
|
||||
</tr>
|
||||
<?php endforeach; else: ?><tr><td colspan="6" class="px-4 py-10 text-center text-sm text-gray-500">尚未配置任何 Bot 实例。</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md p-6 space-y-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-xl font-semibold text-gray-800">新增 Bot 实例</h2><button type="button" id="cancelBotEditBtn" class="hidden text-xs text-gray-500 hover:text-gray-700">取消编辑</button></div>
|
||||
<form id="botForm" class="space-y-3">
|
||||
<input type="hidden" name="id">
|
||||
<input type="text" name="name" placeholder="Bot 名称" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" name="bot_token" placeholder="Telegram Bot Token" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" name="bot_username" placeholder="Bot Username,可选" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<select name="run_mode" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="polling">polling</option><option value="webhook">webhook</option></select>
|
||||
<input type="text" name="webhook_url" placeholder="Webhook URL,可选" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<textarea name="remark" rows="2" placeholder="备注,可选" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"></textarea>
|
||||
<button type="submit" class="w-full bg-primary hover:bg-primary/90 text-white px-4 py-2.5 rounded-lg text-sm" data-create-text="创建 Bot 实例" data-update-text="保存 Bot 修改">创建 Bot 实例</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-t pt-6">
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-xl font-semibold text-gray-800">绑定群配置</h2><button type="button" id="cancelGroupEditBtn" class="hidden text-xs text-gray-500 hover:text-gray-700">取消编辑</button></div>
|
||||
<form id="groupForm" class="space-y-3">
|
||||
<input type="hidden" name="id">
|
||||
<select name="bot_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">选择 Bot</option><?php foreach (($bots ?? []) as $bot): ?><option value="<?= (int)$bot['id'] ?>"><?= htmlspecialchars((string)$bot['name']) ?></option><?php endforeach; ?></select>
|
||||
<select name="game_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">选择游戏</option><?php foreach (($games ?? []) as $game): ?><option value="<?= (int)$game['id'] ?>"><?= htmlspecialchars((string)$game['name']) ?> (<?= htmlspecialchars((string)$game['type']) ?>)</option><?php endforeach; ?></select>
|
||||
<input type="text" name="tg_group_id" placeholder="Telegram 群 ID,例如 -100123456" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" name="group_name" placeholder="群名称" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<select name="group_type" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="group">group</option><option value="supergroup">supergroup</option><option value="channel">channel</option></select>
|
||||
<select name="bet_format_rule_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">下注规则(可选)</option><?php foreach (($rules ?? []) as $rule): ?><option value="<?= (int)$rule['id'] ?>"><?= htmlspecialchars((string)$rule['name']) ?> / <?= htmlspecialchars((string)$rule['rule_code']) ?></option><?php endforeach; ?></select>
|
||||
<input type="text" name="countdown_config" placeholder="倒计时配置,如 60,30,10" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-700"><label class="flex items-center gap-2"><input type="checkbox" name="remind_bet_success" checked>下注成功提醒</label><label class="flex items-center gap-2"><input type="checkbox" name="remind_draw_result" checked>开奖提醒</label><label class="flex items-center gap-2"><input type="checkbox" name="remind_close_countdown" checked>封盘倒计时</label><label class="flex items-center gap-2"><input type="checkbox" name="animation_enabled">开奖动画</label><label class="flex items-center gap-2"><input type="checkbox" name="bet_enabled" checked>允许下注同步</label></div>
|
||||
<button type="submit" class="w-full bg-success hover:bg-success/90 text-white px-4 py-2.5 rounded-lg text-sm" data-create-text="创建群配置" data-update-text="保存群配置修改">创建群配置</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-t pt-6">
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-xl font-semibold text-gray-800">绑定群总账号</h2><button type="button" id="cancelWalletEditBtn" class="hidden text-xs text-gray-500 hover:text-gray-700">取消编辑</button></div>
|
||||
<form id="walletForm" class="space-y-3">
|
||||
<input type="hidden" name="id">
|
||||
<select name="group_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">选择群配置</option><?php foreach (($groups ?? []) as $group): ?><option value="<?= (int)$group['id'] ?>"><?= htmlspecialchars((string)$group['group_name']) ?> / <?= htmlspecialchars((string)$group['tg_group_id']) ?></option><?php endforeach; ?></select>
|
||||
<select name="platform_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">选择网站账号</option><?php foreach (($users ?? []) as $user): ?><option value="<?= (int)$user['id'] ?>"><?= htmlspecialchars((string)$user['username']) ?> / 余额 <?= number_format((float)($user['balance'] ?? 0), 2) ?></option><?php endforeach; ?></select>
|
||||
<select name="wallet_mode" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="master_pool">master_pool</option><option value="per_member">per_member</option></select>
|
||||
<button type="submit" class="w-full bg-warning hover:bg-warning/90 text-white px-4 py-2.5 rounded-lg text-sm" data-create-text="绑定群总账号" data-update-text="保存总账号修改">绑定群总账号</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">群配置总览</h2>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">群</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bot / 游戏</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">开关</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th><th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th></tr></thead><tbody class="divide-y divide-gray-200"><?php if (!empty($groups)): foreach ($groups as $group): ?><?php $active = (int)($group['status'] ?? 0) === 1; $countdownText = ''; if (!empty($group['countdown_config'])) { $decoded = json_decode((string)$group['countdown_config'], true); if (is_array($decoded) && !empty($decoded)) { $countdownText = implode(',', array_map('intval', $decoded)); } } ?><tr class="hover:bg-gray-50"><td class="px-4 py-4 align-top"><div class="font-medium text-gray-800"><?= htmlspecialchars((string)$group['group_name']) ?></div><div class="text-xs text-gray-500 mt-1"><?= htmlspecialchars((string)$group['tg_group_id']) ?> / <?= htmlspecialchars((string)$group['group_type']) ?></div><?php if ($countdownText !== ''): ?><div class="text-xs text-gray-400 mt-1">倒计时:<?= htmlspecialchars($countdownText) ?></div><?php endif; ?></td><td class="px-4 py-4 align-top text-sm text-gray-600"><div><?= htmlspecialchars((string)($group['bot_name'] ?? '-')) ?></div><div class="text-xs text-gray-500 mt-1"><?= htmlspecialchars((string)($group['game_name'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top text-xs text-gray-600"><div>下注:<?= !empty($group['bet_enabled']) ? '开' : '关' ?></div><div>提醒:<?= !empty($group['remind_bet_success']) || !empty($group['remind_draw_result']) || !empty($group['remind_close_countdown']) ? '开' : '关' ?></div><div>动画:<?= !empty($group['animation_enabled']) ? '开' : '关' ?></div></td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700' ?>"><?= $active ? '启用' : '停用' ?></span></td><td class="px-4 py-4 align-top text-right"><div class="flex items-center justify-end gap-2"><button type="button" class="edit-group-btn inline-flex items-center px-3 py-1.5 rounded-lg border border-primary/20 text-primary text-xs hover:bg-primary/5" data-id="<?= (int)$group['id'] ?>" data-bot-id="<?= (int)($group['bot_id'] ?? 0) ?>" data-game-id="<?= (int)($group['game_id'] ?? 0) ?>" data-rule-id="<?= (int)($group['bet_format_rule_id'] ?? 0) ?>" data-tg-group-id="<?= htmlspecialchars((string)($group['tg_group_id'] ?? ''), ENT_QUOTES) ?>" data-group-name="<?= htmlspecialchars((string)($group['group_name'] ?? ''), ENT_QUOTES) ?>" data-group-type="<?= htmlspecialchars((string)($group['group_type'] ?? 'group'), ENT_QUOTES) ?>" data-countdown-config="<?= htmlspecialchars($countdownText, ENT_QUOTES) ?>" data-remind-bet-success="<?= !empty($group['remind_bet_success']) ? 1 : 0 ?>" data-remind-draw-result="<?= !empty($group['remind_draw_result']) ? 1 : 0 ?>" data-remind-close-countdown="<?= !empty($group['remind_close_countdown']) ? 1 : 0 ?>" data-animation-enabled="<?= !empty($group['animation_enabled']) ? 1 : 0 ?>" data-bet-enabled="<?= !empty($group['bet_enabled']) ? 1 : 0 ?>"><i class="fas fa-pen mr-1"></i>编辑</button><button type="button" class="toggle-btn inline-flex items-center px-3 py-1.5 rounded-lg text-xs <?= $active ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-green-50 text-green-600 border border-green-200' ?>" data-url="/admin/bots/groups/toggle" data-id="<?= (int)$group['id'] ?>" data-status="<?= $active ? 0 : 1 ?>"><?= $active ? '停用' : '启用' ?></button></div></td></tr><?php endforeach; else: ?><tr><td colspan="5" class="px-4 py-10 text-center text-sm text-gray-500">暂无群配置。</td></tr><?php endif; ?></tbody></table></div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">群总账号绑定</h2>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">群</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">网站账号</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模式</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th><th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th></tr></thead><tbody class="divide-y divide-gray-200"><?php if (!empty($wallets)): foreach ($wallets as $wallet): ?><?php $active = (int)($wallet['status'] ?? 0) === 1; ?><tr class="hover:bg-gray-50"><td class="px-4 py-4 align-top"><div class="font-medium text-gray-800"><?= htmlspecialchars((string)($wallet['group_name'] ?? '-')) ?></div><div class="text-xs text-gray-500 mt-1"><?= htmlspecialchars((string)($wallet['tg_group_id'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600"><div><?= htmlspecialchars((string)($wallet['platform_username'] ?? '-')) ?></div><div class="text-xs text-gray-500 mt-1">UID <?= (int)($wallet['platform_user_id'] ?? 0) ?> / 余额 <?= number_format((float)($wallet['platform_balance'] ?? 0), 2) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600"><?= htmlspecialchars((string)($wallet['wallet_mode'] ?? 'master_pool')) ?></td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700' ?>"><?= $active ? '启用' : '停用' ?></span></td><td class="px-4 py-4 align-top text-right"><div class="flex items-center justify-end gap-2"><button type="button" class="edit-wallet-btn inline-flex items-center px-3 py-1.5 rounded-lg border border-primary/20 text-primary text-xs hover:bg-primary/5" data-id="<?= (int)$wallet['id'] ?>" data-group-id="<?= (int)($wallet['group_id'] ?? 0) ?>" data-platform-user-id="<?= (int)($wallet['platform_user_id'] ?? 0) ?>" data-wallet-mode="<?= htmlspecialchars((string)($wallet['wallet_mode'] ?? 'master_pool'), ENT_QUOTES) ?>"><i class="fas fa-pen mr-1"></i>编辑</button><button type="button" class="toggle-btn inline-flex items-center px-3 py-1.5 rounded-lg text-xs <?= $active ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-green-50 text-green-600 border border-green-200' ?>" data-url="/admin/bots/wallets/toggle" data-id="<?= (int)$wallet['id'] ?>" data-status="<?= $active ? 0 : 1 ?>"><?= $active ? '停用' : '启用' ?></button></div></td></tr><?php endforeach; else: ?><tr><td colspan="5" class="px-4 py-10 text-center text-sm text-gray-500">暂无群总账号绑定。</td></tr><?php endif; ?></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-md p-6 space-y-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-xl font-semibold text-gray-800">群成员映射</h2><button type="button" id="cancelMemberEditBtn" class="hidden text-xs text-gray-500 hover:text-gray-700">取消编辑</button></div>
|
||||
<form id="memberForm" class="space-y-3">
|
||||
<input type="hidden" name="id">
|
||||
<select name="group_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">选择群配置</option><?php foreach (($groups ?? []) as $group): ?><option value="<?= (int)$group['id'] ?>"><?= htmlspecialchars((string)$group['group_name']) ?> / <?= htmlspecialchars((string)$group['tg_group_id']) ?></option><?php endforeach; ?></select>
|
||||
<input type="text" name="tg_user_id" placeholder="TG 用户 ID" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" name="tg_username" placeholder="TG Username,可选" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" name="tg_nickname" placeholder="TG 昵称,可选" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<select name="platform_user_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">绑定网站账号(可选)</option><?php foreach (($users ?? []) as $user): ?><option value="<?= (int)$user['id'] ?>"><?= htmlspecialchars((string)$user['username']) ?> / 余额 <?= number_format((float)($user['balance'] ?? 0), 2) ?></option><?php endforeach; ?></select>
|
||||
<select name="role" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="member">member</option><option value="admin">admin</option><option value="shill">shill</option></select>
|
||||
<input type="text" name="shill_note" placeholder="若标记为托,可填写备注" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700"><input type="checkbox" name="bet_enabled" checked>允许该成员下注同步</label>
|
||||
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2.5 rounded-lg text-sm" data-create-text="创建成员映射" data-update-text="保存成员修改">创建成员映射</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-t pt-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">成员映射列表</h2>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">成员</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">群 / 网站账号</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">角色</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">下注</th><th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th></tr></thead><tbody class="divide-y divide-gray-200"><?php if (!empty($members)): foreach ($members as $member): ?><?php $betEnabled = (int)($member['bet_enabled'] ?? 0) === 1; ?><tr class="hover:bg-gray-50"><td class="px-4 py-4 align-top"><div class="font-medium text-gray-800"><?= htmlspecialchars((string)($member['tg_nickname'] ?: $member['tg_username'] ?: $member['tg_user_id'])) ?></div><div class="text-xs text-gray-500 mt-1">UID <?= htmlspecialchars((string)$member['tg_user_id']) ?> / @<?= htmlspecialchars((string)($member['tg_username'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600"><div><?= htmlspecialchars((string)($member['group_name'] ?? '-')) ?></div><div class="text-xs text-gray-500 mt-1">网站:<?= htmlspecialchars((string)($member['platform_username'] ?? '未绑定')) ?></div></td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= ($member['role'] ?? 'member') === 'shill' ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-700' ?>"><?= htmlspecialchars((string)($member['role'] ?? 'member')) ?></span></td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $betEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700' ?>"><?= $betEnabled ? '允许' : '关闭' ?></span></td><td class="px-4 py-4 align-top text-right"><div class="flex items-center justify-end gap-2"><button type="button" class="edit-member-btn inline-flex items-center px-3 py-1.5 rounded-lg border border-primary/20 text-primary text-xs hover:bg-primary/5" data-id="<?= (int)$member['id'] ?>" data-group-id="<?= (int)($member['group_id'] ?? 0) ?>" data-tg-user-id="<?= htmlspecialchars((string)($member['tg_user_id'] ?? ''), ENT_QUOTES) ?>" data-tg-username="<?= htmlspecialchars((string)($member['tg_username'] ?? ''), ENT_QUOTES) ?>" data-tg-nickname="<?= htmlspecialchars((string)($member['tg_nickname'] ?? ''), ENT_QUOTES) ?>" data-platform-user-id="<?= (int)($member['platform_user_id'] ?? 0) ?>" data-role="<?= htmlspecialchars((string)($member['role'] ?? 'member'), ENT_QUOTES) ?>" data-bet-enabled="<?= $betEnabled ? 1 : 0 ?>"><i class="fas fa-pen mr-1"></i>编辑</button><button type="button" class="member-bet-toggle-btn inline-flex items-center px-3 py-1.5 rounded-lg text-xs <?= $betEnabled ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-green-50 text-green-600 border border-green-200' ?>" data-id="<?= (int)$member['id'] ?>" data-bet-enabled="<?= $betEnabled ? 0 : 1 ?>"><?= $betEnabled ? '停用下注' : '启用下注' ?></button></div></td></tr><?php endforeach; else: ?><tr><td colspan="5" class="px-4 py-10 text-center text-sm text-gray-500">暂无成员映射。</td></tr><?php endif; ?></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md p-6 space-y-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4"><h2 class="text-xl font-semibold text-gray-800">托号管理</h2><button type="button" id="cancelShillEditBtn" class="hidden text-xs text-gray-500 hover:text-gray-700">取消编辑</button></div>
|
||||
<form id="shillForm" class="space-y-3">
|
||||
<input type="hidden" name="id">
|
||||
<select name="group_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"><option value="">选择群配置</option><?php foreach (($groups ?? []) as $group): ?><option value="<?= (int)$group['id'] ?>"><?= htmlspecialchars((string)$group['group_name']) ?> / <?= htmlspecialchars((string)$group['tg_group_id']) ?></option><?php endforeach; ?></select>
|
||||
<input type="text" name="tg_user_id" placeholder="TG 用户 ID" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" name="note" placeholder="备注,可选" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<button type="submit" class="w-full bg-rose-600 hover:bg-rose-500 text-white px-4 py-2.5 rounded-lg text-sm" data-create-text="创建托号" data-update-text="保存托号修改">创建托号</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-t pt-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">托号列表</h2>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">托号</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">群</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">备注</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th><th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th></tr></thead><tbody class="divide-y divide-gray-200"><?php if (!empty($shills)): foreach ($shills as $shill): ?><?php $enabled = (int)($shill['enabled'] ?? 0) === 1; ?><tr class="hover:bg-gray-50"><td class="px-4 py-4 align-top"><div class="font-medium text-gray-800"><?= htmlspecialchars((string)($shill['tg_nickname'] ?: $shill['tg_username'] ?: $shill['tg_user_id'])) ?></div><div class="text-xs text-gray-500 mt-1">UID <?= htmlspecialchars((string)$shill['tg_user_id']) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600"><?= htmlspecialchars((string)($shill['group_name'] ?? '-')) ?></td><td class="px-4 py-4 align-top text-sm text-gray-600"><?= htmlspecialchars((string)($shill['note'] ?? '-')) ?></td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $enabled ? 'bg-rose-100 text-rose-700' : 'bg-gray-100 text-gray-700' ?>"><?= $enabled ? '排除统计中' : '已停用' ?></span></td><td class="px-4 py-4 align-top text-right"><div class="flex items-center justify-end gap-2"><button type="button" class="edit-shill-btn inline-flex items-center px-3 py-1.5 rounded-lg border border-primary/20 text-primary text-xs hover:bg-primary/5" data-id="<?= (int)$shill['id'] ?>" data-group-id="<?= (int)($shill['group_id'] ?? 0) ?>" data-tg-user-id="<?= htmlspecialchars((string)($shill['tg_user_id'] ?? ''), ENT_QUOTES) ?>" data-note="<?= htmlspecialchars((string)($shill['note'] ?? ''), ENT_QUOTES) ?>"><i class="fas fa-pen mr-1"></i>编辑</button><button type="button" class="shill-toggle-btn inline-flex items-center px-3 py-1.5 rounded-lg text-xs <?= $enabled ? 'bg-red-50 text-red-600 border border-red-200' : 'bg-green-50 text-green-600 border border-green-200' ?>" data-id="<?= (int)$shill['id'] ?>" data-enabled="<?= $enabled ? 0 : 1 ?>"><?= $enabled ? '取消排除' : '重新排除' ?></button></div></td></tr><?php endforeach; else: ?><tr><td colspan="5" class="px-4 py-10 text-center text-sm text-gray-500">暂无托号配置。</td></tr><?php endif; ?></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-800">消息提醒 / 推送运营面</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">先把自动消息、开奖提醒、封盘倒计时、上下分提醒的运营可见性拉出来,便于对照 TG 成品补齐行为。</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs bg-cyan-100 text-cyan-700"><i class="fas fa-wave-square mr-1"></i> Push Ops</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-3">推送类型汇总</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">总数</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">成功</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">失败</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">待发/跳过</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<?php foreach (($pushSummary ?? []) as $pushType => $summary): ?>
|
||||
<?php if ((int)($summary['total'] ?? 0) === 0) continue; ?>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-800"><?= htmlspecialchars((string)$pushType) ?></td>
|
||||
<td class="px-4 py-3 text-gray-600"><?= (int)($summary['total'] ?? 0) ?></td>
|
||||
<td class="px-4 py-3 text-emerald-600"><?= (int)($summary['success'] ?? 0) ?></td>
|
||||
<td class="px-4 py-3 text-red-600"><?= (int)($summary['failed'] ?? 0) ?></td>
|
||||
<td class="px-4 py-3 text-amber-600"><?= (int)($summary['pending'] ?? 0) + (int)($summary['skipped'] ?? 0) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty(array_filter($pushSummary ?? [], static function ($item) { return (int)($item['total'] ?? 0) > 0; }))): ?>
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">暂无推送汇总数据,待 TG runtime 写入。</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-3">群提醒能力快照</h3>
|
||||
<div class="space-y-3">
|
||||
<?php if (!empty($groups)): foreach (array_slice($groups, 0, 8) as $group): ?>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-800"><?= htmlspecialchars((string)($group['group_name'] ?? '-')) ?></div>
|
||||
<div class="text-xs text-gray-500 mt-1"><?= htmlspecialchars((string)($group['tg_group_id'] ?? '-')) ?></div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= !empty($group['status']) ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' ?>"><?= !empty($group['status']) ? '启用中' : '停用中' ?></span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<span class="px-2 py-1 rounded-full <?= !empty($group['remind_bet_success']) ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500' ?>">下注成功</span>
|
||||
<span class="px-2 py-1 rounded-full <?= !empty($group['remind_draw_result']) ? 'bg-sky-100 text-sky-700' : 'bg-gray-100 text-gray-500' ?>">开奖提醒</span>
|
||||
<span class="px-2 py-1 rounded-full <?= !empty($group['remind_close_countdown']) ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-500' ?>">封盘倒计时</span>
|
||||
<span class="px-2 py-1 rounded-full <?= !empty($group['animation_enabled']) ? 'bg-fuchsia-100 text-fuchsia-700' : 'bg-gray-100 text-gray-500' ?>">开奖动画</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; else: ?>
|
||||
<div class="text-sm text-gray-500">暂无群配置,无法展示提醒快照。</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4"><div><h2 class="text-xl font-semibold text-gray-800">最近推送日志</h2><p class="text-sm text-gray-500 mt-1">覆盖 countdown / bet_success / draw / credit / debit / system,先做观测面,再回填自动触发链。</p></div></div>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">群</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">期号 / 消息</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">载荷摘要</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th></tr></thead><tbody class="divide-y divide-gray-200"><?php if (!empty($pushLogs)): foreach ($pushLogs as $pushLog): ?><?php $status = (string)($pushLog['status'] ?? 'pending'); $payload = is_array($pushLog['payload'] ?? null) ? $pushLog['payload'] : []; $summaryParts = []; if (isset($payload['amount'])) { $summaryParts[] = '金额 ' . number_format((float)$payload['amount'], 2); } if (!empty($payload['balance_after'])) { $summaryParts[] = '余额 ' . number_format((float)$payload['balance_after'], 2); } if (!empty($payload['operator'])) { $summaryParts[] = '操作人 ' . (string)$payload['operator']; } if (!empty($payload['username'])) { $summaryParts[] = '用户 ' . (string)$payload['username']; } if (!empty($payload['text'])) { $summaryParts[] = mb_substr((string)$payload['text'], 0, 36); } $summaryText = !empty($summaryParts) ? implode(' / ', $summaryParts) : json_encode($payload, JSON_UNESCAPED_UNICODE); if (!$summaryText) { $summaryText = '-'; } ?><tr class="hover:bg-gray-50"><td class="px-4 py-4 align-top"><div class="font-medium text-gray-800"><?= htmlspecialchars((string)($pushLog['group_name'] ?? '未知群组')) ?></div><div class="text-xs text-gray-500 mt-1"><?= htmlspecialchars((string)($pushLog['tg_group_id'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-cyan-100 text-cyan-700"><?= htmlspecialchars((string)($pushLog['push_type'] ?? 'system')) ?></span></td><td class="px-4 py-4 align-top text-sm text-gray-600"><div>期号:<?= htmlspecialchars((string)($pushLog['period_number'] ?? '-')) ?></div><div class="text-xs text-gray-500 mt-1">TG Msg ID:<?= htmlspecialchars((string)($pushLog['tg_message_id'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600 max-w-xs break-words"><?= htmlspecialchars((string)$summaryText) ?><?php if (!empty($pushLog['error_message'])): ?><div class="text-xs text-red-500 mt-1">错误:<?= htmlspecialchars((string)$pushLog['error_message']) ?></div><?php endif; ?></td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $status === 'success' ? 'bg-green-100 text-green-800' : ($status === 'failed' ? 'bg-red-100 text-red-700' : ($status === 'skipped' ? 'bg-gray-100 text-gray-700' : 'bg-amber-100 text-amber-700')) ?>"><?= htmlspecialchars($status) ?></span></td><td class="px-4 py-4 align-top text-sm text-gray-500"><?= htmlspecialchars((string)($pushLog['created_at'] ?? '-')) ?></td></tr><?php endforeach; else: ?><tr><td colspan="6" class="px-4 py-10 text-center text-sm text-gray-500">暂无推送日志。</td></tr><?php endif; ?></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4"><div><h2 class="text-xl font-semibold text-gray-800">Bot API 请求审计</h2><p class="text-sm text-gray-500 mt-1">对照成品 TG 机器人的发送队列与多账号路由,这里补入网站侧入站请求审计链,便于排查签名、限流、重复请求。</p></div></div>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bot / 群</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">请求</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">幂等 / IP</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">签名 / 响应</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th></tr></thead><tbody class="divide-y divide-gray-200"><?php if (!empty($apiRequestLogs)): foreach ($apiRequestLogs as $apiLog): ?><?php $signatureOk = (int)($apiLog['signature_ok'] ?? 0) === 1; ?><tr class="hover:bg-gray-50"><td class="px-4 py-4 align-top"><div class="font-medium text-gray-800"><?= htmlspecialchars((string)($apiLog['bot_name'] ?? '未知 Bot')) ?></div><div class="text-xs text-gray-500 mt-1"><?= htmlspecialchars((string)($apiLog['group_name'] ?? '-')) ?> / <?= htmlspecialchars((string)($apiLog['tg_group_id'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600"><div><?= htmlspecialchars((string)($apiLog['http_method'] ?? 'POST')) ?></div><div class="text-xs text-gray-500 mt-1 break-all"><?= htmlspecialchars((string)($apiLog['request_uri'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600"><div class="break-all"><?= htmlspecialchars((string)($apiLog['idempotency_key'] ?? '-')) ?></div><div class="text-xs text-gray-500 mt-1">IP: <?= htmlspecialchars((string)($apiLog['client_ip'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top"><div><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $signatureOk ? 'bg-lime-100 text-lime-700' : 'bg-red-100 text-red-700' ?>"><?= $signatureOk ? 'signature ok' : 'signature fail' ?></span></div><div class="text-xs text-gray-500 mt-2">HTTP <?= (int)($apiLog['response_code'] ?? 0) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-500"><?= htmlspecialchars((string)($apiLog['created_at'] ?? '-')) ?></td></tr><?php endforeach; else: ?><tr><td colspan="5" class="px-4 py-10 text-center text-sm text-gray-500">暂无 API 请求日志。</td></tr><?php endif; ?></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4"><div><h2 class="text-xl font-semibold text-gray-800">最近 Bot 下注同步</h2><p class="text-sm text-gray-500 mt-1">用于快速核对群消息→平台订单的同步状态。</p></div></div>
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">群</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">期号</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额 / 注数</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th><th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th></tr></thead><tbody class="divide-y divide-gray-200"><?php if (!empty($recentOrders)): foreach ($recentOrders as $order): ?><?php $ok = ($order['sync_status'] ?? '') === 'success'; $isShill = (int)($order['is_shill'] ?? 0) === 1; ?><tr class="hover:bg-gray-50 transition-colors"><td class="px-4 py-4 align-top"><div class="text-sm font-medium text-gray-800"><?= htmlspecialchars((string)($order['group_name'] ?? '未知群组')) ?></div><div class="text-xs text-gray-500 mt-1"><?= htmlspecialchars((string)($order['tg_group_id'] ?? '-')) ?></div></td><td class="px-4 py-4 align-top text-sm text-gray-600"><?= htmlspecialchars((string)($order['tg_username'] ?? '-')) ?><?php if ($isShill): ?><span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-rose-100 text-rose-700">托</span><?php endif; ?></td><td class="px-4 py-4 align-top text-sm text-gray-600"><?= htmlspecialchars((string)($order['period_number'] ?? '-')) ?></td><td class="px-4 py-4 align-top text-sm text-gray-600"><?= number_format((float)($order['bet_amount_total'] ?? 0), 2) ?> / <?= (int)($order['accepted_bet_count'] ?? 0) ?> 注</td><td class="px-4 py-4 align-top"><span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $ok ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-700' ?>"><span class="w-2 h-2 rounded-full mr-1 <?= $ok ? 'bg-green-500' : 'bg-red-500' ?>"></span><?= htmlspecialchars((string)($order['sync_status'] ?? 'unknown')) ?></span><?php if (!$ok && !empty($order['sync_error'])): ?><div class="text-xs text-red-500 mt-1 max-w-xs break-words"><?= htmlspecialchars((string)$order['sync_error']) ?></div><?php endif; ?></td><td class="px-4 py-4 align-top text-sm text-gray-500"><?= htmlspecialchars((string)($order['created_at'] ?? '-')) ?></td></tr><?php endforeach; else: ?><tr><td colspan="6" class="px-4 py-10 text-center text-sm text-gray-500">暂无 Bot 同步记录。</td></tr><?php endif; ?></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const alertBox = document.getElementById('botAdminAlert');
|
||||
|
||||
function showAlert(success, message) {
|
||||
alertBox.className = 'mb-6 rounded-lg border px-4 py-3 text-sm ' + (success ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700');
|
||||
alertBox.textContent = message;
|
||||
alertBox.classList.remove('hidden');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function setSubmitButtonState(form, isEdit) {
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
if (!submitButton) return;
|
||||
submitButton.textContent = isEdit ? submitButton.dataset.updateText : submitButton.dataset.createText;
|
||||
}
|
||||
|
||||
function setCheckboxValue(form, name, checked) {
|
||||
const input = form.querySelector('[name="' + name + '"]');
|
||||
if (input) input.checked = !!checked;
|
||||
}
|
||||
|
||||
async function requestJson(url, payload) {
|
||||
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'same-origin' });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function submitManagedForm(formId, createUrl, updateUrl, resetHandler) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const payload = {};
|
||||
for (const [key, value] of formData.entries()) payload[key] = value;
|
||||
form.querySelectorAll('input[type="checkbox"]').forEach(function (checkbox) { payload[checkbox.name] = checkbox.checked ? 1 : 0; });
|
||||
const isEdit = !!(payload.id && String(payload.id).trim() !== '');
|
||||
try {
|
||||
const result = await requestJson(isEdit ? updateUrl : createUrl, payload);
|
||||
showAlert(!!result.success, result.message || '请求完成');
|
||||
if (result.success) {
|
||||
if (typeof resetHandler === 'function') resetHandler();
|
||||
setTimeout(function () { window.location.reload(); }, 600);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(false, '请求失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindToggleButtons(selector, url, key) {
|
||||
document.querySelectorAll(selector).forEach(function (button) {
|
||||
button.addEventListener('click', async function () {
|
||||
const payload = { id: Number(button.dataset.id || 0) };
|
||||
payload[key] = Number(button.dataset[key] || 0);
|
||||
try {
|
||||
const result = await requestJson(url, payload);
|
||||
showAlert(!!result.success, result.message || '状态已更新');
|
||||
if (result.success) setTimeout(function () { window.location.reload(); }, 600);
|
||||
} catch (error) {
|
||||
showAlert(false, '状态更新失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindStatusButtons() {
|
||||
document.querySelectorAll('.toggle-btn').forEach(function (button) {
|
||||
button.addEventListener('click', async function () {
|
||||
try {
|
||||
const result = await requestJson(button.dataset.url, {
|
||||
id: Number(button.dataset.id || 0),
|
||||
status: Number(button.dataset.status || 0)
|
||||
});
|
||||
showAlert(!!result.success, result.message || '状态已更新');
|
||||
if (result.success) setTimeout(function () { window.location.reload(); }, 600);
|
||||
} catch (error) {
|
||||
showAlert(false, '状态更新失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindEditForm(formId, cancelId, buttonSelector, fieldMap, defaults) {
|
||||
const form = document.getElementById(formId);
|
||||
const cancelButton = document.getElementById(cancelId);
|
||||
if (!form || !cancelButton) return;
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
const idInput = form.querySelector('[name="id"]');
|
||||
if (idInput) idInput.value = '';
|
||||
Object.keys(defaults || {}).forEach(function (key) {
|
||||
const field = form.querySelector('[name="' + key + '"]');
|
||||
if (!field) return;
|
||||
if (field.type === 'checkbox') {
|
||||
field.checked = !!defaults[key];
|
||||
} else {
|
||||
field.value = defaults[key];
|
||||
}
|
||||
});
|
||||
setSubmitButtonState(form, false);
|
||||
cancelButton.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.querySelectorAll(buttonSelector).forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
Object.keys(fieldMap).forEach(function (fieldName) {
|
||||
const datasetKey = fieldMap[fieldName];
|
||||
const field = form.querySelector('[name="' + fieldName + '"]');
|
||||
if (!field) return;
|
||||
if (field.type === 'checkbox') {
|
||||
field.checked = Number(button.dataset[datasetKey] || 0) === 1;
|
||||
} else {
|
||||
field.value = button.dataset[datasetKey] || '';
|
||||
}
|
||||
});
|
||||
setSubmitButtonState(form, true);
|
||||
cancelButton.classList.remove('hidden');
|
||||
form.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
});
|
||||
|
||||
cancelButton.addEventListener('click', resetForm);
|
||||
resetForm();
|
||||
return resetForm;
|
||||
}
|
||||
|
||||
const resetBotForm = bindEditForm('botForm', 'cancelBotEditBtn', '.edit-bot-btn', {
|
||||
id: 'id', name: 'name', bot_token: 'token', bot_username: 'username', run_mode: 'runMode', webhook_url: 'webhookUrl', remark: 'remark'
|
||||
}, { run_mode: 'polling' });
|
||||
|
||||
const resetGroupForm = bindEditForm('groupForm', 'cancelGroupEditBtn', '.edit-group-btn', {
|
||||
id: 'id', bot_id: 'botId', game_id: 'gameId', bet_format_rule_id: 'ruleId', tg_group_id: 'tgGroupId', group_name: 'groupName', group_type: 'groupType', countdown_config: 'countdownConfig', remind_bet_success: 'remindBetSuccess', remind_draw_result: 'remindDrawResult', remind_close_countdown: 'remindCloseCountdown', animation_enabled: 'animationEnabled', bet_enabled: 'betEnabled'
|
||||
}, { remind_bet_success: true, remind_draw_result: true, remind_close_countdown: true, animation_enabled: false, bet_enabled: true, group_type: 'group' });
|
||||
|
||||
const resetWalletForm = bindEditForm('walletForm', 'cancelWalletEditBtn', '.edit-wallet-btn', {
|
||||
id: 'id', group_id: 'groupId', platform_user_id: 'platformUserId', wallet_mode: 'walletMode'
|
||||
}, { wallet_mode: 'master_pool' });
|
||||
|
||||
const resetMemberForm = bindEditForm('memberForm', 'cancelMemberEditBtn', '.edit-member-btn', {
|
||||
id: 'id', group_id: 'groupId', tg_user_id: 'tgUserId', tg_username: 'tgUsername', tg_nickname: 'tgNickname', platform_user_id: 'platformUserId', role: 'role', bet_enabled: 'betEnabled'
|
||||
}, { role: 'member', bet_enabled: true });
|
||||
|
||||
const resetShillForm = bindEditForm('shillForm', 'cancelShillEditBtn', '.edit-shill-btn', {
|
||||
id: 'id', group_id: 'groupId', tg_user_id: 'tgUserId', note: 'note'
|
||||
}, {});
|
||||
|
||||
submitManagedForm('botForm', '/admin/bots/save', '/admin/bots/update', resetBotForm);
|
||||
submitManagedForm('groupForm', '/admin/bots/groups/save', '/admin/bots/groups/update', resetGroupForm);
|
||||
submitManagedForm('walletForm', '/admin/bots/wallets/save', '/admin/bots/wallets/update', resetWalletForm);
|
||||
submitManagedForm('memberForm', '/admin/bots/members/save', '/admin/bots/members/update', resetMemberForm);
|
||||
submitManagedForm('shillForm', '/admin/bots/shills/save', '/admin/bots/shills/update', resetShillForm);
|
||||
|
||||
bindStatusButtons();
|
||||
bindToggleButtons('.member-bet-toggle-btn', '/admin/bots/members/toggle-bet', 'betEnabled');
|
||||
bindToggleButtons('.shill-toggle-btn', '/admin/bots/shills/toggle', 'enabled');
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<i class="fas fa-clipboard-list text-primary mr-3"></i>
|
||||
跟单计划管理
|
||||
</h1>
|
||||
|
||||
<!-- 新建按钮 -->
|
||||
<div class="mb-4">
|
||||
<button onclick="showAddForm()" class="px-5 py-2 bg-primary text-white rounded-lg text-sm hover:opacity-90 transition">
|
||||
<i class="fas fa-plus mr-1"></i> 新建计划
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 计划列表 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white rounded-xl overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">计划名称</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">类型</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">名次</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">建议金额</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">总期数</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">胜率</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">跟单人数</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<?php if (!empty($plans)): ?>
|
||||
<?php foreach ($plans as $p): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-sm text-gray-500"><?= $p['id'] ?></td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900"><?= htmlspecialchars($p['name']) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-primary/10 text-primary">
|
||||
<?= ['bs'=>'大小','oe'=>'单双','dt'=>'龙虎','sum_bs'=>'冠亚大小'][$p['plan_type']] ?? $p['plan_type'] ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center"><?= $p['target_rank'] ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($p['bet_amount'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center"><?= $p['total_records'] ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $p['win_rate'] >= 50 ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger' ?>">
|
||||
<?= $p['win_rate'] ?>%
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center font-semibold"><?= $p['follower_count'] ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs cursor-pointer <?= $p['status'] ? 'bg-success/10 text-success' : 'bg-gray-200 text-gray-500' ?>"
|
||||
onclick="togglePlan(<?= $p['id'] ?>)">
|
||||
<?= $p['status'] ? '启用' : '禁用' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<button onclick='editPlan(<?= json_encode($p) ?>)' class="text-primary hover:underline text-xs mr-2">编辑</button>
|
||||
<button onclick="deletePlan(<?= $p['id'] ?>)" class="text-danger hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="10" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-clipboard-list text-gray-300 text-5xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900">暂无跟单计划</h3>
|
||||
<p class="mt-1 text-gray-500 text-sm">点击上方按钮新建跟单计划</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/Static/js/admin.js"></script>
|
||||
<script>
|
||||
var planTypes = {bs:'大小', oe:'单双', dt:'龙虎', sum_bs:'冠亚大小'};
|
||||
|
||||
function showAddForm(data) {
|
||||
var isEdit = data && data.id;
|
||||
var html = '<div style="padding:20px">' +
|
||||
'<div style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:13px;color:#666">计划名称</label>' +
|
||||
'<input type="text" id="fp_name" value="' + (data ? data.name : '') + '" class="layui-input" style="width:100%"></div>' +
|
||||
'<div style="display:flex;gap:12px;margin-bottom:12px">' +
|
||||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:13px;color:#666">类型</label>' +
|
||||
'<select id="fp_type" class="layui-input">' +
|
||||
'<option value="bs"' + (data && data.plan_type === 'bs' ? ' selected' : '') + '>大小</option>' +
|
||||
'<option value="oe"' + (data && data.plan_type === 'oe' ? ' selected' : '') + '>单双</option>' +
|
||||
'<option value="dt"' + (data && data.plan_type === 'dt' ? ' selected' : '') + '>龙虎</option>' +
|
||||
'<option value="sum_bs"' + (data && data.plan_type === 'sum_bs' ? ' selected' : '') + '>冠亚大小</option>' +
|
||||
'</select></div>' +
|
||||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:13px;color:#666">目标名次</label>' +
|
||||
'<input type="number" id="fp_rank" min="1" max="10" value="' + (data ? data.target_rank : 1) + '" class="layui-input"></div>' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:13px;color:#666">建议投注金额</label>' +
|
||||
'<input type="number" id="fp_amount" value="' + (data ? data.bet_amount : 100) + '" class="layui-input"></div>' +
|
||||
'</div>';
|
||||
|
||||
layui.use('layer', function() {
|
||||
layui.layer.open({
|
||||
type: 1, title: isEdit ? '编辑计划' : '新建计划',
|
||||
area: ['420px', '360px'], content: html,
|
||||
btn: ['保存', '取消'],
|
||||
yes: function(index) {
|
||||
var postData = {
|
||||
name: document.getElementById('fp_name').value,
|
||||
plan_type: document.getElementById('fp_type').value,
|
||||
target_rank: document.getElementById('fp_rank').value,
|
||||
bet_amount: document.getElementById('fp_amount').value,
|
||||
game_id: 1, status: 1
|
||||
};
|
||||
if (isEdit) postData.id = data.id;
|
||||
fetch('/admin/follow-plans/save', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(postData)
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.success) { layui.layer.close(index); location.reload(); }
|
||||
else { layui.layer.msg(d.message || '保存失败'); }
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editPlan(plan) { showAddForm(plan); }
|
||||
|
||||
function togglePlan(id) {
|
||||
fetch('/admin/follow-plans/toggle', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.success) location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function deletePlan(id) {
|
||||
layui.use('layer', function() {
|
||||
layui.layer.confirm('确定删除此计划?所有相关记录将被清除。', function(index) {
|
||||
fetch('/admin/follow-plans/delete', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.success) { layui.layer.close(index); location.reload(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<i class="fas fa-file-invoice-dollar text-primary mr-3"></i>
|
||||
充提审核
|
||||
</h1>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">待处理</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-warning" id="statPending">
|
||||
<?= (int)($pendingCount ?? 0) ?>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-warning/10 flex items-center justify-center">
|
||||
<i class="fas fa-clock text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">今日充值申请</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-success">
|
||||
<?= (int)($todayDeposit ?? 0) ?>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-down text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">今日提现申请</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-danger">
|
||||
<?= (int)($todayWithdraw ?? 0) ?>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-up text-danger"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 whitespace-nowrap">类型:</label>
|
||||
<select id="filterType" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/50 focus:border-primary">
|
||||
<option value="">全部</option>
|
||||
<option value="deposit">充值</option>
|
||||
<option value="withdraw">提现</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 whitespace-nowrap">状态:</label>
|
||||
<select id="filterStatus" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/50 focus:border-primary">
|
||||
<option value="">全部</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已拒绝</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="btnFilter" class="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-search mr-1"></i>筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 申请列表 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-800">充提申请列表</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">审核用户的充值和提现申请</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white rounded-xl overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">钱包地址</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden md:table-cell">用户备注</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden md:table-cell">管理员备注</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden lg:table-cell">创建时间</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200" id="requestList">
|
||||
<?php if (!empty($requests) && is_array($requests)): ?>
|
||||
<?php foreach ($requests as $req): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors fund-row"
|
||||
data-type="<?= htmlspecialchars((string)($req['type'] ?? '')) ?>"
|
||||
data-status="<?= htmlspecialchars((string)($req['status'] ?? '')) ?>">
|
||||
<td class="px-4 py-4">
|
||||
<span class="text-sm font-medium text-gray-900">#<?= htmlspecialchars((string)($req['id'] ?? '')) ?></span>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="text-sm text-gray-900"><?= htmlspecialchars((string)($req['username'] ?? '未知')) ?></div>
|
||||
<div class="text-xs text-gray-500">ID: <?= htmlspecialchars((string)($req['user_id'] ?? '')) ?></div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<?php $addr = $req['usdt_address'] ?? ''; ?>
|
||||
<?php if ($addr): ?>
|
||||
<div class="text-xs text-gray-700 font-mono max-w-[160px] truncate cursor-pointer" title="<?= htmlspecialchars($addr) ?>" onclick="navigator.clipboard.writeText('<?= htmlspecialchars($addr) ?>');layui.layer&&layui.layer.msg('已复制')">
|
||||
<?= htmlspecialchars($addr) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-xs text-gray-400">未绑定</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<?php if (($req['type'] ?? '') === 'deposit'): ?>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-success/10 text-success">充值</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-danger/10 text-danger">提现</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<span class="text-sm font-semibold text-gray-900">
|
||||
<?= number_format(floatval($req['amount'] ?? 0), 2) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<?php
|
||||
$status = $req['status'] ?? '';
|
||||
$statusMap = [
|
||||
'pending' => ['text' => '待处理', 'class' => 'bg-warning/10 text-warning'],
|
||||
'approved' => ['text' => '已通过', 'class' => 'bg-success/10 text-success'],
|
||||
'rejected' => ['text' => '已拒绝', 'class' => 'bg-danger/10 text-danger'],
|
||||
];
|
||||
$statusInfo = $statusMap[$status] ?? ['text' => $status, 'class' => 'bg-gray-100 text-gray-700'];
|
||||
?>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $statusInfo['class'] ?>">
|
||||
<?= $statusInfo['text'] ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 hidden md:table-cell">
|
||||
<span class="text-sm text-gray-600 max-w-[120px] truncate block">
|
||||
<?= htmlspecialchars((string)($req['remark'] ?? '-')) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 hidden md:table-cell">
|
||||
<span class="text-sm text-gray-600 max-w-[120px] truncate block">
|
||||
<?= htmlspecialchars((string)($req['admin_remark'] ?? '-')) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4 hidden lg:table-cell">
|
||||
<?php if (!empty($req['created_at'])): ?>
|
||||
<div class="text-xs text-gray-500">
|
||||
<?= date('Y-m-d H:i:s', strtotime((string)$req['created_at'])) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<?php if ($status === 'pending'): ?>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="approveRequest(<?= (int)$req['id'] ?>)"
|
||||
class="px-3 py-1 bg-success text-white text-xs rounded-lg hover:bg-success/90 transition-colors">
|
||||
<i class="fas fa-check mr-1"></i>通过
|
||||
</button>
|
||||
<button onclick="rejectRequest(<?= (int)$req['id'] ?>)"
|
||||
class="px-3 py-1 bg-danger text-white text-xs rounded-lg hover:bg-danger/90 transition-colors">
|
||||
<i class="fas fa-times mr-1"></i>拒绝
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-xs text-gray-400">已处理</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="10" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-file-invoice text-gray-300 text-5xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900">暂无充提申请</h3>
|
||||
<p class="mt-1 text-gray-500 text-sm">还没有任何充提申请记录。</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/Static/js/admin.js"></script>
|
||||
<script>
|
||||
layui.use(['layer'], function() {
|
||||
var layer = layui.layer;
|
||||
|
||||
// --- 筛选逻辑 ---
|
||||
document.getElementById('btnFilter').addEventListener('click', function() {
|
||||
var typeVal = document.getElementById('filterType').value;
|
||||
var statusVal = document.getElementById('filterStatus').value;
|
||||
var rows = document.querySelectorAll('.fund-row');
|
||||
rows.forEach(function(row) {
|
||||
var show = true;
|
||||
if (typeVal && row.dataset.type !== typeVal) show = false;
|
||||
if (statusVal && row.dataset.status !== statusVal) show = false;
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// --- 审批通过 ---
|
||||
window.approveRequest = function(id) {
|
||||
layer.prompt({title: '审批通过 - 管理员备注(可留空)', formType: 2, value: ''}, function(remark, promptIndex) {
|
||||
layer.close(promptIndex);
|
||||
var loadIdx = layer.load(1);
|
||||
fetch('/admin/fund-requests/approve', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id, admin_remark: remark})
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
layer.close(loadIdx);
|
||||
if(d.success){
|
||||
layer.msg('审批通过', {icon: 1});
|
||||
setTimeout(function(){ location.reload(); }, 800);
|
||||
} else {
|
||||
layer.msg(d.message || '操作失败', {icon: 2});
|
||||
}
|
||||
})
|
||||
.catch(function(){
|
||||
layer.close(loadIdx);
|
||||
layer.msg('网络错误', {icon: 2});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// --- 拒绝 ---
|
||||
window.rejectRequest = function(id) {
|
||||
layer.prompt({title: '拒绝原因(可留空)', formType: 2, value: ''}, function(remark, promptIndex) {
|
||||
layer.close(promptIndex);
|
||||
var loadIdx = layer.load(1);
|
||||
fetch('/admin/fund-requests/reject', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id, admin_remark: remark})
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
layer.close(loadIdx);
|
||||
if(d.success){
|
||||
layer.msg('已拒绝', {icon: 1});
|
||||
setTimeout(function(){ location.reload(); }, 800);
|
||||
} else {
|
||||
layer.msg(d.message || '操作失败', {icon: 2});
|
||||
}
|
||||
})
|
||||
.catch(function(){
|
||||
layer.close(loadIdx);
|
||||
layer.msg('网络错误', {icon: 2});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// --- 语音提醒 + 标题闪烁轮询 ---
|
||||
var lastPendingCount = <?= (int)($pendingCount ?? 0) ?>;
|
||||
var originalTitle = document.title;
|
||||
setInterval(function() {
|
||||
fetch('/admin/fund-requests/pending-count')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var count = d.count || 0;
|
||||
document.getElementById('statPending').textContent = count;
|
||||
if (count > lastPendingCount) {
|
||||
// 播放短促提示音
|
||||
try {
|
||||
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
var osc = audioCtx.createOscillator();
|
||||
var gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.3;
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.15);
|
||||
} catch(e){}
|
||||
// 标题闪烁
|
||||
document.title = '⚠️ 新充提申请(' + count + ')';
|
||||
setTimeout(function(){ document.title = originalTitle; }, 3000);
|
||||
}
|
||||
lastPendingCount = count;
|
||||
})
|
||||
.catch(function(){});
|
||||
}, 15000);
|
||||
});
|
||||
</script>
|
||||
@@ -36,6 +36,13 @@
|
||||
<a href="/admin/dashboard" class="flex items-center text-primary font-bold text-xl">
|
||||
<?php
|
||||
$adminSettings = \App\Core\SettingsHelper::getAll();
|
||||
// 查询充提待处理数量(侧边栏角标用)
|
||||
try {
|
||||
$_fundDb = new \Db\Database();
|
||||
$pendingFundCount = $_fundDb->count('fund_requests', ['status' => 'pending']);
|
||||
} catch (\Throwable $e) {
|
||||
$pendingFundCount = 0;
|
||||
}
|
||||
?>
|
||||
<img src="<?= htmlspecialchars($adminSettings['site_logo'] ?? '/Static/tm68/logo_tm68.png.png') ?>" class="w-10 mr-2">
|
||||
<span>管理后台</span>
|
||||
@@ -116,6 +123,11 @@
|
||||
<i class="fas fa-list-alt w-5 text-center"></i>
|
||||
<span>投注记录</span>
|
||||
</a>
|
||||
<a href="/admin/fund-requests" class="flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 hover:bg-gray-light main-menu-item relative">
|
||||
<i class="fas fa-file-invoice-dollar w-5 text-center"></i>
|
||||
<span>充提审核</span>
|
||||
<span id="fundRequestBadge" class="absolute right-2 top-1/2 -translate-y-1/2 bg-danger text-white text-xs rounded-full min-w-[20px] h-5 flex items-center justify-center px-1" style="<?= ((int)($pendingFundCount ?? 0)) > 0 ? '' : 'display:none' ?>"><?= (int)($pendingFundCount ?? 0) ?></span>
|
||||
</a>
|
||||
<a href="/admin/finance" class="flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 hover:bg-gray-light main-menu-item">
|
||||
<i class="fas fa-dollar-sign w-5 text-center"></i>
|
||||
<span>财务管理</span>
|
||||
@@ -136,6 +148,14 @@
|
||||
<i class="fas fa-chart-bar w-5 text-center"></i>
|
||||
<span>数据报表</span>
|
||||
</a>
|
||||
<a href="/admin/follow-plans" class="flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 hover:bg-gray-light main-menu-item">
|
||||
<i class="fas fa-clipboard-list w-5 text-center"></i>
|
||||
<span>跟单计划</span>
|
||||
</a>
|
||||
<a href="/admin/bots" class="flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 hover:bg-gray-light main-menu-item">
|
||||
<i class="fas fa-robot w-5 text-center"></i>
|
||||
<span>Bot 管理</span>
|
||||
</a>
|
||||
<a href="/admin/settings" class="flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 hover:bg-gray-light main-menu-item">
|
||||
<i class="fas fa-cog w-5 text-center"></i>
|
||||
<span>系统设置</span>
|
||||
@@ -265,7 +285,7 @@
|
||||
<footer class="bg-white border-t border-gray-200 py-4 md:ml-64 transition-all duration-300">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<p class="text-sm text-gray-500"> © <?=date('Y')?> PK10 极速赛车后台管理系统 </p>
|
||||
<p class="text-sm text-gray-500"> © <?=date('Y')?> F1赛车后台管理系统 </p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -274,6 +294,22 @@
|
||||
<script> const targetUsername = '<?= htmlspecialchars($_SESSION[' username '] ?? ' ') ?>'; </script>
|
||||
<script src="/Static/js/controller.js"> </script>
|
||||
<script src="/Static/js/admin.js"> </script>
|
||||
<script>
|
||||
// 全局轮询充提待处理角标
|
||||
(function(){
|
||||
var badge = document.getElementById('fundRequestBadge');
|
||||
if(!badge) return;
|
||||
setInterval(function(){
|
||||
fetch('/admin/fund-requests/pending-count')
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
var c = d.count || 0;
|
||||
badge.textContent = c;
|
||||
badge.style.display = c > 0 ? '' : 'none';
|
||||
}).catch(function(){});
|
||||
}, 15000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,60 +1,403 @@
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold mb-4">📊 数据报表</h2>
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
|
||||
<i class="fas fa-chart-bar text-primary mr-3"></i>
|
||||
数据报表
|
||||
</h1>
|
||||
|
||||
<!-- 日期筛选 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow mb-6 flex items-center gap-4 flex-wrap">
|
||||
<label class="text-sm text-gray-400">起始:</label><input type="date" value="<?=$dateFrom?>" id="dateFrom" class="border rounded px-3 py-2 text-sm">
|
||||
<label class="text-sm text-gray-400">截止:</label><input type="date" value="<?=$dateTo?>" id="dateTo" class="border rounded px-3 py-2 text-sm">
|
||||
<button onclick="location.href='/admin/reports?from='+document.getElementById('dateFrom').value+'&to='+document.getElementById('dateTo').value" class="px-4 py-2 bg-blue-500 text-white rounded text-sm">筛选</button>
|
||||
<!-- 日期筛选栏 -->
|
||||
<div class="bg-white rounded-xl p-5 card-shadow mb-6">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500">起始日期</label>
|
||||
<input type="text" id="dateFrom" value="<?= htmlspecialchars($dateFrom) ?>" class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-36" readonly placeholder="选择日期">
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500">截止日期</label>
|
||||
<input type="text" id="dateTo" value="<?= htmlspecialchars($dateTo) ?>" class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-36" readonly placeholder="选择日期">
|
||||
</div>
|
||||
<button onclick="doQuery()" class="px-5 py-2 bg-primary text-white rounded-lg text-sm hover:opacity-90 transition">
|
||||
<i class="fas fa-search mr-1"></i> 查询
|
||||
</button>
|
||||
<button onclick="doExport()" class="px-5 py-2 bg-success text-white rounded-lg text-sm hover:opacity-90 transition">
|
||||
<i class="fas fa-file-csv mr-1"></i> 导出CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总览卡片 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">总投注</div><div class="text-xl font-bold text-blue-500"><?=number_format($totalBet,2)?></div></div>
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">总中奖</div><div class="text-xl font-bold text-red-500"><?=number_format($totalWin,2)?></div></div>
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">平台盈利</div><div class="text-xl font-bold <?=$profit>=0?'text-green-500':'text-red-500'?>"><?=number_format($profit,2)?></div></div>
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">佣金</div><div class="text-xl font-bold text-orange-500"><?=number_format($totalCommission,2)?></div></div>
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">总充值</div><div class="text-xl font-bold text-green-500"><?=number_format($totalDeposit,2)?></div></div>
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">总提现</div><div class="text-xl font-bold text-red-500"><?=number_format($totalWithdraw,2)?></div></div>
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">总用户</div><div class="text-xl font-bold"><?=$totalUsers?></div></div>
|
||||
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-xs">新增用户</div><div class="text-xl font-bold text-blue-500"><?=$newUsers?></div></div>
|
||||
<!-- 总体统计卡片 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">总投注</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-warning"><?= number_format((float)$totalBet, 2) ?></h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-warning/10 flex items-center justify-center">
|
||||
<i class="fas fa-coins text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">总派彩</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-primary"><?= number_format((float)$totalWin, 2) ?></h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<i class="fas fa-trophy text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">总利润</p>
|
||||
<h3 class="text-xl font-bold mt-1 <?= $profit >= 0 ? 'text-success' : 'text-danger' ?>"><?= number_format((float)$profit, 2) ?></h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full <?= $profit >= 0 ? 'bg-success/10' : 'bg-danger/10' ?> flex items-center justify-center">
|
||||
<i class="fas fa-chart-line <?= $profit >= 0 ? 'text-success' : 'text-danger' ?>"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">利润率</p>
|
||||
<?php
|
||||
$prColor = 'text-warning';
|
||||
if ($profitRate > 10) $prColor = 'text-success';
|
||||
elseif ($profitRate < 0) $prColor = 'text-danger';
|
||||
?>
|
||||
<h3 class="text-xl font-bold mt-1 <?= $prColor ?>"><?= $profitRate ?>%</h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-warning/10 flex items-center justify-center">
|
||||
<i class="fas fa-percentage text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">总充值</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-success"><?= number_format((float)$totalDeposit, 2) ?></h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-down text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">总提现</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-danger"><?= number_format((float)$totalWithdraw, 2) ?></h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-up text-danger"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">活跃用户</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-primary"><?= $activeUsers ?></h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<i class="fas fa-user-check text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 card-shadow hover-lift">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">新增用户</p>
|
||||
<h3 class="text-xl font-bold mt-1 text-secondary"><?= $newUsers ?></h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-secondary/10 flex items-center justify-center">
|
||||
<i class="fas fa-user-plus text-secondary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每日明细 -->
|
||||
<div class="bg-white rounded-xl shadow overflow-hidden mb-6">
|
||||
<h3 class="font-bold p-4 border-b">每日明细</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50"><tr><th class="px-4 py-2 text-left">日期</th><th class="px-4 py-2">投注</th><th class="px-4 py-2">中奖</th><th class="px-4 py-2">盈利</th><th class="px-4 py-2">充值</th><th class="px-4 py-2">提现</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($dailyStats??[] as $d): $dp=$d['bets']-$d['wins']; ?>
|
||||
<tr class="border-t hover:bg-gray-50">
|
||||
<td class="px-4 py-2"><?=$d['date']?></td>
|
||||
<td class="px-4 py-2 text-center"><?=number_format($d['bets'],2)?></td>
|
||||
<td class="px-4 py-2 text-center"><?=number_format($d['wins'],2)?></td>
|
||||
<td class="px-4 py-2 text-center <?=$dp>=0?'text-green-500':'text-red-500'?>"><?=number_format($dp,2)?></td>
|
||||
<td class="px-4 py-2 text-center"><?=number_format($d['deposits'],2)?></td>
|
||||
<td class="px-4 py-2 text-center"><?=number_format($d['withdraws'],2)?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody></table></div>
|
||||
<!-- 日明细表 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-calendar-alt text-primary mr-2"></i> 日明细统计
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white rounded-xl overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">日期</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">投注额</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">派彩额</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">利润</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">充值</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">提现</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">笔数</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">胜率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<?php if (!empty($dailyStats)): ?>
|
||||
<?php foreach ($dailyStats as $d): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900"><?= $d['date'] ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($d['total_bet'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($d['total_win'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right font-semibold <?= $d['profit'] >= 0 ? 'text-success' : 'text-danger' ?>">
|
||||
<?= $d['profit'] >= 0 ? '+' : '' ?><?= number_format($d['profit'], 2) ?>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-success"><?= number_format($d['deposits'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right text-danger"><?= number_format($d['withdraws'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center"><?= $d['bet_count'] ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $d['win_rate'] > 50 ? 'bg-danger/10 text-danger' : 'bg-success/10 text-success' ?>">
|
||||
<?= $d['win_rate'] ?>%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-chart-bar text-gray-300 text-5xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900">暂无数据</h3>
|
||||
<p class="mt-1 text-gray-500 text-sm">选定日期范围内没有投注记录</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户投注排行 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-ranking-star text-warning mr-2"></i> 用户投注排行(前20)
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white rounded-xl overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-16">排名</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">用户名</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">投注额</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">派彩额</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">平台盈亏</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">笔数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<?php if (!empty($topUsers)): ?>
|
||||
<?php $rank = 1; foreach ($topUsers as $u): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-center">
|
||||
<?php if ($rank <= 3): ?>
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full text-white text-xs font-bold
|
||||
<?= $rank === 1 ? 'bg-yellow-500' : ($rank === 2 ? 'bg-gray-400' : 'bg-amber-600') ?>">
|
||||
<?= $rank ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-sm text-gray-500"><?= $rank ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900"><?= htmlspecialchars($u['username'] ?? '') ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format(floatval($u['total_bet']), 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format(floatval($u['total_win']), 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right font-semibold <?= floatval($u['profit']) >= 0 ? 'text-success' : 'text-danger' ?>">
|
||||
<?= floatval($u['profit']) >= 0 ? '+' : '' ?><?= number_format(floatval($u['profit']), 2) ?>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center"><?= intval($u['bet_count']) ?></td>
|
||||
</tr>
|
||||
<?php $rank++; endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-users text-gray-300 text-5xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900">暂无数据</h3>
|
||||
<p class="mt-1 text-gray-500 text-sm">选定日期范围内没有用户投注</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理报表 -->
|
||||
<?php if(!empty($agentStats)): ?>
|
||||
<div class="bg-white rounded-xl shadow overflow-hidden">
|
||||
<h3 class="font-bold p-4 border-b">代理报表</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50"><tr><th class="px-4 py-2 text-left">代理</th><th class="px-4 py-2">玩家数</th><th class="px-4 py-2">投注</th><th class="px-4 py-2">中奖</th><th class="px-4 py-2">佣金</th><th class="px-4 py-2">盈利</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($agentStats as $as): ?>
|
||||
<tr class="border-t hover:bg-gray-50">
|
||||
<td class="px-4 py-2"><?=htmlspecialchars($as['user']['username']??'')?></td>
|
||||
<td class="px-4 py-2 text-center"><?=$as['players']?></td>
|
||||
<td class="px-4 py-2 text-center"><?=number_format($as['bets'],2)?></td>
|
||||
<td class="px-4 py-2 text-center"><?=number_format($as['wins'],2)?></td>
|
||||
<td class="px-4 py-2 text-center text-orange-500"><?=number_format($as['commission'],2)?></td>
|
||||
<td class="px-4 py-2 text-center <?=$as['profit']>=0?'text-green-500':'text-red-500'?>"><?=number_format($as['profit'],2)?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody></table></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($agentStats)): ?>
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-user-tie text-secondary mr-2"></i> 代理报表
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white rounded-xl overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">代理</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">玩家数</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">投注额</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">中奖额</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">佣金</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">盈利</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<?php foreach ($agentStats as $as): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900"><?= htmlspecialchars($as['user']['username'] ?? '') ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center"><?= $as['players'] ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($as['bets'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($as['wins'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right text-warning"><?= number_format($as['commission'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right font-semibold <?= $as['profit'] >= 0 ? 'text-success' : 'text-danger' ?>">
|
||||
<?= $as['profit'] >= 0 ? '+' : '' ?><?= number_format($as['profit'], 2) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 用户输赢明细 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-money-bill-trend-up text-danger mr-2"></i> 用户输赢明细
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full bg-white rounded-xl overflow-hidden" id="userWinLossTable">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">用户名</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">当前余额</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">总投注</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">总派彩</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">平台盈亏</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">笔数</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">胜率</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">日明细</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<?php if (!empty($userSummary)): ?>
|
||||
<?php foreach ($userSummary as $uid => $us):
|
||||
$uProfit = $us['total_bet'] - $us['total_win'];
|
||||
$uWinRate = $us['bet_count'] > 0 ? round($us['win_count'] / $us['bet_count'] * 100, 1) : 0;
|
||||
?>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900"><?= htmlspecialchars($us['username']) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($us['balance'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($us['total_bet'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right"><?= number_format($us['total_win'], 2) ?></td>
|
||||
<td class="px-4 py-3 text-sm text-right font-semibold <?= $uProfit >= 0 ? 'text-success' : 'text-danger' ?>">
|
||||
<?= $uProfit >= 0 ? '+' : '' ?><?= number_format($uProfit, 2) ?>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center"><?= $us['bet_count'] ?></td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $uWinRate > 50 ? 'bg-danger/10 text-danger' : 'bg-success/10 text-success' ?>">
|
||||
<?= $uWinRate ?>%
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<button onclick="toggleDays('days_<?= $uid ?>')" class="text-primary hover:underline text-xs">
|
||||
<i class="fas fa-chevron-down"></i> 展开
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="days_<?= $uid ?>" style="display:none" class="bg-gray-50/50">
|
||||
<td colspan="8" class="px-6 py-2">
|
||||
<table class="w-full text-xs">
|
||||
<thead><tr class="text-gray-400">
|
||||
<th class="py-1 text-left">日期</th>
|
||||
<th class="py-1 text-right">投注</th>
|
||||
<th class="py-1 text-right">派彩</th>
|
||||
<th class="py-1 text-right">盈亏</th>
|
||||
<th class="py-1 text-center">笔数</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($us['days'] as $day): ?>
|
||||
<tr class="border-t border-gray-100">
|
||||
<td class="py-1 text-gray-600"><?= $day['date'] ?></td>
|
||||
<td class="py-1 text-right"><?= number_format($day['bet'], 2) ?></td>
|
||||
<td class="py-1 text-right"><?= number_format($day['win'], 2) ?></td>
|
||||
<td class="py-1 text-right font-semibold <?= $day['profit'] >= 0 ? 'text-success' : 'text-danger' ?>">
|
||||
<?= $day['profit'] >= 0 ? '+' : '' ?><?= number_format($day['profit'], 2) ?>
|
||||
</td>
|
||||
<td class="py-1 text-center"><?= $day['count'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-money-bill text-gray-300 text-5xl mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900">暂无数据</h3>
|
||||
<p class="mt-1 text-gray-500 text-sm">选定日期范围内没有用户投注记录</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/Static/js/admin.js"></script>
|
||||
<script>
|
||||
layui.use(['laydate', 'layer'], function() {
|
||||
var laydate = layui.laydate;
|
||||
|
||||
laydate.render({
|
||||
elem: '#dateFrom',
|
||||
type: 'date',
|
||||
format: 'yyyy-MM-dd',
|
||||
value: '<?= htmlspecialchars($dateFrom) ?>',
|
||||
max: '<?= htmlspecialchars($dateTo) ?>'
|
||||
});
|
||||
|
||||
laydate.render({
|
||||
elem: '#dateTo',
|
||||
type: 'date',
|
||||
format: 'yyyy-MM-dd',
|
||||
value: '<?= htmlspecialchars($dateTo) ?>',
|
||||
max: 0
|
||||
});
|
||||
});
|
||||
|
||||
function doQuery() {
|
||||
var from = document.getElementById('dateFrom').value;
|
||||
var to = document.getElementById('dateTo').value;
|
||||
window.location.href = '/admin/reports?from=' + from + '&to=' + to;
|
||||
}
|
||||
|
||||
function toggleDays(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.display = el.style.display === 'none' ? '' : 'none';
|
||||
var btn = el.previousElementSibling.querySelector('button');
|
||||
if (btn) {
|
||||
var isOpen = el.style.display !== 'none';
|
||||
btn.innerHTML = isOpen ? '<i class="fas fa-chevron-up"></i> 收起' : '<i class="fas fa-chevron-down"></i> 展开';
|
||||
}
|
||||
}
|
||||
|
||||
function doExport() {
|
||||
var from = document.getElementById('dateFrom').value;
|
||||
var to = document.getElementById('dateTo').value;
|
||||
window.location.href = '/admin/reports/export?from=' + from + '&to=' + to;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -99,6 +99,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 客服设置 -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
<i class="fas fa-headset text-primary mr-2"></i>
|
||||
客服设置
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="customer_service_url" class="block text-sm font-medium text-gray-700 mb-1">客服链接</label>
|
||||
<input type="text" id="customer_service_url" name="customer_service_url"
|
||||
value="<?= htmlspecialchars($settings['customer_service_url'] ?? '') ?>"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
|
||||
placeholder="请输入客服链接,如 https://t.me/your_support">
|
||||
<p class="text-xs text-gray-500 mt-1">用户充值时将跳转至此链接联系客服,支持 Telegram、WhatsApp 等链接</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" onclick="resetSettings()"
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
<h2 class="text-2xl font-bold mb-2">🌊 放水控制 & 投注限额</h2>
|
||||
<p class="text-gray-500 text-sm mb-6">放水 = 控制玩家胜率。百分比越低,平台赢得越多。设为50%表示公平对赌。</p>
|
||||
|
||||
<!-- 平台盈利率目标 -->
|
||||
<div class="bg-white rounded-xl p-4 shadow mb-6">
|
||||
<h3 class="font-bold mb-2">🎯 平台盈利率目标</h3>
|
||||
<div style="display:flex;align-items:center;gap:15px;flex-wrap:wrap">
|
||||
<span>目标盈利率:</span>
|
||||
<input type="number" id="targetProfitRate" value="<?=htmlspecialchars($targetProfitRate ?? '15')?>"
|
||||
min="0" max="100" step="1" class="w-24 border rounded px-2 py-1 text-sm text-center">
|
||||
<span>%</span>
|
||||
<button onclick="saveProfitRate()" class="px-4 py-1 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm">保存</button>
|
||||
<span style="color:#999;font-size:12px">说明:0=取最大利润(原逻辑),15=平台每期目标盈利15%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// 投注类型中文映射
|
||||
$betTypeLabels = [
|
||||
@@ -87,6 +100,12 @@ function getBetLabel($type, $labels) {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function saveProfitRate(){
|
||||
const rate=parseFloat(document.getElementById('targetProfitRate').value);
|
||||
if(isNaN(rate)||rate<0||rate>100){alert('请输入0-100之间的数值');return;}
|
||||
const r=await fetch('/admin/water/profit-rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target_profit_rate:rate})});
|
||||
const d=await r.json();alert(d.status==='success'?'盈利率目标保存成功!':d.message);
|
||||
}
|
||||
async function saveWater(){
|
||||
const items=[];
|
||||
document.querySelectorAll('.water-pct').forEach(el=>{
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php use App\Core\I18n; I18n::init(); $t = function($k,$p=[]){return I18n::t($k,$p);}; ?>
|
||||
<!DOCTYPE html><html lang="<?=I18n::getLang()?>">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title><?=$t('follow_plan')?></title>
|
||||
<link rel="stylesheet" href="/Static/css/app.css">
|
||||
<style>
|
||||
.page-header{display:flex;align-items:center;padding:12px 16px;background:#fff;border-bottom:1px solid #F0F0F0;position:sticky;top:0;z-index:10}
|
||||
.page-header .back{width:32px;height:32px;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.page-header .title{flex:1;text-align:center;font-size:17px;font-weight:600;color:#333}
|
||||
.page-header .balance{font-size:13px;color:#1E90FF;display:flex;align-items:center;gap:4px}
|
||||
.game-select{background:#fff;padding:12px 16px;border-bottom:1px solid #F0F0F0}
|
||||
.game-select select{width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:14px;color:#333;background:#F9F9F9}
|
||||
.stats-cards{display:flex;gap:12px;padding:16px;background:#fff;margin-top:1px}
|
||||
.stat-card{flex:1;background:linear-gradient(135deg,#E8F4FF,#D0E8FF);border-radius:12px;padding:16px;text-align:center}
|
||||
.stat-card .label{font-size:12px;color:#1E90FF;margin-bottom:6px}
|
||||
.stat-card .value{font-size:22px;font-weight:700;color:#333}
|
||||
.plan-card{background:#fff;margin:8px 16px;border-radius:12px;padding:16px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
|
||||
.plan-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
|
||||
.plan-name{font-size:15px;font-weight:600;color:#333}
|
||||
.plan-type-badge{padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;color:#fff;background:#1E90FF}
|
||||
.plan-stats{display:flex;gap:16px;margin-bottom:12px}
|
||||
.plan-stat{text-align:center;flex:1}
|
||||
.plan-stat .ps-label{font-size:11px;color:#999;margin-bottom:2px}
|
||||
.plan-stat .ps-value{font-size:16px;font-weight:700}
|
||||
.plan-stat .ps-value.win{color:#4CAF50}
|
||||
.plan-stat .ps-value.lose{color:#FF4444}
|
||||
.plan-recent{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:12px}
|
||||
.plan-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff}
|
||||
.plan-dot.w{background:#4CAF50}
|
||||
.plan-dot.l{background:#FF4444}
|
||||
.plan-actions{display:flex;gap:8px}
|
||||
.follow-btn{flex:1;padding:10px;border-radius:8px;text-align:center;font-size:14px;font-weight:600;cursor:pointer;transition:all .2s;border:none}
|
||||
.follow-btn.active{background:#FF4444;color:#fff}
|
||||
.follow-btn.inactive{background:linear-gradient(135deg,#1E90FF,#0066CC);color:#fff}
|
||||
.follow-btn:active{transform:scale(.98)}
|
||||
.empty-plans{text-align:center;padding:60px 20px}
|
||||
.empty-plans .icon{font-size:48px;margin-bottom:16px;opacity:.3}
|
||||
.empty-plans .text{font-size:14px;color:#999}
|
||||
.plan-type-select{background:#fff;padding:12px 16px;border-bottom:1px solid #F0F0F0}
|
||||
.type-tabs{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.type-tab{padding:6px 16px;border-radius:20px;font-size:13px;color:#666;background:#F0F0F0;cursor:pointer;border:none}
|
||||
.type-tab.active{background:#1E90FF;color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background:#F5F5F5;padding-bottom:20px">
|
||||
|
||||
<div class="page-header">
|
||||
<a href="/profile" class="back"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></a>
|
||||
<div class="title"><?=$t('follow_plan')?></div>
|
||||
<div class="balance"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v12M8 10h8M8 14h8"/></svg> <?=number_format($user['balance']??0,2)?></div>
|
||||
</div>
|
||||
|
||||
<div class="game-select">
|
||||
<select>
|
||||
<option><?=htmlspecialchars($game['name'] ?? $t('pk10_title'))?></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="label"><?=$t('total_win_rate')?></div>
|
||||
<div class="value"><?=$summary['win_rate']?>%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label"><?=$t('total_profit')?></div>
|
||||
<div class="value" style="color:<?=$summary['profit']>=0?'#4CAF50':'#FF4444'?>"><?=$summary['profit']>=0?'+':''?><?=number_format($summary['profit'],2)?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if(empty($plans)): ?>
|
||||
<div class="empty-plans">
|
||||
<div class="icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ccc" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/></svg>
|
||||
</div>
|
||||
<div class="text"><?=$t('no_follow_plan')?></div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<?php
|
||||
$typeNames = ['bs'=>$t('big').'/'.$t('small'), 'oe'=>$t('odd').'/'.$t('even'), 'dt'=>$t('dragon').'/'.$t('tiger'), 'sum_bs'=>$t('sum_big').'/'.$t('sum_small')];
|
||||
foreach($plans as $plan):
|
||||
$isFollowing = isset($userFollows[$plan['id']]);
|
||||
?>
|
||||
<div class="plan-card">
|
||||
<div class="plan-header">
|
||||
<div class="plan-name"><?=htmlspecialchars($plan['name'])?></div>
|
||||
<span class="plan-type-badge"><?=$typeNames[$plan['plan_type']]??$plan['plan_type']?></span>
|
||||
</div>
|
||||
<div class="plan-stats">
|
||||
<div class="plan-stat">
|
||||
<div class="ps-label"><?=$t('total_win_rate')?></div>
|
||||
<div class="ps-value <?=$plan['win_rate']>=50?'win':'lose'?>"><?=$plan['win_rate']?>%</div>
|
||||
</div>
|
||||
<div class="plan-stat">
|
||||
<div class="ps-label"><?=$t('bet_amount')?></div>
|
||||
<div class="ps-value"><?=number_format($plan['bet_amount'],0)?></div>
|
||||
</div>
|
||||
<div class="plan-stat">
|
||||
<div class="ps-label">总期数</div>
|
||||
<div class="ps-value"><?=$plan['total_records']?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php if(!empty($plan['recent'])): ?>
|
||||
<div class="plan-recent">
|
||||
<?php foreach(array_reverse($plan['recent']) as $rec): ?>
|
||||
<div class="plan-dot <?=$rec['is_win']?'w':'l'?>"><?=$rec['is_win']?'W':'L'?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="plan-actions">
|
||||
<button class="follow-btn <?=$isFollowing?'active':'inactive'?>" onclick="toggleFollow(<?=$plan['id']?>,this)">
|
||||
<?=$isFollowing?'取消跟单':'开始跟单'?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function toggleFollow(planId, btn) {
|
||||
fetch('/api/follow-plan/toggle', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({plan_id: planId})
|
||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||
if (d.success) location.reload();
|
||||
else alert(d.message || '操作失败');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -44,15 +44,15 @@
|
||||
<div class="user-balance"><?=number_format($user['balance']??0,2)?></div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="act-btn" onclick="alert('<?=$t('deposit')?>')">
|
||||
<button class="act-btn" onclick="doDeposit()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
||||
<span><?=$t('deposit')?></span>
|
||||
</button>
|
||||
<button class="act-btn" onclick="alert('<?=$t('withdraw')?>')">
|
||||
<button class="act-btn" onclick="doWithdraw()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
<span><?=$t('withdraw')?></span>
|
||||
</button>
|
||||
<button class="act-btn" onclick="alert('<?=$t('transfer')?>')">
|
||||
<button class="act-btn" onclick="doTransfer()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
<span><?=$t('transfer')?></span>
|
||||
</button>
|
||||
@@ -93,5 +93,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function doDeposit(){
|
||||
var url='<?=\App\Core\SettingsHelper::get("customer_service_url")?>';
|
||||
if(!url){alert('<?=$t("contact_admin")?>');return;}
|
||||
var amount=prompt('<?=$t("deposit_amount")?>');
|
||||
if(!amount||isNaN(amount)||parseFloat(amount)<=0)return;
|
||||
fetch('/api/fund-request',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({type:'deposit',amount:parseFloat(amount)})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
if(d.success){alert('<?=$t("deposit_submitted")?>');window.open(url,'_blank');location.reload();}
|
||||
else{alert(d.message||'<?=$t("error")?>');}
|
||||
});
|
||||
}
|
||||
function doWithdraw(){
|
||||
var amount=prompt('<?=$t("withdraw_amount")?>');
|
||||
if(!amount||isNaN(amount)||parseFloat(amount)<=0)return;
|
||||
fetch('/api/fund-request',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({type:'withdraw',amount:parseFloat(amount)})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
if(d.success){alert('<?=$t("withdraw_submitted")?>');location.reload();}
|
||||
else{alert(d.message||'<?=$t("error")?>');}
|
||||
});
|
||||
}
|
||||
function doTransfer(){
|
||||
var to=prompt('<?=$t("transfer_to")?>');
|
||||
if(!to||!to.trim())return;
|
||||
var amount=prompt('<?=$t("transfer_amount")?>');
|
||||
if(!amount||isNaN(amount)||parseFloat(amount)<=0)return;
|
||||
if(!confirm('<?=$t("transfer_confirm")?> '+to+' , <?=$t("amount")?>: '+amount))return;
|
||||
fetch('/api/transfer',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({to_username:to.trim(),amount:parseFloat(amount)})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
if(d.success){alert(d.message||'<?=$t("transfer_success")?>');location.reload();}
|
||||
else{alert(d.message||'<?=$t("error")?>');}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<?php $navActive='game'; include __DIR__.'/_nav.php'; ?>
|
||||
</body></html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($settings['site_title'] ?? 'PK10 Speed Racing') ?></title>
|
||||
<title><?= htmlspecialchars($settings['site_title'] ?? 'F1 Racing') ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($settings['site_description'] ?? '') ?>">
|
||||
<link rel="shortcut icon" href="<?= htmlspecialchars($settings['site_favicon'] ?? '/Static/css/favicon.ico') ?>" type="image/x-icon">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
@@ -1,36 +1,250 @@
|
||||
<?php use App\Core\I18n; I18n::init(); $t = function($k,$p=[]){return I18n::t($k,$p);}; $gameId = $game['id'] ?? 0; ?>
|
||||
<?php use App\Core\I18n; I18n::init(); $t = function($k,$p=[]){return I18n::t($k,$p);}; $gameId = $game['id'] ?? 0;
|
||||
// 赔率直接在视图查询,确保不依赖控制器传值
|
||||
$_oddsDb = new \Db\Database();
|
||||
$_oddsRaw = $_oddsDb->select('game_odds','*',['game_id'=>$gameId]);
|
||||
$oddsMap = [];
|
||||
foreach(($_oddsRaw??[]) as $_o){ $oddsMap[$_o['type'].'_'.$_o['target']]=(float)$_o['odds']; }
|
||||
unset($_oddsDb,$_oddsRaw,$_o);
|
||||
?>
|
||||
<!DOCTYPE html><html lang="<?=I18n::getLang()?>">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
||||
<title><?=$t('pk10_title')?></title>
|
||||
<link rel="stylesheet" href="/Static/css/app.css">
|
||||
<?php if(!empty($game['stream_url'])): ?><link rel="stylesheet" href="/Static/ckplayer/css/ckplayer.css"><?php endif; ?>
|
||||
<style>
|
||||
/* 赛车赛道 — 保留3D透视 */
|
||||
.race-wrapper{perspective:800px;perspective-origin:50% 100%}
|
||||
.race-track{background:linear-gradient(180deg,#2d5a2d 0%,#1e4a1e 50%,#163a16 100%);border-radius:12px;overflow:hidden;position:relative;transform:rotateX(25deg);transform-origin:50% 100%;box-shadow:0 -20px 60px rgba(0,128,0,.08),inset 0 0 80px rgba(0,0,0,.3)}
|
||||
.race-track::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:repeating-linear-gradient(90deg,transparent,transparent calc(10% - 1px),rgba(255,255,255,.05) calc(10% - 1px),rgba(255,255,255,.05) 10%);pointer-events:none;z-index:1}
|
||||
.finish-line{position:absolute;right:5%;top:0;bottom:0;width:4px;background:repeating-linear-gradient(180deg,#fff 0,#fff 4px,#000 4px,#000 8px);opacity:.4;z-index:2}
|
||||
.start-line{position:absolute;left:5%;top:0;bottom:0;width:2px;background:rgba(255,255,255,.2);z-index:2}
|
||||
.track-lane{height:36px;display:flex;align-items:center;border-bottom:1px solid rgba(255,255,255,.06);padding:0 8px;position:relative;overflow:hidden}
|
||||
.track-lane:nth-child(odd){background:rgba(255,255,255,.02)}
|
||||
.track-lane .progress-bar{position:absolute;left:0;top:0;height:100%;opacity:.15;width:5%;border-radius:0 4px 4px 0;transition:width .3s ease}
|
||||
.car-container{position:absolute;left:3%;top:50%;transform:translateY(-50%);z-index:10;display:flex;align-items:center;gap:4px;transition:left 0.3s ease}
|
||||
.race-car{width:32px;height:24px;border-radius:4px 12px 12px 4px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;color:#fff;flex-shrink:0;position:relative;box-shadow:2px 2px 6px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.3);text-shadow:0 1px 2px rgba(0,0,0,.5)}
|
||||
.race-car::after{content:'';position:absolute;right:-6px;top:50%;transform:translateY(-50%);width:0;height:0;border-left:6px solid currentColor;border-top:4px solid transparent;border-bottom:4px solid transparent;opacity:.4}
|
||||
.rc1{background:linear-gradient(180deg,#ff6b6b,#c0392b);color:#c0392b}.rc2{background:linear-gradient(180deg,#74b9ff,#2980b9);color:#2980b9}
|
||||
.rc3{background:linear-gradient(180deg,#55efc4,#27ae60);color:#27ae60}.rc4{background:linear-gradient(180deg,#ffeaa7,#f39c12);color:#f39c12}
|
||||
.rc5{background:linear-gradient(180deg,#dda0dd,#8e44ad);color:#8e44ad}.rc6{background:linear-gradient(180deg,#81ecec,#16a085);color:#16a085}
|
||||
.rc7{background:linear-gradient(180deg,#fab1a0,#e67e22);color:#e67e22}.rc8{background:linear-gradient(180deg,#fd79a8,#e91e63);color:#e91e63}
|
||||
.rc9{background:linear-gradient(180deg,#a0d2db,#00bcd4);color:#00bcd4}.rc10{background:linear-gradient(180deg,#c8e6c9,#689f38);color:#689f38}
|
||||
.exhaust{position:absolute;left:-8px;top:50%;transform:translateY(-50%);font-size:8px;opacity:0;pointer-events:none}
|
||||
/* ===== F1赛车开奖场景 ===== */
|
||||
/* 场景容器 */
|
||||
.race-scene{background:#1a1a2e;border-radius:10px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.6);margin:6px 0}
|
||||
/* 场景顶栏 — 深色渐变 */
|
||||
.race-scene-hd{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;padding:6px 10px;background:linear-gradient(180deg,#2a2a3e,#1a1a2e);border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.rs-row1{display:flex;align-items:center;gap:6px;width:100%}
|
||||
.rs-title{font-size:13px;font-weight:900;font-style:italic;color:#ff3333;text-shadow:0 0 12px rgba(255,50,50,.5),1px 1px 0 #800;letter-spacing:1px;white-space:nowrap;font-family:'Arial Black',sans-serif;flex-shrink:0}
|
||||
.rs-period{font-size:12px;color:#ccc;font-family:monospace;white-space:nowrap;font-weight:700;flex:1;text-align:right}
|
||||
.rs-sound{width:24px;height:24px;border-radius:50%;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;font-size:14px;line-height:1}
|
||||
.rs-toggle{cursor:pointer;font-size:14px;opacity:0.7;transition:transform 0.3s;width:24px;height:24px;border-radius:50%;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.race-scene.collapsed .rs-toggle{transform:rotate(180deg)}
|
||||
.race-scene.collapsed .race-track,.race-scene.collapsed .race-skyline,.race-scene.collapsed .race-overlay,.race-scene.collapsed .podium-overlay{display:none}
|
||||
.race-scene.collapsed{min-height:auto;height:auto}
|
||||
.race-scene.collapsed .race-scene-hd{margin-bottom:0}
|
||||
.rs-balls{display:flex;gap:3px;width:100%;justify-content:center;flex-wrap:nowrap}
|
||||
.rs-ball{width:20px;height:20px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5);flex-shrink:0;border:1px solid rgba(255,255,255,.3);box-shadow:0 2px 4px rgba(0,0,0,.3)}
|
||||
/* 天际线 — 蓝天+北京地标(参考图核心元素)*/
|
||||
.race-skyline{height:72px;background:linear-gradient(180deg,#4a9ed8 0%,#6bb5e0 30%,#8cc8ea 60%,#b8dff0 85%,#d0e8d0 100%);position:relative;overflow:hidden}
|
||||
.race-skyline svg{position:absolute;bottom:0;left:0;width:100%;height:100%}
|
||||
/* 赛道地面 — 绿草+灰色赛道 */
|
||||
.race-track-ground{height:8px;background:linear-gradient(180deg,#5a8a3a,#4a7a2a);position:relative}
|
||||
.race-track-ground::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:repeating-linear-gradient(90deg,#fff 0,#fff 6px,transparent 6px,transparent 12px);opacity:.5}
|
||||
/* 赛道主体 */
|
||||
.race-track{background:#1a1a2e;position:relative;overflow:hidden;min-height:260px}
|
||||
/* 终点线 — 棋盘格(左侧) */
|
||||
.finish-line{position:absolute;left:3%;top:0;bottom:0;width:14px;z-index:2;background:repeating-conic-gradient(#fff 0% 25%,#333 0% 50%) 0 0/7px 7px;opacity:.4}
|
||||
.start-line{position:absolute;right:5%;top:0;bottom:0;width:2px;background:rgba(255,255,255,.15);z-index:2}
|
||||
/* 车道 */
|
||||
.track-lane{height:38px;display:flex;align-items:center;border-bottom:1px solid rgba(255,255,255,.06);position:relative;overflow:hidden}
|
||||
.track-lane:nth-child(even){background:rgba(255,255,255,.02)}
|
||||
.track-lane .progress-bar{position:absolute;right:0;top:0;height:100%;opacity:.08;width:5%;border-radius:4px 0 0 4px;transition:width .3s ease}
|
||||
.lane-label{position:absolute;right:6px;top:50%;transform:translateY(-50%);color:rgba(255,255,255,.12);font-size:9px;z-index:0;font-weight:700}
|
||||
/* 赛道叠加层(倒计时+红绿灯)— 参考图:中央显示,不完全遮住车 */
|
||||
.race-overlay{position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:20;pointer-events:none;gap:14px;background:rgba(10,10,30,.6);opacity:0;transition:opacity .4s}
|
||||
.race-overlay.show{opacity:1}
|
||||
/* 数字倒计时 — 复古LCD数码管风格(参考图核心) */
|
||||
@font-face{font-family:'LCD';src:local('Courier New')}
|
||||
.race-timer-display{font-family:'Courier New','LCD',monospace;font-size:52px;font-weight:900;color:#fff;text-shadow:0 0 30px rgba(255,255,255,.5),0 0 60px rgba(255,255,255,.15);letter-spacing:6px;background:rgba(0,0,0,.6);padding:8px 24px;border-radius:8px;border:2px solid rgba(255,255,255,.1)}
|
||||
.race-timer-display .ms{font-size:28px;opacity:.5;margin-left:2px;vertical-align:baseline}
|
||||
/* 红绿灯 — 5灯椭圆形(参考图风格) */
|
||||
.traffic-lights{display:flex;gap:8px;padding:10px 24px;background:linear-gradient(180deg,#333,#111);border-radius:30px;border:2px solid #555;box-shadow:0 4px 16px rgba(0,0,0,.8)}
|
||||
.tl{width:36px;height:28px;border-radius:14px;background:radial-gradient(ellipse at 45% 40%,#333,#1a1a1a);border:2px solid #555;box-shadow:inset 0 1px 3px rgba(255,255,255,.1);transition:all .15s}
|
||||
.tl.red{background:radial-gradient(ellipse at 40% 35%,#ff5555,#cc0000);box-shadow:0 0 16px rgba(255,0,0,.8),0 0 32px rgba(255,0,0,.3),inset 0 -2px 4px rgba(0,0,0,.3);border-color:#ff3333}
|
||||
.tl.yellow{background:radial-gradient(ellipse at 40% 35%,#ffdd44,#ccaa00);box-shadow:0 0 16px rgba(255,200,0,.8),0 0 32px rgba(255,200,0,.3),inset 0 -2px 4px rgba(0,0,0,.3);border-color:#ffcc33}
|
||||
.tl.green{background:radial-gradient(ellipse at 40% 35%,#44ff66,#00cc33);box-shadow:0 0 16px rgba(0,255,0,.8),0 0 32px rgba(0,255,0,.3),inset 0 -2px 4px rgba(0,0,0,.3);border-color:#33ff55}
|
||||
/* 赛车容器 */
|
||||
.car-container{position:absolute;right:3%;top:50%;transform:translateY(-50%);z-index:10;display:flex;align-items:center;transition:right 0.3s ease}
|
||||
/* 赛车图片 */
|
||||
.car-svg{width:60px;height:auto;flex-shrink:0;filter:drop-shadow(2px 2px 3px rgba(0,0,0,.6));object-fit:contain;pointer-events:none}
|
||||
/* 排气火焰(车尾=右侧) */
|
||||
.car-flame{position:absolute;right:-4px;top:50%;transform:translateY(-50%);width:14px;height:10px;opacity:0;pointer-events:none}
|
||||
.car-flame i{position:absolute;display:block;border-radius:50% 20% 50% 50%;transform-origin:left center}
|
||||
.car-flame i:nth-child(1){width:10px;height:6px;background:#ff5500;right:0;top:2px;animation:flame1 .12s infinite alternate}
|
||||
.car-flame i:nth-child(2){width:7px;height:4px;background:#ff8800;right:3px;top:3px;animation:flame2 .1s infinite alternate}
|
||||
.car-flame i:nth-child(3){width:4px;height:3px;background:#ffcc00;right:5px;top:3.5px;animation:flame3 .08s infinite alternate}
|
||||
@keyframes flame1{0%{opacity:.7;transform:scaleX(1) scaleY(1)}100%{opacity:1;transform:scaleX(1.4) scaleY(.6)}}
|
||||
@keyframes flame2{0%{opacity:.6;transform:scaleX(1)}100%{opacity:.9;transform:scaleX(1.3) translateX(1px)}}
|
||||
@keyframes flame3{0%{opacity:.5;transform:scaleX(1)}100%{opacity:1;transform:scaleX(1.5)}}
|
||||
.car-container.moving .car-flame{opacity:.6}
|
||||
.car-container.sprinting .car-flame{opacity:1;width:22px;right:-10px}
|
||||
/* 风尘粒子(车尾=右侧) */
|
||||
.car-wind{position:absolute;right:-16px;top:50%;transform:translateY(-50%);width:16px;height:20px;overflow:hidden;opacity:0;pointer-events:none}
|
||||
.car-wind i{position:absolute;display:block;height:1px;border-radius:1px;background:rgba(255,255,255,.25)}
|
||||
.car-wind i:nth-child(1){width:6px;top:3px;right:2px;animation:windP .5s infinite linear}
|
||||
.car-wind i:nth-child(2){width:4px;top:8px;right:5px;animation:windP .4s .1s infinite linear}
|
||||
.car-wind i:nth-child(3){width:5px;top:13px;right:1px;animation:windP .45s .2s infinite linear}
|
||||
.car-wind i:nth-child(4){width:3px;top:17px;right:6px;animation:windP .35s .05s infinite linear}
|
||||
@keyframes windP{0%{opacity:.6;transform:translateX(0)}100%{opacity:0;transform:translateX(14px)}}
|
||||
.car-container.moving .car-wind{opacity:.5}
|
||||
.car-container.sprinting .car-wind{opacity:.9;width:28px;right:-24px}
|
||||
/* 冲刺速度线(车尾=右侧) */
|
||||
.car-container.sprinting::before{content:'';position:absolute;right:-30px;top:50%;width:24px;height:1px;background:linear-gradient(270deg,transparent,rgba(255,255,255,.15));transform:translateY(-50%);animation:spdLine .3s infinite}
|
||||
.car-container.sprinting::after{content:'';position:absolute;right:-22px;top:calc(50% + 5px);width:16px;height:1px;background:linear-gradient(270deg,transparent,rgba(255,255,255,.08));transform:translateY(-50%);animation:spdLine .35s .1s infinite}
|
||||
@keyframes spdLine{0%{opacity:0;transform:translateY(-50%) scaleX(.5)}50%{opacity:1}100%{opacity:0;transform:translateY(-50%) scaleX(1) translateX(8px)}}
|
||||
/* 排烟(车尾=右侧) */
|
||||
.exhaust{position:absolute;right:-6px;top:50%;transform:translateY(-50%);font-size:6px;opacity:0;pointer-events:none;color:rgba(255,255,255,.3)}
|
||||
.car-container.moving .exhaust{animation:exhaustSmoke .8s infinite}
|
||||
@keyframes exhaustSmoke{0%{opacity:.6;transform:translateY(-50%) translateX(0) scale(1)}100%{opacity:0;transform:translateY(-50%) translateX(-15px) scale(1.5)}}
|
||||
.car-container.sprinting .race-car{box-shadow:2px 2px 6px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.3),-8px 0 20px rgba(255,255,255,.1)}
|
||||
.car-container.sprinting .exhaust{animation:exhaustSmoke .4s infinite}
|
||||
.lane-label{position:absolute;left:4px;top:50%;transform:translateY(-50%);color:rgba(255,255,255,.25);font-size:10px;z-index:0}
|
||||
.rank-badge{position:absolute;right:-24px;top:50%;transform:translateY(-50%);font-size:9px;font-weight:700;color:#ffd700;opacity:0;transition:opacity .5s;text-shadow:0 0 4px rgba(255,215,0,.6)}
|
||||
.car-container.show-rank .rank-badge{opacity:1}
|
||||
@keyframes readyShake{0%,100%{transform:translateY(-50%) translateX(0)}25%{transform:translateY(-50%) translateX(2px)}75%{transform:translateY(-50%) translateX(-2px)}}
|
||||
.car-container.ready{animation:readyShake .3s infinite}
|
||||
.car-container.sprinting .exhaust{animation:exhaustSmoke .35s infinite;font-size:8px}
|
||||
@keyframes exhaustSmoke{0%{opacity:.5;transform:translateY(-50%) translateX(0) scale(1)}100%{opacity:0;transform:translateY(-50%) translateX(18px) scale(2)}}
|
||||
/* ===== 升级特效1:轮下火花粒子(车尾=右侧) ===== */
|
||||
.car-sparks{position:absolute;right:4px;bottom:-2px;width:20px;height:16px;opacity:0;pointer-events:none;overflow:visible}
|
||||
.car-sparks i{position:absolute;display:block;width:3px;height:3px;border-radius:50%;background:#ffaa00;bottom:0;right:50%}
|
||||
.car-sparks i:nth-child(1){animation:spark1 .4s infinite linear;background:#ff6600}
|
||||
.car-sparks i:nth-child(2){animation:spark2 .35s .05s infinite linear;background:#ffcc00}
|
||||
.car-sparks i:nth-child(3){animation:spark3 .45s .1s infinite linear;background:#ff8800}
|
||||
.car-sparks i:nth-child(4){animation:spark4 .38s .15s infinite linear;background:#ffdd44}
|
||||
.car-sparks i:nth-child(5){animation:spark5 .42s .08s infinite linear;background:#ff5500}
|
||||
.car-sparks i:nth-child(6){animation:spark6 .36s .12s infinite linear;background:#ffaa00}
|
||||
@keyframes spark1{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(18px,-12px) scale(.3)}}
|
||||
@keyframes spark2{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(14px,10px) scale(.2)}}
|
||||
@keyframes spark3{0%{opacity:.9;transform:translate(0,0) scale(.8)}100%{opacity:0;transform:translate(22px,-6px) scale(.2)}}
|
||||
@keyframes spark4{0%{opacity:1;transform:translate(0,0) scale(.7)}100%{opacity:0;transform:translate(16px,8px) scale(.1)}}
|
||||
@keyframes spark5{0%{opacity:.8;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(20px,-14px) scale(.2)}}
|
||||
@keyframes spark6{0%{opacity:.9;transform:translate(0,0) scale(.6)}100%{opacity:0;transform:translate(12px,12px) scale(.1)}}
|
||||
.car-container.sprinting .car-sparks{opacity:1}
|
||||
.car-container.moving .car-sparks{opacity:.3}
|
||||
/* ===== 升级特效2:赛道震动 ===== */
|
||||
@keyframes trackShake{0%,100%{transform:translateX(0) translateY(0)}10%{transform:translateX(.5px) translateY(-.3px)}30%{transform:translateX(-.4px) translateY(.5px)}50%{transform:translateX(.3px) translateY(.2px)}70%{transform:translateX(-.5px) translateY(-.4px)}90%{transform:translateX(.4px) translateY(.3px)}}
|
||||
.race-track.shaking{animation:trackShake .15s infinite linear}
|
||||
/* ===== 升级特效3:车身光晕 ===== */
|
||||
.car-container.sprinting .car-svg{filter:drop-shadow(2px 2px 3px rgba(0,0,0,.6)) drop-shadow(0 0 8px var(--car-glow,rgba(255,150,0,.6)))}
|
||||
.car-container.sprinting{z-index:11}
|
||||
/* ===== 升级特效7:刹车烟雾(车尾=右侧) ===== */
|
||||
.car-brake-smoke{position:absolute;right:-2px;bottom:-4px;width:30px;height:20px;opacity:0;pointer-events:none;overflow:visible}
|
||||
.car-brake-smoke i{position:absolute;display:block;border-radius:50%;background:rgba(200,200,200,.4);bottom:2px;right:8px}
|
||||
.car-brake-smoke i:nth-child(1){width:8px;height:8px;animation:brakeSmoke1 .6s ease-out forwards}
|
||||
.car-brake-smoke i:nth-child(2){width:6px;height:6px;animation:brakeSmoke2 .5s .05s ease-out forwards}
|
||||
.car-brake-smoke i:nth-child(3){width:10px;height:8px;animation:brakeSmoke3 .7s .1s ease-out forwards}
|
||||
@keyframes brakeSmoke1{0%{opacity:.6;transform:translate(0,0) scale(.5)}100%{opacity:0;transform:translate(20px,-8px) scale(2.5)}}
|
||||
@keyframes brakeSmoke2{0%{opacity:.5;transform:translate(0,0) scale(.4)}100%{opacity:0;transform:translate(12px,6px) scale(2)}}
|
||||
@keyframes brakeSmoke3{0%{opacity:.4;transform:translate(0,0) scale(.3)}100%{opacity:0;transform:translate(24px,-4px) scale(3)}}
|
||||
.car-container.braking .car-brake-smoke{opacity:1}
|
||||
.car-container.braking .car-flame{opacity:0!important}
|
||||
.car-container.braking .car-sparks{opacity:0!important}
|
||||
/* ===== 升级特效4:冲线爆炸(终点=左侧) ===== */
|
||||
.finish-burst{position:absolute;left:3%;top:0;bottom:0;width:60px;z-index:30;pointer-events:none;opacity:0}
|
||||
.finish-burst.active{opacity:1}
|
||||
.finish-burst::before{content:'';position:absolute;top:50%;left:50%;width:10px;height:10px;background:#fff;border-radius:50%;transform:translate(-50%,-50%);animation:finishFlash .6s ease-out forwards}
|
||||
@keyframes finishFlash{0%{opacity:1;transform:translate(-50%,-50%) scale(1);box-shadow:0 0 20px #fff,0 0 60px #ffd700}50%{opacity:.8;transform:translate(-50%,-50%) scale(8);box-shadow:0 0 40px #fff,0 0 80px #ffd700}100%{opacity:0;transform:translate(-50%,-50%) scale(12)}}
|
||||
.finish-burst .fp{position:absolute;width:4px;height:4px;border-radius:50%;top:50%;left:50%}
|
||||
.finish-burst.active .fp{animation:finishParticle .8s ease-out forwards}
|
||||
.fp:nth-child(1){background:#ff3333;animation-delay:0s}
|
||||
.fp:nth-child(2){background:#3399ff;animation-delay:.02s}
|
||||
.fp:nth-child(3){background:#ffcc00;animation-delay:.04s}
|
||||
.fp:nth-child(4){background:#33ff66;animation-delay:.06s}
|
||||
.fp:nth-child(5){background:#ff66cc;animation-delay:.08s}
|
||||
.fp:nth-child(6){background:#ff8800;animation-delay:.03s}
|
||||
.fp:nth-child(7){background:#66ffff;animation-delay:.05s}
|
||||
.fp:nth-child(8){background:#ffff33;animation-delay:.07s}
|
||||
@keyframes finishParticle{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--fpx,20px),var(--fpy,-30px)) scale(.3)}}
|
||||
/* ===== 升级特效5:领奖台彩带雨 ===== */
|
||||
.confetti-container{position:absolute;top:0;left:0;right:0;bottom:0;overflow:hidden;pointer-events:none;z-index:3}
|
||||
.confetti{position:absolute;width:6px;height:10px;top:-20px;opacity:.9}
|
||||
.podium-overlay.show .confetti{animation:confettiFall var(--cf-dur,3s) var(--cf-delay,0s) linear forwards}
|
||||
@keyframes confettiFall{0%{opacity:1;transform:translateY(0) rotateZ(0deg) rotateX(0deg)}25%{transform:translateY(60px) rotateZ(90deg) rotateX(180deg) translateX(15px)}50%{transform:translateY(140px) rotateZ(200deg) rotateX(360deg) translateX(-10px)}75%{opacity:.7;transform:translateY(220px) rotateZ(300deg) rotateX(540deg) translateX(20px)}100%{opacity:0;transform:translateY(320px) rotateZ(400deg) rotateX(720deg) translateX(5px)}}
|
||||
/* 排名徽章(车头=左侧,徽章显示在左边) */
|
||||
.rank-badge{position:absolute;left:-32px;top:50%;transform:translateY(-50%);font-size:10px;font-weight:900;color:#ffd700;opacity:0;transition:opacity .3s,transform .3s;text-shadow:0 0 8px rgba(255,215,0,.8),0 0 16px rgba(255,215,0,.4);letter-spacing:1px}
|
||||
.car-container.show-rank .rank-badge{opacity:1;animation:rankPop .4s ease-out}
|
||||
@keyframes rankPop{0%{transform:translateY(-50%) scale(0);opacity:0}60%{transform:translateY(-50%) scale(1.3)}100%{transform:translateY(-50%) scale(1);opacity:1}}
|
||||
/* 就绪抖动 — 升级:更有力的引擎感 */
|
||||
@keyframes readyShake{0%,100%{transform:translateY(-50%) translateX(0)}15%{transform:translateY(-50%) translateX(1.5px) translateY(calc(-50% + .5px))}35%{transform:translateY(-50%) translateX(-1px) translateY(calc(-50% - .3px))}55%{transform:translateY(-50%) translateX(2px)}75%{transform:translateY(-50%) translateX(-1.5px) translateY(calc(-50% + .3px))}90%{transform:translateY(-50%) translateX(.5px)}}
|
||||
.car-container.ready{animation:readyShake .2s infinite}
|
||||
/* ===== 结算领奖台 ===== */
|
||||
.podium-overlay{position:absolute;top:0;left:0;right:0;bottom:0;z-index:25;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at 50% 60%,#0a2a4a 0%,#061428 50%,#020a14 100%);opacity:0;pointer-events:none;transition:opacity .6s}
|
||||
.podium-overlay.show{opacity:1;pointer-events:auto}
|
||||
/* 星光粒子 */
|
||||
.podium-stars{position:absolute;top:0;left:0;right:0;bottom:0;overflow:hidden}
|
||||
.podium-stars::before,.podium-stars::after{content:'';position:absolute;width:2px;height:2px;border-radius:50%;box-shadow:25px 20px #fff,80px 60px #fff,140px 30px #fff,200px 80px #fff,260px 15px #fff,320px 55px #fff,60px 110px #fff,170px 130px #fff,110px 75px rgba(255,255,255,.5),230px 100px rgba(255,255,255,.5),290px 40px rgba(255,255,255,.3),50px 45px rgba(255,255,255,.3),350px 90px #fff,15px 85px rgba(255,255,255,.4);animation:starsTwinkle 3s infinite alternate}
|
||||
.podium-stars::after{animation-delay:1.5s;box-shadow:35px 90px #fff,95px 25px #fff,155px 110px #fff,215px 45px #fff,275px 85px #fff,335px 20px #fff,75px 70px rgba(255,255,255,.5),185px 35px rgba(255,255,255,.5)}
|
||||
@keyframes starsTwinkle{0%{opacity:.3}50%{opacity:.8}100%{opacity:.4}}
|
||||
/* 光柱 */
|
||||
.podium-overlay.show .podium-stage::before{content:'';position:absolute;top:-80px;left:50%;width:120px;height:200px;transform:translateX(-50%);background:linear-gradient(180deg,rgba(100,200,255,.15),transparent);clip-path:polygon(30% 0%,70% 0%,100% 100%,0% 100%);pointer-events:none;animation:lightPillar 2s ease-in-out infinite alternate}
|
||||
@keyframes lightPillar{0%{opacity:.3}100%{opacity:.6}}
|
||||
/* 领奖台布局 */
|
||||
.podium-stage{display:flex;align-items:flex-end;justify-content:center;gap:6px;position:relative;z-index:2}
|
||||
.podium-car{display:flex;flex-direction:column;align-items:center;opacity:0;transform:translateY(30px) scale(.8)}
|
||||
.podium-overlay.show .podium-1st{animation:podiumIn .6s .3s forwards}
|
||||
.podium-overlay.show .podium-2nd{animation:podiumIn .6s .5s forwards}
|
||||
.podium-overlay.show .podium-3rd{animation:podiumIn .6s .7s forwards}
|
||||
@keyframes podiumIn{to{opacity:1;transform:translateY(0) scale(1)}}
|
||||
.podium-rank{font-weight:900;font-size:20px;color:#c0c0c0;text-shadow:0 2px 8px rgba(0,0,0,.5);margin-bottom:2px;font-style:italic}
|
||||
.podium-rank.gold{font-size:28px;color:#ffd700;text-shadow:0 0 20px rgba(255,215,0,.5),0 2px 4px rgba(0,0,0,.5)}
|
||||
.podium-car-svg{width:80px;height:auto;filter:drop-shadow(0 4px 8px rgba(0,0,0,.5));object-fit:contain}
|
||||
.podium-1st .podium-car-svg{width:110px;height:auto;filter:drop-shadow(0 6px 12px rgba(0,0,0,.5))}
|
||||
/* 赛道地面动态纹理 */
|
||||
.race-track::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:repeating-linear-gradient(90deg,transparent 0,transparent 40px,rgba(255,255,255,.02) 40px,rgba(255,255,255,.02) 42px);animation:roadScroll 1.5s linear infinite;z-index:0;pointer-events:none}
|
||||
@keyframes roadScroll{from{background-position-x:0}to{background-position-x:84px}}
|
||||
/* 车道分割虚线 */
|
||||
.track-lane::before{content:'';position:absolute;bottom:-1px;left:5%;right:10%;height:1px;background:repeating-linear-gradient(90deg,rgba(255,255,255,.1) 0,rgba(255,255,255,.1) 12px,transparent 12px,transparent 24px);animation:dashScroll 2s linear infinite;pointer-events:none}
|
||||
@keyframes dashScroll{from{background-position-x:0}to{background-position-x:48px}}
|
||||
/* 底部信息栏 */
|
||||
.race-scene-ft{display:flex;align-items:center;padding:6px 12px;background:linear-gradient(180deg,#1a1a2e,#0f0f1e);border-top:1px solid rgba(255,255,255,.06);gap:12px;font-size:11px;color:#aaa;flex-wrap:wrap}
|
||||
.race-ft-period{font-family:monospace;font-weight:700;color:#fff}
|
||||
.race-ft-tag{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;font-weight:600;font-size:10px}
|
||||
.race-ft-tag.sum-tag{background:rgba(30,144,255,.15);color:#6ab5ff}
|
||||
.race-ft-tag.dt-tag{color:#ffaa33}
|
||||
/* 快捷投注面板 */
|
||||
.quick-section{background:#fff;border-radius:10px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||||
.quick-title{font-size:12px;color:var(--text3);font-weight:600;margin-bottom:8px}
|
||||
.quick-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
|
||||
.quick-btn{padding:14px 8px;border-radius:10px;text-align:center;font-size:14px;font-weight:700;color:#fff;cursor:pointer;transition:all .15s;user-select:none}
|
||||
.quick-btn:active{transform:scale(.96);opacity:.85}
|
||||
.qb-red{background:linear-gradient(135deg,#e74c3c,#c0392b)}
|
||||
.qb-blue{background:linear-gradient(135deg,#3498db,#2980b9)}
|
||||
.qb-orange{background:linear-gradient(135deg,#f39c12,#d68910)}
|
||||
.qb-purple{background:linear-gradient(135deg,#9b59b6,#7d3c98)}
|
||||
.quick-hint{text-align:center;font-size:11px;color:#bbb;margin-top:4px}
|
||||
/* 遗漏统计面板 */
|
||||
.miss-tabs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.miss-tab{padding:4px 10px;border-radius:16px;font-size:12px;color:#666;background:#f0f0f0;cursor:pointer;font-weight:600;transition:all .15s}
|
||||
.miss-tab.active{background:var(--primary);color:#fff}
|
||||
.miss-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}
|
||||
.miss-cell{background:#fff;border-radius:8px;padding:8px 4px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||||
.miss-num{font-size:11px;font-weight:700;margin-bottom:4px}
|
||||
.miss-val{font-size:16px;font-weight:700;color:var(--primary)}
|
||||
.miss-val.hot{color:var(--danger)}
|
||||
.miss-val.cold{color:var(--text3)}
|
||||
.miss-section{background:#fff;border-radius:10px;padding:10px 12px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||||
.miss-label{font-size:12px;color:var(--text3);font-weight:600;margin-bottom:6px}
|
||||
.miss-two-side{display:grid;grid-template-columns:repeat(2,1fr);gap:6px}
|
||||
.miss-ts-item{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:#f9f9f9;border-radius:6px}
|
||||
.miss-ts-name{font-size:12px;color:#666}
|
||||
.miss-ts-val{font-size:14px;font-weight:700;color:var(--primary)}
|
||||
/* 长龙统计面板 */
|
||||
.dragon-empty{padding:30px 0;text-align:center;color:#999;font-size:13px}
|
||||
.dragon-item{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#fff;border-radius:10px;margin-bottom:6px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||||
.dragon-rank{font-size:12px;color:var(--primary);font-weight:600;min-width:48px;flex-shrink:0}
|
||||
.dragon-type{display:inline-block;padding:2px 8px;border-radius:4px;color:#fff;font-size:11px;font-weight:600;min-width:32px;text-align:center}
|
||||
.dragon-bar-wrap{flex:1;height:16px;background:#f0f0f0;border-radius:8px;overflow:hidden;position:relative}
|
||||
.dragon-bar{height:100%;border-radius:8px;transition:width .4s ease;min-width:16px}
|
||||
.dragon-count{font-size:13px;font-weight:700;color:var(--text);min-width:28px;text-align:right}
|
||||
.dragon-fire{font-size:11px;margin-left:-2px}
|
||||
/* 开奖视频入口按钮 */
|
||||
.race-entry{display:flex;align-items:center;gap:8px;padding:8px 12px;margin:6px 0;background:#fff;border-radius:10px;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.08);border:1px solid #eee;transition:box-shadow .2s}
|
||||
.race-entry:active{box-shadow:0 1px 2px rgba(0,0,0,.05)}
|
||||
.race-entry-thumb{width:80px;height:48px;border-radius:6px;background:linear-gradient(135deg,#1a1a2e,#2a2a4e);display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;flex-shrink:0}
|
||||
.race-entry-thumb::after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,#4a9ed8 0%,#6bb5e0 50%,#3a6a3a 100%);opacity:.3}
|
||||
.race-entry-thumb svg{position:relative;z-index:1}
|
||||
.race-entry-info{flex:1;min-width:0}
|
||||
.race-entry-title{font-size:13px;font-weight:700;color:#333}
|
||||
.race-entry-desc{font-size:11px;color:#999;margin-top:2px}
|
||||
.race-entry-live{background:#cc0000;color:#fff;font-size:9px;font-weight:700;padding:1px 5px;border-radius:3px;letter-spacing:1px}
|
||||
/* 弹窗遮罩 */
|
||||
.race-modal-mask{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:100;display:none;align-items:flex-start;justify-content:center;padding-top:40px}
|
||||
.race-modal-mask.show{display:flex}
|
||||
/* 弹窗内容 */
|
||||
.race-modal{width:100%;max-width:480px;max-height:90vh;overflow-y:auto;border-radius:12px;box-shadow:0 8px 40px rgba(0,0,0,.4);position:relative;animation:modalIn .25s ease}
|
||||
@keyframes modalIn{from{opacity:0;transform:translateY(-20px) scale(.95)}to{opacity:1;transform:translateY(0) scale(1)}}
|
||||
/* 弹窗顶栏 */
|
||||
.race-modal-hd{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#1a1a2e;border-radius:12px 12px 0 0;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||
.race-modal-title{font-size:14px;font-weight:700;color:#fff}
|
||||
.race-modal-close{display:flex;align-items:center;gap:6px;color:#aaa;font-size:12px;cursor:pointer;background:none;border:none;padding:4px 8px;border-radius:4px}
|
||||
.race-modal-close:active{background:rgba(255,255,255,.1)}
|
||||
/* 顶部栏 */
|
||||
.pk-header{position:sticky;top:0;z-index:40;background:#fff;border-bottom:1px solid #F0F0F0;padding:10px 15px;display:flex;align-items:center;justify-content:space-between}
|
||||
.pk-header .title{font-weight:700;font-size:16px;color:#333;display:flex;align-items:center;gap:6px}
|
||||
@@ -49,33 +263,55 @@
|
||||
.seal-status{color:var(--danger);font-weight:600;font-size:13px}
|
||||
.draw-label{font-size:13px;font-weight:700;color:#333}
|
||||
.draw-countdown{color:var(--primary);font-weight:700;font-size:15px;font-family:monospace}
|
||||
/* 左右布局 */
|
||||
/* 左右布局(参考图 dsn 风格) */
|
||||
.bet-layout{display:flex;min-height:50vh}
|
||||
.side-menu{width:72px;background:#fff;border-right:1px solid #F0F0F0;flex-shrink:0}
|
||||
.side-menu .mi{display:flex;align-items:center;justify-content:center;height:48px;font-size:13px;color:#666;cursor:pointer;border-left:3px solid transparent;transition:all .2s}
|
||||
.side-menu .mi{display:flex;align-items:center;justify-content:center;height:44px;font-size:13px;color:#666;cursor:pointer;border-left:3px solid transparent;transition:all .2s}
|
||||
.side-menu .mi.active{color:var(--primary);font-weight:600;border-left-color:var(--primary);background:#F0F8FF}
|
||||
.bet-content{flex:1;overflow-y:auto;max-height:60vh;background:#fff}
|
||||
/* 投注行 */
|
||||
.bet-section-title{text-align:center;padding:10px 0 6px;font-size:14px;font-weight:600;color:#333;border-bottom:1px solid #F0F0F0;position:relative}
|
||||
.bet-section-title::after{content:'▲';font-size:8px;color:#999;margin-left:4px}
|
||||
/* 投注行(参考图风格 — 宽松、清爽) */
|
||||
.bet-section-title{text-align:center;padding:12px 0 8px;font-size:14px;font-weight:600;color:#333;border-bottom:1px solid #F0F0F0;position:relative}
|
||||
.bet-section-title::after{content:'▲';font-size:8px;color:#bbb;margin-left:4px;vertical-align:middle}
|
||||
.bet-row{display:flex;border-bottom:1px solid #F5F5F5}
|
||||
.bet-cell{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;padding:14px 10px;cursor:pointer;font-size:14px;color:#333;transition:background .15s}
|
||||
.bet-cell{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;padding:16px 10px;cursor:pointer;font-size:14px;color:#333;transition:background .15s}
|
||||
.bet-cell:first-child{border-right:1px solid #F5F5F5}
|
||||
.bet-cell:active,.bet-cell.selected{background:#E8F4FF}
|
||||
.bet-cell:active{background:#E8F4FF}
|
||||
.bet-cell .odds{color:var(--primary);font-weight:600;font-size:14px}
|
||||
/* 筹码 */
|
||||
.chip-bar{background:#fff;padding:8px 15px;border-top:1px solid #F0F0F0;display:flex;align-items:center;gap:8px}
|
||||
.chip{border-radius:50%;width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;cursor:pointer;transition:transform .15s}
|
||||
.chip.active{transform:scale(1.15);box-shadow:0 0 0 2px var(--primary)}
|
||||
/* 提交栏 */
|
||||
.submit-bar{background:#fff;padding:10px 15px;border-top:1px solid #F0F0F0;display:flex;align-items:center;gap:10px}
|
||||
.submit-bar .info{flex:1;font-size:12px;color:#999}
|
||||
.submit-bar .info b{color:var(--primary)}
|
||||
/* 底部投注操作区 — 参考图 dsn 风格(在底部导航上方) */
|
||||
.bet-bottom-bar{position:fixed;bottom:60px;left:0;right:0;z-index:50;background:#fff;box-shadow:0 -2px 12px rgba(0,0,0,.1)}
|
||||
/* 已选N注浮动胶囊 */
|
||||
.bet-float-badge{position:absolute;top:-22px;left:50%;transform:translateX(-50%);background:#FF8C00;color:#fff;font-size:12px;font-weight:700;padding:4px 16px;border-radius:20px 20px 0 0;display:flex;align-items:center;gap:6px;white-space:nowrap;box-shadow:0 -2px 8px rgba(255,140,0,.3);cursor:pointer;transition:opacity .2s}
|
||||
.bet-float-badge.hide{opacity:0;pointer-events:none}
|
||||
.bet-float-badge .count{font-size:16px}
|
||||
.bet-float-badge svg{width:18px;height:18px}
|
||||
/* 金额按钮行 */
|
||||
.amount-row{display:flex;align-items:center;gap:6px;padding:8px 12px;border-top:1px solid #F0F0F0}
|
||||
.amount-btn{flex:1;height:36px;display:flex;align-items:center;justify-content:center;border:1px solid #ddd;border-radius:6px;font-size:14px;font-weight:600;color:#333;background:#fff;cursor:pointer;transition:all .15s}
|
||||
.amount-btn.active{border-color:var(--primary);color:var(--primary);background:#F0F8FF}
|
||||
.amount-btn:active{background:#F0F0F0}
|
||||
.amount-custom{width:36px;height:36px;flex:none;border-radius:50%;border:1px solid #ddd;display:flex;align-items:center;justify-content:center;cursor:pointer;background:#fff;color:#1E90FF}
|
||||
/* 输入行 */
|
||||
.bet-input-row{display:flex;align-items:center;gap:8px;padding:6px 12px}
|
||||
.bet-input-row input{flex:1;height:36px;border:1px solid #ddd;border-radius:6px;padding:0 12px;font-size:13px;color:#333;outline:none}
|
||||
.bet-input-row input:focus{border-color:var(--primary)}
|
||||
.bet-input-row input::placeholder{color:#bbb}
|
||||
.bet-preset-btn{height:36px;padding:0 12px;border:1px solid #ddd;border-radius:18px;font-size:12px;color:#666;background:#fff;cursor:pointer;display:flex;align-items:center;gap:4px}
|
||||
/* 取消确认按钮行 */
|
||||
.bet-action-row{display:flex;gap:10px;padding:6px 12px 10px}
|
||||
.bet-action-row .btn-cancel{flex:1;height:40px;border:1px solid #ddd;border-radius:8px;font-size:15px;font-weight:600;color:#666;background:#fff;cursor:pointer}
|
||||
.bet-action-row .btn-confirm{flex:1;height:40px;border:none;border-radius:8px;font-size:15px;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--primary),var(--primary-dark));cursor:pointer;box-shadow:0 2px 8px rgba(30,144,255,.3)}
|
||||
.bet-action-row .btn-confirm:active{transform:scale(.98)}
|
||||
/* 选中态 — 蓝色边框 + 勾勾(参考图) */
|
||||
.bet-cell.selected{background:#E8F4FF;border:1px solid var(--primary);position:relative}
|
||||
.bet-cell.selected::after{content:'✓';position:absolute;top:2px;right:4px;width:18px;height:18px;background:var(--primary);color:#fff;font-size:10px;border-radius:0 0 0 6px;display:flex;align-items:center;justify-content:center}
|
||||
/* 投注金额角标 */
|
||||
.bet-amount-badge{position:absolute;top:-4px;right:-4px;background:var(--danger);color:#fff;font-size:9px;font-weight:700;min-width:18px;height:18px;border-radius:9px;display:flex;align-items:center;justify-content:center;padding:0 4px;z-index:2}
|
||||
.bet-cell,.bet-btn{position:relative}
|
||||
/* 冠亚和结果角标 */
|
||||
.section-hot-badge{display:inline-flex;align-items:center;justify-content:center;background:var(--danger);color:#fff;font-size:10px;font-weight:700;min-width:20px;height:20px;border-radius:10px;padding:0 5px;margin-left:6px;vertical-align:middle}
|
||||
</style>
|
||||
</head>
|
||||
<body class="pb-nav" style="background:#F5F5F5">
|
||||
<body class="pb-nav" style="background:#F5F5F5;padding-bottom:240px">
|
||||
|
||||
<!-- 顶部栏 -->
|
||||
<header class="pk-header">
|
||||
@@ -117,33 +353,191 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D赛车动画区 — 完整保留 -->
|
||||
<div class="race-wrapper">
|
||||
<div class="race-track p-2" id="raceArea">
|
||||
<?php $hasVideo=!empty($game['stream_url']); ?>
|
||||
<?php if($hasVideo): ?>
|
||||
<div id="pk10VideoWrap" style="display:none">
|
||||
<div id="pk10VideoPlayer" data-stream="<?=htmlspecialchars($game['stream_url'])?>" style="width:100%;aspect-ratio:16/9;background:#000"></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 开奖视频入口(点击打开弹窗) -->
|
||||
<div class="race-entry" id="raceEntryBtn" onclick="openRaceModal()">
|
||||
<div class="race-entry-thumb">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="#fff" opacity=".8"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
</div>
|
||||
<div class="race-entry-info">
|
||||
<div class="race-entry-title">F1赛车开奖视频 <span class="race-entry-live">LIVE</span></div>
|
||||
<div class="race-entry-desc" id="raceEntryDesc">点击查看开奖动画</div>
|
||||
</div>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- 开奖动画弹窗 -->
|
||||
<div class="race-modal-mask" id="raceModalMask" onclick="if(event.target===this)closeRaceModal()">
|
||||
<div class="race-modal">
|
||||
<!-- 弹窗顶栏 -->
|
||||
<div class="race-modal-hd">
|
||||
<span class="race-modal-title">F1赛车开奖视频</span>
|
||||
<button class="race-modal-close" onclick="closeRaceModal()">小屏 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<!-- 赛车场景(移入弹窗内) -->
|
||||
<div class="race-scene" id="raceSceneWrap" style="margin:0;border-radius:0">
|
||||
<!-- 场景顶栏 -->
|
||||
<div class="race-scene-hd">
|
||||
<div class="rs-row1">
|
||||
<span class="rs-title">F1赛车</span>
|
||||
<span class="rs-period" id="raceScenePeriod">期数:---</span>
|
||||
<span class="rs-sound" id="rsSoundBtn" onclick="toggleRaceSound()" style="cursor:pointer">🔊</span>
|
||||
<span class="rs-toggle" onclick="toggleRaceScene()" title="收起/展开">▲</span>
|
||||
</div>
|
||||
<div class="rs-balls" id="raceSceneBalls"><span style="color:#666;font-size:10px">---</span></div>
|
||||
</div>
|
||||
<!-- 天际线 — 蓝天 + 北京地标(鸟巢、水立方、央视大楼、国家大剧院、冰丝带) -->
|
||||
<div class="race-skyline">
|
||||
<svg viewBox="0 0 500 72" preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 远景云 -->
|
||||
<ellipse cx="80" cy="15" rx="40" ry="8" fill="rgba(255,255,255,.3)"/>
|
||||
<ellipse cx="350" cy="12" rx="30" ry="6" fill="rgba(255,255,255,.25)"/>
|
||||
<ellipse cx="450" cy="20" rx="25" ry="5" fill="rgba(255,255,255,.2)"/>
|
||||
<!-- 国家大剧院(左侧椭圆穹顶)-->
|
||||
<ellipse cx="60" cy="60" rx="35" ry="18" fill="#8ab5cc" stroke="#a0c8d8" stroke-width=".5"/>
|
||||
<ellipse cx="60" cy="60" rx="28" ry="14" fill="#9bc5d8" opacity=".5"/>
|
||||
<line x1="60" y1="42" x2="60" y2="60" stroke="rgba(255,255,255,.1)" stroke-width=".5"/>
|
||||
<!-- 鸟巢(中左侧编织体育馆)-->
|
||||
<path d="M140,68 L142,40 Q155,32 168,32 Q181,32 194,40 L196,68 Z" fill="#9aaa8a" stroke="#b0c0a0" stroke-width=".5"/>
|
||||
<path d="M145,45 Q168,36 191,45" fill="none" stroke="rgba(255,255,255,.15)" stroke-width="1"/>
|
||||
<path d="M148,50 Q168,42 188,50" fill="none" stroke="rgba(255,255,255,.1)" stroke-width=".8"/>
|
||||
<path d="M150,56 Q168,49 186,56" fill="none" stroke="rgba(255,255,255,.08)" stroke-width=".6"/>
|
||||
<!-- 交叉编织纹理 -->
|
||||
<line x1="155" y1="35" x2="175" y2="65" stroke="rgba(255,255,255,.06)" stroke-width=".5"/>
|
||||
<line x1="168" y1="32" x2="150" y2="65" stroke="rgba(255,255,255,.06)" stroke-width=".5"/>
|
||||
<line x1="180" y1="35" x2="190" y2="65" stroke="rgba(255,255,255,.06)" stroke-width=".5"/>
|
||||
<!-- 水立方(蓝色方盒子)-->
|
||||
<rect x="220" y="38" width="50" height="30" rx="2" fill="#5ba8d0" stroke="#70bce0" stroke-width=".5"/>
|
||||
<!-- 气泡纹理 -->
|
||||
<circle cx="230" cy="45" r="3" fill="rgba(255,255,255,.1)" stroke="rgba(255,255,255,.08)" stroke-width=".3"/>
|
||||
<circle cx="240" cy="50" r="4" fill="rgba(255,255,255,.08)" stroke="rgba(255,255,255,.06)" stroke-width=".3"/>
|
||||
<circle cx="250" cy="44" r="3.5" fill="rgba(255,255,255,.1)" stroke="rgba(255,255,255,.08)" stroke-width=".3"/>
|
||||
<circle cx="260" cy="52" r="3" fill="rgba(255,255,255,.08)" stroke="rgba(255,255,255,.06)" stroke-width=".3"/>
|
||||
<circle cx="235" cy="58" r="3.5" fill="rgba(255,255,255,.06)" stroke="rgba(255,255,255,.05)" stroke-width=".3"/>
|
||||
<circle cx="248" cy="56" r="2.5" fill="rgba(255,255,255,.1)" stroke="rgba(255,255,255,.06)" stroke-width=".3"/>
|
||||
<circle cx="258" cy="42" r="2" fill="rgba(255,255,255,.12)"/>
|
||||
<!-- 央视大楼(Z字形/门框双塔)-->
|
||||
<rect x="310" y="22" width="12" height="46" fill="#7a8a9a" stroke="#90a0b0" stroke-width=".3"/>
|
||||
<rect x="340" y="22" width="12" height="46" fill="#7a8a9a" stroke="#90a0b0" stroke-width=".3"/>
|
||||
<rect x="310" y="22" width="42" height="10" rx="1" fill="#8595a5" stroke="#90a0b0" stroke-width=".3"/>
|
||||
<path d="M322,32 L340,32" stroke="rgba(255,255,255,.1)" stroke-width=".5"/>
|
||||
<!-- 窗户 -->
|
||||
<rect x="313" y="35" width="3" height="3" fill="rgba(255,255,200,.1)" rx=".5"/>
|
||||
<rect x="313" y="41" width="3" height="3" fill="rgba(255,255,200,.15)" rx=".5"/>
|
||||
<rect x="313" y="47" width="3" height="3" fill="rgba(255,255,200,.1)" rx=".5"/>
|
||||
<rect x="343" y="35" width="3" height="3" fill="rgba(255,255,200,.12)" rx=".5"/>
|
||||
<rect x="343" y="41" width="3" height="3" fill="rgba(255,255,200,.1)" rx=".5"/>
|
||||
<rect x="343" y="47" width="3" height="3" fill="rgba(255,255,200,.15)" rx=".5"/>
|
||||
<!-- 冰丝带(右侧弧形建筑)-->
|
||||
<path d="M400,68 L402,42 Q410,30 425,28 Q440,30 448,42 L450,68 Z" fill="#a0c8e0" stroke="#b0d8f0" stroke-width=".5"/>
|
||||
<path d="M405,35 Q425,25 445,35" fill="none" stroke="rgba(255,255,255,.2)" stroke-width="1.5"/>
|
||||
<path d="M408,42 Q425,34 442,42" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="1"/>
|
||||
<!-- 远景小楼群 -->
|
||||
<rect x="105" y="55" width="8" height="13" fill="#7a9aaa" opacity=".5"/>
|
||||
<rect x="115" y="50" width="6" height="18" fill="#7090a0" opacity=".5"/>
|
||||
<rect x="285" y="52" width="10" height="16" fill="#7a8a9a" opacity=".4"/>
|
||||
<rect x="375" y="48" width="8" height="20" fill="#7a90a0" opacity=".4"/>
|
||||
<rect x="385" y="52" width="6" height="16" fill="#8aa0b0" opacity=".3"/>
|
||||
<!-- 地面线 -->
|
||||
<rect x="0" y="68" width="500" height="4" fill="#5a8a3a" opacity=".8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 草地分割线 -->
|
||||
<div class="race-track-ground"></div>
|
||||
<!-- 赛道主体 -->
|
||||
<div class="race-track" id="raceArea" style="position:relative">
|
||||
<div class="start-line"></div>
|
||||
<div class="finish-line"></div>
|
||||
<?php for($i=1;$i<=10;$i++): ?>
|
||||
<!-- 冲线爆炸特效 -->
|
||||
<div class="finish-burst" id="finishBurst">
|
||||
<span class="fp" style="--fpx:25px;--fpy:-35px"></span>
|
||||
<span class="fp" style="--fpx:-20px;--fpy:-28px"></span>
|
||||
<span class="fp" style="--fpx:30px;--fpy:20px"></span>
|
||||
<span class="fp" style="--fpx:-15px;--fpy:32px"></span>
|
||||
<span class="fp" style="--fpx:18px;--fpy:-42px"></span>
|
||||
<span class="fp" style="--fpx:-28px;--fpy:15px"></span>
|
||||
<span class="fp" style="--fpx:35px;--fpy:8px"></span>
|
||||
<span class="fp" style="--fpx:-10px;--fpy:-20px"></span>
|
||||
</div>
|
||||
<!-- 倒计时+红绿灯叠加层 -->
|
||||
<div class="race-overlay" id="raceOverlay">
|
||||
<div class="race-timer-display" id="raceTimerDisplay">00:00</div>
|
||||
<div class="traffic-lights" id="trafficLights">
|
||||
<span class="tl" id="tl1"></span><span class="tl" id="tl2"></span>
|
||||
<span class="tl" id="tl3"></span><span class="tl" id="tl4"></span>
|
||||
<span class="tl" id="tl5"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 结算领奖台叠加层 -->
|
||||
<div class="podium-overlay" id="podiumOverlay">
|
||||
<div class="podium-stars"></div>
|
||||
<!-- 彩带雨 -->
|
||||
<div class="confetti-container" id="confettiBox"></div>
|
||||
<div class="podium-stage">
|
||||
<div class="podium-car podium-2nd" id="podium2nd">
|
||||
<span class="podium-rank">2nd</span>
|
||||
<img class="podium-car-svg" id="podiumImg2" src="" alt="2nd" draggable="false">
|
||||
</div>
|
||||
<div class="podium-car podium-1st" id="podium1st">
|
||||
<span class="podium-rank gold">1st</span>
|
||||
<img class="podium-car-svg" id="podiumImg1" src="" alt="1st" draggable="false">
|
||||
</div>
|
||||
<div class="podium-car podium-3rd" id="podium3rd">
|
||||
<span class="podium-rank">3rd</span>
|
||||
<img class="podium-car-svg" id="podiumImg3" src="" alt="3rd" draggable="false">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
$carColors = [
|
||||
1 =>['#f1c40f','#d4ac0d'],2 =>['#3498db','#2980b9'],3 =>['#555555','#333333'],
|
||||
4 =>['#e67e22','#ca6f1e'],5 =>['#2ecc71','#27ae60'],6 =>['#2c3e99','#1a2a6c'],
|
||||
7 =>['#999999','#777777'],8 =>['#e74c3c','#c0392b'],9 =>['#cc2222','#991111'],
|
||||
10=>['#27ae60','#1e8449']
|
||||
];
|
||||
for($i=1;$i<=10;$i++): $c=$carColors[$i]; ?>
|
||||
<div class="track-lane" id="trackLane<?=$i?>">
|
||||
<div class="progress-bar c<?=$i?>" id="bar<?=$i?>"></div>
|
||||
<span class="lane-label">#<?=$i?></span>
|
||||
<div class="car-container" id="carContainer<?=$i?>">
|
||||
<div class="race-car rc<?=$i?>"><?=$i?></div>
|
||||
<span class="exhaust">•</span>
|
||||
<div class="car-container" id="carContainer<?=$i?>" style="--car-glow:<?=$c[0]?>80">
|
||||
<div class="car-flame"><i></i><i></i><i></i></div>
|
||||
<div class="car-wind"><i></i><i></i><i></i><i></i></div>
|
||||
<div class="car-sparks"><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="car-brake-smoke"><i></i><i></i><i></i></div>
|
||||
<img class="car-svg" src="/Static/img/cars/car<?=$i?>.png" alt="Car <?=$i?>" draggable="false">
|
||||
<span class="exhaust">•</span>
|
||||
<span class="rank-badge" id="rankBadge<?=$i?>"></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部信息栏(参考图风格:期号 + 冠亚和 + 龙虎) -->
|
||||
<div class="race-scene-ft" id="raceSceneFt">
|
||||
<span>期号:<span class="race-ft-period" id="ftPeriodNum">---</span></span>
|
||||
<span>冠亚和 <span class="race-ft-tag sum-tag" id="ftSumVal">-</span> <span class="race-ft-tag sum-tag" id="ftSumBs">-</span> <span class="race-ft-tag sum-tag" id="ftSumOe">-</span></span>
|
||||
<span>龙虎 <span id="ftDtTags" style="display:inline-flex;gap:2px">---</span></span>
|
||||
</div>
|
||||
</div><!-- /race-scene -->
|
||||
</div><!-- /race-modal -->
|
||||
</div><!-- /race-modal-mask -->
|
||||
|
||||
<!-- 左右布局投注面板 -->
|
||||
<!-- 投注面板(参考图:左侧菜单 + 右侧内容) -->
|
||||
<div id="betPanel">
|
||||
<div class="bet-layout">
|
||||
<!-- 左侧菜单 -->
|
||||
<div class="side-menu">
|
||||
<div class="side-menu" id="sideMenu">
|
||||
<div class="mi active" onclick="showBetTab('bs',this)" id="tabBs"><?=$t('two_side')?></div>
|
||||
<div class="mi" onclick="showBetTab('rank',this)" id="tabRank"><?=$t('rank_1_10')?></div>
|
||||
<div class="mi" onclick="showBetTab('sum',this)" id="tabSum"><?=$t('sum_bs_tab')?></div>
|
||||
<div class="mi" onclick="showBetTab('quick',this)" id="tabQuick"><?=$t('quick')?></div>
|
||||
<div class="mi" onclick="showBetTab('dragon',this)" id="tabDragon"><?=$t('streak')?></div>
|
||||
<div class="mi" onclick="showBetTab('miss',this)" id="tabMiss"><?=$t('missing')?></div>
|
||||
</div>
|
||||
<!-- 右侧投注内容 -->
|
||||
<div class="bet-content">
|
||||
@@ -151,30 +545,31 @@
|
||||
<!-- 两面(大小+单双+龙虎)-->
|
||||
<div id="panelBs">
|
||||
<!-- 冠亚和 大小单双 -->
|
||||
<div class="bet-section-title"><?=$t('sum')?></div>
|
||||
<?php $odds=function($t,$v)use($oddsMap){return $oddsMap[$t.'_'.$v]??1.95;}; ?>
|
||||
<div class="bet-section-title"><?=$t('sum')?><span class="section-hot-badge" id="sumHotBadge" style="display:none"></span></div>
|
||||
<div class="bet-row">
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_big',this)"><?=$t('sum_bs_big')?> <span class="odds">2.2</span></div>
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_small',this)"><?=$t('sum_bs_small')?> <span class="odds">1.79</span></div>
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_big',this)"><?=$t('sum_bs_big')?> <span class="odds"><?=$odds('sum_bs','sum_big')?></span></div>
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_small',this)"><?=$t('sum_bs_small')?> <span class="odds"><?=$odds('sum_bs','sum_small')?></span></div>
|
||||
</div>
|
||||
<div class="bet-row">
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_odd',this)"><?=$t('sum_bs_odd')?> <span class="odds">1.79</span></div>
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_even',this)"><?=$t('sum_bs_even')?> <span class="odds">2.2</span></div>
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_odd',this)"><?=$t('sum_bs_odd')?> <span class="odds"><?=$odds('sum_bs','sum_odd')?></span></div>
|
||||
<div class="bet-cell" onclick="addBet('sum_bs','sum_even',this)"><?=$t('sum_bs_even')?> <span class="odds"><?=$odds('sum_bs','sum_even')?></span></div>
|
||||
</div>
|
||||
<?php for($r=1;$r<=10;$r++): $label=$r<=2?($r==1?$t('champion'):$t('runner_up')):$t('rank_n',['n'=>$r]); ?>
|
||||
<!-- 名次标题 -->
|
||||
<div class="bet-section-title"><?=$label?></div>
|
||||
<div class="bet-row">
|
||||
<div class="bet-cell" onclick="addBet('bs','rank<?=$r?>_big',this)"><?=$t('big')?> <span class="odds">1.995</span></div>
|
||||
<div class="bet-cell" onclick="addBet('bs','rank<?=$r?>_small',this)"><?=$t('small')?> <span class="odds">1.995</span></div>
|
||||
<div class="bet-cell" onclick="addBet('bs','rank<?=$r?>_big',this)"><?=$t('big')?> <span class="odds"><?=$odds('bs','rank'.$r.'_big')?></span></div>
|
||||
<div class="bet-cell" onclick="addBet('bs','rank<?=$r?>_small',this)"><?=$t('small')?> <span class="odds"><?=$odds('bs','rank'.$r.'_small')?></span></div>
|
||||
</div>
|
||||
<div class="bet-row">
|
||||
<div class="bet-cell" onclick="addBet('oe','rank<?=$r?>_odd',this)"><?=$t('odd')?> <span class="odds">1.995</span></div>
|
||||
<div class="bet-cell" onclick="addBet('oe','rank<?=$r?>_even',this)"><?=$t('even')?> <span class="odds">1.995</span></div>
|
||||
<div class="bet-cell" onclick="addBet('oe','rank<?=$r?>_odd',this)"><?=$t('odd')?> <span class="odds"><?=$odds('oe','rank'.$r.'_odd')?></span></div>
|
||||
<div class="bet-cell" onclick="addBet('oe','rank<?=$r?>_even',this)"><?=$t('even')?> <span class="odds"><?=$odds('oe','rank'.$r.'_even')?></span></div>
|
||||
</div>
|
||||
<?php if($r<=5): $pairs=[[1,10],[2,9],[3,8],[4,7],[5,6]]; $p=$pairs[$r-1]; ?>
|
||||
<div class="bet-row">
|
||||
<div class="bet-cell" onclick="addBet('dt','dt<?=$r?>_dragon',this)"><?=$t('dragon')?> <span class="odds">1.995</span></div>
|
||||
<div class="bet-cell" onclick="addBet('dt','dt<?=$r?>_tiger',this)"><?=$t('tiger')?> <span class="odds">1.995</span></div>
|
||||
<div class="bet-cell" onclick="addBet('dt','dt<?=$r?>_dragon',this)"><?=$t('dragon')?> <span class="odds"><?=$odds('dt','dt'.$r.'_dragon')?></span></div>
|
||||
<div class="bet-cell" onclick="addBet('dt','dt<?=$r?>_tiger',this)"><?=$t('tiger')?> <span class="odds"><?=$odds('dt','dt'.$r.'_tiger')?></span></div>
|
||||
</div>
|
||||
<?php endif; endfor; ?>
|
||||
</div>
|
||||
@@ -186,9 +581,10 @@
|
||||
<div style="font-size:13px;font-weight:600;color:#333;margin-bottom:6px"><?=$label?></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px">
|
||||
<?php for($c=1;$c<=10;$c++): ?>
|
||||
<div onclick="addBet('rank','rank<?=$r?>_<?=$c?>',this)" class="bet-btn" data-odds="9.80">
|
||||
<?php $rankOdds=$odds('rank','rank'.$r.'_'.$c); ?>
|
||||
<div onclick="addBet('rank','rank<?=$r?>_<?=$c?>',this)" class="bet-btn" data-odds="<?=$rankOdds?>">
|
||||
<div class="car c<?=$c?>" style="width:24px;height:24px;font-size:11px;margin:0 auto 2px"><?=$c?></div>
|
||||
<div style="color:#999;font-size:10px">9.80</div>
|
||||
<div style="color:#999;font-size:10px"><?=$rankOdds?></div>
|
||||
</div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
@@ -200,40 +596,103 @@
|
||||
<div id="panelSum" class="hidden" style="padding:10px">
|
||||
<div style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px"><?=$t('sum_value')?> (3-19)</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px">
|
||||
<?php $sumOdds=[3=>140,4=>70,5=>35,6=>23,7=>17.5,8=>14,9=>11.6,10=>10,11=>8.8,12=>8.8,13=>10,14=>11.6,15=>14,16=>17.5,17=>23,18=>35,19=>70];
|
||||
foreach($sumOdds as $v=>$o): ?>
|
||||
<?php $sumOddsFallback=[3=>140,4=>70,5=>35,6=>23,7=>17.5,8=>14,9=>11.6,10=>10,11=>8.8,12=>8.8,13=>10,14=>11.6,15=>14,16=>17.5,17=>23,18=>35,19=>70];
|
||||
foreach($sumOddsFallback as $v=>$fallback): $sumO=$oddsMap['sum_sum_'.$v]??$fallback; ?>
|
||||
<div onclick="addBet('sum','sum_<?=$v?>',this)" class="bet-btn">
|
||||
<div style="font-weight:700"><?=$v?></div><div style="color:#999;font-size:10px"><?=$o?></div>
|
||||
<div style="font-weight:700"><?=$v?></div><div style="color:#999;font-size:10px"><?=$sumO?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px">
|
||||
<div onclick="addBet('sum_bs','sum_big',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_big')?> <span class="odds">1.95</span></div>
|
||||
<div onclick="addBet('sum_bs','sum_small',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_small')?> <span class="odds">1.95</span></div>
|
||||
<div onclick="addBet('sum_bs','sum_odd',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_odd')?> <span class="odds">1.95</span></div>
|
||||
<div onclick="addBet('sum_bs','sum_even',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_even')?> <span class="odds">1.95</span></div>
|
||||
<div onclick="addBet('sum_bs','sum_big',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_big')?> <span class="odds"><?=$odds('sum_bs','sum_big')?></span></div>
|
||||
<div onclick="addBet('sum_bs','sum_small',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_small')?> <span class="odds"><?=$odds('sum_bs','sum_small')?></span></div>
|
||||
<div onclick="addBet('sum_bs','sum_odd',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_odd')?> <span class="odds"><?=$odds('sum_bs','sum_odd')?></span></div>
|
||||
<div onclick="addBet('sum_bs','sum_even',this)" class="bet-cell" style="border:1px solid #F0F0F0;border-radius:8px"><?=$t('sum_even')?> <span class="odds"><?=$odds('sum_bs','sum_even')?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷投注 -->
|
||||
<div id="panelQuick" class="hidden" style="padding:10px">
|
||||
<div class="quick-section">
|
||||
<div class="quick-title"><?=$t('two_side')?> — <?=$t('rank_n',['n'=>'1~10'])?></div>
|
||||
<div class="quick-grid">
|
||||
<div class="quick-btn qb-red" onclick="quickBet('bs','big')"><?=$t('big')?></div>
|
||||
<div class="quick-btn qb-blue" onclick="quickBet('bs','small')"><?=$t('small')?></div>
|
||||
<div class="quick-btn qb-orange" onclick="quickBet('oe','odd')"><?=$t('odd')?></div>
|
||||
<div class="quick-btn qb-purple" onclick="quickBet('oe','even')"><?=$t('even')?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-section">
|
||||
<div class="quick-title"><?=$t('dragon_tiger')?> — <?=$t('rank_n',['n'=>'1~5'])?></div>
|
||||
<div class="quick-grid">
|
||||
<div class="quick-btn qb-red" onclick="quickBet('dt','dragon')"><?=$t('dragon')?></div>
|
||||
<div class="quick-btn qb-blue" onclick="quickBet('dt','tiger')"><?=$t('tiger')?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-section">
|
||||
<div class="quick-title"><?=$t('sum')?></div>
|
||||
<div class="quick-grid">
|
||||
<div class="quick-btn qb-red" onclick="quickBet('sum_bs','sum_big')"><?=$t('sum_big')?></div>
|
||||
<div class="quick-btn qb-blue" onclick="quickBet('sum_bs','sum_small')"><?=$t('sum_small')?></div>
|
||||
<div class="quick-btn qb-orange" onclick="quickBet('sum_bs','sum_odd')"><?=$t('sum_odd')?></div>
|
||||
<div class="quick-btn qb-purple" onclick="quickBet('sum_bs','sum_even')"><?=$t('sum_even')?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-hint"><?=$t('quick_hint')?></div>
|
||||
</div>
|
||||
|
||||
<!-- 长龙统计 -->
|
||||
<div id="panelDragon" class="hidden" style="padding:8px 10px">
|
||||
<div id="dragonStreakList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 遗漏统计 -->
|
||||
<div id="panelMiss" class="hidden" style="padding:8px 10px">
|
||||
<div class="miss-tabs" id="missTabs">
|
||||
<span class="miss-tab active" onclick="switchMissRank(0,this)"><?=$t('champion')?></span>
|
||||
<span class="miss-tab" onclick="switchMissRank(1,this)"><?=$t('runner_up')?></span>
|
||||
<?php for($i=3;$i<=10;$i++): ?>
|
||||
<span class="miss-tab" onclick="switchMissRank(<?=$i-1?>,this)"><?=$i?></span>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<div id="missGrid" class="miss-grid"></div>
|
||||
<div class="miss-section" style="margin-top:8px">
|
||||
<div class="miss-label"><?=$t('two_side')?></div>
|
||||
<div id="missTwoSide" class="miss-two-side"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /bet-content -->
|
||||
</div><!-- /bet-layout -->
|
||||
|
||||
<!-- 筹码+提交栏 -->
|
||||
<div class="chip-bar">
|
||||
<div onclick="selectChip(10)" class="chip active" style="background:#e74c3c" data-amount="10">10</div>
|
||||
<div onclick="selectChip(50)" class="chip" style="background:#3498db" data-amount="50">50</div>
|
||||
<div onclick="selectChip(100)" class="chip" style="background:#2ecc71" data-amount="100">100</div>
|
||||
<div onclick="selectChip(500)" class="chip" style="background:#9b59b6" data-amount="500">500</div>
|
||||
<div onclick="showCustomChip()" class="chip" style="background:#f39c12" data-amount="custom" id="customChipBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></div>
|
||||
</div>
|
||||
<div id="customChipInput" class="hidden" style="padding:8px 15px;background:#fff;display:flex;gap:8px">
|
||||
<input type="number" id="customAmount" min="1" step="1" placeholder="<?=$t('amount')?>" class="input" style="flex:1;text-align:center">
|
||||
<button onclick="applyCustomChip()" class="btn btn-primary btn-sm"><?=$t('confirm')?></button>
|
||||
</div>
|
||||
<div class="submit-bar">
|
||||
<div class="info"><?=$t('total')?>: <b id="betTotal">0</b> x<span id="betCount">0</span></div>
|
||||
<button onclick="clearBets()" class="btn btn-outline btn-sm"><?=$t('cancel')?></button>
|
||||
<button onclick="submitBets()" id="submitBtn" class="btn btn-primary btn-sm"><?=$t('place_bet')?></button>
|
||||
<!-- 底部投注操作栏(参考图 dsn 风格:已选N注 + 金额按钮 + 输入框 + 取消确认) -->
|
||||
<div class="bet-bottom-bar" id="betBottomBar">
|
||||
<!-- 已选N注浮动胶囊 -->
|
||||
<div class="bet-float-badge hide" id="betFloatBadge" onclick="toggleBetPanel()">
|
||||
已选 <span class="count" id="betCount">0</span> 注
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
|
||||
</div>
|
||||
<!-- 快捷金额按钮 -->
|
||||
<div class="amount-row">
|
||||
<div class="amount-btn" onclick="selectChip(50,this)" data-amount="50">50</div>
|
||||
<div class="amount-btn" onclick="selectChip(100,this)" data-amount="100">100</div>
|
||||
<div class="amount-btn" onclick="selectChip(200,this)" data-amount="200">200</div>
|
||||
<div class="amount-btn active" onclick="selectChip(500,this)" data-amount="500">500</div>
|
||||
<div class="amount-btn" onclick="selectChip(1000,this)" data-amount="1000">1000</div>
|
||||
<div class="amount-custom" onclick="toggleCustomInput()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自定义金额输入 -->
|
||||
<div class="bet-input-row" id="customInputRow">
|
||||
<input type="number" id="customAmount" min="1" step="1" placeholder="最低输入1元">
|
||||
<div class="bet-preset-btn" onclick="applyCustomChip()">预设 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg></div>
|
||||
</div>
|
||||
<!-- 取消 / 确认 -->
|
||||
<div class="bet-action-row">
|
||||
<button class="btn-cancel" onclick="clearBets()"><?=$t('cancel')?></button>
|
||||
<button class="btn-confirm" onclick="submitBets()"><?=$t('confirm')?></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本期已下注 -->
|
||||
@@ -266,85 +725,83 @@ foreach($sumOdds as $v=>$o): ?>
|
||||
<!-- 底部导航 -->
|
||||
<?php $navActive='game'; include __DIR__.'/_nav.php'; ?>
|
||||
|
||||
<!-- PK10 Config: PHP variables passed to JS -->
|
||||
<script>
|
||||
const GAME_ID=<?=$gameId?>;
|
||||
let selectedChip=10,bets={},periodData=null,pollTimer=null;
|
||||
const rankNames={1:'<?=$t('champion')?>',2:'<?=$t('runner_up')?>',3:'<?=$t('rank_n',['n'=>3])?>',4:'<?=$t('rank_n',['n'=>4])?>',5:'<?=$t('rank_n',['n'=>5])?>',6:'<?=$t('rank_n',['n'=>6])?>',7:'<?=$t('rank_n',['n'=>7])?>',8:'<?=$t('rank_n',['n'=>8])?>',9:'<?=$t('rank_n',['n'=>9])?>',10:'<?=$t('rank_n',['n'=>10])?>'};
|
||||
const dtPairs={1:[1,10],2:[2,9],3:[3,8],4:[4,7],5:[5,6]};
|
||||
const I18N={big:'<?=$t('big')?>',small:'<?=$t('small')?>',odd:'<?=$t('odd')?>',even:'<?=$t('even')?>',dragon:'<?=$t('dragon')?>',tiger:'<?=$t('tiger')?>',sum:'<?=$t('sum')?>',sum_big:'<?=$t('sum_big')?>',sum_small:'<?=$t('sum_small')?>',sum_odd:'<?=$t('sum_odd')?>',sum_even:'<?=$t('sum_even')?>',car:'<?=$t('car_no')?>',no_bets:'<?=$t('no_data')?>',total:'<?=$t('total')?>',items:'',profit:'<?=$t('win')?>',loss:'<?=$t('lose')?>',draw:'<?=$t('pending')?>',net_err:'<?=$t('error')?>',rank_prefix:'<?=$t('rank_n',['n'=>''])?>',drawing:'<?=$t('drawing')?>',settled_label:'<?=$t('settled')?>'};
|
||||
function formatBetLabel(type,value){
|
||||
let m;
|
||||
if(type==='rank'&&(m=value.match(/^rank(\d+)_(\d+)$/))){return(rankNames[+m[1]]||I18N.rank_prefix+m[1])+' #'+m[2];}
|
||||
if(type==='bs'&&(m=value.match(/^rank(\d+)_(big|small)$/))){return(rankNames[+m[1]]||I18N.rank_prefix+m[1])+' '+(m[2]==='big'?I18N.big:I18N.small);}
|
||||
if(type==='oe'&&(m=value.match(/^rank(\d+)_(odd|even)$/))){return(rankNames[+m[1]]||I18N.rank_prefix+m[1])+' '+(m[2]==='odd'?I18N.odd:I18N.even);}
|
||||
if(type==='dt'&&(m=value.match(/^dt(\d+)_(dragon|tiger)$/))){const p=dtPairs[+m[1]]||[0,0];return(rankNames[p[0]]||p[0])+'vs'+(rankNames[p[1]]||p[1])+' '+(m[2]==='dragon'?I18N.dragon:I18N.tiger);}
|
||||
if(type==='sum'&&(m=value.match(/^sum_(\d+)$/))){return I18N.sum+' '+m[1];}
|
||||
if(type==='sum_bs'){const sm={sum_big:I18N.sum_big,sum_small:I18N.sum_small,sum_odd:I18N.sum_odd,sum_even:I18N.sum_even};return sm[value]||value;}
|
||||
return type+':'+value;
|
||||
}
|
||||
function selectChip(a){selectedChip=a;document.querySelectorAll('.chip').forEach(c=>{const amt=c.dataset.amount;c.classList.toggle('active',amt!=='custom'&&parseInt(amt)===a);});document.getElementById('customChipInput').classList.add('hidden');}
|
||||
function showCustomChip(){const b=document.getElementById('customChipInput');b.classList.toggle('hidden');if(!b.classList.contains('hidden'))document.getElementById('customAmount').focus();}
|
||||
function applyCustomChip(){const v=parseInt(document.getElementById('customAmount').value);if(!v||v<=0){showToast('<?=$t('error')?>: > 0','error');return;}selectedChip=v;document.querySelectorAll('.chip').forEach(c=>c.classList.remove('active'));const cb=document.getElementById('customChipBtn');cb.classList.add('active');cb.textContent=v>=1000?(v/1000)+'k':v;document.getElementById('customChipInput').classList.add('hidden');}
|
||||
function addBet(type,target,el){if(periodData&&periodData.status!=='pending'){showToast('<?=$t('period_closed')?>','error');return;}const key=type+'_'+target;if(!bets[key])bets[key]={type,value:target,amount:0,el:el};bets[key].amount+=selectedChip;el.classList.add('selected');let badge=el.querySelector('.bet-amount-badge');if(!badge){badge=document.createElement('span');badge.className='bet-amount-badge';el.appendChild(badge);}badge.textContent=bets[key].amount>=1000?(bets[key].amount/1000).toFixed(1)+'k':bets[key].amount;updateBetSummary();}
|
||||
function clearBets(){bets={};document.querySelectorAll('.bet-btn.selected,.bet-cell.selected').forEach(e=>{e.classList.remove('selected');const b=e.querySelector('.bet-amount-badge');if(b)b.remove();});updateBetSummary();}
|
||||
function updateBetSummary(){let total=0,count=0;Object.values(bets).forEach(b=>{total+=b.amount;count++;});document.getElementById('betTotal').textContent=total.toFixed(0);document.getElementById('betCount').textContent=count;}
|
||||
async function submitBets(){const arr=Object.values(bets);if(!arr.length){showToast('<?=$t('place_bet')?>','error');return;}if(!periodData||!periodData.period_number){showToast('<?=$t('waiting')?>','error');return;}try{const r=await fetch('/api/bet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({game_id:GAME_ID,period_number:periodData.period_number,bets:arr})});const d=await r.json();if(d.success){showToast(d.message,'ok');document.getElementById('balanceDisplay').textContent=parseFloat(d.new_balance).toFixed(2);clearBets();pollPeriod();}else{showToast(d.message,'error');}}catch(e){showToast(I18N.net_err,'error');}}
|
||||
function showBetTab(tab,btn){['Rank','Bs','Sum'].forEach(t=>{const p=document.getElementById('panel'+t);if(p)p.classList.toggle('hidden',t.toLowerCase()!==tab);});document.querySelectorAll('.side-menu .mi').forEach(m=>m.classList.remove('active'));if(btn)btn.classList.add('active');}
|
||||
|
||||
// ===== 赛车动画系统(完整保留) =====
|
||||
let raceAnimState='idle',lastAnimatedResult=null,idleAnimTimer=null,carPositions={},raceFrameTimer=null;
|
||||
const FINISH_LINE=88,START_LINE=3;
|
||||
for(let i=1;i<=10;i++)carPositions[i]=START_LINE;
|
||||
|
||||
function setCarPosition(n,pct,dur){const c=document.getElementById('carContainer'+n);const b=document.getElementById('bar'+n);if(!c)return;c.style.transition='left '+dur+'s ease';c.style.left=pct+'%';if(b){b.style.transition='width '+dur+'s ease';b.style.width=Math.min(pct+3,92)+'%';}carPositions[n]=pct;}
|
||||
function setCarMoving(n,moving,sprinting){const c=document.getElementById('carContainer'+n);if(!c)return;c.classList.toggle('moving',moving);c.classList.toggle('sprinting',!!sprinting);c.classList.remove('ready');}
|
||||
function setRankBadge(n,rank){const badge=document.getElementById('rankBadge'+n);if(!badge)return;const c=document.getElementById('carContainer'+n);if(rank>0){const labels=['1st','2nd','3rd','4th','5th','6th','7th','8th','9th','10th'];badge.textContent=labels[rank-1]||rank;if(c)c.classList.add('show-rank');}else{badge.textContent='';if(c)c.classList.remove('show-rank');}}
|
||||
// ANIM: idle
|
||||
function startIdleAnimation(){if(idleAnimTimer)return;raceAnimState='waiting';for(let i=1;i<=10;i++){setCarPosition(i,START_LINE+Math.random()*5,0.5);setCarMoving(i,true,false);setRankBadge(i,0);}idleAnimTimer=setInterval(function(){if(raceAnimState!=='waiting'){stopIdleAnimation();return;}for(let i=1;i<=10;i++){if(Math.random()<0.5){const cur=carPositions[i]||START_LINE;const target=Math.max(START_LINE,Math.min(15,cur+(Math.random()-0.5)*6));setCarPosition(i,target,0.5+Math.random()*0.3);}}},800);}
|
||||
function stopIdleAnimation(){if(idleAnimTimer){clearInterval(idleAnimTimer);idleAnimTimer=null;}if(raceFrameTimer){clearInterval(raceFrameTimer);raceFrameTimer=null;}for(let i=1;i<=10;i++){setCarMoving(i,false,false);const c=document.getElementById('carContainer'+i);if(c)c.classList.remove('ready');}}
|
||||
function startReadyAnimation(){stopIdleAnimation();raceAnimState='ready';for(let i=1;i<=10;i++){setCarPosition(i,START_LINE,0.8);setCarMoving(i,true,false);setRankBadge(i,0);const c=document.getElementById('carContainer'+i);if(c)c.classList.add('ready');}}
|
||||
// ANIM: race
|
||||
function animateRace(result){if(!result||result.length<10)return;const rk=result.join(',');if(lastAnimatedResult===rk&&raceAnimState==='finished')return;lastAnimatedResult=rk;stopIdleAnimation();raceAnimState='racing';const rankMap={};for(let i=0;i<10;i++)rankMap[result[i]]=i;const carSpeeds={},carTargets={};for(let i=1;i<=10;i++){const rank=rankMap[i]!==undefined?rankMap[i]:9;carSpeeds[i]=1.0-rank*0.06;carTargets[i]=FINISH_LINE-rank*7.5;setCarMoving(i,true,true);setRankBadge(i,0);}
|
||||
for(let i=1;i<=10;i++){const s=carSpeeds[i];setCarPosition(i,15+s*20+Math.random()*5,1.2+Math.random()*0.3);}
|
||||
setTimeout(function(){if(raceAnimState!=='racing')return;for(let i=1;i<=10;i++){const s=carSpeeds[i];setCarPosition(i,35+s*30+Math.random()*5,1.5+Math.random()*0.5);}},1500);
|
||||
setTimeout(function(){if(raceAnimState!=='racing')return;for(let i=1;i<=10;i++){const dur=1.5+rankMap[i]*0.05+Math.random()*0.2;setCarPosition(i,carTargets[i],dur);}},3500);
|
||||
setTimeout(function(){if(raceAnimState!=='racing')return;raceAnimState='finished';for(let i=1;i<=10;i++){setCarMoving(i,false,false);const rank=rankMap[i]!==undefined?rankMap[i]+1:0;setRankBadge(i,rank);}},5500);}
|
||||
function resetRace(){stopIdleAnimation();raceAnimState='idle';lastAnimatedResult=null;for(let i=1;i<=10;i++){const c=document.getElementById('carContainer'+i);const b=document.getElementById('bar'+i);if(c){c.style.transition='left 0.5s ease';c.style.left=START_LINE+'%';c.classList.remove('moving','sprinting','ready','show-rank');}if(b){b.style.transition='width 0.5s ease';b.style.width='5%';}carPositions[i]=START_LINE;setRankBadge(i,0);}}
|
||||
function renderResult(result,container){if(!result)return;const el=document.getElementById(container);el.innerHTML='';['rank_1','rank_2','rank_3','rank_4','rank_5','rank_6','rank_7','rank_8','rank_9','rank_10'].forEach(k=>{const v=result[k];if(!v)return;const d=document.createElement('div');d.className='car c'+v;d.style.cssText='width:28px;height:28px;font-size:11px';d.textContent=v;el.appendChild(d);});
|
||||
const tags=document.getElementById('lastResultTags');if(!tags)return;tags.innerHTML='';const r1=+result.rank_1,r2=+result.rank_2;if(r1&&r2){const sum=r1+r2;const items=[sum,(sum>=12?I18N.big:I18N.small),(sum%2?I18N.odd:I18N.even)];for(let i=1;i<=5;i++){const a=+result['rank_'+i],b=+result['rank_'+(11-i)];if(a&&b)items.push(a>b?I18N.dragon:I18N.tiger);}items.forEach(t=>{const s=document.createElement('span');s.textContent=t;tags.appendChild(s);});}}
|
||||
// 投注记录渲染
|
||||
function renderMyBets(myBets){const list=document.getElementById('myBetsList');const countEl=document.getElementById('myBetsCount');if(!myBets||!myBets.length){list.innerHTML='<div style="color:var(--text3);font-size:12px;text-align:center;padding:8px">'+I18N.no_bets+'</div>';countEl.textContent='0';return;}countEl.textContent=myBets.length;let total=0,html='';myBets.forEach(b=>{const label=formatBetLabel(b.bet_type,b.bet_value);const amt=parseFloat(b.amount);total+=amt;const sl=b.status==='pending'?'<span style="color:var(--warn)">'+I18N.draw+'</span>':b.status==='win'?'<span style="color:var(--success)">+'+parseFloat(b.win_amount).toFixed(0)+'</span>':'<span style="color:var(--danger)">-'+amt.toFixed(0)+'</span>';html+='<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:4px 0;border-bottom:1px solid var(--border)"><span style="color:var(--text2)">'+label+'</span><span style="color:var(--primary)">'+amt.toFixed(0)+' <span style="color:var(--text3)">x'+b.odds+'</span></span>'+sl+'</div>';});html+='<div style="display:flex;justify-content:space-between;font-size:12px;padding-top:4px;color:var(--text3)"><span>'+I18N.total+'</span><span style="color:var(--primary);font-weight:700">'+total.toFixed(0)+'</span></div>';list.innerHTML=html;}
|
||||
function renderLastMyBets(lastMyBets){const panel=document.getElementById('lastResultPanel');const list=document.getElementById('lastMyBetsList');const profitEl=document.getElementById('lastProfitDisplay');if(!lastMyBets||!lastMyBets.length){panel.classList.add('hidden');return;}panel.classList.remove('hidden');let totalWin=0,totalBet=0,html='';lastMyBets.forEach(b=>{const label=formatBetLabel(b.bet_type,b.bet_value);const amt=parseFloat(b.amount);totalBet+=amt;if(b.status==='win'){const win=parseFloat(b.win_amount);totalWin+=win+amt;html+='<div style="display:flex;justify-content:space-between;font-size:12px;padding:2px 0"><span style="color:var(--text2)">'+label+'</span><span style="color:var(--success)">+'+win.toFixed(0)+'</span></div>';}else{html+='<div style="display:flex;justify-content:space-between;font-size:12px;padding:2px 0"><span style="color:var(--text2)">'+label+'</span><span style="color:var(--danger)">-'+amt.toFixed(0)+'</span></div>';}});list.innerHTML=html;const profit=totalWin-totalBet;if(profit>0){profitEl.style.color='var(--success)';profitEl.textContent='+'+profit.toFixed(0);}else if(profit<0){profitEl.style.color='var(--danger)';profitEl.textContent=profit.toFixed(0);}else{profitEl.style.color='var(--text3)';profitEl.textContent='0';}}
|
||||
// 倒计时
|
||||
let localCountdown=0,lastPollStatus='',lastPeriodId=0,drawnResultShown=false;
|
||||
setInterval(function(){if(localCountdown>0)localCountdown--;const m=Math.floor(localCountdown/60),s=localCountdown%60;document.getElementById('countdown').textContent=localCountdown>0?m+':'+(s<10?'0':'')+s:'--';},1000);
|
||||
// 轮询期号
|
||||
async function pollPeriod(){try{const r=await fetch('/api/period/current?game_id='+GAME_ID);const d=await r.json();if(!d.success)return;periodData=d.data;
|
||||
const serverRemaining=periodData.remaining_seconds||0;const periodChanged=(periodData.id&&periodData.id!==lastPeriodId);const statusChanged=(periodData.status!==lastPollStatus);const driftTooMuch=Math.abs(localCountdown-serverRemaining)>3;
|
||||
if(periodChanged||statusChanged||driftTooMuch||localCountdown<=0)localCountdown=serverRemaining;
|
||||
if(d.balance!==null&&d.balance!==undefined)document.getElementById('balanceDisplay').textContent=parseFloat(d.balance).toFixed(2);
|
||||
// 状态徽章
|
||||
const badge=document.getElementById('statusBadge');const statusMap={pending:['<?=$t('betting')?>','seal-status','color:var(--success)'],locked:['<?=$t('sealed')?>','seal-status','color:var(--danger)'],drawn:[I18N.drawing,'seal-status','color:var(--warn)'],settled:[I18N.settled_label,'seal-status','color:var(--warn)']};const s=statusMap[periodData.status]||statusMap.pending;badge.textContent=s[0];badge.className=s[1];badge.style.cssText=s[2];
|
||||
// 期号
|
||||
const fullPn=periodData.period_number||'---';const shortPn=fullPn.length>8?fullPn.slice(0,-4)+'-'+fullPn.slice(-4):fullPn;const pnEl=document.getElementById('periodNum');pnEl.textContent=shortPn;pnEl.title=fullPn;
|
||||
// 提取结果
|
||||
function extractResultArr(res){if(!res)return null;const arr=[res.rank_1,res.rank_2,res.rank_3,res.rank_4,res.rank_5,res.rank_6,res.rank_7,res.rank_8,res.rank_9,res.rank_10];return arr.every(v=>v)?arr:null;}
|
||||
// 投注中→待机动画
|
||||
if(periodData.status==='pending'){if(statusChanged||periodChanged){if(d.last_result&&periodChanged&&raceAnimState!=='finished'){const ra=extractResultArr(d.last_result);if(ra){animateRace(ra);renderResult(d.last_result,'lastResult');const lp=d.last_result.period_number||'';document.getElementById('lastPeriodNum').textContent=lp.length>4?'#'+lp.slice(-4):lp;setTimeout(function(){resetRace();setTimeout(startIdleAnimation,500);},6500);}else{resetRace();setTimeout(startIdleAnimation,600);}}else{resetRace();setTimeout(startIdleAnimation,600);}}else if(raceAnimState==='idle'){startIdleAnimation();}}
|
||||
// 封盘→比赛或预备
|
||||
if(periodData.status==='locked'){const raceRes=extractResultArr(d.race_result);if(raceRes&&raceAnimState!=='racing'&&raceAnimState!=='finished'){animateRace(raceRes);}else if(!raceRes&&(statusChanged||(raceAnimState!=='ready'&&raceAnimState!=='racing'&&raceAnimState!=='finished'))){startReadyAnimation();}}
|
||||
// 开奖/结算
|
||||
if(d.last_result&&(periodData.status==='drawn'||periodData.status==='settled')){if(statusChanged||periodChanged){const lp=d.last_result.period_number||'';document.getElementById('lastPeriodNum').textContent=lp.length>4?'#'+lp.slice(-4):lp;const ra=extractResultArr(d.last_result);if(ra&&raceAnimState!=='finished')animateRace(ra);setTimeout(()=>{renderResult(d.last_result,'lastResult');},5500);}}
|
||||
// 首次加载
|
||||
if(d.last_result&&!lastAnimatedResult&&raceAnimState==='idle'){const ra=extractResultArr(d.last_result);if(ra){const lp=d.last_result.period_number||'';document.getElementById('lastPeriodNum').textContent=lp.length>4?'#'+lp.slice(-4):lp;animateRace(ra);setTimeout(()=>{renderResult(d.last_result,'lastResult');if(periodData.status==='pending')setTimeout(function(){resetRace();setTimeout(startIdleAnimation,500);},1500);},5500);}}
|
||||
renderMyBets(d.my_bets||[]);renderLastMyBets(d.last_my_bets||[]);
|
||||
// 历史
|
||||
if(d.history&&periodChanged){const hl=document.getElementById('historyList');hl.innerHTML='';d.history.forEach(h=>{const row=document.createElement('div');row.className='card';row.style.cssText='padding:6px 8px;margin-bottom:4px';const pn=h.period_number||'';const sp=pn.length>4?'#'+pn.slice(-4):pn;let balls='';['rank_1','rank_2','rank_3','rank_4','rank_5','rank_6','rank_7','rank_8','rank_9','rank_10'].forEach(k=>{if(h[k])balls+='<div class="car c'+h[k]+'" style="width:18px;height:18px;font-size:8px;flex-shrink:0">'+h[k]+'</div>';});row.innerHTML='<div style="color:var(--text3);font-size:10px;margin-bottom:2px">'+sp+'</div><div style="display:flex;gap:2px;flex-wrap:wrap">'+balls+'</div>';hl.appendChild(row);});}
|
||||
lastPollStatus=periodData.status;lastPeriodId=periodData.id||0;
|
||||
}catch(e){console.error('pollPeriod error:',e);}}
|
||||
function showToast(msg,type){const t=document.createElement('div');t.style.cssText='position:fixed;top:80px;left:50%;transform:translateX(-50%);padding:10px 24px;border-radius:24px;font-size:13px;z-index:999;color:#fff;box-shadow:0 4px 16px rgba(0,0,0,.15)';t.style.background=type==='ok'?'var(--success)':'var(--danger)';t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),2500);}
|
||||
async function setLang(l){await fetch('/api/set-lang',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({lang:l})});location.reload();}
|
||||
pollPeriod();
|
||||
(function dynamicPoll(){const delay=(lastPollStatus==='locked'||lastPollStatus==='drawn'||lastPollStatus==='settled')?1500:3000;setTimeout(function(){pollPeriod().then(dynamicPoll).catch(dynamicPoll);},delay);})();
|
||||
var PK10_CONFIG={
|
||||
gameId:<?=$gameId?>,
|
||||
lang:{
|
||||
champion:'<?=$t('champion')?>',runner_up:'<?=$t('runner_up')?>',
|
||||
rank_3:'<?=$t('rank_n',['n'=>3])?>',rank_4:'<?=$t('rank_n',['n'=>4])?>',rank_5:'<?=$t('rank_n',['n'=>5])?>',
|
||||
rank_6:'<?=$t('rank_n',['n'=>6])?>',rank_7:'<?=$t('rank_n',['n'=>7])?>',rank_8:'<?=$t('rank_n',['n'=>8])?>',
|
||||
rank_9:'<?=$t('rank_n',['n'=>9])?>',rank_10:'<?=$t('rank_n',['n'=>10])?>',
|
||||
big:'<?=$t('big')?>',small:'<?=$t('small')?>',odd:'<?=$t('odd')?>',even:'<?=$t('even')?>',
|
||||
dragon:'<?=$t('dragon')?>',tiger:'<?=$t('tiger')?>',sum:'<?=$t('sum')?>',
|
||||
sum_big:'<?=$t('sum_big')?>',sum_small:'<?=$t('sum_small')?>',sum_odd:'<?=$t('sum_odd')?>',sum_even:'<?=$t('sum_even')?>',
|
||||
car_no:'<?=$t('car_no')?>',no_data:'<?=$t('no_data')?>',total:'<?=$t('total')?>',
|
||||
win:'<?=$t('win')?>',lose:'<?=$t('lose')?>',pending_status:'<?=$t('pending')?>',
|
||||
error:'<?=$t('error')?>',rank_prefix:'<?=$t('rank_n',['n'=>''])?>',
|
||||
drawing:'<?=$t('drawing')?>',settled:'<?=$t('settled')?>',
|
||||
period_closed:'<?=$t('period_closed')?>',place_bet:'<?=$t('place_bet')?>',
|
||||
waiting:'<?=$t('waiting')?>',bets_added:'<?=$t('bets_added')?>',
|
||||
sealed:'<?=$t('sealed')?>',betting:'<?=$t('betting')?>'
|
||||
}
|
||||
};
|
||||
// Shared global state (accessible by all pk10-*.js files)
|
||||
var GAME_ID=PK10_CONFIG.gameId;
|
||||
var selectedChip=500,bets={},periodData=null,pollTimer=null,historyCache=[];
|
||||
var rankNames={1:PK10_CONFIG.lang.champion,2:PK10_CONFIG.lang.runner_up,3:PK10_CONFIG.lang.rank_3,4:PK10_CONFIG.lang.rank_4,5:PK10_CONFIG.lang.rank_5,6:PK10_CONFIG.lang.rank_6,7:PK10_CONFIG.lang.rank_7,8:PK10_CONFIG.lang.rank_8,9:PK10_CONFIG.lang.rank_9,10:PK10_CONFIG.lang.rank_10};
|
||||
var dtPairs={1:[1,10],2:[2,9],3:[3,8],4:[4,7],5:[5,6]};
|
||||
var I18N={big:PK10_CONFIG.lang.big,small:PK10_CONFIG.lang.small,odd:PK10_CONFIG.lang.odd,even:PK10_CONFIG.lang.even,dragon:PK10_CONFIG.lang.dragon,tiger:PK10_CONFIG.lang.tiger,sum:PK10_CONFIG.lang.sum,sum_big:PK10_CONFIG.lang.sum_big,sum_small:PK10_CONFIG.lang.sum_small,sum_odd:PK10_CONFIG.lang.sum_odd,sum_even:PK10_CONFIG.lang.sum_even,car:PK10_CONFIG.lang.car_no,no_bets:PK10_CONFIG.lang.no_data,total:PK10_CONFIG.lang.total,items:'',profit:PK10_CONFIG.lang.win,loss:PK10_CONFIG.lang.lose,draw:PK10_CONFIG.lang.pending_status,net_err:PK10_CONFIG.lang.error,rank_prefix:PK10_CONFIG.lang.rank_prefix,drawing:PK10_CONFIG.lang.drawing,settled_label:PK10_CONFIG.lang.settled,period_closed:PK10_CONFIG.lang.period_closed,place_bet:PK10_CONFIG.lang.place_bet,waiting:PK10_CONFIG.lang.waiting,bets_added:PK10_CONFIG.lang.bets_added};
|
||||
// Race state
|
||||
var raceAnimState='idle',lastAnimatedResult=null,idleAnimTimer=null,carPositions={};
|
||||
var raceFrameTimer=null,ballRollTimer=null;
|
||||
var FINISH_LINE=88,START_LINE=3;
|
||||
for(var _i=1;_i<=10;_i++)carPositions[_i]=START_LINE;
|
||||
var CAR_COLORS={1:['#f1c40f','#d4ac0d'],2:['#3498db','#2980b9'],3:['#555555','#333333'],4:['#e67e22','#ca6f1e'],5:['#2ecc71','#27ae60'],6:['#2c3e99','#1a2a6c'],7:['#999999','#777777'],8:['#e74c3c','#c0392b'],9:['#cc2222','#991111'],10:['#27ae60','#1e8449']};
|
||||
var trafficLightTimers=[];
|
||||
var raceModalOpen=false,currentView='anim';
|
||||
// Poll state
|
||||
var localCountdown=0,localLockCountdown=0,lastPollStatus='',lastPeriodId=0,drawnResultShown=false;
|
||||
var cachedRaceResult=null,raceTriggered=false,pendingRaceHeader=null;
|
||||
// Bet state
|
||||
var curMissRank=0;
|
||||
</script>
|
||||
<!-- PK10 JS modules (order matters) -->
|
||||
<?php $jsVer = '20260325b'; ?>
|
||||
<script src="/Static/js/pk10-sound.js?v=<?=$jsVer?>"></script>
|
||||
<script src="/Static/js/pk10-race.js?v=<?=$jsVer?>"></script>
|
||||
<script src="/Static/js/pk10-bet.js?v=<?=$jsVer?>"></script>
|
||||
<script src="/Static/js/pk10-poll.js?v=<?=$jsVer?>"></script>
|
||||
<?php if(!empty($game['stream_url'])): ?>
|
||||
<script src="/Static/ckplayer/hls.js/hls.min.js"></script>
|
||||
<script src="/Static/ckplayer/flv.js/flv.min.js"></script>
|
||||
<script src="/Static/ckplayer/mpegts.js/mpegts.min.js"></script>
|
||||
<script src="/Static/ckplayer/js/ckplayer.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
var el=document.getElementById('pk10VideoPlayer');
|
||||
if(!el||typeof ckplayer!=='function')return;
|
||||
var url=el.getAttribute('data-stream')||'';
|
||||
if(!url)return;
|
||||
var type='hls';
|
||||
if(url.indexOf('.flv')>-1)type='flv';
|
||||
else if(url.indexOf('.ts')>-1||url.indexOf('mpegts')>-1)type='mpegts';
|
||||
window.pk10Player=ckplayer({
|
||||
container:'#pk10VideoPlayer',
|
||||
live:true,
|
||||
autoplay:true,
|
||||
volume:0,
|
||||
controls:true,
|
||||
rightBar:false,
|
||||
video:url,
|
||||
type:type,
|
||||
playsinline:true
|
||||
});
|
||||
setTimeout(function(){
|
||||
var v=document.querySelector('#pk10VideoPlayer video');
|
||||
if(v){v.muted=true;v.play().catch(function(){});}
|
||||
},800);
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</body></html>
|
||||
|
||||
@@ -88,7 +88,7 @@ for($i=1;$i<=10;$i++) $rankLabels[$i]=$i<=2?($i==1?$t('champion'):$t('runner_up'
|
||||
|
||||
<script>
|
||||
const DATA=<?=json_encode(array_values($results??[]))?>;
|
||||
const COLORS=['','#e74c3c','#3498db','#2ecc71','#f39c12','#9b59b6','#1abc9c','#e67e22','#e91e63','#00bcd4','#8bc34a'];
|
||||
const COLORS=['','#f1c40f','#3498db','#555555','#e67e22','#2ecc71','#2c3e99','#999999','#e74c3c','#cc2222','#27ae60'];
|
||||
const rankLabels=<?=json_encode(array_values($rankLabels))?>;
|
||||
const I18N={big:'<?=$t('big')?>',small:'<?=$t('small')?>',odd:'<?=$t('odd')?>',even:'<?=$t('even')?>',dragon:'<?=$t('dragon')?>',tiger:'<?=$t('tiger')?>',sum_big:'<?=$t('sum_bs_big')?>',sum_small:'<?=$t('sum_bs_small')?>',sum_odd:'<?=$t('sum_bs_odd')?>',sum_even:'<?=$t('sum_bs_even')?>'};
|
||||
let curRank=1,curBead='rank';
|
||||
|
||||
@@ -49,15 +49,15 @@
|
||||
<div class="wallet-header">
|
||||
<div class="wallet-title"><?=$t('balance')?></div>
|
||||
<div class="wallet-actions">
|
||||
<button class="action-btn" onclick="alert('<?=$t('deposit')?>')">
|
||||
<button class="action-btn" onclick="doDeposit()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
||||
<span><?=$t('deposit')?></span>
|
||||
</button>
|
||||
<button class="action-btn" onclick="alert('<?=$t('withdraw')?>')">
|
||||
<button class="action-btn" onclick="doWithdraw()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
<span><?=$t('withdraw')?></span>
|
||||
</button>
|
||||
<button class="action-btn" onclick="alert('<?=$t('transfer')?>')">
|
||||
<button class="action-btn" onclick="doTransfer()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
<span><?=$t('transfer')?></span>
|
||||
</button>
|
||||
@@ -81,6 +81,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报表与跟单 -->
|
||||
<div class="section">
|
||||
<div class="section-title"><?=$t('bet_history')?></div>
|
||||
<a href="/report" class="menu-item" style="text-decoration:none">
|
||||
<div class="menu-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4z"/></svg></div>
|
||||
<div class="menu-text"><?=$t('report_query')?></div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
<a href="/follow-plan" class="menu-item" style="text-decoration:none">
|
||||
<div class="menu-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#1E90FF" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><path d="M9 14l2 2 4-4"/></svg></div>
|
||||
<div class="menu-text"><?=$t('follow_plan')?></div>
|
||||
<div class="menu-arrow">›</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 投注记录 -->
|
||||
<div class="section">
|
||||
<div class="section-title"><?=$t('bet_history')?></div>
|
||||
@@ -111,6 +126,25 @@
|
||||
<?php endforeach; endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- 充提记录 -->
|
||||
<div class="section">
|
||||
<div class="section-title"><?=$t('fund_request_history')?></div>
|
||||
<?php if(empty($fundRequests)): ?>
|
||||
<div style="text-align:center;color:#999;padding:40px 20px;font-size:14px"><?=$t('no_data')?></div>
|
||||
<?php else: foreach($fundRequests as $fr): $isDeposit=$fr['type']==='deposit'; ?>
|
||||
<div class="menu-item" style="cursor:default">
|
||||
<div style="flex:1">
|
||||
<div style="font-size:14px;color:#333"><?=$isDeposit?$t('deposit'):$t('withdraw')?></div>
|
||||
<div style="font-size:11px;color:#999;margin-top:2px"><?=$fr['created_at']?></div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-size:15px;font-weight:600;color:<?=$isDeposit?'#4CAF50':'#FF4444'?>"><?=$isDeposit?'+':'-'?><?=number_format($fr['amount'],2)?></div>
|
||||
<span style="font-size:12px;color:<?=$fr['status']==='approved'?'#4CAF50':($fr['status']==='rejected'?'#FF4444':'#FF9800')?>"><?=$t('fund_status_'.$fr['status'])?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- 交易记录 -->
|
||||
<div class="section">
|
||||
<div class="section-title"><?=$t('transaction_history')?></div>
|
||||
@@ -143,5 +177,52 @@
|
||||
|
||||
<script>
|
||||
async function bindUsdt(){const addr=document.getElementById('usdtAddr').value.trim();const r=await fetch('/api/bind-usdt',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({usdt_address:addr})});const d=await r.json();alert(d.message);if(d.success)location.reload();}
|
||||
|
||||
function doDeposit(){
|
||||
var url='<?=\App\Core\SettingsHelper::get("customer_service_url")?>';
|
||||
if(!url){alert('<?=$t("contact_admin")?>');return;}
|
||||
var amount=prompt('<?=$t("deposit_amount")?>');
|
||||
if(!amount||isNaN(amount)||parseFloat(amount)<=0)return;
|
||||
fetch('/api/fund-request',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({type:'deposit',amount:parseFloat(amount)})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
if(d.success){
|
||||
alert('<?=$t("deposit_submitted")?>');
|
||||
window.open(url,'_blank');
|
||||
location.reload();
|
||||
} else {
|
||||
alert(d.message||'<?=$t("error")?>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doWithdraw(){
|
||||
var amount=prompt('<?=$t("withdraw_amount")?>');
|
||||
if(!amount||isNaN(amount)||parseFloat(amount)<=0)return;
|
||||
fetch('/api/fund-request',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({type:'withdraw',amount:parseFloat(amount)})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
if(d.success){alert('<?=$t("withdraw_submitted")?>');location.reload();}
|
||||
else{alert(d.message||'<?=$t("error")?>');}
|
||||
});
|
||||
}
|
||||
function doTransfer(){
|
||||
var to=prompt('<?=$t("transfer_to")?>');
|
||||
if(!to||!to.trim())return;
|
||||
var amount=prompt('<?=$t("transfer_amount")?>');
|
||||
if(!amount||isNaN(amount)||parseFloat(amount)<=0)return;
|
||||
if(!confirm('<?=$t("transfer_confirm")?> '+to+' , <?=$t("amount")?>: '+amount))return;
|
||||
fetch('/api/transfer',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({to_username:to.trim(),amount:parseFloat(amount)})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
if(d.success){alert(d.message||'<?=$t("transfer_success")?>');location.reload();}
|
||||
else{alert(d.message||'<?=$t("error")?>');}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php use App\Core\I18n; I18n::init(); $t = function($k,$p=[]){return I18n::t($k,$p);}; ?>
|
||||
<!DOCTYPE html><html lang="<?=I18n::getLang()?>">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title><?=$t('report_query')?></title>
|
||||
<link rel="stylesheet" href="/Static/css/app.css">
|
||||
<style>
|
||||
.page-header{display:flex;align-items:center;padding:12px 16px;background:#fff;border-bottom:1px solid #F0F0F0;position:sticky;top:0;z-index:10}
|
||||
.page-header .back{width:32px;height:32px;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.page-header .title{flex:1;text-align:center;font-size:17px;font-weight:600;color:#333}
|
||||
.page-header .balance{font-size:13px;color:#1E90FF;display:flex;align-items:center;gap:4px}
|
||||
.date-tabs{display:flex;gap:0;background:#fff;padding:12px 16px 0;flex-wrap:wrap}
|
||||
.date-tab{padding:6px 14px;font-size:13px;color:#666;cursor:pointer;border-bottom:2px solid transparent;background:none;border-top:none;border-left:none;border-right:none}
|
||||
.date-tab.active{color:#1E90FF;border-bottom-color:#1E90FF;font-weight:600}
|
||||
.report-table{width:100%;background:#fff;margin-top:1px}
|
||||
.report-table th{font-size:12px;color:#999;font-weight:400;padding:10px 8px;text-align:center;border-bottom:1px solid #F0F0F0}
|
||||
.report-table td{font-size:13px;color:#333;padding:10px 8px;text-align:center;border-bottom:1px solid #F5F5F5}
|
||||
.report-table .win{color:#4CAF50;font-weight:600}
|
||||
.report-table .lose{color:#FF4444;font-weight:600}
|
||||
.empty-state{text-align:center;padding:60px 20px;color:#999;font-size:14px}
|
||||
.summary-bar{display:flex;justify-content:space-around;background:#fff;padding:12px 16px;border-bottom:1px solid #F0F0F0}
|
||||
.summary-item{text-align:center}
|
||||
.summary-item .label{font-size:11px;color:#999}
|
||||
.summary-item .value{font-size:16px;font-weight:700;margin-top:2px}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background:#F5F5F5;padding-bottom:20px">
|
||||
|
||||
<div class="page-header">
|
||||
<a href="/profile" class="back"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></a>
|
||||
<div class="title"><?=$t('report_query')?></div>
|
||||
<div class="balance"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v12M8 10h8M8 14h8"/></svg> <?=number_format($user['balance']??0,2)?></div>
|
||||
</div>
|
||||
|
||||
<div class="date-tabs">
|
||||
<button class="date-tab active" data-range="today"><?=$t('today')?></button>
|
||||
<button class="date-tab" data-range="yesterday"><?=$t('yesterday')?></button>
|
||||
<button class="date-tab" data-range="7days"><?=$t('last_7_days')?></button>
|
||||
<button class="date-tab" data-range="30days"><?=$t('last_30_days')?></button>
|
||||
<button class="date-tab" data-range="custom"><?=$t('custom_date')?></button>
|
||||
</div>
|
||||
|
||||
<div id="customDateRow" style="display:none;background:#fff;padding:8px 16px;gap:8px;align-items:center">
|
||||
<input type="date" id="dateFrom" style="flex:1;padding:6px 8px;border:1px solid #ddd;border-radius:6px;font-size:13px">
|
||||
<span style="color:#999">-</span>
|
||||
<input type="date" id="dateTo" style="flex:1;padding:6px 8px;border:1px solid #ddd;border-radius:6px;font-size:13px">
|
||||
<button onclick="loadReport()" style="padding:6px 16px;background:#1E90FF;color:#fff;border:none;border-radius:6px;font-size:13px"><?=$t('search')?></button>
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
<div class="summary-item">
|
||||
<div class="label"><?=$t('bet_amount')?></div>
|
||||
<div class="value" id="sumBet">0.00</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label"><?=$t('effective_flow')?></div>
|
||||
<div class="value" id="sumFlow">0.00</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label"><?=$t('win_loss')?></div>
|
||||
<div class="value" id="sumWinLoss" style="color:#333">0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?=$t('bet_type')?></th>
|
||||
<th><?=$t('bet_amount')?></th>
|
||||
<th><?=$t('amount')?></th>
|
||||
<th><?=$t('effective_flow')?></th>
|
||||
<th><?=$t('rebate_amount')?></th>
|
||||
<th><?=$t('win_loss')?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportBody">
|
||||
<tr><td colspan="6" class="empty-state"><?=$t('no_data')?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
var currentRange='today';
|
||||
document.querySelectorAll('.date-tab').forEach(function(btn){
|
||||
btn.addEventListener('click',function(){
|
||||
document.querySelectorAll('.date-tab').forEach(function(b){b.classList.remove('active');});
|
||||
btn.classList.add('active');
|
||||
currentRange=btn.dataset.range;
|
||||
document.getElementById('customDateRow').style.display=currentRange==='custom'?'flex':'none';
|
||||
if(currentRange!=='custom')loadReport();
|
||||
});
|
||||
});
|
||||
|
||||
function getDateRange(){
|
||||
var now=new Date();
|
||||
var fmt=function(d){var y=d.getFullYear(),m=('0'+(d.getMonth()+1)).slice(-2),dd=('0'+d.getDate()).slice(-2);return y+'-'+m+'-'+dd;};
|
||||
if(currentRange==='today')return{from:fmt(now),to:fmt(now)};
|
||||
if(currentRange==='yesterday'){var y=new Date(now);y.setDate(y.getDate()-1);return{from:fmt(y),to:fmt(y)};}
|
||||
if(currentRange==='7days'){var d7=new Date(now);d7.setDate(d7.getDate()-6);return{from:fmt(d7),to:fmt(now)};}
|
||||
if(currentRange==='30days'){var d30=new Date(now);d30.setDate(d30.getDate()-29);return{from:fmt(d30),to:fmt(now)};}
|
||||
return{from:document.getElementById('dateFrom').value,to:document.getElementById('dateTo').value};
|
||||
}
|
||||
|
||||
function loadReport(){
|
||||
var r=getDateRange();
|
||||
if(!r.from||!r.to)return;
|
||||
fetch('/api/user-report?from='+r.from+'&to='+r.to)
|
||||
.then(function(res){return res.json();})
|
||||
.then(function(d){
|
||||
if(!d.success)return;
|
||||
var body=document.getElementById('reportBody');
|
||||
var sumBet=0,sumFlow=0,sumWL=0;
|
||||
if(!d.data||!d.data.length){
|
||||
body.innerHTML='<tr><td colspan="6" class="empty-state"><?=$t('no_data')?></td></tr>';
|
||||
document.getElementById('sumBet').textContent='0.00';
|
||||
document.getElementById('sumFlow').textContent='0.00';
|
||||
document.getElementById('sumWinLoss').textContent='0.00';
|
||||
return;
|
||||
}
|
||||
var typeMap={bs:'<?=$t('big')?>/<?=$t('small')?>',oe:'<?=$t('odd')?>/<?=$t('even')?>',dt:'<?=$t('dragon')?>/<?=$t('tiger')?>',rank:'<?=$t('rank_1_10')?>',sum:'<?=$t('sum')?>',sum_bs:'<?=$t('sum_bs_tab')?>'};
|
||||
var html='';
|
||||
d.data.forEach(function(row){
|
||||
var wl=parseFloat(row.win_loss);
|
||||
var typeName=typeMap[row.type]||row.type;
|
||||
sumBet+=parseFloat(row.bet_count);
|
||||
sumFlow+=parseFloat(row.effective_flow);
|
||||
sumWL+=wl;
|
||||
html+='<tr><td>'+typeName+'</td><td>'+row.bet_count+'</td><td>'+parseFloat(row.amount).toFixed(2)+'</td><td>'+parseFloat(row.effective_flow).toFixed(2)+'</td><td>'+parseFloat(row.rebate).toFixed(2)+'</td><td class="'+(wl>=0?'win':'lose')+'">'+wl.toFixed(2)+'</td></tr>';
|
||||
});
|
||||
body.innerHTML=html;
|
||||
document.getElementById('sumBet').textContent=sumBet;
|
||||
document.getElementById('sumFlow').textContent=sumFlow.toFixed(2);
|
||||
var wlEl=document.getElementById('sumWinLoss');
|
||||
wlEl.textContent=sumWL.toFixed(2);
|
||||
wlEl.style.color=sumWL>=0?'#4CAF50':'#FF4444';
|
||||
});
|
||||
}
|
||||
loadReport();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE `bot_push_logs`
|
||||
ADD COLUMN `claim_token` varchar(64) DEFAULT NULL COMMENT '推送领取令牌' AFTER `error_message`,
|
||||
ADD COLUMN `claimed_at` datetime DEFAULT NULL COMMENT '推送领取时间' AFTER `claim_token`;
|
||||
|
||||
ALTER TABLE `bot_push_logs`
|
||||
ADD KEY `idx_claim_token` (`claim_token`),
|
||||
ADD KEY `idx_claimed_at` (`claimed_at`);
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 跟单计划表
|
||||
CREATE TABLE IF NOT EXISTS `follow_plans` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`game_id` int(11) NOT NULL DEFAULT 1,
|
||||
`name` varchar(100) NOT NULL COMMENT '计划名称,如:大小计划、单双计划',
|
||||
`plan_type` varchar(30) NOT NULL COMMENT '计划类型:bs(大小), oe(单双), dt(龙虎), sum_bs(冠亚大小)',
|
||||
`target_rank` int(11) NOT NULL DEFAULT 1 COMMENT '目标名次 1-10',
|
||||
`strategy` varchar(30) NOT NULL DEFAULT 'follow' COMMENT '策略:follow(跟投), reverse(反投)',
|
||||
`bet_amount` decimal(15,2) NOT NULL DEFAULT 100.00 COMMENT '每期建议投注额',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用',
|
||||
`created_by` int(11) DEFAULT NULL COMMENT '创建管理员ID',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_game_status` (`game_id`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 跟单计划每期推荐记录
|
||||
CREATE TABLE IF NOT EXISTS `follow_plan_records` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`plan_id` int(11) NOT NULL,
|
||||
`period_id` int(11) NOT NULL,
|
||||
`period_number` varchar(50) NOT NULL,
|
||||
`recommend_value` varchar(20) NOT NULL COMMENT '推荐值:big/small/odd/even/dragon/tiger',
|
||||
`result_value` varchar(20) DEFAULT NULL COMMENT '实际结果',
|
||||
`is_win` tinyint(1) DEFAULT NULL COMMENT '1=赢 0=输 NULL=未开奖',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_plan_period` (`plan_id`, `period_id`),
|
||||
KEY `idx_plan_id` (`plan_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 用户跟单记录
|
||||
CREATE TABLE IF NOT EXISTS `user_follow_plans` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`plan_id` int(11) NOT NULL,
|
||||
`is_active` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否正在跟单',
|
||||
`total_bets` int(11) NOT NULL DEFAULT 0 COMMENT '总跟注期数',
|
||||
`total_wins` int(11) NOT NULL DEFAULT 0 COMMENT '总赢期数',
|
||||
`total_profit` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT '累计盈亏',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_plan` (`user_id`, `plan_id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 充提申请表
|
||||
CREATE TABLE IF NOT EXISTS `fund_requests` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`type` enum('deposit','withdraw') NOT NULL COMMENT '充值/提现',
|
||||
`amount` decimal(15,2) NOT NULL,
|
||||
`status` enum('pending','approved','rejected') DEFAULT 'pending',
|
||||
`remark` text COMMENT '用户备注',
|
||||
`admin_remark` text COMMENT '管理员备注',
|
||||
`operator_id` int(11) DEFAULT NULL COMMENT '审核人ID',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`processed_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_type_status` (`type`, `status`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充提申请记录';
|
||||
@@ -0,0 +1,226 @@
|
||||
-- =========================================
|
||||
-- TG Bot Integration - Database Migration v1
|
||||
-- 目标:为 Telegram 机器人接入提供独立业务层,不直接侵入核心下注/账务表
|
||||
-- 兼容:MySQL 5.7+
|
||||
-- =========================================
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 1) 机器人实例表
|
||||
CREATE TABLE IF NOT EXISTS `bot_instances` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(64) NOT NULL COMMENT '机器人名称',
|
||||
`bot_token` varchar(255) NOT NULL COMMENT 'Telegram Bot Token',
|
||||
`bot_username` varchar(64) DEFAULT NULL COMMENT '机器人用户名',
|
||||
`bot_key` varchar(64) NOT NULL COMMENT '公开访问标识,用于X-Bot-Key',
|
||||
`bot_secret` varchar(128) NOT NULL COMMENT 'Bot API 共享密钥/HMAC secret',
|
||||
`webhook_url` varchar(255) DEFAULT NULL COMMENT 'Webhook 地址(可为空)',
|
||||
`run_mode` enum('polling','webhook') NOT NULL DEFAULT 'polling',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
|
||||
`remark` varchar(255) DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_bot_name` (`name`),
|
||||
UNIQUE KEY `uk_bot_key` (`bot_key`),
|
||||
UNIQUE KEY `uk_bot_username` (`bot_username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='TG机器人实例';
|
||||
|
||||
-- 2) 下注格式规则表(每群可配置不同格式)
|
||||
CREATE TABLE IF NOT EXISTS `bot_bet_format_rules` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`rule_code` varchar(64) NOT NULL COMMENT '规则编码,如 pk10_v1',
|
||||
`name` varchar(64) NOT NULL COMMENT '规则名称',
|
||||
`game_id` int(11) NOT NULL COMMENT '对应游戏ID',
|
||||
`parser_type` varchar(32) NOT NULL DEFAULT 'regex' COMMENT 'regex/json/custom',
|
||||
`rule_config` json DEFAULT NULL COMMENT '解析规则JSON',
|
||||
`example_text` text COMMENT '示例文本',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_rule_code` (`rule_code`),
|
||||
KEY `idx_game_id` (`game_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='TG下注格式规则';
|
||||
|
||||
-- 3) 群配置表
|
||||
CREATE TABLE IF NOT EXISTS `bot_groups` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`bot_id` int(11) NOT NULL COMMENT '关联 bot_instances.id',
|
||||
`tg_group_id` varchar(32) NOT NULL COMMENT 'Telegram群ID,保留-100前缀',
|
||||
`group_name` varchar(128) NOT NULL COMMENT '群名称快照',
|
||||
`group_type` varchar(16) NOT NULL DEFAULT 'group' COMMENT 'group/supergroup/channel',
|
||||
`game_id` int(11) NOT NULL COMMENT '群默认游戏',
|
||||
`bet_format_rule_id` int(11) DEFAULT NULL COMMENT '关联 bot_bet_format_rules.id',
|
||||
`remind_bet_success` tinyint(1) NOT NULL DEFAULT 1 COMMENT '下注成功提醒',
|
||||
`remind_draw_result` tinyint(1) NOT NULL DEFAULT 1 COMMENT '开奖提醒',
|
||||
`remind_close_countdown` tinyint(1) NOT NULL DEFAULT 1 COMMENT '封盘倒计时提醒',
|
||||
`countdown_config` json DEFAULT NULL COMMENT '如 [60,30,10]',
|
||||
`animation_enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '开奖动画/截图开关',
|
||||
`bet_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否允许群内下注同步',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tg_group_id` (`tg_group_id`),
|
||||
KEY `idx_bot_id` (`bot_id`),
|
||||
KEY `idx_game_id` (`game_id`),
|
||||
KEY `idx_rule_id` (`bet_format_rule_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='TG群配置';
|
||||
|
||||
-- 4) 群总账号绑定表
|
||||
CREATE TABLE IF NOT EXISTS `bot_group_wallets` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int(11) NOT NULL COMMENT '关联 bot_groups.id',
|
||||
`platform_user_id` int(11) NOT NULL COMMENT '网站总账号 users.id',
|
||||
`platform_username_snapshot` varchar(64) DEFAULT NULL COMMENT '用户名快照',
|
||||
`wallet_mode` enum('master_pool','per_member') NOT NULL DEFAULT 'master_pool' COMMENT '首发建议 master_pool',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_group_wallet` (`group_id`),
|
||||
KEY `idx_platform_user_id` (`platform_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群绑定的网站总账号';
|
||||
|
||||
-- 5) 群成员映射表
|
||||
CREATE TABLE IF NOT EXISTS `bot_group_members` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int(11) NOT NULL,
|
||||
`tg_user_id` varchar(32) NOT NULL,
|
||||
`tg_username` varchar(64) DEFAULT NULL,
|
||||
`tg_nickname` varchar(128) DEFAULT NULL,
|
||||
`platform_user_id` int(11) DEFAULT NULL COMMENT '如后续启用 per_member 模式使用',
|
||||
`role` enum('member','admin','shill') NOT NULL DEFAULT 'member',
|
||||
`bet_enabled` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`last_seen_at` datetime DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_group_tg_user` (`group_id`,`tg_user_id`),
|
||||
KEY `idx_platform_user_id` (`platform_user_id`),
|
||||
KEY `idx_role` (`role`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群成员映射';
|
||||
|
||||
-- 6) 托号表(冗余显式表,便于运营查询与统计排除)
|
||||
CREATE TABLE IF NOT EXISTS `bot_shills` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int(11) NOT NULL,
|
||||
`tg_user_id` varchar(32) NOT NULL,
|
||||
`note` varchar(255) DEFAULT NULL,
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_group_shill` (`group_id`,`tg_user_id`),
|
||||
KEY `idx_enabled` (`enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='托号配置';
|
||||
|
||||
-- 7) 消息下注订单表
|
||||
CREATE TABLE IF NOT EXISTS `bot_bet_orders` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int(11) NOT NULL,
|
||||
`tg_chat_id` varchar(32) NOT NULL,
|
||||
`tg_message_id` bigint(20) NOT NULL,
|
||||
`tg_user_id` varchar(32) NOT NULL,
|
||||
`tg_username` varchar(64) DEFAULT NULL,
|
||||
`platform_user_id` int(11) NOT NULL COMMENT '首发取群总账号',
|
||||
`game_id` int(11) NOT NULL,
|
||||
`period_number` varchar(50) NOT NULL,
|
||||
`raw_text` text NOT NULL COMMENT '原始Telegram消息',
|
||||
`parsed_payload_json` json DEFAULT NULL COMMENT '解析后的标准bets结构',
|
||||
`bet_amount_total` decimal(15,2) NOT NULL DEFAULT '0.00',
|
||||
`accepted_bet_count` int(11) NOT NULL DEFAULT 0,
|
||||
`platform_order_ref` varchar(64) DEFAULT NULL COMMENT '平台侧订单引用,可为空后续回填',
|
||||
`idempotency_key` varchar(128) NOT NULL COMMENT '如 tg:-100xxx:12345',
|
||||
`sync_status` enum('pending','success','failed','duplicate') NOT NULL DEFAULT 'pending',
|
||||
`sync_error` varchar(255) DEFAULT NULL,
|
||||
`is_shill` tinyint(1) NOT NULL DEFAULT 0 COMMENT '下注者是否托号快照',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_idempotency_key` (`idempotency_key`),
|
||||
UNIQUE KEY `uk_chat_message` (`tg_chat_id`,`tg_message_id`),
|
||||
KEY `idx_group_id` (`group_id`),
|
||||
KEY `idx_platform_user_id` (`platform_user_id`),
|
||||
KEY `idx_period_number` (`period_number`),
|
||||
KEY `idx_sync_status` (`sync_status`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='TG消息下注订单';
|
||||
|
||||
-- 8) Bot API 请求日志(审计+重放排查)
|
||||
CREATE TABLE IF NOT EXISTS `bot_api_request_logs` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`bot_id` int(11) DEFAULT NULL,
|
||||
`group_id` int(11) DEFAULT NULL,
|
||||
`request_uri` varchar(255) NOT NULL,
|
||||
`http_method` varchar(10) NOT NULL,
|
||||
`idempotency_key` varchar(128) DEFAULT NULL,
|
||||
`request_body` mediumtext,
|
||||
`response_body` mediumtext,
|
||||
`response_code` int(11) DEFAULT NULL,
|
||||
`client_ip` varchar(64) DEFAULT NULL,
|
||||
`signature_ok` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_bot_id` (`bot_id`),
|
||||
KEY `idx_group_id` (`group_id`),
|
||||
KEY `idx_idempotency_key` (`idempotency_key`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Bot API请求日志';
|
||||
|
||||
-- 9) 推送日志(开奖/封盘/下注成功/上下分)
|
||||
CREATE TABLE IF NOT EXISTS `bot_push_logs` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int(11) NOT NULL,
|
||||
`period_number` varchar(50) DEFAULT NULL,
|
||||
`push_type` enum('countdown','bet_success','draw','credit','debit','system') NOT NULL,
|
||||
`payload_json` json DEFAULT NULL,
|
||||
`tg_message_id` bigint(20) DEFAULT NULL,
|
||||
`status` enum('pending','success','failed','skipped') NOT NULL DEFAULT 'pending',
|
||||
`error_message` varchar(255) DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_group_id` (`group_id`),
|
||||
KEY `idx_period_number` (`period_number`),
|
||||
KEY `idx_push_type` (`push_type`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Bot推送日志';
|
||||
|
||||
-- 10) 群上下分申请/记录表
|
||||
CREATE TABLE IF NOT EXISTS `bot_fund_requests` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int(11) NOT NULL,
|
||||
`tg_user_id` varchar(32) DEFAULT NULL,
|
||||
`platform_user_id` int(11) NOT NULL,
|
||||
`request_type` enum('credit','debit') NOT NULL,
|
||||
`amount` decimal(15,2) NOT NULL,
|
||||
`reason` varchar(255) DEFAULT NULL,
|
||||
`idempotency_key` varchar(128) NOT NULL,
|
||||
`transaction_id` int(11) DEFAULT NULL COMMENT '关联 transactions.id',
|
||||
`status` enum('pending','approved','rejected','failed') NOT NULL DEFAULT 'pending',
|
||||
`operator_id` int(11) DEFAULT NULL COMMENT '后台审核人/系统操作者',
|
||||
`operator_type` varchar(16) DEFAULT NULL COMMENT 'admin/employee/system',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_fund_idempotency_key` (`idempotency_key`),
|
||||
KEY `idx_group_id` (`group_id`),
|
||||
KEY `idx_platform_user_id` (`platform_user_id`),
|
||||
KEY `idx_transaction_id` (`transaction_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Bot上下分记录';
|
||||
|
||||
-- 11) 为 transactions 增加 Bot 来源标识(不破坏现有逻辑)
|
||||
ALTER TABLE `transactions`
|
||||
ADD COLUMN `source` varchar(20) DEFAULT NULL COMMENT '来源:web/admin/employee/bot' AFTER `operator_type`,
|
||||
ADD COLUMN `source_ref` varchar(64) DEFAULT NULL COMMENT '来源引用:bot订单/请求号' AFTER `source`;
|
||||
|
||||
-- 12) 为 bets 增加 Bot 来源引用(便于订单追踪)
|
||||
ALTER TABLE `bets`
|
||||
ADD COLUMN `source` varchar(20) DEFAULT NULL COMMENT '来源:web/bot' AFTER `agent_id`,
|
||||
ADD COLUMN `source_ref` varchar(64) DEFAULT NULL COMMENT '来源引用:idempotency_key/order_ref' AFTER `source`;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'app_name' => 'PK10 স্পিড রেসিং',
|
||||
'app_name' => 'F1 রেসিং',
|
||||
'login' => 'লগইন', 'register' => 'নিবন্ধন', 'logout' => 'লগআউট',
|
||||
'username' => 'ব্যবহারকারীর নাম', 'password' => 'পাসওয়ার্ড', 'email' => 'ইমেইল',
|
||||
'confirm_password' => 'পাসওয়ার্ড নিশ্চিত করুন', 'submit' => 'জমা দিন', 'cancel' => 'বাতিল',
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
'login_failed' => 'ভুল ব্যবহারকারীর নাম বা পাসওয়ার্ড',
|
||||
'account_disabled' => 'অ্যাকাউন্ট নিষ্ক্রিয় করা হয়েছে',
|
||||
'email_not_verified' => 'অনুগ্রহ করে প্রথমে ইমেইল যাচাই করুন',
|
||||
'pk10_title' => 'PK10 স্পিড রেসিং', 'period' => 'পিরিয়ড',
|
||||
'pk10_title' => 'F1 রেসিং', 'period' => 'পিরিয়ড',
|
||||
'current_period' => 'বর্তমান পিরিয়ড', 'last_result' => 'আগের ফলাফল',
|
||||
'history' => 'ইতিহাস', 'place_bet' => 'বাজি ধরুন', 'my_bets' => 'আমার বাজি',
|
||||
'betting' => 'বাজি চলছে', 'closed' => 'বন্ধ', 'drawing' => 'ড্র হচ্ছে',
|
||||
@@ -68,4 +68,6 @@ return [
|
||||
'sum_bs_big' => 'যোগফল বড়', 'sum_bs_small' => 'যোগফল ছোট',
|
||||
'sum_bs_odd' => 'যোগফল বিজোড়', 'sum_bs_even' => 'যোগফল জোড়',
|
||||
'notify_bell' => 'বিজ্ঞপ্তি',
|
||||
'quick_hint' => 'ব্যাচ বেট যোগ করতে ট্যাপ করুন',
|
||||
'bets_added' => ' টি বাজি যোগ হয়েছে',
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
return [
|
||||
// Common
|
||||
'app_name' => 'PK10 Speed Racing',
|
||||
'app_name' => 'F1 Racing',
|
||||
'login' => 'Login',
|
||||
'register' => 'Register',
|
||||
'logout' => 'Logout',
|
||||
@@ -54,7 +54,7 @@ return [
|
||||
'email_not_verified' => 'Please verify your email first',
|
||||
|
||||
// Game
|
||||
'pk10_title' => 'PK10 Speed Racing',
|
||||
'pk10_title' => 'F1 Racing',
|
||||
'period' => 'Period',
|
||||
'current_period' => 'Current Period',
|
||||
'last_result' => 'Last Result',
|
||||
@@ -157,4 +157,40 @@ return [
|
||||
'sum_bs_big' => 'Sum Big', 'sum_bs_small' => 'Sum Small',
|
||||
'sum_bs_odd' => 'Sum Odd', 'sum_bs_even' => 'Sum Even',
|
||||
'notify_bell' => 'Notify',
|
||||
'coming_soon' => 'Coming Soon',
|
||||
'deposit_confirm_msg' => 'You will be redirected to customer service. Please contact them for deposit address.',
|
||||
'deposit_amount' => 'Enter deposit amount',
|
||||
'deposit_submitted' => 'Deposit request submitted. Please contact customer service to complete payment.',
|
||||
'withdraw_amount' => 'Enter withdrawal amount',
|
||||
'withdraw_submitted' => 'Withdrawal request submitted, pending review',
|
||||
'contact_admin' => 'Please contact admin to configure service link',
|
||||
'transfer_to' => 'Enter recipient username',
|
||||
'transfer_amount' => 'Enter transfer amount',
|
||||
'transfer_confirm' => 'Confirm transfer to',
|
||||
'transfer_success' => 'Transfer successful',
|
||||
'amount' => 'Amount',
|
||||
'fund_request_history' => 'Deposit/Withdraw History',
|
||||
'fund_status_pending' => 'Pending',
|
||||
'fund_status_approved' => 'Approved',
|
||||
'fund_status_rejected' => 'Rejected',
|
||||
'quick_hint' => 'Tap to add batch bets',
|
||||
'bets_added' => ' bets added',
|
||||
|
||||
// Report & Follow Plan
|
||||
'report_query' => 'Reports',
|
||||
'follow_plan' => 'Follow Plan',
|
||||
'today' => 'Today',
|
||||
'yesterday' => 'Yesterday',
|
||||
'last_7_days' => 'Last 7 Days',
|
||||
'last_30_days' => 'Last 30 Days',
|
||||
'custom_date' => 'Custom',
|
||||
'bet_type' => 'Type',
|
||||
'bet_amount' => 'Bet',
|
||||
'effective_flow' => 'Valid Flow',
|
||||
'rebate_amount' => 'Rebate',
|
||||
'win_loss' => 'Win/Loss',
|
||||
'total_win_rate' => 'Win Rate',
|
||||
'total_profit' => 'Total P&L',
|
||||
'no_follow_plan' => 'Sorry, you have no follow plans at the moment',
|
||||
'bs_plan' => 'Big/Small Plan',
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'app_name' => 'PK10 Speed Racing',
|
||||
'app_name' => 'F1 Racing',
|
||||
'login' => 'Mag-login', 'register' => 'Magrehistro', 'logout' => 'Mag-logout',
|
||||
'username' => 'Username', 'password' => 'Password', 'email' => 'Email',
|
||||
'confirm_password' => 'Kumpirmahin ang Password', 'submit' => 'Isumite', 'cancel' => 'Kanselahin',
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
'login_failed' => 'Mali ang username o password',
|
||||
'account_disabled' => 'Na-disable ang account',
|
||||
'email_not_verified' => 'Paki-verify muna ang iyong email',
|
||||
'pk10_title' => 'PK10 Speed Racing', 'period' => 'Period',
|
||||
'pk10_title' => 'F1 Racing', 'period' => 'Period',
|
||||
'current_period' => 'Kasalukuyang Period', 'last_result' => 'Nakaraang Resulta',
|
||||
'history' => 'Kasaysayan', 'place_bet' => 'Tumaya', 'my_bets' => 'Mga Taya Ko',
|
||||
'betting' => 'Tumataya', 'closed' => 'Sarado', 'drawing' => 'Hinuhugot',
|
||||
@@ -68,4 +68,6 @@ return [
|
||||
'sum_bs_big' => 'Kabuuang Malaki', 'sum_bs_small' => 'Kabuuang Maliit',
|
||||
'sum_bs_odd' => 'Kabuuang Odd', 'sum_bs_even' => 'Kabuuang Even',
|
||||
'notify_bell' => 'Abiso',
|
||||
'quick_hint' => 'I-tap para magdagdag ng batch na taya',
|
||||
'bets_added' => ' taya naidagdag',
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'app_name' => 'PK10 Lumba Laju',
|
||||
'app_name' => 'F1 Racing',
|
||||
'login' => 'Log Masuk', 'register' => 'Daftar', 'logout' => 'Log Keluar',
|
||||
'username' => 'Nama Pengguna', 'password' => 'Kata Laluan', 'email' => 'Emel',
|
||||
'confirm_password' => 'Sahkan Kata Laluan', 'submit' => 'Hantar', 'cancel' => 'Batal',
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
'login_failed' => 'Nama pengguna atau kata laluan salah',
|
||||
'account_disabled' => 'Akaun telah dilumpuhkan',
|
||||
'email_not_verified' => 'Sila sahkan emel anda dahulu',
|
||||
'pk10_title' => 'PK10 Lumba Laju', 'period' => 'Tempoh',
|
||||
'pk10_title' => 'F1 Racing', 'period' => 'Tempoh',
|
||||
'current_period' => 'Tempoh Semasa', 'last_result' => 'Keputusan Lepas',
|
||||
'history' => 'Sejarah', 'place_bet' => 'Letak Taruhan', 'my_bets' => 'Taruhan Saya',
|
||||
'betting' => 'Bertaruh', 'closed' => 'Ditutup', 'drawing' => 'Cabutan',
|
||||
@@ -68,4 +68,6 @@ return [
|
||||
'sum_bs_big' => 'Jumlah Besar', 'sum_bs_small' => 'Jumlah Kecil',
|
||||
'sum_bs_odd' => 'Jumlah Ganjil', 'sum_bs_even' => 'Jumlah Genap',
|
||||
'notify_bell' => 'Pemberitahuan',
|
||||
'quick_hint' => 'Ketik untuk tambah pertaruhan pukal',
|
||||
'bets_added' => ' pertaruhan ditambah',
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'app_name' => 'PK10 แข่งรถความเร็ว',
|
||||
'app_name' => 'F1 แข่งรถ',
|
||||
'login' => 'เข้าสู่ระบบ', 'register' => 'สมัครสมาชิก', 'logout' => 'ออกจากระบบ',
|
||||
'username' => 'ชื่อผู้ใช้', 'password' => 'รหัสผ่าน', 'email' => 'อีเมล',
|
||||
'confirm_password' => 'ยืนยันรหัสผ่าน', 'submit' => 'ส่ง', 'cancel' => 'ยกเลิก',
|
||||
@@ -22,7 +22,7 @@ return [
|
||||
'login_failed' => 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง',
|
||||
'account_disabled' => 'บัญชีถูกปิดใช้งาน',
|
||||
'email_not_verified' => 'กรุณายืนยันอีเมลก่อน',
|
||||
'pk10_title' => 'PK10 แข่งรถความเร็ว', 'period' => 'งวด',
|
||||
'pk10_title' => 'F1 แข่งรถ', 'period' => 'งวด',
|
||||
'current_period' => 'งวดปัจจุบัน', 'last_result' => 'ผลก่อนหน้า',
|
||||
'history' => 'ประวัติ', 'place_bet' => 'วางเดิมพัน', 'my_bets' => 'เดิมพันของฉัน',
|
||||
'betting' => 'กำลังเดิมพัน', 'closed' => 'ปิดรับ', 'drawing' => 'กำลังออกผล',
|
||||
@@ -68,4 +68,6 @@ return [
|
||||
'sum_bs_big' => 'รวมใหญ่', 'sum_bs_small' => 'รวมเล็ก',
|
||||
'sum_bs_odd' => 'รวมคี่', 'sum_bs_even' => 'รวมคู่',
|
||||
'notify_bell' => 'แจ้งเตือน',
|
||||
'quick_hint' => 'แตะเพื่อเพิ่มเดิมพันเป็นชุด',
|
||||
'bets_added' => ' รายการเพิ่มแล้ว',
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'app_name' => 'PK10 Đua Xe Tốc Độ',
|
||||
'app_name' => 'F1 Đua Xe',
|
||||
'login' => 'Đăng nhập', 'register' => 'Đăng ký', 'logout' => 'Đăng xuất',
|
||||
'username' => 'Tên đăng nhập', 'password' => 'Mật khẩu', 'email' => 'Email',
|
||||
'confirm_password' => 'Xác nhận mật khẩu', 'submit' => 'Gửi', 'cancel' => 'Hủy',
|
||||
@@ -22,7 +22,7 @@ return [
|
||||
'login_failed' => 'Sai tên đăng nhập hoặc mật khẩu',
|
||||
'account_disabled' => 'Tài khoản đã bị vô hiệu hóa',
|
||||
'email_not_verified' => 'Vui lòng xác minh email trước',
|
||||
'pk10_title' => 'PK10 Đua Xe Tốc Độ', 'period' => 'Kỳ',
|
||||
'pk10_title' => 'F1 Đua Xe', 'period' => 'Kỳ',
|
||||
'current_period' => 'Kỳ hiện tại', 'last_result' => 'Kết quả trước',
|
||||
'history' => 'Lịch sử', 'place_bet' => 'Đặt cược', 'my_bets' => 'Cược của tôi',
|
||||
'betting' => 'Đang cược', 'closed' => 'Đã đóng', 'drawing' => 'Đang quay',
|
||||
@@ -69,4 +69,6 @@ return [
|
||||
'sum_bs_big' => 'Tổng tài', 'sum_bs_small' => 'Tổng xỉu',
|
||||
'sum_bs_odd' => 'Tổng lẻ', 'sum_bs_even' => 'Tổng chẵn',
|
||||
'notify_bell' => 'Thông báo',
|
||||
'quick_hint' => 'Nhấn để thêm cược hàng loạt',
|
||||
'bets_added' => ' cược đã thêm',
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
return [
|
||||
'app_name' => 'PK10 极速赛车',
|
||||
'app_name' => 'F1 赛车',
|
||||
'login' => '登录', 'register' => '注册', 'logout' => '退出',
|
||||
'username' => '用户名', 'password' => '密码', 'email' => '邮箱',
|
||||
'confirm_password' => '确认密码', 'submit' => '提交', 'cancel' => '取消',
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
'register_success' => '注册成功,请验证邮箱',
|
||||
'login_failed' => '用户名或密码错误', 'account_disabled' => '账户已被禁用',
|
||||
'email_not_verified' => '请先验证邮箱',
|
||||
'pk10_title' => 'PK10 极速赛车', 'period' => '期号',
|
||||
'pk10_title' => 'F1 赛车', 'period' => '期号',
|
||||
'current_period' => '当前期', 'last_result' => '上期结果',
|
||||
'history' => '历史记录', 'place_bet' => '下注', 'my_bets' => '我的投注',
|
||||
'betting' => '投注中', 'closed' => '已封盘', 'drawing' => '开奖中',
|
||||
@@ -71,4 +71,40 @@ return [
|
||||
'sum_bs_big' => '冠亚大', 'sum_bs_small' => '冠亚小',
|
||||
'sum_bs_odd' => '冠亚单', 'sum_bs_even' => '冠亚双',
|
||||
'notify_bell' => '提醒',
|
||||
'coming_soon' => '即将开放',
|
||||
'deposit_confirm_msg' => '即将跳转到客服页面,请联系客服获取充值地址',
|
||||
'deposit_amount' => '请输入充值金额',
|
||||
'deposit_submitted' => '充值申请已提交,请联系客服完成付款',
|
||||
'withdraw_amount' => '请输入提现金额',
|
||||
'withdraw_submitted' => '提现申请已提交,请等待审核',
|
||||
'contact_admin' => '请联系管理员配置客服链接',
|
||||
'transfer_to' => '请输入对方用户名',
|
||||
'transfer_amount' => '请输入转账金额',
|
||||
'transfer_confirm' => '确认转账给',
|
||||
'transfer_success' => '转账成功',
|
||||
'amount' => '金额',
|
||||
'fund_request_history' => '充提记录',
|
||||
'fund_status_pending' => '待审核',
|
||||
'fund_status_approved' => '已通过',
|
||||
'fund_status_rejected' => '已拒绝',
|
||||
'quick_hint' => '点击按钮一键添加对应注单',
|
||||
'bets_added' => '注已添加',
|
||||
|
||||
// 报表查询 & 跟单计划
|
||||
'report_query' => '报表查询',
|
||||
'follow_plan' => '跟单计划',
|
||||
'today' => '今日',
|
||||
'yesterday' => '昨日',
|
||||
'last_7_days' => '近7天',
|
||||
'last_30_days' => '近30天',
|
||||
'custom_date' => '自定义',
|
||||
'bet_type' => '类型',
|
||||
'bet_amount' => '下注',
|
||||
'effective_flow' => '有效流水',
|
||||
'rebate_amount' => '退水',
|
||||
'win_loss' => '输赢',
|
||||
'total_win_rate' => '总胜率',
|
||||
'total_profit' => '总盈亏',
|
||||
'no_follow_plan' => '对不起,你现暂无任何跟单计划',
|
||||
'bs_plan' => '大小计划',
|
||||
];
|
||||
|
||||
@@ -5647,10 +5647,29 @@
|
||||
*/
|
||||
res.htm=function(val){
|
||||
if(!isUndefined(val)){
|
||||
res.innerHTML=val;
|
||||
if (typeof val === 'string' && /<script|<iframe|on\w+\s*=|javascript:/i.test(val)) {
|
||||
return;
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
var parser = new DOMParser();
|
||||
var doc = parser.parseFromString('<div>' + val + '</div>', 'text/html');
|
||||
var root = doc.body.firstChild;
|
||||
res.replaceChildren();
|
||||
while (root && root.firstChild) {
|
||||
res.appendChild(root.firstChild);
|
||||
}
|
||||
}
|
||||
else if (val && typeof val === 'object' && val.nodeType) {
|
||||
res.replaceChildren(val);
|
||||
}
|
||||
else{
|
||||
return res.innerHTML;
|
||||
res.textContent=String(val);
|
||||
}
|
||||
}
|
||||
else{
|
||||
return Array.prototype.map.call(res.childNodes,function(node){
|
||||
return new XMLSerializer().serializeToString(node);
|
||||
}).join('');
|
||||
}
|
||||
};
|
||||
/*
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
--danger:#FF4444;--success:#4CAF50;--warn:#FF9800;
|
||||
--text:#333;--text2:#666;--text3:#999;--border:#E5E5E5;
|
||||
--shadow:0 2px 12px rgba(0,0,0,.08);--radius:16px;--radius-sm:12px;
|
||||
--c1:#e74c3c;--c2:#3498db;--c3:#2ecc71;--c4:#f39c12;--c5:#9b59b6;
|
||||
--c6:#1abc9c;--c7:#e67e22;--c8:#e91e63;--c9:#00bcd4;--c10:#8bc34a;
|
||||
--c1:#f1c40f;--c2:#3498db;--c3:#555555;--c4:#e67e22;--c5:#2ecc71;
|
||||
--c6:#2c3e99;--c7:#999999;--c8:#e74c3c;--c9:#cc2222;--c10:#27ae60;
|
||||
--nav-h:60px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -17,6 +17,94 @@
|
||||
* @param {string} type - 提示类型(success/error/warning/info)
|
||||
*/
|
||||
|
||||
function createIconElement(iconClass) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = `fas ${iconClass}`;
|
||||
return icon;
|
||||
}
|
||||
|
||||
function setNotificationContent(container, iconClass, message) {
|
||||
container.replaceChildren();
|
||||
const icon = createIconElement(iconClass);
|
||||
const text = document.createElement('span');
|
||||
text.style.wordWrap = 'break-word';
|
||||
text.style.flex = '1';
|
||||
text.style.maxWidth = 'calc(100% - 24px)';
|
||||
text.textContent = String(message ?? '');
|
||||
container.append(icon, text);
|
||||
}
|
||||
|
||||
function buildConfirmDialog(title, msg) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'bg-white rounded-lg shadow-lg w-64 text-center overflow-hidden';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'px-4 py-3 border-b border-gray-200';
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'text-lg font-semibold text-gray-800';
|
||||
heading.textContent = String(title ?? '');
|
||||
header.appendChild(heading);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'px-4 py-5';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'text-gray-600';
|
||||
text.textContent = String(msg ?? '');
|
||||
body.appendChild(text);
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'px-4 py-3 flex justify-center gap-3';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'px-4 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors';
|
||||
cancelBtn.textContent = '取消';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.className = 'px-4 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors';
|
||||
okBtn.textContent = '确定';
|
||||
|
||||
footer.append(cancelBtn, okBtn);
|
||||
panel.append(header, body, footer);
|
||||
|
||||
return { panel, cancelBtn, okBtn };
|
||||
}
|
||||
|
||||
function safelyReplaceHtml(target, html) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(String(html || ''), 'text/html');
|
||||
doc.querySelectorAll('script').forEach(node => node.remove());
|
||||
target.replaceChildren(...Array.from(doc.body.childNodes));
|
||||
}
|
||||
|
||||
function renderStatusBadge(target, text, className) {
|
||||
target.replaceChildren();
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${className}`;
|
||||
badge.textContent = text;
|
||||
target.appendChild(badge);
|
||||
}
|
||||
|
||||
function captureButtonContent(button) {
|
||||
return Array.from(button.childNodes).map(node => node.cloneNode(true));
|
||||
}
|
||||
|
||||
function restoreButtonContent(button, nodes) {
|
||||
button.replaceChildren(...nodes.map(node => node.cloneNode(true)));
|
||||
}
|
||||
|
||||
function setSubmitButtonLoading(button, isLoading, originalNodes = []) {
|
||||
if (isLoading) {
|
||||
button.replaceChildren();
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fa fa-spinner fa-spin mr-2';
|
||||
const text = document.createTextNode(' 保存中...');
|
||||
button.append(icon, text);
|
||||
return;
|
||||
}
|
||||
|
||||
restoreButtonContent(button, originalNodes);
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'success') {
|
||||
const styles = {success: 'bg-green-500', error: 'bg-red-500', warning: 'bg-yellow-500', info: 'bg-blue-500' };
|
||||
const icons = {success: 'fa-check-circle', error: 'fa-exclamation-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
|
||||
@@ -31,7 +119,7 @@
|
||||
note.style.transform = 'translateX(calc(100% + 20px))';
|
||||
note.className = '';
|
||||
note.classList.add(...baseClasses, styles[type]);
|
||||
note.innerHTML = `<i class="fas ${icons[type]}"></i><span style="word-wrap: break-word; flex: 1; max-width: calc(100% - 24px);">${message}</span>`;
|
||||
setNotificationContent(note, icons[type], message);
|
||||
setTimeout(() => {
|
||||
note.style.transform = 'translateX(0)';
|
||||
}, 10);
|
||||
@@ -49,22 +137,9 @@
|
||||
return new Promise(resolve => {
|
||||
const mask = document.createElement("div");
|
||||
mask.className = "fixed inset-0 bg-black/50 flex items-center justify-center z-50";
|
||||
mask.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-lg w-64 text-center overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">${title}</h3>
|
||||
</div>
|
||||
<div class="px-4 py-5">
|
||||
<p class="text-gray-600">${msg}</p>
|
||||
</div>
|
||||
<div class="px-4 py-3 flex justify-center gap-3">
|
||||
<button class="px-4 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"> 取消 </button>
|
||||
<button class="px-4 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors"> 确定 </button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const { panel, cancelBtn, okBtn } = buildConfirmDialog(title, msg);
|
||||
mask.appendChild(panel);
|
||||
document.body.appendChild(mask);
|
||||
const [cancelBtn, okBtn] = mask.querySelectorAll("button");
|
||||
cancelBtn.onclick = () => { mask.remove(); resolve(false); };
|
||||
okBtn.onclick = () => { mask.remove(); resolve(true); };
|
||||
});
|
||||
@@ -221,7 +296,7 @@
|
||||
.then(html => {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
mainContent.innerHTML = html;
|
||||
safelyReplaceHtml(mainContent, html);
|
||||
highlightActiveMenu();
|
||||
|
||||
// 触发子页面加载完成事件
|
||||
@@ -318,11 +393,7 @@
|
||||
: 'bg-gray-100 text-gray-800';
|
||||
|
||||
// 渲染状态标签
|
||||
statusDisplay.innerHTML = `
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}">
|
||||
${statusText}
|
||||
</span>
|
||||
`;
|
||||
renderStatusBadge(statusDisplay, statusText, statusClass);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -362,10 +433,10 @@
|
||||
const submitBtn = profileForm.querySelector('button[type="submit"]');
|
||||
if (!submitBtn) return;
|
||||
|
||||
const originalText = submitBtn.innerHTML;
|
||||
const originalNodes = captureButtonContent(submitBtn);
|
||||
// 禁用按钮并显示加载状态
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 保存中...';
|
||||
setSubmitButtonLoading(submitBtn, true, originalNodes);
|
||||
|
||||
try {
|
||||
const formData = new FormData(profileForm);
|
||||
@@ -403,7 +474,7 @@
|
||||
} finally {
|
||||
// 恢复按钮状态
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
setSubmitButtonLoading(submitBtn, false, originalNodes);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
(function() {
|
||||
|
||||
// 常量定义 - 类名移除avif并添加ja-前缀
|
||||
const CLOSE_ICON = ` <img src="https://yanxuan.nosdn.127.net/0c64ce12c71cf276504cb2e15164d4ff.png"width="25px"> `;
|
||||
const RESET_ICON = ` <img src="https://yanxuan.nosdn.127.net/8296ea39d8c548ed320f79a483756cd9.jpg"width="25px" > `;
|
||||
const PREVIEW_ICON = ` <img class="ja-action-icon" src="https://yanxuan.nosdn.127.net/520b793c329df6349a25404efefbd0f4.png"/> `;
|
||||
const COPY_ICON = ` <img class="ja-action-icon" src="https://yanxuan.nosdn.127.net/0732b6e6d1f249dad11ac7d004af5964.png"/> `;
|
||||
const UP_ICON = ` <img class="ja-button-icon" src="https://yanxuan.nosdn.127.net/94494a4dc561ae21157f50764ddf035e.png"/> `;
|
||||
const ERRO_ICON = ` <img class="ja-erro-icon" src="https://yanxuan.nosdn.127.net/74777259ff515f056e8fc340df28fcd7.png"/> `;
|
||||
|
||||
// 基础配置
|
||||
const scriptBaseUrl = getScriptBaseUrl();
|
||||
const API_CONFIG = {
|
||||
@@ -76,11 +68,265 @@ function getScriptBaseUrl() {
|
||||
window.addEventListener('resize', setVh);
|
||||
window.addEventListener('orientationchange', setVh);
|
||||
|
||||
function createIconWrapper(className, iconType) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = className;
|
||||
wrapper.replaceChildren(createIconNode(iconType));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function createImageElement(src, alt = '', className = '', width = '') {
|
||||
const image = document.createElement('img');
|
||||
image.src = src;
|
||||
image.alt = alt;
|
||||
if (width) {
|
||||
image.width = Number(width);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
function createIconNode(type) {
|
||||
switch (type) {
|
||||
case 'close':
|
||||
return createImageElement('https://yanxuan.nosdn.127.net/0c64ce12c71cf276504cb2e15164d4ff.png', '', '', '25');
|
||||
case 'reset':
|
||||
return createImageElement('https://yanxuan.nosdn.127.net/8296ea39d8c548ed320f79a483756cd9.jpg', '', '', '25');
|
||||
case 'preview':
|
||||
return createImageElement('https://yanxuan.nosdn.127.net/520b793c329df6349a25404efefbd0f4.png', '', 'ja-action-icon');
|
||||
case 'copy':
|
||||
return createImageElement('https://yanxuan.nosdn.127.net/0732b6e6d1f249dad11ac7d004af5964.png', '', 'ja-action-icon');
|
||||
case 'upload':
|
||||
return createImageElement('https://yanxuan.nosdn.127.net/94494a4dc561ae21157f50764ddf035e.png', '', 'ja-button-icon');
|
||||
case 'error':
|
||||
return createImageElement('https://yanxuan.nosdn.127.net/74777259ff515f056e8fc340df28fcd7.png', '', 'ja-erro-icon');
|
||||
default:
|
||||
return document.createTextNode('');
|
||||
}
|
||||
}
|
||||
|
||||
function createUploadingIcon() {
|
||||
return createFontIcon('uploading-icon');
|
||||
}
|
||||
|
||||
|
||||
function createLoadingContainer(message = '加载中...') {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'ja-loading-container';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'loading-icon';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.className = 'ja-loading-text';
|
||||
text.textContent = message;
|
||||
|
||||
container.append(icon, text);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createStatusContainer(type, message) {
|
||||
const container = document.createElement('div');
|
||||
container.className = `ja-${type}-container`;
|
||||
container.appendChild(createIconNode('error'));
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.className = `ja-${type}-text`;
|
||||
text.textContent = String(message || '');
|
||||
container.appendChild(text);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function createMediaCard(item) {
|
||||
const itemElement = document.createElement('div');
|
||||
itemElement.className = 'ja-media-item';
|
||||
|
||||
const thumbnail = document.createElement('div');
|
||||
thumbnail.className = 'ja-item-thumbnail';
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.src = item.url;
|
||||
image.alt = item.name;
|
||||
image.className = 'ja-thumbnail-image';
|
||||
image.loading = 'lazy';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ja-item-overlay';
|
||||
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'ja-copy-button';
|
||||
copyButton.dataset.url = item.url;
|
||||
copyButton.appendChild(createIconNode('copy'));
|
||||
|
||||
const previewButton = document.createElement('button');
|
||||
previewButton.className = 'ja-preview-button';
|
||||
previewButton.dataset.url = item.url;
|
||||
previewButton.appendChild(createIconNode('preview'));
|
||||
|
||||
overlay.append(copyButton, previewButton);
|
||||
thumbnail.append(image, overlay);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'ja-item-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'ja-item-name';
|
||||
name.textContent = item.name;
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'ja-item-details';
|
||||
|
||||
const size = document.createElement('span');
|
||||
size.className = 'ja-item-size';
|
||||
size.textContent = formatFileSize(item.size);
|
||||
|
||||
const dimensions = document.createElement('span');
|
||||
dimensions.className = 'ja-item-dimensions';
|
||||
dimensions.textContent = `${item.width}*${item.height}`;
|
||||
|
||||
details.append(size, dimensions);
|
||||
info.append(name, details);
|
||||
itemElement.append(thumbnail, info);
|
||||
|
||||
return itemElement;
|
||||
}
|
||||
|
||||
function createUploadErrorItem(fileName, reason) {
|
||||
const errorItem = document.createElement('div');
|
||||
errorItem.className = 'ja-upload-error';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ja-error-header';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ja-error-filename';
|
||||
name.textContent = fileName;
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = 'ja-error-status';
|
||||
status.textContent = '错误';
|
||||
|
||||
header.append(name, status);
|
||||
|
||||
const message = document.createElement('div');
|
||||
message.className = 'ja-error-message';
|
||||
message.textContent = reason;
|
||||
|
||||
errorItem.append(header, message);
|
||||
return errorItem;
|
||||
}
|
||||
|
||||
function createUploadSuccessItem(data) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'ja-media-item';
|
||||
|
||||
const thumbnail = document.createElement('div');
|
||||
thumbnail.className = 'ja-item-thumbnail relative';
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.src = data.url;
|
||||
image.alt = data.name;
|
||||
image.className = 'ja-thumbnail-image';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ja-item-overlay';
|
||||
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'ja-copy-button';
|
||||
copyButton.dataset.url = data.url;
|
||||
copyButton.appendChild(createIconNode('copy'));
|
||||
|
||||
const previewButton = document.createElement('button');
|
||||
previewButton.className = 'ja-preview-button';
|
||||
previewButton.dataset.url = data.url;
|
||||
previewButton.appendChild(createIconNode('preview'));
|
||||
|
||||
overlay.append(copyButton, previewButton);
|
||||
thumbnail.append(image, overlay);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'ja-item-info';
|
||||
|
||||
const detailTop = document.createElement('div');
|
||||
detailTop.className = 'ja-item-details';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ja-item-name';
|
||||
name.textContent = data.name;
|
||||
|
||||
const success = document.createElement('span');
|
||||
success.className = 'ja-upload-success';
|
||||
success.textContent = '已完成';
|
||||
|
||||
detailTop.append(name, success);
|
||||
|
||||
const detailBottom = document.createElement('div');
|
||||
detailBottom.className = 'ja-item-details';
|
||||
|
||||
const size = document.createElement('span');
|
||||
size.className = 'ja-item-size';
|
||||
size.textContent = formatFileSize(data.size || 0);
|
||||
|
||||
const dimensions = document.createElement('span');
|
||||
dimensions.className = 'ja-item-dimensions';
|
||||
dimensions.textContent = `${data.width}*${data.height}`;
|
||||
|
||||
detailBottom.append(size, dimensions);
|
||||
info.append(detailTop, detailBottom);
|
||||
wrapper.append(thumbnail, info);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function createUploadFailureContent(message) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'ja-upload-header';
|
||||
|
||||
const fileName = document.createElement('span');
|
||||
fileName.className = 'ja-upload-filename';
|
||||
|
||||
const failure = document.createElement('span');
|
||||
failure.className = 'ja-upload-failure';
|
||||
|
||||
header.append(fileName, failure);
|
||||
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'ja-upload-error-message';
|
||||
detail.textContent = message;
|
||||
|
||||
fragment.append(header, detail);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function createPreviewModal(url) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'ja-image-preview-modal';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'ja-modal-content';
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.src = url;
|
||||
image.alt = '预览图片';
|
||||
image.className = 'ja-modal-image';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'ja-close-button';
|
||||
button.appendChild(createIconNode('close'));
|
||||
|
||||
content.append(image, button);
|
||||
modal.appendChild(content);
|
||||
|
||||
return { modal, content, image, button };
|
||||
}
|
||||
|
||||
// UI 创建函数 - 所有ID和类名添加ja-前缀并移除avif
|
||||
function createUI() {
|
||||
// 动态插入 CSS,禁止顶部下拉刷新
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
style.textContent = `
|
||||
html, body {
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
@@ -100,14 +346,14 @@ function getScriptBaseUrl() {
|
||||
// 创建关闭按钮
|
||||
closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'ja-modal-close';
|
||||
closeBtn.innerHTML = `${CLOSE_ICON}`;
|
||||
closeBtn.appendChild(createIconNode('close'));
|
||||
|
||||
|
||||
// 创建重置按钮
|
||||
resetBtn = document.createElement('button');
|
||||
resetBtn.className = 'ja-img-close';
|
||||
resetBtn.setAttribute('data-action', 'reset');
|
||||
resetBtn.innerHTML = `${RESET_ICON}`;
|
||||
resetBtn.appendChild(createIconNode('reset'));
|
||||
|
||||
// 标签页导航
|
||||
const tabs = document.createElement('div');
|
||||
@@ -143,27 +389,56 @@ function getScriptBaseUrl() {
|
||||
|
||||
const qualityLabel = document.createElement('label');
|
||||
qualityLabel.className = 'ja-param-label';
|
||||
qualityLabel.innerHTML = `
|
||||
<span class="ja-param-title">质量设置 (1-100)</span>
|
||||
<div class="ja-range-wrapper">
|
||||
<input type="range" id="ja-quality" min="1" max="100" value="60" class="ja-range-input">
|
||||
<span id="ja-quality-value" class="ja-range-span">60</span>
|
||||
</div>
|
||||
`;
|
||||
const qualityTitle = document.createElement('span');
|
||||
qualityTitle.className = 'ja-param-title';
|
||||
qualityTitle.textContent = '质量设置 (1-100)';
|
||||
|
||||
const qualityWrapper = document.createElement('div');
|
||||
qualityWrapper.className = 'ja-range-wrapper';
|
||||
|
||||
const qualityRange = document.createElement('input');
|
||||
qualityRange.type = 'range';
|
||||
qualityRange.id = 'ja-quality';
|
||||
qualityRange.min = '1';
|
||||
qualityRange.max = '100';
|
||||
qualityRange.value = '60';
|
||||
qualityRange.className = 'ja-range-input';
|
||||
|
||||
const qualityValue = document.createElement('span');
|
||||
qualityValue.id = 'ja-quality-value';
|
||||
qualityValue.className = 'ja-range-span';
|
||||
qualityValue.textContent = '60';
|
||||
|
||||
qualityWrapper.append(qualityRange, qualityValue);
|
||||
qualityLabel.append(qualityTitle, qualityWrapper);
|
||||
|
||||
const widthLabel = document.createElement('label');
|
||||
widthLabel.className = 'ja-param-label';
|
||||
widthLabel.innerHTML = `
|
||||
<span class="ja-param-title">转换宽度</span>
|
||||
<input type="number" id="ja-width" placeholder="留空为自动" class="ja-text-input">
|
||||
`;
|
||||
const widthTitle = document.createElement('span');
|
||||
widthTitle.className = 'ja-param-title';
|
||||
widthTitle.textContent = '转换宽度';
|
||||
|
||||
const widthInput = document.createElement('input');
|
||||
widthInput.type = 'number';
|
||||
widthInput.id = 'ja-width';
|
||||
widthInput.placeholder = '留空为自动';
|
||||
widthInput.className = 'ja-text-input';
|
||||
|
||||
widthLabel.append(widthTitle, widthInput);
|
||||
|
||||
const heightLabel = document.createElement('label');
|
||||
heightLabel.className = 'ja-param-label';
|
||||
heightLabel.innerHTML = `
|
||||
<span class="ja-param-title">转换高度</span>
|
||||
<input type="number" id="ja-height" placeholder="留空为自动" class="ja-text-input">
|
||||
`;
|
||||
const heightTitle = document.createElement('span');
|
||||
heightTitle.className = 'ja-param-title';
|
||||
heightTitle.textContent = '转换高度';
|
||||
|
||||
const heightInput = document.createElement('input');
|
||||
heightInput.type = 'number';
|
||||
heightInput.id = 'ja-height';
|
||||
heightInput.placeholder = '留空为自动';
|
||||
heightInput.className = 'ja-text-input';
|
||||
|
||||
heightLabel.append(heightTitle, heightInput);
|
||||
|
||||
// 上传控件
|
||||
const uploadInput = document.createElement('input');
|
||||
@@ -176,10 +451,12 @@ function getScriptBaseUrl() {
|
||||
const uploadBtn = document.createElement('button');
|
||||
uploadBtn.id = 'ja-upload-btn';
|
||||
uploadBtn.className = 'ja-button ja-button-primary';
|
||||
uploadBtn.innerHTML = `
|
||||
<div id="ja-upload-svg"> ${UP_ICON} </div>
|
||||
<div id="ja-upload-status">开始上传</div>
|
||||
`;
|
||||
const uploadSvg = createIconWrapper('ja-upload-svg', 'upload');
|
||||
uploadSvg.id = 'ja-upload-svg';
|
||||
const uploadStatus = document.createElement('div');
|
||||
uploadStatus.id = 'ja-upload-status';
|
||||
uploadStatus.textContent = '开始上传';
|
||||
uploadBtn.append(uploadSvg, uploadStatus);
|
||||
|
||||
const uploadResults = document.createElement('div');
|
||||
uploadResults.id = 'ja-upload-results';
|
||||
@@ -189,9 +466,9 @@ function getScriptBaseUrl() {
|
||||
const loadMoreBtn = document.createElement('button');
|
||||
loadMoreBtn.id = 'ja-load-more';
|
||||
loadMoreBtn.className = 'ja-load-more ja-button ja-button-secondary mt-4';
|
||||
loadMoreBtn.innerHTML = `
|
||||
<i class="fa fa-refresh mr-2"></i> 加载更多
|
||||
`;
|
||||
const loadMoreIcon = document.createElement('i');
|
||||
loadMoreIcon.className = 'fa fa-refresh mr-2';
|
||||
loadMoreBtn.append(loadMoreIcon, document.createTextNode(' 加载更多'));
|
||||
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -343,7 +620,7 @@ function getScriptBaseUrl() {
|
||||
|
||||
// 清空上传结果
|
||||
const uploadResults = document.getElementById('ja-upload-results');
|
||||
if (uploadResults) uploadResults.innerHTML = '';
|
||||
if (uploadResults) uploadResults.replaceChildren();
|
||||
|
||||
// 清空文件选择
|
||||
const uploadInput = document.getElementById('ja-upload-input');
|
||||
@@ -384,22 +661,12 @@ function getScriptBaseUrl() {
|
||||
|
||||
// 首次加载显示加载状态
|
||||
if (isInitialLoad) {
|
||||
grid.innerHTML = `
|
||||
<div class="ja-loading-container">
|
||||
<i class="loading-icon"></i>
|
||||
<p class="ja-loading-text">加载中...</p>
|
||||
</div>
|
||||
`;
|
||||
grid.replaceChildren(createLoadingContainer());
|
||||
// 初始加载时隐藏加载更多按钮
|
||||
loadMoreBtn.style.display = 'none';
|
||||
} else {
|
||||
// 非首次加载时显示加载状态
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'ja-loading-container';
|
||||
loadingIndicator.innerHTML = `
|
||||
<i class="loading-icon"></i>
|
||||
<p class="ja-loading-text">加载中...</p>
|
||||
`;
|
||||
const loadingIndicator = createLoadingContainer();
|
||||
grid.appendChild(loadingIndicator);
|
||||
}
|
||||
|
||||
@@ -430,15 +697,17 @@ function getScriptBaseUrl() {
|
||||
|
||||
// 控制加载更多按钮显示
|
||||
if (state.currentPage >= state.totalPages) {
|
||||
loadMoreBtn.innerHTML = `
|
||||
<i class="fa fa-check mr-2"></i> 没有更多图片了
|
||||
`;
|
||||
loadMoreBtn.replaceChildren();
|
||||
const doneIcon = document.createElement('i');
|
||||
doneIcon.className = 'fa fa-check mr-2';
|
||||
loadMoreBtn.append(doneIcon, document.createTextNode(' 没有更多图片了'));
|
||||
loadMoreBtn.disabled = true;
|
||||
loadMoreBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
} else {
|
||||
loadMoreBtn.innerHTML = `
|
||||
<i class="fa fa-refresh mr-2"></i> 加载更多
|
||||
`;
|
||||
loadMoreBtn.replaceChildren();
|
||||
const refreshIcon = document.createElement('i');
|
||||
refreshIcon.className = 'fa fa-refresh mr-2';
|
||||
loadMoreBtn.append(refreshIcon, document.createTextNode(' 加载更多'));
|
||||
loadMoreBtn.disabled = false;
|
||||
loadMoreBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
@@ -452,23 +721,13 @@ function getScriptBaseUrl() {
|
||||
}
|
||||
} else {
|
||||
// 错误处理
|
||||
grid.innerHTML = `
|
||||
<div class="ja-error-container">
|
||||
${ERRO_ICON}
|
||||
<p class="ja-error-text">${data.message}</p>
|
||||
</div>
|
||||
`;
|
||||
grid.replaceChildren(createStatusContainer('error', data.message));
|
||||
loadMoreBtn.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取图片列表失败:', error);
|
||||
grid.innerHTML = `
|
||||
<div class="ja-error-container">
|
||||
${ERRO_ICON}
|
||||
<p class="ja-error-text">网络错误,请重试</p>
|
||||
</div>
|
||||
`;
|
||||
grid.replaceChildren(createStatusContainer('error', '网络错误,请重试'));
|
||||
loadMoreBtn.style.display = 'none';
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -498,39 +757,17 @@ function getScriptBaseUrl() {
|
||||
function renderMediaGrid() {
|
||||
const grid = document.getElementById('ja-media-grid');
|
||||
const itemsToRender = state.mediaItems;
|
||||
grid.innerHTML = '';
|
||||
grid.replaceChildren();
|
||||
|
||||
if (itemsToRender.length === 0) {
|
||||
// 空状态显示
|
||||
grid.innerHTML = `
|
||||
<div class="ja-empty-container">
|
||||
${ERRO_ICON}
|
||||
<p class="ja-empty-text">没有找到图片</p>
|
||||
</div>
|
||||
`;
|
||||
grid.replaceChildren(createStatusContainer('empty', '没有找到图片'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常渲染图片列表
|
||||
itemsToRender.forEach(item => {
|
||||
const itemElement = document.createElement('div');
|
||||
itemElement.className = 'ja-media-item';
|
||||
itemElement.innerHTML = `
|
||||
<div class="ja-item-thumbnail">
|
||||
<img src="${item.url}" alt="${item.name}" class="ja-thumbnail-image" loading="lazy">
|
||||
<div class="ja-item-overlay">
|
||||
<button class="ja-copy-button" data-url="${item.url}"> ${COPY_ICON} </button>
|
||||
<button class="ja-preview-button" data-url="${item.url}"> ${PREVIEW_ICON} </button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ja-item-info">
|
||||
<div class="ja-item-name">${item.name}</div>
|
||||
<div class="ja-item-details">
|
||||
<span class="ja-item-size">${formatFileSize(item.size)}</span>
|
||||
<span class="ja-item-dimensions">${item.width}*${item.height}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const itemElement = createMediaCard(item);
|
||||
grid.appendChild(itemElement);
|
||||
});
|
||||
}
|
||||
@@ -561,15 +798,7 @@ function getScriptBaseUrl() {
|
||||
if (invalidFiles.length > 0) {
|
||||
const uploadResults = document.getElementById('ja-upload-results');
|
||||
invalidFiles.forEach(({ file, reason }) => {
|
||||
const errorItem = document.createElement('div');
|
||||
errorItem.className = 'ja-upload-error';
|
||||
errorItem.innerHTML = `
|
||||
<div class="ja-error-header">
|
||||
<span class="ja-error-filename"></span>
|
||||
<span class="ja-error-status">错误</span>
|
||||
</div>
|
||||
<div class="ja-error-message">${reason}</div>
|
||||
`;
|
||||
const errorItem = createUploadErrorItem(file.name, reason);
|
||||
uploadResults.appendChild(errorItem);
|
||||
});
|
||||
}
|
||||
@@ -588,7 +817,7 @@ function uploadFiles(files) {
|
||||
const height = document.getElementById('ja-height').value;
|
||||
const uploadResults = document.getElementById('ja-upload-results');
|
||||
saveFormState(width, height);
|
||||
uploadResults.innerHTML = '';
|
||||
uploadResults.replaceChildren();
|
||||
|
||||
files.forEach(file => {
|
||||
const formData = new FormData();
|
||||
@@ -622,7 +851,7 @@ function uploadFiles(files) {
|
||||
progressItem.style.width = `${percentComplete}%`;
|
||||
if (ja.statusText) {
|
||||
ja.statusText.textContent = `上传中 ${Math.round(percentComplete)}%`;
|
||||
ja.upsvg.innerHTML = '<i class="uploading-icon"></i>';
|
||||
ja.upsvg.replaceChildren(createUploadingIcon());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -684,37 +913,14 @@ function resetUploadButton() {
|
||||
const ja = window.ja;
|
||||
if (ja.statusText) {
|
||||
ja.statusText.textContent = `开始上传`;
|
||||
ja.upsvg.innerHTML = `
|
||||
${UP_ICON}
|
||||
`;
|
||||
ja.upsvg.replaceChildren(createIconNode('upload'));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理上传成功
|
||||
function handleUploadSuccess(response, progressItem) {
|
||||
updateButtonVisibility(true);
|
||||
progressItem.innerHTML = `
|
||||
|
||||
<div class="ja-media-item">
|
||||
<div class="ja-item-thumbnail relative">
|
||||
<img src="${response.data.url}" alt="${response.data.name}" class="ja-thumbnail-image">
|
||||
<div class="ja-item-overlay">
|
||||
<button class="ja-copy-button" data-url="${response.data.url}"> ${COPY_ICON} </button>
|
||||
<button class="ja-preview-button" data-url="${response.data.url}"> ${PREVIEW_ICON} </button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ja-item-info">
|
||||
<div class="ja-item-details">
|
||||
<span class="ja-item-name">${response.data.name}</span>
|
||||
<span class="ja-upload-success">已完成</span>
|
||||
</div>
|
||||
<div class="ja-item-details">
|
||||
<span class="ja-item-size">${formatFileSize(response.data.size || 0)}</span>
|
||||
<span class="ja-item-dimensions">${response.data.width}*${response.data.height}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
progressItem.replaceChildren(createUploadSuccessItem(response.data));
|
||||
|
||||
// 确保响应中包含正确的URL
|
||||
if (!response.data || !response.data.url) {
|
||||
@@ -748,28 +954,12 @@ function resetUploadButton() {
|
||||
// 处理上传失败
|
||||
function handleUploadError(file, message, progressItem) {
|
||||
progressItem.className = 'ja-upload-item ja-upload-error';
|
||||
progressItem.innerHTML = `
|
||||
<div class="ja-upload-header">
|
||||
<span class="ja-upload-filename"></span>
|
||||
<span class="ja-upload-failure"></span>
|
||||
</div>
|
||||
<div class="ja-upload-error-message">${message}</div>
|
||||
`;
|
||||
progressItem.replaceChildren(createUploadFailureContent(message));
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
function previewFile(url) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'ja-image-preview-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="ja-modal-content">
|
||||
<img src="${url}" alt="预览图片" class="ja-modal-image">
|
||||
<button class="ja-close-button">${CLOSE_ICON}</button>
|
||||
</div>
|
||||
`;
|
||||
const modalContent = modal.querySelector('.ja-modal-content');
|
||||
const image = modal.querySelector('.ja-modal-image');
|
||||
const closeButton = modal.querySelector('.ja-close-button');
|
||||
const { modal, content: modalContent, image, button: closeButton } = createPreviewModal(url);
|
||||
|
||||
// 关闭模态窗口
|
||||
closeButton.addEventListener('click', () => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* PK10 Betting Logic
|
||||
* Depends on: pk10-sound.js, pk10-race.js
|
||||
* Globals expected: GAME_ID, selectedChip, bets, periodData, historyCache,
|
||||
* rankNames, dtPairs, I18N, curMissRank
|
||||
*/
|
||||
|
||||
function formatBetLabel(type,value){
|
||||
var m;
|
||||
if(type==='rank'&&(m=value.match(/^rank(\d+)_(\d+)$/))){return(rankNames[+m[1]]||I18N.rank_prefix+m[1])+' #'+m[2];}
|
||||
if(type==='bs'&&(m=value.match(/^rank(\d+)_(big|small)$/))){return(rankNames[+m[1]]||I18N.rank_prefix+m[1])+' '+(m[2]==='big'?I18N.big:I18N.small);}
|
||||
if(type==='oe'&&(m=value.match(/^rank(\d+)_(odd|even)$/))){return(rankNames[+m[1]]||I18N.rank_prefix+m[1])+' '+(m[2]==='odd'?I18N.odd:I18N.even);}
|
||||
if(type==='dt'&&(m=value.match(/^dt(\d+)_(dragon|tiger)$/))){var p=dtPairs[+m[1]]||[0,0];return(rankNames[p[0]]||p[0])+'vs'+(rankNames[p[1]]||p[1])+' '+(m[2]==='dragon'?I18N.dragon:I18N.tiger);}
|
||||
if(type==='sum'&&(m=value.match(/^sum_(\d+)$/))){return I18N.sum+' '+m[1];}
|
||||
if(type==='sum_bs'){var sm={sum_big:I18N.sum_big,sum_small:I18N.sum_small,sum_odd:I18N.sum_odd,sum_even:I18N.sum_even};return sm[value]||value;}
|
||||
return type+':'+value;
|
||||
}
|
||||
function selectChip(a,el){selectedChip=a;document.querySelectorAll('.amount-btn').forEach(function(c){c.classList.remove('active');});if(el)el.classList.add('active');
|
||||
var ci=document.getElementById('customAmount');if(ci)ci.value=a;}
|
||||
function toggleCustomInput(){var row=document.getElementById('customInputRow');if(row)row.style.display=row.style.display==='none'?'flex':'none';}
|
||||
function applyCustomChip(){var v=parseInt(document.getElementById('customAmount').value);if(!v||v<=0){showToast(I18N.error+': > 0','error');return;}selectedChip=v;document.querySelectorAll('.amount-btn').forEach(function(c){c.classList.remove('active');});}
|
||||
function addBet(type,target,el){if(periodData&&periodData.status!=='pending'){showToast(I18N.period_closed,'error');return;}var key=type+'_'+target;if(!bets[key])bets[key]={type:type,value:target,amount:0,el:el};bets[key].amount+=selectedChip;el.classList.add('selected');var badge=el.querySelector('.bet-amount-badge');if(!badge){badge=document.createElement('span');badge.className='bet-amount-badge';el.appendChild(badge);}badge.textContent=bets[key].amount>=1000?(bets[key].amount/1000).toFixed(1)+'k':bets[key].amount;updateBetSummary();}
|
||||
function clearBets(){bets={};document.querySelectorAll('.bet-btn.selected,.bet-cell.selected').forEach(function(e){e.classList.remove('selected');var b=e.querySelector('.bet-amount-badge');if(b)b.remove();});updateBetSummary();}
|
||||
function updateBetSummary(){var total=0,count=0;Object.values(bets).forEach(function(b){total+=b.amount;count++;});document.getElementById('betCount').textContent=count;
|
||||
var badge=document.getElementById('betFloatBadge');if(badge)badge.classList.toggle('hide',count===0);}
|
||||
function toggleBetPanel(){/* placeholder */}
|
||||
function submitBets(){var arr=Object.values(bets);if(!arr.length){showToast(I18N.place_bet,'error');return;}if(!periodData||!periodData.period_number){showToast(I18N.waiting,'error');return;}fetch('/api/bet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({game_id:GAME_ID,period_number:periodData.period_number,bets:arr})}).then(function(r){return r.json();}).then(function(d){if(d.success){showToast(d.message,'ok');document.getElementById('balanceDisplay').textContent=parseFloat(d.new_balance).toFixed(2);clearBets();pollPeriod();}else{showToast(d.message,'error');}}).catch(function(e){showToast(I18N.net_err,'error');});}
|
||||
function showBetTab(tab,btn){['Rank','Bs','Sum','Quick','Dragon','Miss'].forEach(function(t){var p=document.getElementById('panel'+t);if(p)p.classList.toggle('hidden',t.toLowerCase()!==tab);});document.querySelectorAll('.side-menu .mi').forEach(function(m){m.classList.remove('active');});if(btn)btn.classList.add('active');if(tab==='dragon')renderDragonStreak();if(tab==='miss')renderMissPanel();}
|
||||
|
||||
// ===== Quick bet =====
|
||||
function quickBet(type,side){
|
||||
if(periodData&&periodData.status!=='pending'){showToast(I18N.period_closed,'error');return;}
|
||||
var count=0;
|
||||
if(type==='bs'||type==='oe'){
|
||||
for(var r=1;r<=10;r++){
|
||||
var target='rank'+r+'_'+side;
|
||||
var key=type+'_'+target;
|
||||
if(!bets[key])bets[key]={type:type,value:target,amount:0};
|
||||
bets[key].amount+=selectedChip;count++;
|
||||
syncBetCellUI(key,bets[key].amount);
|
||||
}
|
||||
} else if(type==='dt'){
|
||||
for(var r=1;r<=5;r++){
|
||||
var target='dt'+r+'_'+side;
|
||||
var key='dt_'+target;
|
||||
if(!bets[key])bets[key]={type:'dt',value:target,amount:0};
|
||||
bets[key].amount+=selectedChip;count++;
|
||||
syncBetCellUI(key,bets[key].amount);
|
||||
}
|
||||
} else if(type==='sum_bs'){
|
||||
var key='sum_bs_'+side;
|
||||
if(!bets[key])bets[key]={type:'sum_bs',value:side,amount:0};
|
||||
bets[key].amount+=selectedChip;count=1;
|
||||
syncBetCellUI(key,bets[key].amount);
|
||||
}
|
||||
updateBetSummary();
|
||||
showToast(count+I18N.bets_added,'ok');
|
||||
}
|
||||
function syncBetCellUI(key,amount){
|
||||
var parts=key.split('_');var type=parts[0];
|
||||
var value=key.substring(type.length+1);
|
||||
if(type==='sum'){type='sum_bs';value=key.substring(7);}
|
||||
var cells=document.querySelectorAll('.bet-cell,.bet-btn');
|
||||
for(var i=0;i<cells.length;i++){
|
||||
var onclick=cells[i].getAttribute('onclick')||'';
|
||||
if(onclick.indexOf("addBet('"+type+"','"+value+"'")>-1){
|
||||
cells[i].classList.add('selected');
|
||||
var badge=cells[i].querySelector('.bet-amount-badge');
|
||||
if(!badge){badge=document.createElement('span');badge.className='bet-amount-badge';cells[i].appendChild(badge);}
|
||||
badge.textContent=amount>=1000?(amount/1000).toFixed(1)+'k':amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Miss stats =====
|
||||
function switchMissRank(idx,el){
|
||||
curMissRank=idx;
|
||||
document.querySelectorAll('#missTabs .miss-tab').forEach(function(t){t.classList.remove('active');});
|
||||
if(el)el.classList.add('active');
|
||||
renderMissPanel();
|
||||
}
|
||||
function renderMissPanel(){
|
||||
var D=historyCache;
|
||||
var grid=document.getElementById('missGrid');
|
||||
var tsBox=document.getElementById('missTwoSide');
|
||||
if(!grid||!tsBox)return;
|
||||
if(!D||!D.length){grid.innerHTML='<div style="grid-column:span 5;text-align:center;color:#999;padding:20px">'+I18N.no_bets+'</div>';tsBox.innerHTML='';return;}
|
||||
var r=curMissRank+1;
|
||||
var html='';
|
||||
for(var num=1;num<=10;num++){
|
||||
var miss=-1;
|
||||
for(var i=0;i<D.length;i++){if(+D[i]['rank_'+r]===num){miss=i;break;}}
|
||||
var val=miss===-1?D.length+'+':miss;
|
||||
var cls='';
|
||||
if(miss===0)cls='hot';
|
||||
else if(miss===-1||miss>=5)cls='cold';
|
||||
html+='<div class="miss-cell"><div class="miss-num"><span class="car c'+num+'" style="width:22px;height:22px;font-size:10px;display:inline-flex">'+num+'</span></div><div class="miss-val '+cls+'">'+val+'</div></div>';
|
||||
}
|
||||
grid.innerHTML=html;
|
||||
var v0=+D[0]['rank_'+r];
|
||||
var sides=[
|
||||
{name:I18N.big,check:function(v){return v>5;}},
|
||||
{name:I18N.small,check:function(v){return v<=5;}},
|
||||
{name:I18N.odd,check:function(v){return v%2===1;}},
|
||||
{name:I18N.even,check:function(v){return v%2===0;}}
|
||||
];
|
||||
if(r<=5){
|
||||
sides.push({name:I18N.dragon,check:function(v,row){return +row['rank_'+r]>+row['rank_'+(11-r)];}});
|
||||
sides.push({name:I18N.tiger,check:function(v,row){return +row['rank_'+r]<+row['rank_'+(11-r)];}});
|
||||
}
|
||||
var tsHtml='';
|
||||
sides.forEach(function(sd){
|
||||
var miss=-1;
|
||||
for(var i=0;i<D.length;i++){var v=+D[i]['rank_'+r];if(sd.check(v,D[i])){miss=i;break;}}
|
||||
var val=miss===-1?D.length+'+':miss;
|
||||
tsHtml+='<div class="miss-ts-item"><span class="miss-ts-name">'+sd.name+'</span><span class="miss-ts-val">'+val+'</span></div>';
|
||||
});
|
||||
tsBox.innerHTML=tsHtml;
|
||||
}
|
||||
|
||||
// ===== Dragon streak stats =====
|
||||
function renderDragonStreak(){
|
||||
var box=document.getElementById('dragonStreakList');
|
||||
if(!box)return;
|
||||
var D=historyCache;
|
||||
if(!D||D.length<2){box.innerHTML='<div class="dragon-empty">'+I18N.no_bets+'</div>';return;}
|
||||
var streaks=[];
|
||||
var COLORS={big:'#e74c3c',small:'#3498db',odd:'#f39c12',even:'#9b59b6',dragon:'#e74c3c',tiger:'#3498db'};
|
||||
var LABELS={big:I18N.big,small:I18N.small,odd:I18N.odd,even:I18N.even,dragon:I18N.dragon,tiger:I18N.tiger};
|
||||
for(var r=1;r<=10;r++){
|
||||
var v0=+D[0]['rank_'+r];if(!v0)continue;
|
||||
var bs0=v0>5?'big':'small',bsCnt=1;
|
||||
for(var i=1;i<D.length;i++){var v=+D[i]['rank_'+r];if((v>5?'big':'small')===bs0)bsCnt++;else break;}
|
||||
if(bsCnt>=2)streaks.push({rank:rankNames[r],key:bs0,color:COLORS[bs0],label:LABELS[bs0],count:bsCnt});
|
||||
var oe0=v0%2?'odd':'even',oeCnt=1;
|
||||
for(var i=1;i<D.length;i++){var v=+D[i]['rank_'+r];if((v%2?'odd':'even')===oe0)oeCnt++;else break;}
|
||||
if(oeCnt>=2)streaks.push({rank:rankNames[r],key:oe0,color:COLORS[oe0],label:LABELS[oe0],count:oeCnt});
|
||||
if(r<=5){
|
||||
var a=+D[0]['rank_'+r],b=+D[0]['rank_'+(11-r)];
|
||||
var dt0=a>b?'dragon':'tiger',dtCnt=1;
|
||||
for(var i=1;i<D.length;i++){var a2=+D[i]['rank_'+r],b2=+D[i]['rank_'+(11-r)];if((a2>b2?'dragon':'tiger')===dt0)dtCnt++;else break;}
|
||||
if(dtCnt>=2)streaks.push({rank:rankNames[r],key:dt0,color:COLORS[dt0],label:LABELS[dt0],count:dtCnt});
|
||||
}
|
||||
}
|
||||
var s0=(+D[0].rank_1)+(+D[0].rank_2);
|
||||
if(s0){
|
||||
var sbs0=s0>=12?'big':'small',sbsCnt=1;
|
||||
for(var i=1;i<D.length;i++){var s=(+D[i].rank_1)+(+D[i].rank_2);if((s>=12?'big':'small')===sbs0)sbsCnt++;else break;}
|
||||
if(sbsCnt>=2)streaks.push({rank:I18N.sum,key:sbs0,color:COLORS[sbs0],label:LABELS[sbs0],count:sbsCnt});
|
||||
var soe0=s0%2?'odd':'even',soeCnt=1;
|
||||
for(var i=1;i<D.length;i++){var s=(+D[i].rank_1)+(+D[i].rank_2);if((s%2?'odd':'even')===soe0)soeCnt++;else break;}
|
||||
if(soeCnt>=2)streaks.push({rank:I18N.sum,key:soe0,color:COLORS[soe0],label:LABELS[soe0],count:soeCnt});
|
||||
}
|
||||
streaks.sort(function(a,b){return b.count-a.count;});
|
||||
if(!streaks.length){box.innerHTML='<div class="dragon-empty">'+I18N.no_bets+'</div>';return;}
|
||||
var maxCnt=streaks[0].count;
|
||||
var html='';
|
||||
streaks.forEach(function(s){
|
||||
var pct=Math.round(s.count/Math.max(maxCnt,1)*100);
|
||||
var fire=s.count>=5?'🔥':s.count>=3?'💨':'';
|
||||
html+='<div class="dragon-item">'
|
||||
+'<span class="dragon-rank">'+s.rank+'</span>'
|
||||
+'<span class="dragon-type" style="background:'+s.color+'">'+s.label+'</span>'
|
||||
+'<div class="dragon-bar-wrap"><div class="dragon-bar" style="width:'+pct+'%;background:'+s.color+'"></div></div>'
|
||||
+'<span class="dragon-count">'+s.count+'</span>'
|
||||
+(fire?'<span class="dragon-fire">'+fire+'</span>':'')
|
||||
+'</div>';
|
||||
});
|
||||
box.innerHTML=html;
|
||||
}
|
||||
|
||||
// ===== Render result =====
|
||||
function renderResult(result,container){if(!result)return;var el=document.getElementById(container);el.innerHTML='';['rank_1','rank_2','rank_3','rank_4','rank_5','rank_6','rank_7','rank_8','rank_9','rank_10'].forEach(function(k){var v=result[k];if(!v)return;var d=document.createElement('div');d.className='car c'+v;d.style.cssText='width:28px;height:28px;font-size:11px';d.textContent=v;el.appendChild(d);});
|
||||
var tags=document.getElementById('lastResultTags');if(!tags)return;tags.innerHTML='';var r1=+result.rank_1,r2=+result.rank_2;if(r1&&r2){var sum=r1+r2;var items=[sum,(sum>=12?I18N.big:I18N.small),(sum%2?I18N.odd:I18N.even)];for(var i=1;i<=5;i++){var a=+result['rank_'+i],b=+result['rank_'+(11-i)];if(a&&b)items.push(a>b?I18N.dragon:I18N.tiger);}items.forEach(function(t){var s=document.createElement('span');s.textContent=t;tags.appendChild(s);});
|
||||
var badge=document.getElementById('sumHotBadge');if(badge){badge.textContent=sum;badge.style.display='inline-flex';}}}
|
||||
|
||||
// ===== Render my bets =====
|
||||
function renderMyBets(myBets){var list=document.getElementById('myBetsList');var countEl=document.getElementById('myBetsCount');if(!myBets||!myBets.length){list.innerHTML='<div style="color:var(--text3);font-size:12px;text-align:center;padding:8px">'+I18N.no_bets+'</div>';countEl.textContent='0';return;}countEl.textContent=myBets.length;var total=0,html='';myBets.forEach(function(b){var label=formatBetLabel(b.bet_type,b.bet_value);var amt=parseFloat(b.amount);total+=amt;var sl=b.status==='pending'?'<span style="color:var(--warn)">'+I18N.draw+'</span>':b.status==='win'?'<span style="color:var(--success)">+'+parseFloat(b.win_amount).toFixed(0)+'</span>':'<span style="color:var(--danger)">-'+amt.toFixed(0)+'</span>';html+='<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;padding:4px 0;border-bottom:1px solid var(--border)"><span style="color:var(--text2)">'+label+'</span><span style="color:var(--primary)">'+amt.toFixed(0)+' <span style="color:var(--text3)">x'+b.odds+'</span></span>'+sl+'</div>';});html+='<div style="display:flex;justify-content:space-between;font-size:12px;padding-top:4px;color:var(--text3)"><span>'+I18N.total+'</span><span style="color:var(--primary);font-weight:700">'+total.toFixed(0)+'</span></div>';list.innerHTML=html;}
|
||||
function renderLastMyBets(lastMyBets){var panel=document.getElementById('lastResultPanel');var list=document.getElementById('lastMyBetsList');var profitEl=document.getElementById('lastProfitDisplay');if(!lastMyBets||!lastMyBets.length){panel.classList.add('hidden');return;}panel.classList.remove('hidden');var totalWin=0,totalBet=0,html='';lastMyBets.forEach(function(b){var label=formatBetLabel(b.bet_type,b.bet_value);var amt=parseFloat(b.amount);totalBet+=amt;if(b.status==='win'){var win=parseFloat(b.win_amount);totalWin+=win+amt;html+='<div style="display:flex;justify-content:space-between;font-size:12px;padding:2px 0"><span style="color:var(--text2)">'+label+'</span><span style="color:var(--success)">+'+win.toFixed(0)+'</span></div>';}else{html+='<div style="display:flex;justify-content:space-between;font-size:12px;padding:2px 0"><span style="color:var(--text2)">'+label+'</span><span style="color:var(--danger)">-'+amt.toFixed(0)+'</span></div>';}});list.innerHTML=html;var profit=totalWin-totalBet;if(profit>0){profitEl.style.color='var(--success)';profitEl.textContent='+'+profit.toFixed(0);}else if(profit<0){profitEl.style.color='var(--danger)';profitEl.textContent=profit.toFixed(0);}else{profitEl.style.color='var(--text3)';profitEl.textContent='0';}}
|
||||
|
||||
// ===== Toast & Lang =====
|
||||
function showToast(msg,type){var t=document.createElement('div');t.style.cssText='position:fixed;top:80px;left:50%;transform:translateX(-50%);padding:10px 24px;border-radius:24px;font-size:13px;z-index:999;color:#fff;box-shadow:0 4px 16px rgba(0,0,0,.15)';t.style.background=type==='ok'?'var(--success)':'var(--danger)';t.textContent=msg;document.body.appendChild(t);setTimeout(function(){t.remove();},2500);}
|
||||
function setLang(l){fetch('/api/set-lang',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({lang:l})}).then(function(){location.reload();});}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* PK10 Polling & Countdown
|
||||
* Depends on: pk10-sound.js, pk10-race.js, pk10-bet.js
|
||||
* Globals expected: GAME_ID, periodData, historyCache, I18N, localCountdown,
|
||||
* localLockCountdown, lastPollStatus, lastPeriodId, drawnResultShown,
|
||||
* cachedRaceResult, raceTriggered, pendingRaceHeader, raceAnimState,
|
||||
* PK10_CONFIG (for status labels)
|
||||
*/
|
||||
|
||||
// ===== Countdown interval =====
|
||||
setInterval(function(){if(localCountdown>0)localCountdown--;if(localLockCountdown>0)localLockCountdown--;var m=Math.floor(localCountdown/60),s=localCountdown%60;document.getElementById('countdown').textContent=localCountdown>0?m+':'+(s<10?'0':'')+s:'--';
|
||||
var badge=document.getElementById('statusBadge');
|
||||
if(lastPollStatus==='pending'&&localLockCountdown>0){var lm=Math.floor(localLockCountdown/60),ls=localLockCountdown%60;badge.textContent=lm+':'+(ls<10?'0':'')+ls;badge.style.cssText='color:var(--success)';}
|
||||
else if(lastPollStatus==='pending'&&localLockCountdown<=0){badge.textContent=PK10_CONFIG.lang.sealed;badge.style.cssText='color:var(--danger)';}
|
||||
if(raceAnimState==='waiting')updateRaceOverlayTimer(localCountdown);
|
||||
if(localCountdown>0&&localCountdown<=10&&raceAnimState==='waiting')SoundFX.tick();
|
||||
},1000);
|
||||
|
||||
// ===== Trigger race sequence =====
|
||||
function triggerRaceSequence(){
|
||||
if(raceTriggered||raceAnimState!=='waiting')return;
|
||||
raceTriggered=true;
|
||||
var remaining=localCountdown*1000;
|
||||
var readyDur=3000;
|
||||
var raceDur=Math.max(5500,remaining-readyDur-500);
|
||||
raceDur=Math.min(raceDur,25000);
|
||||
var waitBeforeRace=Math.max(0,remaining-readyDur-raceDur);
|
||||
startReadyAnimation();
|
||||
setTimeout(function(){
|
||||
if(cachedRaceResult&&raceAnimState==='ready'){
|
||||
var raceRes=cachedRaceResult;cachedRaceResult=null;
|
||||
pendingRaceHeader=raceRes._raw||null;
|
||||
animateRace(raceRes.arr,raceDur);
|
||||
}
|
||||
},readyDur+waitBeforeRace);
|
||||
}
|
||||
|
||||
// ===== Poll period =====
|
||||
function pollPeriod(){return fetch('/api/period/current?game_id='+GAME_ID).then(function(r){return r.json();}).then(function(d){if(!d.success)return;periodData=d.data;
|
||||
var serverRemaining=periodData.remaining_seconds||0;var periodChanged=(periodData.id&&periodData.id!==lastPeriodId);var statusChanged=(periodData.status!==lastPollStatus);var driftTooMuch=Math.abs(localCountdown-serverRemaining)>3;
|
||||
if(periodChanged||statusChanged||driftTooMuch||localCountdown<=0)localCountdown=serverRemaining;
|
||||
var serverLockCountdown=periodData.lock_countdown||0;if(periodChanged||statusChanged||Math.abs(localLockCountdown-serverLockCountdown)>3)localLockCountdown=serverLockCountdown;
|
||||
if(d.balance!==null&&d.balance!==undefined)document.getElementById('balanceDisplay').textContent=parseFloat(d.balance).toFixed(2);
|
||||
// Status badge
|
||||
var badge=document.getElementById('statusBadge');var statusMap={pending:[PK10_CONFIG.lang.betting,'seal-status','color:var(--success)'],locked:[PK10_CONFIG.lang.sealed,'seal-status','color:var(--danger)'],drawn:[I18N.drawing,'seal-status','color:var(--warn)'],settled:[I18N.settled_label,'seal-status','color:var(--warn)']};var s=statusMap[periodData.status]||statusMap.pending;badge.textContent=s[0];badge.className=s[1];badge.style.cssText=s[2];
|
||||
switchVideoOrAnim(periodData.status,statusChanged);
|
||||
// Period number
|
||||
var fullPn=periodData.period_number||'---';var shortPn=fullPn.length>8?fullPn.slice(0,-4)+'-'+fullPn.slice(-4):fullPn;var pnEl=document.getElementById('periodNum');pnEl.textContent=shortPn;pnEl.title=fullPn;
|
||||
// Extract result array
|
||||
function extractResultArr(res){if(!res)return null;var arr=[res.rank_1,res.rank_2,res.rank_3,res.rank_4,res.rank_5,res.rank_6,res.rank_7,res.rank_8,res.rank_9,res.rank_10];return arr.every(function(v){return v;})?arr:null;}
|
||||
|
||||
// Scene header sync
|
||||
if(raceAnimState!=='racing'&&raceAnimState!=='finished'&&raceAnimState!=='ready'){
|
||||
if(d.last_result) updateSceneHeader(d.last_result, d.last_result.period_number||fullPn);
|
||||
}
|
||||
|
||||
// === Pending → idle ===
|
||||
if(periodData.status==='pending'){
|
||||
cachedRaceResult=null;raceTriggered=false;
|
||||
if(d.last_result){
|
||||
var lp=d.last_result.period_number||'';
|
||||
document.getElementById('lastPeriodNum').textContent=lp.length>4?'#'+lp.slice(-4):lp;
|
||||
renderResult(d.last_result,'lastResult');
|
||||
}
|
||||
if(raceAnimState==='finished'||raceAnimState==='racing'||raceAnimState==='ready'){
|
||||
if(!window._podiumTimer){
|
||||
var protectMs=8000;
|
||||
if(raceAnimState==='ready')protectMs=16000;
|
||||
else if(raceAnimState==='racing')protectMs=12000;
|
||||
window._podiumTimer=setTimeout(function(){
|
||||
resetRace();
|
||||
startIdleAnimation();
|
||||
window._podiumTimer=null;
|
||||
},protectMs);
|
||||
}
|
||||
} else if(raceAnimState!=='waiting'){
|
||||
resetRace();
|
||||
startIdleAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
// === Locked → cache result + trigger sprint ===
|
||||
if(periodData.status==='locked'){
|
||||
var raceRes=extractResultArr(d.race_result);
|
||||
if(raceRes&&!cachedRaceResult){
|
||||
cachedRaceResult={arr:raceRes,_raw:d.race_result};
|
||||
}
|
||||
if(cachedRaceResult)triggerRaceSequence();
|
||||
}
|
||||
|
||||
// === Drawn/Settled → show result + podium ===
|
||||
if(d.last_result&&(periodData.status==='drawn'||periodData.status==='settled')){
|
||||
if(statusChanged||periodChanged){
|
||||
var lp=d.last_result.period_number||'';
|
||||
document.getElementById('lastPeriodNum').textContent=lp.length>4?'#'+lp.slice(-4):lp;
|
||||
var ra=extractResultArr(d.last_result);
|
||||
if(ra&&raceAnimState!=='finished'&&raceAnimState!=='racing'&&raceAnimState!=='ready'){
|
||||
cachedRaceResult=null;
|
||||
pendingRaceHeader=d.last_result;
|
||||
animateRace(ra);
|
||||
} else if(raceAnimState!=='racing'&&raceAnimState!=='ready'){
|
||||
updateSceneHeader(d.last_result, lp);
|
||||
}
|
||||
setTimeout(function(){renderResult(d.last_result,'lastResult');},5500);
|
||||
}
|
||||
}
|
||||
|
||||
// === First load ===
|
||||
if(d.last_result&&!lastAnimatedResult&&raceAnimState==='idle'){
|
||||
var ra=extractResultArr(d.last_result);
|
||||
if(ra){
|
||||
var lp=d.last_result.period_number||'';
|
||||
document.getElementById('lastPeriodNum').textContent=lp.length>4?'#'+lp.slice(-4):lp;
|
||||
renderResult(d.last_result,'lastResult');
|
||||
startIdleAnimation();
|
||||
}
|
||||
}
|
||||
renderMyBets(d.my_bets||[]);renderLastMyBets(d.last_my_bets||[]);
|
||||
// History
|
||||
if(d.history&&periodChanged){historyCache=d.history;var hl=document.getElementById('historyList');hl.innerHTML='';d.history.forEach(function(h){var row=document.createElement('div');row.className='card';row.style.cssText='padding:6px 8px;margin-bottom:4px';var pn=h.period_number||'';var sp=pn.length>4?'#'+pn.slice(-4):pn;var balls='';['rank_1','rank_2','rank_3','rank_4','rank_5','rank_6','rank_7','rank_8','rank_9','rank_10'].forEach(function(k){if(h[k])balls+='<div class="car c'+h[k]+'" style="width:18px;height:18px;font-size:8px;flex-shrink:0">'+h[k]+'</div>';});row.innerHTML='<div style="color:var(--text3);font-size:10px;margin-bottom:2px">'+sp+'</div><div style="display:flex;gap:2px;flex-wrap:wrap">'+balls+'</div>';hl.appendChild(row);});if(!document.getElementById('panelDragon').classList.contains('hidden'))renderDragonStreak();}
|
||||
lastPollStatus=periodData.status;lastPeriodId=periodData.id||0;
|
||||
}).catch(function(e){console.error('pollPeriod error:',e);});}
|
||||
|
||||
// ===== Start polling =====
|
||||
pollPeriod();
|
||||
(function dynamicPoll(){var delay=(lastPollStatus==='locked'||lastPollStatus==='drawn'||lastPollStatus==='settled')?1500:3000;setTimeout(function(){pollPeriod().then(dynamicPoll).catch(dynamicPoll);},delay);})();
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* PK10 Race Animation System
|
||||
* Depends on: pk10-sound.js (SoundFX)
|
||||
* Globals expected: raceAnimState, lastAnimatedResult, idleAnimTimer, carPositions,
|
||||
* raceFrameTimer, ballRollTimer, FINISH_LINE, START_LINE, trafficLightTimers,
|
||||
* CAR_COLORS, I18N, raceModalOpen, currentView, cachedRaceResult, pendingRaceHeader
|
||||
*/
|
||||
|
||||
function setCarPosition(n,pct,dur){var c=document.getElementById('carContainer'+n);var b=document.getElementById('bar'+n);if(!c)return;c.style.transition='right '+dur+'s ease';c.style.right=pct+'%';if(b){b.style.transition='width '+dur+'s ease';b.style.width=Math.min(pct+3,92)+'%';}carPositions[n]=pct;}
|
||||
function setCarMoving(n,moving,sprinting){var c=document.getElementById('carContainer'+n);if(!c)return;c.classList.toggle('moving',moving);c.classList.toggle('sprinting',!!sprinting);c.classList.remove('ready','braking');}
|
||||
function setCarBraking(n){var c=document.getElementById('carContainer'+n);if(!c)return;c.classList.remove('sprinting','moving');c.classList.add('braking');setTimeout(function(){if(c)c.classList.remove('braking');},800);}
|
||||
function setRankBadge(n,rank){var badge=document.getElementById('rankBadge'+n);if(!badge)return;var c=document.getElementById('carContainer'+n);if(rank>0){var labels=['1st','2nd','3rd','4th','5th','6th','7th','8th','9th','10th'];badge.textContent=labels[rank-1]||rank;if(c)c.classList.add('show-rank');}else{badge.textContent='';if(c)c.classList.remove('show-rank');}}
|
||||
|
||||
// ANIM: idle
|
||||
function startIdleAnimation(){
|
||||
if(idleAnimTimer)return;raceAnimState='waiting';hidePodium();
|
||||
showRaceOverlay();clearTrafficLights();
|
||||
for(var i=1;i<=10;i++){
|
||||
setCarPosition(i,START_LINE,0.6);
|
||||
setCarMoving(i,false,false);
|
||||
setRankBadge(i,0);
|
||||
}
|
||||
idleAnimTimer=true;}
|
||||
function stopIdleAnimation(){if(idleAnimTimer&&idleAnimTimer!==true){clearInterval(idleAnimTimer);}idleAnimTimer=null;if(raceFrameTimer){cancelAnimationFrame(raceFrameTimer);raceFrameTimer=null;}for(var i=1;i<=10;i++){setCarMoving(i,false,false);var c=document.getElementById('carContainer'+i);if(c)c.classList.remove('ready');}}
|
||||
function startReadyAnimation(){stopIdleAnimation();raceAnimState='ready';hidePodium();showRaceOverlay();
|
||||
for(var i=1;i<=10;i++){setCarPosition(i,START_LINE,0.8);setCarMoving(i,true,false);setRankBadge(i,0);var c=document.getElementById('carContainer'+i);if(c)c.classList.add('ready');}
|
||||
SoundFX.engineIdle();
|
||||
startTrafficLightSequence();}
|
||||
|
||||
// ANIM: race
|
||||
function triggerFinishBurst(){var fb=document.getElementById('finishBurst');if(!fb)return;fb.classList.remove('active');void fb.offsetWidth;fb.classList.add('active');setTimeout(function(){fb.classList.remove('active');},1000);}
|
||||
function generateConfetti(){var box=document.getElementById('confettiBox');if(!box)return;box.innerHTML='';var colors=['#ff3333','#3399ff','#ffcc00','#33ff66','#ff66cc','#ff8800','#66ffff','#ffff33','#9b59b6','#1abc9c'];for(var i=0;i<40;i++){var c=document.createElement('div');c.className='confetti';c.style.left=Math.random()*100+'%';c.style.background=colors[i%colors.length];c.style.setProperty('--cf-dur',(2.5+Math.random()*2)+'s');c.style.setProperty('--cf-delay',(Math.random()*1.5)+'s');c.style.width=(4+Math.random()*4)+'px';c.style.height=(6+Math.random()*8)+'px';c.style.borderRadius=Math.random()>.5?'50%':'2px';box.appendChild(c);}}
|
||||
function startTrackShake(){var t=document.getElementById('raceArea');if(t)t.classList.add('shaking');}
|
||||
function stopTrackShake(){var t=document.getElementById('raceArea');if(t)t.classList.remove('shaking');}
|
||||
|
||||
/* Direct positioning (rAF driven, no CSS transition) */
|
||||
function setCarPosDirect(n,pct){var c=document.getElementById('carContainer'+n),b=document.getElementById('bar'+n);if(!c)return;c.style.transition='none';c.style.right=pct+'%';if(b){b.style.transition='none';b.style.width=Math.min(pct+3,92)+'%';}carPositions[n]=pct;}
|
||||
|
||||
/* Race script generation (high-frequency overtaking) */
|
||||
function _raceScript(rankMap,carTargets){
|
||||
var sc={},i,w,c,tmp,f;
|
||||
var N=25,T=[],PF=[],PG=[],B=[];
|
||||
for(i=0;i<=N;i++){f=i/N;T.push(f);
|
||||
PF.push(START_LINE+f*(FINISH_LINE-START_LINE));
|
||||
PG.push(f<0.82?(1.0+Math.abs(Math.sin(f*Math.PI*6))*0.8):(1.0+(f-0.82)/0.18*6.5));
|
||||
B.push(f<0.78?f*0.06:0.05+(f-0.78)/0.22*0.95);
|
||||
}B[0]=0;B[N]=1;PG[0]=0;
|
||||
var FO=[];for(w=0;w<=N;w++){var a=[];for(c=1;c<=10;c++)a.push(c);for(i=9;i>0;i--){var j=Math.floor(Math.random()*(i+1));tmp=a[i];a[i]=a[j];a[j]=tmp;}FO.push(a);}
|
||||
var fin=[];for(c=1;c<=10;c++)fin[rankMap[c]]=c;FO[N]=fin.slice();
|
||||
var W=fin[0],S2=fin[1];
|
||||
for(w=1;w<Math.floor(N*.65);w++){var wi=FO[w].indexOf(W);if(wi<5){var si=5+Math.floor(Math.random()*4);if(si<10){tmp=FO[w][wi];FO[w][wi]=FO[w][si];FO[w][si]=tmp;}}}
|
||||
for(w=Math.floor(N*.85);w<N;w++){wi=FO[w].indexOf(W);if(wi>1){si=Math.floor(Math.random()*2);tmp=FO[w][wi];FO[w][wi]=FO[w][si];FO[w][si]=tmp;}}
|
||||
if(S2)for(w=Math.floor(N*.4);w<N-2;w++){var s2i=FO[w].indexOf(S2);if(s2i>2&&Math.random()>.3){tmp=FO[w][s2i];FO[w][s2i]=FO[w][0];FO[w][0]=tmp;}}
|
||||
for(c=1;c<=10;c++){sc[c]=[];for(w=0;w<=N;w++){
|
||||
if(!w){sc[c].push({t:0,p:START_LINE});continue;}
|
||||
var fk=FO[w].indexOf(c),rk=rankMap[c]!==undefined?rankMap[c]:9;
|
||||
var br=fk*(1-B[w])+rk*B[w];
|
||||
var p=PF[w]-br*PG[w]+(Math.random()-.5)*1.5;
|
||||
sc[c].push({t:T[w],p:Math.max(START_LINE,Math.min(FINISH_LINE+2,p))});
|
||||
}}return sc;}
|
||||
|
||||
/* Smoothstep interpolation */
|
||||
function _interp(wp,t){if(t<=wp[0].t)return wp[0].p;var L=wp.length-1;if(t>=wp[L].t)return wp[L].p;for(var i=0;i<L;i++){if(t>=wp[i].t&&t<=wp[i+1].t){var s=(t-wp[i].t)/(wp[i+1].t-wp[i].t);s=s*s*(3-2*s);return wp[i].p+(wp[i+1].p-wp[i].p)*s;}}return wp[L].p;}
|
||||
|
||||
/* Core race animation (dramatic chase version) */
|
||||
function animateRace(result,dur){if(!result||result.length<10)return;var rk=result.join(',');if(lastAnimatedResult===rk&&raceAnimState==='finished')return;
|
||||
lastAnimatedResult=rk;stopIdleAnimation();hideRaceOverlay();hidePodium();raceAnimState='racing';
|
||||
dur=dur||5500;
|
||||
var rankMap={};for(var i=0;i<10;i++)rankMap[result[i]]=i;
|
||||
var carTargets={};for(var i=1;i<=10;i++){var r=rankMap[i]!==undefined?rankMap[i]:9;carTargets[i]=FINISH_LINE-r*7.5;setCarMoving(i,true,false);setRankBadge(i,0);}
|
||||
var script=_raceScript(rankMap,carTargets);
|
||||
startBallRoll(dur);
|
||||
var t0=performance.now(),shook=false,sprinted=false;
|
||||
function tick(now){if(raceAnimState!=='racing')return;
|
||||
var el=now-t0,t=Math.min(el/dur,1);
|
||||
if(!shook&&t>.10){shook=true;startTrackShake();SoundFX.engineStart();}
|
||||
if(!sprinted&&t>.16){sprinted=true;for(var i=1;i<=10;i++)setCarMoving(i,true,true);}
|
||||
for(var c=1;c<=10;c++)setCarPosDirect(c,_interp(script[c],t));
|
||||
if(t>=1){for(var i=1;i<=10;i++){setCarPosDirect(i,carTargets[i]);setCarBraking(i);}
|
||||
SoundFX.brakeSqueal();triggerFinishBurst();SoundFX.finish();
|
||||
setTimeout(function(){if(raceAnimState!=='racing')return;raceAnimState='finished';stopTrackShake();stopBallRoll();for(var i=1;i<=10;i++){setCarMoving(i,false,false);var c=document.getElementById('carContainer'+i);if(c)c.classList.remove('braking');var rank=rankMap[i]!==undefined?rankMap[i]+1:0;setRankBadge(i,rank);}
|
||||
var badge=document.getElementById('statusBadge');if(badge){badge.textContent=I18N.drawing;badge.style.cssText='color:var(--warn)';}
|
||||
if(pendingRaceHeader){updateSceneHeader(pendingRaceHeader,pendingRaceHeader.period_number||'');pendingRaceHeader=null;}
|
||||
setTimeout(function(){generateConfetti();showPodium(result);SoundFX.podium();},800);},500);return;}
|
||||
raceFrameTimer=requestAnimationFrame(tick);}
|
||||
raceFrameTimer=requestAnimationFrame(tick);}
|
||||
|
||||
function resetRace(){stopIdleAnimation();hideRaceOverlay();hidePodium();stopTrackShake();stopBallRoll();raceAnimState='idle';lastAnimatedResult=null;cachedRaceResult=null;pendingRaceHeader=null;if(window._podiumTimer){clearTimeout(window._podiumTimer);window._podiumTimer=null;}var fb=document.getElementById('finishBurst');if(fb)fb.classList.remove('active');var cb=document.getElementById('confettiBox');if(cb)cb.innerHTML='';for(var i=1;i<=10;i++){var c=document.getElementById('carContainer'+i);var b=document.getElementById('bar'+i);if(c){c.style.transition='right 0.5s ease';c.style.right=START_LINE+'%';c.classList.remove('moving','sprinting','ready','show-rank','braking');}if(b){b.style.transition='width 0.5s ease';b.style.width='5%';}carPositions[i]=START_LINE;setRankBadge(i,0);}}
|
||||
|
||||
// ===== Scene controls (traffic lights + podium + scene header) =====
|
||||
function showRaceOverlay(){var ov=document.getElementById('raceOverlay');if(ov)ov.classList.add('show');}
|
||||
function hideRaceOverlay(){var ov=document.getElementById('raceOverlay');if(ov)ov.classList.remove('show');clearTrafficLights();}
|
||||
function clearTrafficLights(){trafficLightTimers.forEach(function(t){clearTimeout(t);});trafficLightTimers=[];for(var i=1;i<=5;i++){var l=document.getElementById('tl'+i);if(l)l.className='tl';}}
|
||||
function startTrafficLightSequence(){clearTrafficLights();
|
||||
trafficLightTimers.push(setTimeout(function(){document.getElementById('tl1').className='tl red';SoundFX.trafficBeep(4);},300));
|
||||
trafficLightTimers.push(setTimeout(function(){document.getElementById('tl2').className='tl red';SoundFX.trafficBeep(3);},600));
|
||||
trafficLightTimers.push(setTimeout(function(){document.getElementById('tl3').className='tl yellow';SoundFX.trafficBeep(2);},900));
|
||||
trafficLightTimers.push(setTimeout(function(){document.getElementById('tl4').className='tl red';SoundFX.trafficBeep(1);},1200));
|
||||
trafficLightTimers.push(setTimeout(function(){document.getElementById('tl5').className='tl red';SoundFX.trafficBeep(0);},1500));
|
||||
trafficLightTimers.push(setTimeout(function(){for(var i=1;i<=5;i++)document.getElementById('tl'+i).className='tl green';SoundFX.trafficBeep(0);setTimeout(clearTrafficLights,500);},2500));}
|
||||
function updateRaceOverlayTimer(secs){var td=document.getElementById('raceTimerDisplay');if(!td)return;var m=Math.floor(secs/60),s=secs%60;td.innerHTML=(m<10?'0':'')+m+':'+(s<10?'0':'')+s+'<span class="ms">00</span>';}
|
||||
|
||||
// ===== Ball roll effect =====
|
||||
function startBallRoll(dur){
|
||||
stopBallRoll();
|
||||
var balls=document.getElementById('raceSceneBalls');
|
||||
if(!balls)return;
|
||||
dur=dur||5500;
|
||||
var speed=100;
|
||||
function roll(){
|
||||
var arr=[];for(var i=1;i<=10;i++)arr.push(i);
|
||||
for(var i=arr.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=arr[i];arr[i]=arr[j];arr[j]=t;}
|
||||
var html='';
|
||||
arr.forEach(function(v){var cc=CAR_COLORS[v];html+='<span class="rs-ball" style="background:'+(cc?cc[0]:'#666')+'">'+v+'</span>';});
|
||||
balls.innerHTML=html;
|
||||
}
|
||||
roll();
|
||||
ballRollTimer=setInterval(roll,speed);
|
||||
var slow1=Math.round(dur*0.4);
|
||||
var slow2=Math.round(dur*0.75);
|
||||
setTimeout(function(){if(ballRollTimer){clearInterval(ballRollTimer);ballRollTimer=setInterval(roll,200);}},slow1);
|
||||
setTimeout(function(){if(ballRollTimer){clearInterval(ballRollTimer);ballRollTimer=setInterval(roll,400);}},slow2);
|
||||
}
|
||||
function stopBallRoll(){
|
||||
if(ballRollTimer){clearInterval(ballRollTimer);ballRollTimer=null;}
|
||||
}
|
||||
|
||||
function updateSceneHeader(result,periodNum){
|
||||
var balls=document.getElementById('raceSceneBalls');var pn=document.getElementById('raceScenePeriod');
|
||||
if(pn&&periodNum){var short=periodNum.length>6?periodNum.slice(-6):periodNum;pn.textContent='\u671F\u6570\uFF1A'+short;}
|
||||
if(balls&&result){var html='';['rank_1','rank_2','rank_3','rank_4','rank_5','rank_6','rank_7','rank_8','rank_9','rank_10'].forEach(function(k){var v=result[k];if(v){var cc=CAR_COLORS[v];html+='<span class="rs-ball" style="background:'+(cc?cc[0]:'#666')+'">'+v+'</span>';}});balls.innerHTML=html||'<span style="color:#666;font-size:10px">---</span>';}
|
||||
updateSceneFooter(result,periodNum);
|
||||
}
|
||||
function updateSceneFooter(result,periodNum){
|
||||
var ftPn=document.getElementById('ftPeriodNum');
|
||||
if(ftPn&&periodNum)ftPn.textContent=periodNum;
|
||||
if(!result)return;
|
||||
var r1=+result.rank_1,r2=+result.rank_2;
|
||||
if(r1&&r2){
|
||||
var sum=r1+r2;
|
||||
var ftSv=document.getElementById('ftSumVal');if(ftSv)ftSv.textContent=sum;
|
||||
var ftSb=document.getElementById('ftSumBs');if(ftSb)ftSb.textContent=sum>=12?I18N.big:I18N.small;
|
||||
var ftSo=document.getElementById('ftSumOe');if(ftSo)ftSo.textContent=sum%2?I18N.odd:I18N.even;
|
||||
}
|
||||
var ftDt=document.getElementById('ftDtTags');
|
||||
if(ftDt){var html='';for(var i=1;i<=5;i++){var a=+result['rank_'+i],b=+result['rank_'+(11-i)];if(a&&b){var isDragon=a>b;html+='<span class="race-ft-tag dt-tag" style="color:'+(isDragon?'#ff6633':'#33aaff')+'">'+(isDragon?I18N.dragon:I18N.tiger)+'</span>';}}ftDt.innerHTML=html||'---';}
|
||||
}
|
||||
|
||||
// ===== Toggle scene collapse =====
|
||||
function toggleRaceScene(){var scene=document.querySelector('.race-scene');if(!scene)return;scene.classList.toggle('collapsed');localStorage.setItem('raceSceneCollapsed',scene.classList.contains('collapsed')?'1':'0');}
|
||||
// Init collapsed state + sound icon
|
||||
(function(){var btn=document.querySelector('.rs-sound');if(btn)btn.textContent=SoundFX.enabled?'🔊':'🔇';if(localStorage.getItem('raceSceneCollapsed')==='1'){var s=document.querySelector('.race-scene');if(s)s.classList.add('collapsed');}})();
|
||||
|
||||
// ===== Modal control =====
|
||||
function openRaceModal(){
|
||||
document.getElementById('raceModalMask').classList.add('show');
|
||||
raceModalOpen=true;
|
||||
document.body.style.overflow='hidden';
|
||||
}
|
||||
function closeRaceModal(){
|
||||
document.getElementById('raceModalMask').classList.remove('show');
|
||||
raceModalOpen=false;
|
||||
document.body.style.overflow='';
|
||||
SoundFX.stopAll();
|
||||
}
|
||||
function getPodiumCarSrc(num){return '/Static/img/cars/car'+num+'.png';}
|
||||
function showPodium(result){if(!result||result.length<3)return;document.getElementById('podiumImg1').src=getPodiumCarSrc(result[0]);document.getElementById('podiumImg2').src=getPodiumCarSrc(result[1]);document.getElementById('podiumImg3').src=getPodiumCarSrc(result[2]);var po=document.getElementById('podiumOverlay');if(po)po.classList.add('show');}
|
||||
function hidePodium(){var po=document.getElementById('podiumOverlay');if(po)po.classList.remove('show');}
|
||||
|
||||
// ===== Video/Anim switch =====
|
||||
function switchVideoOrAnim(status,changed){
|
||||
currentView='anim';
|
||||
var desc=document.getElementById('raceEntryDesc');
|
||||
if(desc){
|
||||
var map={pending:'\u7B49\u5F85\u5F00\u5956\u4E2D...',locked:'\u6B63\u5728\u5F00\u5956\uFF01',drawn:'\u5F00\u5956\u7ED3\u675F',settled:'\u5DF2\u7ED3\u7B97'};
|
||||
desc.textContent=map[status]||'\u70B9\u51FB\u67E5\u770B\u5F00\u5956\u52A8\u753B';
|
||||
}
|
||||
}
|
||||
function tryResumeVideo(){var v=document.querySelector('#pk10VideoPlayer video');if(v){v.muted=true;v.play().catch(function(){});}}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* PK10 Sound Engine (SoundFX)
|
||||
* Depends on: (none - first to load)
|
||||
* Provides: SoundFX, toggleRaceSound
|
||||
*
|
||||
* Sound only plays when the race modal is open (raceModalOpen === true).
|
||||
* betSuccess/error are exempt — they are UI feedback, not race sounds.
|
||||
*/
|
||||
var SoundFX={
|
||||
enabled:localStorage.getItem('raceSoundOn')!=='0',
|
||||
_cache:{},
|
||||
// Core play — checks both enabled AND raceModalOpen
|
||||
_play:function(name,vol,startAt){if(!this.enabled||!window.raceModalOpen)return null;var a=new Audio('/Static/sounds/'+name+'.mp3');a.volume=Math.min(vol||0.6,1);if(startAt)a.currentTime=startAt;a.play().catch(function(){});return a;},
|
||||
_stop:function(key){if(this._cache[key]){try{this._cache[key].pause();this._cache[key].currentTime=0;}catch(e){}delete this._cache[key];}},
|
||||
engineIdle:function(){this._cache.idle=this._play('engine',0.3);},
|
||||
trafficBeep:function(n){this._play('beep',n>0?0.4:0.6);},
|
||||
engineStart:function(){this._stop('idle');this._cache.eng=this._play('engine',0.7);},
|
||||
accelerate:function(){this._stop('eng');this._cache.race=this._play('racing',0.5);},
|
||||
gearShift:function(){this._play('engine',0.25,1.5);},
|
||||
whoosh:function(){this._play('whoosh',0.5);},
|
||||
brakeSqueal:function(){this._stop('race');this._play('brake',0.6);},
|
||||
finish:function(){this._play('finish',0.7);},
|
||||
podium:function(){this._play('finish',0.5);},
|
||||
// tick — race countdown sound, only when modal open
|
||||
tick:function(){if(!this.enabled||!window.raceModalOpen)return;try{var ctx=new(window.AudioContext||window.webkitAudioContext)();var o=ctx.createOscillator(),g=ctx.createGain();o.type='sine';o.frequency.value=800;g.gain.setValueAtTime(0.1,ctx.currentTime);g.gain.linearRampToValueAtTime(0,ctx.currentTime+0.05);o.connect(g).connect(ctx.destination);o.start();o.stop(ctx.currentTime+0.05);}catch(e){}},
|
||||
// betSuccess/error — UI feedback, always plays when enabled (no modal check)
|
||||
betSuccess:function(){if(!this.enabled)return;try{var ctx=new(window.AudioContext||window.webkitAudioContext)();[880,1175].forEach(function(f,i){var o=ctx.createOscillator(),g=ctx.createGain();o.type='sine';o.frequency.value=f;g.gain.setValueAtTime(0.08,ctx.currentTime+i*0.08);g.gain.linearRampToValueAtTime(0,ctx.currentTime+i*0.08+0.12);o.connect(g).connect(ctx.destination);o.start(ctx.currentTime+i*0.08);o.stop(ctx.currentTime+i*0.08+0.12);});}catch(e){}},
|
||||
error:function(){if(!this.enabled)return;try{var ctx=new(window.AudioContext||window.webkitAudioContext)();var o=ctx.createOscillator(),g=ctx.createGain();o.type='square';o.frequency.setValueAtTime(200,ctx.currentTime);o.frequency.setValueAtTime(150,ctx.currentTime+0.15);g.gain.setValueAtTime(0.06,ctx.currentTime);g.gain.linearRampToValueAtTime(0,ctx.currentTime+0.3);o.connect(g).connect(ctx.destination);o.start();o.stop(ctx.currentTime+0.3);}catch(e){}},
|
||||
// stopAll — kill all cached sounds (called when modal closes)
|
||||
stopAll:function(){for(var k in this._cache){this._stop(k);}},
|
||||
toggle:function(){this.enabled=!this.enabled;localStorage.setItem('raceSoundOn',this.enabled?'1':'0');return this.enabled;}
|
||||
};
|
||||
function toggleRaceSound(){var on=SoundFX.toggle();var btn=document.querySelector('.rs-sound');if(btn)btn.textContent=on?'🔊':'🔇';if(on)SoundFX.tick();}
|
||||
@@ -49,6 +49,7 @@ require_once $rootDir . '/App/Core/GameFactory.php';
|
||||
require_once $rootDir . '/App/Core/PK10Algorithm.php';
|
||||
require_once $rootDir . '/App/Core/DiceAlgorithm.php';
|
||||
require_once $rootDir . '/App/Core/XocDiaAlgorithm.php';
|
||||
require_once $rootDir . '/App/Core/SettingsHelper.php';
|
||||
|
||||
use Db\Database;
|
||||
use App\Core\GameFactory;
|
||||
@@ -208,9 +209,24 @@ function processGame($db, $game) {
|
||||
} else {
|
||||
doDraw($db, $periodId, $gameId, $algoClass);
|
||||
}
|
||||
// 推送开奖结果到 bot 群组
|
||||
$drawResult = json_decode($db->get('periods', 'result', ['id' => $periodId]) ?: '[]', true);
|
||||
if (!empty($drawResult)) {
|
||||
$pk10Row = $db->get('pk10_results', '*', ['period_id' => $periodId]);
|
||||
$championSum = $pk10Row ? (int)$pk10Row['champion_sum'] : ($drawResult[0] + $drawResult[1]);
|
||||
queueBotPush($db, $gameId, 'draw', [
|
||||
'event' => 'period_drawn',
|
||||
'period_id' => $periodId,
|
||||
'period_number' => $periodNum,
|
||||
'result' => $drawResult,
|
||||
'champion_sum' => $championSum,
|
||||
'manual' => false,
|
||||
], ['remind_draw_result' => 1], $periodNum);
|
||||
}
|
||||
|
||||
logToDb($db, $gameId, $periodId, 'draw', '自动开奖');
|
||||
$status = 'drawn';
|
||||
return;
|
||||
// fall through to settle immediately
|
||||
}
|
||||
|
||||
// === 阶段3:结算 + 开新期 ===
|
||||
@@ -301,7 +317,8 @@ function doPreDraw($db, $periodId, $gameId, $algoClass) {
|
||||
]);
|
||||
|
||||
if (!empty($waterConfig) && !empty($bets)) {
|
||||
return $algoClass::generateControlledResult($bets, $waterConfig);
|
||||
$targetProfitRate = floatval(\App\Core\SettingsHelper::get('target_profit_rate'));
|
||||
return $algoClass::generateControlledResult($bets, $waterConfig, 100, $targetProfitRate);
|
||||
}
|
||||
return $algoClass::generateResult();
|
||||
}
|
||||
@@ -356,6 +373,14 @@ function doSettle($db, $period, $algoClass, $gameType) {
|
||||
$bets = $db->select('bets', '*', ['period_id' => $periodId, 'status' => 'pending']);
|
||||
if (empty($bets)) {
|
||||
$db->update('periods', ['status' => 'settled', 'updated_at' => date('Y-m-d H:i:s')], ['id' => $periodId]);
|
||||
$gameId = (int)$period['game_id'];
|
||||
queueBotPush($db, $gameId, 'system', [
|
||||
'event' => 'period_settled',
|
||||
'period_id' => $periodId,
|
||||
'period_number' => $period['period_number'],
|
||||
'wins' => 0, 'losses' => 0, 'total_payout' => 0,
|
||||
'message' => 'No bets to settle',
|
||||
], [], $period['period_number']);
|
||||
logInfo(" ✓ 结算: 无投注,直接结算");
|
||||
return;
|
||||
}
|
||||
@@ -402,6 +427,18 @@ function doSettle($db, $period, $algoClass, $gameType) {
|
||||
|
||||
doSettleAgentCommissions($db, $bets, $periodId);
|
||||
$db->update('periods', ['status' => 'settled', 'updated_at' => date('Y-m-d H:i:s')], ['id' => $periodId]);
|
||||
|
||||
// 推送结算结果到 bot 群组
|
||||
$gameId = (int)$period['game_id'];
|
||||
queueBotPush($db, $gameId, 'system', [
|
||||
'event' => 'period_settled',
|
||||
'period_id' => $periodId,
|
||||
'period_number' => $period['period_number'],
|
||||
'wins' => $wins,
|
||||
'losses' => $losses,
|
||||
'total_payout' => (float)$totalPayout,
|
||||
], [], $period['period_number']);
|
||||
|
||||
$db->medoo->pdo->commit();
|
||||
logInfo(" ✓ {$gameName}结算: 中{$wins}笔 负{$losses}笔 派奖{$totalPayout}");
|
||||
} catch (\Throwable $e) {
|
||||
@@ -451,3 +488,31 @@ function doSettleAgentCommissions($db, $bets, $periodId) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Bot 推送队列
|
||||
// ============================
|
||||
function queueBotPush($db, int $gameId, string $pushType, array $payload, array $groupFilters = [], ?string $periodNumber = null): void {
|
||||
if ($gameId <= 0) return;
|
||||
try {
|
||||
$where = array_merge(['game_id' => $gameId, 'status' => 1], $groupFilters);
|
||||
$groups = $db->select('bot_groups', ['id'], $where) ?: [];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($groups as $group) {
|
||||
$db->insert('bot_push_logs', [
|
||||
'group_id' => (int)$group['id'],
|
||||
'period_number' => $periodNumber,
|
||||
'push_type' => $pushType,
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'status' => 'pending',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
if (!empty($groups)) {
|
||||
logInfo(" ✓ Bot推送: {$pushType} → " . count($groups) . "个群组");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
logInfo(" ⚠ Bot推送失败: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ spl_autoload_register(function ($class) {
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($class, 'App\\Services\\')) {
|
||||
$file = __DIR__ . DIRECTORY_SEPARATOR . $classPath;
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($class, 'App\\Core\\')) {
|
||||
$relativePath = substr($classPath, strlen('App' . DIRECTORY_SEPARATOR . 'Core' . DIRECTORY_SEPARATOR));
|
||||
$file = APP_CORE_PATH . $relativePath;
|
||||
|
||||
@@ -1,29 +1,60 @@
|
||||
<?php
|
||||
// PK10 极速赛车平台 - 路由配置
|
||||
// 按模块分组,便于维护和扩展
|
||||
return [
|
||||
// === 前台 ===
|
||||
|
||||
// ============================================================
|
||||
// 前台 - 页面
|
||||
// ============================================================
|
||||
['GET', '/', '\App\Controllers\Web\HomeController@index'],
|
||||
['GET', '/game/pk10', '\App\Controllers\Web\HomeController@pk10Game'],
|
||||
['GET', '/game/pk10/stats', '\App\Controllers\Web\HomeController@pk10Stats'],
|
||||
['GET', '/lottery', '\App\Controllers\Web\HomeController@lottery'],
|
||||
['GET', '/details', '\App\Controllers\Web\HomeController@details'],
|
||||
['GET', '/profile', '\App\Controllers\Web\HomeController@profile'],
|
||||
|
||||
// 前台 - 通用游戏入口
|
||||
['GET', '/game/{code}', '\App\Controllers\Web\GameController@play'],
|
||||
['GET', '/game/{code}/stats', '\App\Controllers\Web\GameController@stats'],
|
||||
|
||||
// ============================================================
|
||||
// 前台 - 报表 & 跟单
|
||||
// ============================================================
|
||||
['GET', '/report', '\App\Controllers\Web\ReportWebController@report'],
|
||||
['GET', '/api/user-report', '\App\Controllers\Web\ReportWebController@userReportApi'],
|
||||
['GET', '/follow-plan', '\App\Controllers\Web\FollowPlanController@followPlan'],
|
||||
['POST', '/api/follow-plan/toggle', '\App\Controllers\Web\FollowPlanController@toggleFollow'],
|
||||
|
||||
// ============================================================
|
||||
// 前台 - 交易(充提/转账)
|
||||
// ============================================================
|
||||
['POST', '/api/fund-request', '\App\Controllers\Web\TransactionController@fundRequest'],
|
||||
['POST', '/api/transfer', '\App\Controllers\Web\TransactionController@transfer'],
|
||||
|
||||
// ============================================================
|
||||
// 前台 - 用户设置
|
||||
// ============================================================
|
||||
['POST', '/api/bind-usdt', '\App\Controllers\Web\HomeController@bindUsdt'],
|
||||
['POST', '/api/set-lang', '\App\Controllers\Web\HomeController@setLang'],
|
||||
|
||||
// ============================================================
|
||||
// 前台 - 投注 & 期号
|
||||
// ============================================================
|
||||
['POST', '/api/bet', '\App\Controllers\Web\BetController@placeBet'],
|
||||
['GET', '/api/period/current', '\App\Controllers\Web\PeriodController@getCurrent'],
|
||||
|
||||
// ============================================================
|
||||
// 前台 - 认证
|
||||
// ============================================================
|
||||
['GET', '/login', '\App\Controllers\Web\AuthController@loginPage'],
|
||||
['POST', '/api/auth/login', '\App\Controllers\Web\AuthController@loginSubmit'],
|
||||
['POST', '/api/auth/register', '\App\Controllers\Web\AuthController@registerSubmit'],
|
||||
['POST', '/api/auth/send-code', '\App\Controllers\Web\AuthController@sendVerifyCode'],
|
||||
['GET', '/logout', '\App\Controllers\Web\AuthController@logout'],
|
||||
['POST', '/api/bet', '\App\Controllers\Web\BetController@placeBet'],
|
||||
['GET', '/api/period/current', '\App\Controllers\Web\PeriodController@getCurrent'],
|
||||
|
||||
// === 通用游戏入口 ===
|
||||
['GET', '/game/{code}', '\App\Controllers\Web\GameController@play'],
|
||||
['GET', '/game/{code}/stats', '\App\Controllers\Web\GameController@stats'],
|
||||
|
||||
['GET', '/profile', '\App\Controllers\Web\HomeController@profile'],
|
||||
['POST', '/api/bind-usdt', '\App\Controllers\Web\HomeController@bindUsdt'],
|
||||
['POST', '/api/set-lang', '\App\Controllers\Web\HomeController@setLang'],
|
||||
|
||||
// === 代理前台 ===
|
||||
// ============================================================
|
||||
// 前台 - 代理
|
||||
// ============================================================
|
||||
['GET', '/agent', '\App\Controllers\Web\AgentController@dashboard'],
|
||||
['GET', '/agent/odds', '\App\Controllers\Web\AgentController@odds'],
|
||||
['GET', '/agent/bets', '\App\Controllers\Web\AgentController@bets'],
|
||||
@@ -31,31 +62,41 @@ return [
|
||||
['POST', '/api/agent/set-odds', '\App\Controllers\Web\AgentController@setOdds'],
|
||||
['GET', '/api/agent/commissions', '\App\Controllers\Web\AgentController@commissionsApi'],
|
||||
|
||||
// === 员工入口 ===
|
||||
// ============================================================
|
||||
// 员工入口
|
||||
// ============================================================
|
||||
['GET', '/employee/login', '\App\Controllers\Admin\EmployeeController@loginPage'],
|
||||
['POST', '/employee/login', '\App\Controllers\Admin\EmployeeController@loginSubmit'],
|
||||
['GET', '/employee/dashboard', '\App\Controllers\Admin\EmployeeController@dashboard'],
|
||||
['POST', '/employee/adjust-balance', '\App\Controllers\Admin\EmployeeController@adjustBalance'],
|
||||
|
||||
// === 系统安装 ===
|
||||
// ============================================================
|
||||
// 系统安装
|
||||
// ============================================================
|
||||
['GET|POST', '/install', '\App\Controllers\Admin\InstallController@index'],
|
||||
|
||||
// === 后台认证 ===
|
||||
// ============================================================
|
||||
// 后台 - 认证
|
||||
// ============================================================
|
||||
['GET', '/admin/login', '\App\Controllers\Admin\AuthController@loginPage'],
|
||||
['POST', '/admin/login', '\App\Controllers\Admin\AuthController@loginSubmit'],
|
||||
['GET', '/admin/logout', '\App\Controllers\Admin\AuthController@logout'],
|
||||
['GET', '/admin/register', '\App\Controllers\Admin\AuthController@registerPage'],
|
||||
['POST', '/admin/register', '\App\Controllers\Admin\AuthController@registerSubmit'],
|
||||
|
||||
// === 后台首页 ===
|
||||
// ============================================================
|
||||
// 后台 - 首页 & 设置
|
||||
// ============================================================
|
||||
['GET', '/admin', '\App\Controllers\Admin\HomeController@index'],
|
||||
['GET', '/admin/dashboard', '\App\Controllers\Admin\HomeController@index'],
|
||||
['GET', '/admin/settings', '\App\Controllers\Admin\SettingsController@index'],
|
||||
['GET', '/admin/settings/get', '\App\Controllers\Admin\SettingsController@get'],
|
||||
['POST', '/admin/settings/save', '\App\Controllers\Admin\SettingsController@save'],
|
||||
['POST', '/admin/settings/smtp-test', '\App\Controllers\Admin\SettingsController@smtpTest'],
|
||||
['POST', '/admin/settings/smtp-test','\App\Controllers\Admin\SettingsController@smtpTest'],
|
||||
|
||||
// === PK10期号管理 ===
|
||||
// ============================================================
|
||||
// 后台 - PK10 期号管理
|
||||
// ============================================================
|
||||
['GET', '/admin/pk10-periods', '\App\Controllers\Admin\PK10PeriodController@index'],
|
||||
['GET', '/admin/pk10-periods/{id}', '\App\Controllers\Admin\PK10PeriodController@get'],
|
||||
['POST', '/admin/pk10-periods/draw', '\App\Controllers\Admin\PK10PeriodController@draw'],
|
||||
@@ -63,40 +104,71 @@ return [
|
||||
['POST', '/admin/pk10-periods/lock', '\App\Controllers\Admin\PK10PeriodController@lock'],
|
||||
['POST', '/admin/pk10-periods/start', '\App\Controllers\Admin\PK10PeriodController@start'],
|
||||
|
||||
// === 游戏管理 ===
|
||||
// ============================================================
|
||||
// 后台 - 游戏管理
|
||||
// ============================================================
|
||||
['GET', '/admin/games', '\App\Controllers\Admin\GameController@index'],
|
||||
['GET', '/admin/games/{id}', '\App\Controllers\Admin\GameController@get'],
|
||||
['GET', '/admin/games/odds/{id}', '\App\Controllers\Admin\GameController@getOdds'],
|
||||
['POST', '/admin/games/odds/update', '\App\Controllers\Admin\GameController@updateOdds'],
|
||||
['POST', '/admin/games/update', '\App\Controllers\Admin\GameController@update'],
|
||||
|
||||
// === 放水与限额 ===
|
||||
// ============================================================
|
||||
// 后台 - 跟单计划管理
|
||||
// ============================================================
|
||||
['GET', '/admin/follow-plans', '\App\Controllers\Admin\FollowPlanController@index'],
|
||||
['POST', '/admin/follow-plans/save', '\App\Controllers\Admin\FollowPlanController@save'],
|
||||
['POST', '/admin/follow-plans/delete', '\App\Controllers\Admin\FollowPlanController@delete'],
|
||||
['POST', '/admin/follow-plans/toggle', '\App\Controllers\Admin\FollowPlanController@toggle'],
|
||||
|
||||
// ============================================================
|
||||
// 后台 - 放水与限额
|
||||
// ============================================================
|
||||
['GET', '/admin/water', '\App\Controllers\Admin\WaterController@index'],
|
||||
['POST', '/admin/water/update', '\App\Controllers\Admin\WaterController@updateWater'],
|
||||
['POST', '/admin/water/limits', '\App\Controllers\Admin\WaterController@updateLimits'],
|
||||
['POST', '/admin/water/profit-rate', '\App\Controllers\Admin\WaterController@updateProfitRate'],
|
||||
|
||||
// === 投注记录 ===
|
||||
// ============================================================
|
||||
// 后台 - 投注记录
|
||||
// ============================================================
|
||||
['GET', '/admin/bets', '\App\Controllers\Admin\BetController@index'],
|
||||
|
||||
// === 财务 ===
|
||||
// ============================================================
|
||||
// 后台 - 充提审核
|
||||
// ============================================================
|
||||
['GET', '/admin/fund-requests', '\App\Controllers\Admin\FundRequestController@index'],
|
||||
['POST', '/admin/fund-requests/approve', '\App\Controllers\Admin\FundRequestController@approve'],
|
||||
['POST', '/admin/fund-requests/reject', '\App\Controllers\Admin\FundRequestController@reject'],
|
||||
['GET', '/admin/fund-requests/pending-count', '\App\Controllers\Admin\FundRequestController@pendingCount'],
|
||||
|
||||
// ============================================================
|
||||
// 后台 - 财务
|
||||
// ============================================================
|
||||
['GET', '/admin/finance', '\App\Controllers\Admin\FinanceController@index'],
|
||||
['GET', '/admin/finance/{id}', '\App\Controllers\Admin\FinanceController@get'],
|
||||
['POST', '/admin/finance/deposit', '\App\Controllers\Admin\FinanceController@deposit'],
|
||||
['POST', '/admin/finance/withdraw', '\App\Controllers\Admin\FinanceController@withdraw'],
|
||||
|
||||
// === 用户 ===
|
||||
// ============================================================
|
||||
// 后台 - 用户管理
|
||||
// ============================================================
|
||||
['GET', '/admin/users', '\App\Controllers\Admin\UserController@index'],
|
||||
['GET', '/admin/users/{id}', '\App\Controllers\Admin\UserController@get'],
|
||||
['POST', '/admin/users/delete/{id}', '\App\Controllers\Admin\UserController@delete'],
|
||||
['POST', '/admin/users/update', '\App\Controllers\Admin\UserController@update'],
|
||||
['POST', '/admin/users/balance/{id}', '\App\Controllers\Admin\UserController@adjustBalance'],
|
||||
|
||||
// === 管理员 ===
|
||||
// ============================================================
|
||||
// 后台 - 管理员
|
||||
// ============================================================
|
||||
['GET', '/admin/admins', '\App\Controllers\Admin\AdminController@index'],
|
||||
['POST', '/admin/admins/update', '\App\Controllers\Admin\AdminController@update'],
|
||||
['POST', '/admin/admins/delete/{id}', '\App\Controllers\Admin\AdminController@delete'],
|
||||
|
||||
// === 代理管理 ===
|
||||
// ============================================================
|
||||
// 后台 - 代理管理
|
||||
// ============================================================
|
||||
['GET', '/admin/agents', '\App\Controllers\Admin\AgentController@index'],
|
||||
['POST', '/admin/agents/update', '\App\Controllers\Admin\AgentController@update'],
|
||||
['POST', '/admin/agents/delete/{id}', '\App\Controllers\Admin\AgentController@delete'],
|
||||
@@ -104,36 +176,92 @@ return [
|
||||
['POST', '/admin/agents/odds/update', '\App\Controllers\Admin\AgentController@updateOdds'],
|
||||
['GET', '/admin/agents/commissions/{id}', '\App\Controllers\Admin\AgentController@commissions'],
|
||||
|
||||
// === 员工管理 ===
|
||||
// ============================================================
|
||||
// 后台 - 员工管理
|
||||
// ============================================================
|
||||
['GET', '/admin/employees', '\App\Controllers\Admin\EmployeeController@index'],
|
||||
['POST', '/admin/employees/update', '\App\Controllers\Admin\EmployeeController@update'],
|
||||
['POST', '/admin/employees/delete/{id}', '\App\Controllers\Admin\EmployeeController@delete'],
|
||||
|
||||
// === 虚拟账户 ===
|
||||
// ============================================================
|
||||
// 后台 - 虚拟账户
|
||||
// ============================================================
|
||||
['GET', '/admin/virtual', '\App\Controllers\Admin\VirtualAccountController@index'],
|
||||
['POST', '/admin/virtual/create', '\App\Controllers\Admin\VirtualAccountController@create'],
|
||||
['POST', '/admin/virtual/balance', '\App\Controllers\Admin\VirtualAccountController@adjustBalance'],
|
||||
['POST', '/admin/virtual/delete/{id}', '\App\Controllers\Admin\VirtualAccountController@delete'],
|
||||
|
||||
// === 统计报表 ===
|
||||
// ============================================================
|
||||
// 后台 - 统计报表
|
||||
// ============================================================
|
||||
['GET', '/admin/reports', '\App\Controllers\Admin\ReportController@index'],
|
||||
['GET', '/admin/reports/export', '\App\Controllers\Admin\ReportController@export'],
|
||||
|
||||
// === 图库 ===
|
||||
// ============================================================
|
||||
// 后台 - Bot 管理
|
||||
// ============================================================
|
||||
['GET', '/admin/bots', '\App\Controllers\Admin\BotController@index'],
|
||||
['POST', '/admin/bots/save', '\App\Controllers\Admin\BotController@saveBot'],
|
||||
['POST', '/admin/bots/update', '\App\Controllers\Admin\BotController@updateBot'],
|
||||
['POST', '/admin/bots/toggle', '\App\Controllers\Admin\BotController@toggleBot'],
|
||||
['POST', '/admin/bots/groups/save', '\App\Controllers\Admin\BotController@saveGroup'],
|
||||
['POST', '/admin/bots/groups/update', '\App\Controllers\Admin\BotController@updateGroup'],
|
||||
['POST', '/admin/bots/groups/toggle', '\App\Controllers\Admin\BotController@toggleGroup'],
|
||||
['POST', '/admin/bots/wallets/save', '\App\Controllers\Admin\BotController@saveWallet'],
|
||||
['POST', '/admin/bots/wallets/update', '\App\Controllers\Admin\BotController@updateWallet'],
|
||||
['POST', '/admin/bots/wallets/toggle', '\App\Controllers\Admin\BotController@toggleWallet'],
|
||||
['POST', '/admin/bots/members/save', '\App\Controllers\Admin\BotController@saveMember'],
|
||||
['POST', '/admin/bots/members/update', '\App\Controllers\Admin\BotController@updateMember'],
|
||||
['POST', '/admin/bots/members/toggle-bet', '\App\Controllers\Admin\BotController@toggleMemberBet'],
|
||||
['POST', '/admin/bots/shills/save', '\App\Controllers\Admin\BotController@saveShill'],
|
||||
['POST', '/admin/bots/shills/update', '\App\Controllers\Admin\BotController@updateShill'],
|
||||
['POST', '/admin/bots/shills/toggle', '\App\Controllers\Admin\BotController@toggleShill'],
|
||||
|
||||
// ============================================================
|
||||
// 后台 - 图库
|
||||
// ============================================================
|
||||
['GET', '/admin/images', '\App\Controllers\Admin\ImagesController@index'],
|
||||
['GET', '/admin/images/list', '\App\Controllers\Admin\ImagesController@list'],
|
||||
['POST', '/admin/images/upload', '\App\Controllers\Admin\ImagesController@upload'],
|
||||
['POST', '/admin/images/delete', '\App\Controllers\Admin\ImagesController@delete'],
|
||||
|
||||
// === 自动开期 ===
|
||||
// ============================================================
|
||||
// 后台 - 自动开期
|
||||
// ============================================================
|
||||
['GET', '/admin/auto-period', '\App\Controllers\Admin\AutoPeriodController@index'],
|
||||
['POST', '/admin/auto-period/update', '\App\Controllers\Admin\AutoPeriodController@update'],
|
||||
['POST', '/admin/auto-period/toggle-all', '\App\Controllers\Admin\AutoPeriodController@toggleAll'],
|
||||
['GET', '/admin/auto-period/status', '\App\Controllers\Admin\AutoPeriodController@status'],
|
||||
|
||||
// === 插件 ===
|
||||
// ============================================================
|
||||
// 后台 - 插件
|
||||
// ============================================================
|
||||
['GET', '/admin/plugins', '\App\Controllers\Admin\PluginController@manage'],
|
||||
['GET', '/admin/plugins/{pluginName}', '\App\Controllers\Admin\PluginController@detail'],
|
||||
['GET', '/admin/plugins/toggle/{pluginName}', '\App\Controllers\Admin\PluginController@toggleStatus'],
|
||||
['GET', '/admin/plugins/install/{pluginName}', '\App\Controllers\Admin\PluginController@install'],
|
||||
['GET', '/admin/plugins/uninstall/{pluginName}', '\App\Controllers\Admin\PluginController@uninstall'],
|
||||
['GET', '/admin/plugins/uninstall/{pluginName}','\App\Controllers\Admin\PluginController@uninstall'],
|
||||
|
||||
// ============================================================
|
||||
// API - Bot(Telegram 机器人)
|
||||
// ============================================================
|
||||
['POST', '/api/bot/auth/ping', '\App\Controllers\Api\BotController@ping'],
|
||||
['GET', '/api/bot/runtime/instances', '\App\Controllers\Api\BotController@getRuntimeInstances'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/config', '\App\Controllers\Api\BotController@getGroupConfig'],
|
||||
['POST', '/api/bot/groups/{tgGroupId}/members/touch', '\App\Controllers\Api\BotController@touchMember'],
|
||||
['POST', '/api/bot/groups/{tgGroupId}/members/bind', '\App\Controllers\Api\BotController@bindMember'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/members', '\App\Controllers\Api\BotController@getGroupMembers'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/period/current', '\App\Controllers\Api\BotController@getCurrentPeriod'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/draw/latest', '\App\Controllers\Api\BotController@getDrawLatest'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/history', '\App\Controllers\Api\BotController@getHistory'],
|
||||
['POST', '/api/bot/groups/{tgGroupId}/actions/toggle-setting','\App\Controllers\Api\BotController@toggleGroupSetting'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/stats/daily', '\App\Controllers\Api\BotController@getDailyStats'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/stats/member', '\App\Controllers\Api\BotController@getMemberStats'],
|
||||
['GET|POST', '/api/bot/groups/{tgGroupId}/pushes/pending', '\App\Controllers\Api\BotController@getPendingPushes'],
|
||||
['POST', '/api/bot/groups/{tgGroupId}/pushes/ack', '\App\Controllers\Api\BotController@ackPushes'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/odds', '\App\Controllers\Api\BotController@getOdds'],
|
||||
['GET', '/api/bot/groups/{tgGroupId}/bet-format', '\App\Controllers\Api\BotController@getBetFormatRules'],
|
||||
['POST', '/api/bot/groups/{tgGroupId}/bets', '\App\Controllers\Api\BotController@placeGroupBet'],
|
||||
['POST', '/api/bot/groups/{tgGroupId}/credit', '\App\Controllers\Api\BotController@credit'],
|
||||
['POST', '/api/bot/groups/{tgGroupId}/debit', '\App\Controllers\Api\BotController@debit'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
<?php
|
||||
/**
|
||||
* 重构验证测试脚本
|
||||
* 覆盖:语法检查、路由完整性、Service层、Controller调用链、JS文件、JS全局变量、View文件、i18n、路由无死链
|
||||
*/
|
||||
|
||||
$ROOT = dirname(__DIR__);
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$results = [];
|
||||
|
||||
function test_pass($desc) {
|
||||
global $pass, $results;
|
||||
$pass++;
|
||||
$results[] = "[PASS] $desc";
|
||||
}
|
||||
function test_fail($desc, $reason) {
|
||||
global $fail, $results;
|
||||
$fail++;
|
||||
$results[] = "[FAIL] $desc - $reason";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. PHP 语法检查
|
||||
// ============================================================
|
||||
echo "=== 1. PHP Syntax Check ===\n";
|
||||
$phpFiles = [];
|
||||
$dirs = ['App', 'Db', 'Lang', 'Models', 'Core', 'cron'];
|
||||
foreach ($dirs as $dir) {
|
||||
$path = $ROOT . '/' . $dir;
|
||||
if (!is_dir($path)) continue;
|
||||
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));
|
||||
foreach ($it as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||||
$phpFiles[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check root-level PHP files
|
||||
foreach (glob($ROOT . '/*.php') as $f) {
|
||||
$phpFiles[] = $f;
|
||||
}
|
||||
|
||||
$syntaxErrors = [];
|
||||
foreach ($phpFiles as $file) {
|
||||
$output = [];
|
||||
$ret = 0;
|
||||
exec('php -l ' . escapeshellarg($file) . ' 2>&1', $output, $ret);
|
||||
if ($ret !== 0) {
|
||||
$relPath = str_replace($ROOT . '/', '', $file);
|
||||
$syntaxErrors[] = $relPath . ': ' . implode(' ', $output);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($syntaxErrors)) {
|
||||
test_pass("PHP syntax check: all " . count($phpFiles) . " files pass");
|
||||
} else {
|
||||
foreach ($syntaxErrors as $err) {
|
||||
test_fail("PHP syntax error", $err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 路由完整性验证
|
||||
// ============================================================
|
||||
echo "=== 2. Route Integrity ===\n";
|
||||
$routes = include $ROOT . '/routes.php';
|
||||
$seenPaths = [];
|
||||
$routeIssues = 0;
|
||||
|
||||
foreach ($routes as $idx => $route) {
|
||||
$method = $route[0];
|
||||
$path = $route[1];
|
||||
$action = $route[2];
|
||||
|
||||
// 检查重复路由 path (method+path)
|
||||
$key = $method . ':' . $path;
|
||||
if (isset($seenPaths[$key])) {
|
||||
test_fail("Route duplicate", "'{$key}' duplicated at line " . ($idx + 1));
|
||||
$routeIssues++;
|
||||
}
|
||||
$seenPaths[$key] = true;
|
||||
|
||||
// 解析 Controller@method
|
||||
if (strpos($action, '@') === false) {
|
||||
test_fail("Route format", "'{$path}' action '{$action}' missing @method");
|
||||
$routeIssues++;
|
||||
continue;
|
||||
}
|
||||
list($controllerClass, $methodName) = explode('@', $action);
|
||||
|
||||
// 转换类名到文件路径
|
||||
$controllerFile = $ROOT . '/' . str_replace('\\', '/', ltrim($controllerClass, '\\')) . '.php';
|
||||
if (!file_exists($controllerFile)) {
|
||||
test_fail("Route controller missing", "'{$path}' -> {$controllerClass} file not found: {$controllerFile}");
|
||||
$routeIssues++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查方法是否存在(通过正则搜索)
|
||||
$content = file_get_contents($controllerFile);
|
||||
if (!preg_match('/function\s+' . preg_quote($methodName) . '\s*\(/', $content)) {
|
||||
test_fail("Route method missing", "'{$path}' -> {$controllerClass}@{$methodName} method not found");
|
||||
$routeIssues++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($routeIssues === 0) {
|
||||
test_pass("Route integrity: all " . count($routes) . " routes valid");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. Service 层验证
|
||||
// ============================================================
|
||||
echo "=== 3. Service Layer ===\n";
|
||||
|
||||
// TransactionService 方法检查
|
||||
$tsFile = $ROOT . '/App/Services/TransactionService.php';
|
||||
if (!file_exists($tsFile)) {
|
||||
test_fail("TransactionService", "File not found");
|
||||
} else {
|
||||
$tsContent = file_get_contents($tsFile);
|
||||
$tsMethods = ['transfer', 'fundRequest'];
|
||||
foreach ($tsMethods as $m) {
|
||||
if (preg_match('/function\s+' . $m . '\s*\(/', $tsContent)) {
|
||||
test_pass("TransactionService has {$m}() method");
|
||||
} else {
|
||||
test_fail("TransactionService", "Missing method {$m}()");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReportService 方法检查
|
||||
$rsFile = $ROOT . '/App/Services/ReportService.php';
|
||||
if (!file_exists($rsFile)) {
|
||||
test_fail("ReportService", "File not found");
|
||||
} else {
|
||||
$rsContent = file_get_contents($rsFile);
|
||||
$rsMethods = ['getUserReport', 'getOverviewStats', 'getDailyStats', 'getUserWinLoss', 'getTopUsers', 'getAgentStats'];
|
||||
foreach ($rsMethods as $m) {
|
||||
if (preg_match('/function\s+' . $m . '\s*\(/', $rsContent)) {
|
||||
test_pass("ReportService has {$m}() method");
|
||||
} else {
|
||||
test_fail("ReportService", "Missing method {$m}()");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Service 纯业务逻辑检查(不含 HTTP 操作)
|
||||
$serviceFiles = glob($ROOT . '/App/Services/*.php');
|
||||
$forbiddenPatterns = [
|
||||
'header\s*\(' => 'header()',
|
||||
'\becho\b' => 'echo',
|
||||
'\$_GET' => '$_GET',
|
||||
'\$_POST' => '$_POST',
|
||||
'\$_SESSION' => '$_SESSION',
|
||||
];
|
||||
foreach ($serviceFiles as $sf) {
|
||||
$sfContent = file_get_contents($sf);
|
||||
$sfName = basename($sf);
|
||||
$sfClean = true;
|
||||
foreach ($forbiddenPatterns as $pattern => $label) {
|
||||
if (preg_match('/' . $pattern . '/', $sfContent)) {
|
||||
test_fail("Service purity: {$sfName}", "Contains forbidden pattern: {$label}");
|
||||
$sfClean = false;
|
||||
}
|
||||
}
|
||||
if ($sfClean) {
|
||||
test_pass("Service purity: {$sfName} is clean (no HTTP operations)");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. Controller → Service 调用链验证
|
||||
// ============================================================
|
||||
echo "=== 4. Controller-Service Call Chain ===\n";
|
||||
|
||||
// TransactionController must call TransactionService
|
||||
$tcFile = $ROOT . '/App/Controllers/Web/TransactionController.php';
|
||||
if (file_exists($tcFile)) {
|
||||
$tcContent = file_get_contents($tcFile);
|
||||
// fundRequest calls TransactionService
|
||||
if (preg_match('/TransactionService/', $tcContent) && preg_match('/function\s+fundRequest/', $tcContent)) {
|
||||
test_pass("TransactionController::fundRequest calls TransactionService");
|
||||
} else {
|
||||
test_fail("TransactionController", "fundRequest does not call TransactionService");
|
||||
}
|
||||
// transfer calls TransactionService
|
||||
if (preg_match('/TransactionService/', $tcContent) && preg_match('/function\s+transfer/', $tcContent)) {
|
||||
test_pass("TransactionController::transfer calls TransactionService");
|
||||
} else {
|
||||
test_fail("TransactionController", "transfer does not call TransactionService");
|
||||
}
|
||||
} else {
|
||||
test_fail("TransactionController", "File not found");
|
||||
}
|
||||
|
||||
// ReportWebController must call ReportService
|
||||
$rwcFile = $ROOT . '/App/Controllers/Web/ReportWebController.php';
|
||||
if (file_exists($rwcFile)) {
|
||||
$rwcContent = file_get_contents($rwcFile);
|
||||
if (preg_match('/ReportService/', $rwcContent) && preg_match('/function\s+userReportApi/', $rwcContent)) {
|
||||
test_pass("ReportWebController::userReportApi calls ReportService");
|
||||
} else {
|
||||
test_fail("ReportWebController", "userReportApi does not call ReportService");
|
||||
}
|
||||
} else {
|
||||
test_fail("ReportWebController", "File not found");
|
||||
}
|
||||
|
||||
// Admin\ReportController must call ReportService
|
||||
$arcFile = $ROOT . '/App/Controllers/Admin/ReportController.php';
|
||||
if (file_exists($arcFile)) {
|
||||
$arcContent = file_get_contents($arcFile);
|
||||
$arcOk = true;
|
||||
if (!preg_match('/ReportService/', $arcContent)) {
|
||||
test_fail("Admin\\ReportController", "Does not reference ReportService");
|
||||
$arcOk = false;
|
||||
}
|
||||
if (preg_match('/function\s+index/', $arcContent) && preg_match('/ReportService/', $arcContent)) {
|
||||
test_pass("Admin\\ReportController::index calls ReportService");
|
||||
} else if ($arcOk) {
|
||||
test_fail("Admin\\ReportController", "index does not call ReportService");
|
||||
}
|
||||
if (preg_match('/function\s+export/', $arcContent) && preg_match('/ReportService/', $arcContent)) {
|
||||
test_pass("Admin\\ReportController::export calls ReportService");
|
||||
} else if ($arcOk) {
|
||||
test_fail("Admin\\ReportController", "export does not call ReportService");
|
||||
}
|
||||
} else {
|
||||
test_fail("Admin\\ReportController", "File not found");
|
||||
}
|
||||
|
||||
// HomeController must NOT contain old methods
|
||||
$hcFile = $ROOT . '/App/Controllers/Web/HomeController.php';
|
||||
if (file_exists($hcFile)) {
|
||||
$hcContent = file_get_contents($hcFile);
|
||||
$removedMethods = ['fundRequest', 'transfer', 'report', 'followPlan', 'userReportApi'];
|
||||
$hcClean = true;
|
||||
foreach ($removedMethods as $rm) {
|
||||
if (preg_match('/function\s+' . $rm . '\s*\(/', $hcContent)) {
|
||||
test_fail("HomeController cleanup", "Still contains method {$rm}()");
|
||||
$hcClean = false;
|
||||
}
|
||||
}
|
||||
if ($hcClean) {
|
||||
test_pass("HomeController no longer contains removed methods (fundRequest/transfer/report/followPlan/userReportApi)");
|
||||
}
|
||||
} else {
|
||||
test_fail("HomeController", "File not found");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. JS 文件验证
|
||||
// ============================================================
|
||||
echo "=== 5. JS File Validation ===\n";
|
||||
|
||||
$jsFiles = ['pk10-sound.js', 'pk10-race.js', 'pk10-bet.js', 'pk10-poll.js'];
|
||||
foreach ($jsFiles as $jsf) {
|
||||
$jsPath = $ROOT . '/Static/js/' . $jsf;
|
||||
if (!file_exists($jsPath)) {
|
||||
test_fail("JS file exists: {$jsf}", "File not found");
|
||||
continue;
|
||||
}
|
||||
$jsSize = filesize($jsPath);
|
||||
if ($jsSize === 0) {
|
||||
test_fail("JS file non-empty: {$jsf}", "File is empty");
|
||||
continue;
|
||||
}
|
||||
test_pass("JS file exists and non-empty: {$jsf} ({$jsSize} bytes)");
|
||||
|
||||
// No PHP tags
|
||||
$jsContent = file_get_contents($jsPath);
|
||||
if (preg_match('/<\?php|<\?=/', $jsContent)) {
|
||||
test_fail("JS no PHP tags: {$jsf}", "Contains PHP tags");
|
||||
} else {
|
||||
test_pass("JS no PHP tags: {$jsf}");
|
||||
}
|
||||
}
|
||||
|
||||
// pk10.php checks
|
||||
$pk10File = $ROOT . '/App/Views/Web/pk10.php';
|
||||
if (file_exists($pk10File)) {
|
||||
$pk10Content = file_get_contents($pk10File);
|
||||
|
||||
// window.PK10_CONFIG
|
||||
if (strpos($pk10Content, 'PK10_CONFIG') !== false) {
|
||||
test_pass("pk10.php contains PK10_CONFIG configuration block");
|
||||
} else {
|
||||
test_fail("pk10.php PK10_CONFIG", "Missing window.PK10_CONFIG block");
|
||||
}
|
||||
|
||||
// 4 script src references
|
||||
$scriptCount = 0;
|
||||
foreach ($jsFiles as $jsf) {
|
||||
if (strpos($pk10Content, $jsf) !== false) {
|
||||
$scriptCount++;
|
||||
}
|
||||
}
|
||||
if ($scriptCount === 4) {
|
||||
test_pass("pk10.php includes all 4 pk10-*.js script references");
|
||||
} else {
|
||||
test_fail("pk10.php script references", "Found {$scriptCount}/4 JS file references");
|
||||
}
|
||||
|
||||
// JS 引用顺序: sound → race → bet → poll
|
||||
$positions = [];
|
||||
foreach ($jsFiles as $jsf) {
|
||||
$pos = strpos($pk10Content, $jsf);
|
||||
if ($pos !== false) {
|
||||
$positions[$jsf] = $pos;
|
||||
}
|
||||
}
|
||||
if (count($positions) === 4) {
|
||||
$ordered = array_keys($positions);
|
||||
usort($ordered, function($a, $b) use ($positions) {
|
||||
return $positions[$a] - $positions[$b];
|
||||
});
|
||||
$expected = ['pk10-sound.js', 'pk10-race.js', 'pk10-bet.js', 'pk10-poll.js'];
|
||||
if ($ordered === $expected) {
|
||||
test_pass("pk10.php JS include order correct: sound -> race -> bet -> poll");
|
||||
} else {
|
||||
test_fail("pk10.php JS order", "Expected: " . implode(' -> ', $expected) . " Got: " . implode(' -> ', $ordered));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
test_fail("pk10.php", "File not found");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 6. JS 全局变量/函数完整性
|
||||
// ============================================================
|
||||
echo "=== 6. JS Global Function Integrity ===\n";
|
||||
|
||||
$jsSoundContent = file_exists($ROOT . '/Static/js/pk10-sound.js') ? file_get_contents($ROOT . '/Static/js/pk10-sound.js') : '';
|
||||
$jsRaceContent = file_exists($ROOT . '/Static/js/pk10-race.js') ? file_get_contents($ROOT . '/Static/js/pk10-race.js') : '';
|
||||
$jsBetContent = file_exists($ROOT . '/Static/js/pk10-bet.js') ? file_get_contents($ROOT . '/Static/js/pk10-bet.js') : '';
|
||||
$jsPollContent = file_exists($ROOT . '/Static/js/pk10-poll.js') ? file_get_contents($ROOT . '/Static/js/pk10-poll.js') : '';
|
||||
|
||||
// SoundFX in pk10-sound.js
|
||||
if (preg_match('/\bSoundFX\b/', $jsSoundContent)) {
|
||||
test_pass("SoundFX defined in pk10-sound.js");
|
||||
} else {
|
||||
test_fail("JS global: SoundFX", "Not found in pk10-sound.js");
|
||||
}
|
||||
|
||||
// Race functions in pk10-race.js
|
||||
$raceFuncs = ['animateRace', 'startIdleAnimation', 'resetRace', 'updateSceneHeader'];
|
||||
foreach ($raceFuncs as $rf) {
|
||||
if (preg_match('/\b' . $rf . '\b/', $jsRaceContent)) {
|
||||
test_pass("{$rf} defined in pk10-race.js");
|
||||
} else {
|
||||
test_fail("JS global: {$rf}", "Not found in pk10-race.js");
|
||||
}
|
||||
}
|
||||
|
||||
// Bet functions in pk10-bet.js
|
||||
$betFuncs = ['renderMyBets', 'renderLastMyBets', 'formatBetLabel'];
|
||||
foreach ($betFuncs as $bf) {
|
||||
if (preg_match('/\b' . $bf . '\b/', $jsBetContent)) {
|
||||
test_pass("{$bf} defined in pk10-bet.js");
|
||||
} else {
|
||||
test_fail("JS global: {$bf}", "Not found in pk10-bet.js");
|
||||
}
|
||||
}
|
||||
|
||||
// pollPeriod in pk10-poll.js
|
||||
if (preg_match('/\bpollPeriod\b/', $jsPollContent)) {
|
||||
test_pass("pollPeriod defined in pk10-poll.js");
|
||||
} else {
|
||||
test_fail("JS global: pollPeriod", "Not found in pk10-poll.js");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 7. View 文件验证
|
||||
// ============================================================
|
||||
echo "=== 7. View File Validation ===\n";
|
||||
|
||||
// report.php
|
||||
$reportView = $ROOT . '/App/Views/Web/report.php';
|
||||
if (file_exists($reportView)) {
|
||||
$rvContent = file_get_contents($reportView);
|
||||
test_pass("report.php exists");
|
||||
if (strpos($rvContent, '/api/user-report') !== false) {
|
||||
test_pass("report.php contains /api/user-report fetch call");
|
||||
} else {
|
||||
test_fail("report.php content", "Missing /api/user-report fetch call");
|
||||
}
|
||||
} else {
|
||||
test_fail("report.php", "File not found");
|
||||
}
|
||||
|
||||
// follow_plan.php
|
||||
$fpView = $ROOT . '/App/Views/Web/follow_plan.php';
|
||||
if (file_exists($fpView)) {
|
||||
$fpContent = file_get_contents($fpView);
|
||||
test_pass("follow_plan.php exists");
|
||||
if (strpos($fpContent, 'PK10_CONFIG') !== false || strpos($fpContent, 'pk10') !== false || strpos($fpContent, 'follow') !== false) {
|
||||
test_pass("follow_plan.php contains expected content");
|
||||
} else {
|
||||
test_fail("follow_plan.php content", "Missing expected PK10_CONFIG or related content");
|
||||
}
|
||||
} else {
|
||||
test_fail("follow_plan.php", "File not found");
|
||||
}
|
||||
|
||||
// profile.php
|
||||
$profileView = $ROOT . '/App/Views/Web/profile.php';
|
||||
if (file_exists($profileView)) {
|
||||
$pvContent = file_get_contents($profileView);
|
||||
$profileOk = true;
|
||||
if (strpos($pvContent, '/report') !== false) {
|
||||
test_pass("profile.php contains /report link");
|
||||
} else {
|
||||
test_fail("profile.php link", "Missing /report link");
|
||||
$profileOk = false;
|
||||
}
|
||||
if (strpos($pvContent, '/follow-plan') !== false || strpos($pvContent, 'follow_plan') !== false || strpos($pvContent, 'follow-plan') !== false) {
|
||||
test_pass("profile.php contains /follow-plan link");
|
||||
} else {
|
||||
test_fail("profile.php link", "Missing /follow-plan link");
|
||||
}
|
||||
} else {
|
||||
test_fail("profile.php", "File not found");
|
||||
}
|
||||
|
||||
// pk10.php localLockCountdown
|
||||
if (file_exists($pk10File)) {
|
||||
$pk10Content = $pk10Content ?? file_get_contents($pk10File);
|
||||
if (strpos($pk10Content, 'localLockCountdown') !== false) {
|
||||
test_pass("pk10.php contains localLockCountdown in statusBadge code");
|
||||
} else {
|
||||
test_fail("pk10.php localLockCountdown", "Missing localLockCountdown reference");
|
||||
}
|
||||
} else {
|
||||
test_fail("pk10.php", "File not found");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 8. i18n 验证
|
||||
// ============================================================
|
||||
echo "=== 8. i18n Validation ===\n";
|
||||
|
||||
$zhFile = $ROOT . '/Lang/zh.php';
|
||||
$enFile = $ROOT . '/Lang/en.php';
|
||||
|
||||
if (!file_exists($zhFile) || !file_exists($enFile)) {
|
||||
test_fail("i18n files", "zh.php or en.php not found");
|
||||
} else {
|
||||
$zhLang = include $zhFile;
|
||||
$enLang = include $enFile;
|
||||
|
||||
$requiredKeys = ['report_query', 'follow_plan', 'today', 'yesterday'];
|
||||
foreach ($requiredKeys as $rk) {
|
||||
if (isset($zhLang[$rk])) {
|
||||
test_pass("zh.php has key '{$rk}'");
|
||||
} else {
|
||||
test_fail("zh.php i18n", "Missing key '{$rk}'");
|
||||
}
|
||||
if (isset($enLang[$rk])) {
|
||||
test_pass("en.php has key '{$rk}'");
|
||||
} else {
|
||||
test_fail("en.php i18n", "Missing key '{$rk}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Key count match
|
||||
$zhCount = count($zhLang);
|
||||
$enCount = count($enLang);
|
||||
if ($zhCount === $enCount) {
|
||||
test_pass("i18n key count match: zh={$zhCount}, en={$enCount}");
|
||||
} else {
|
||||
$diff = abs($zhCount - $enCount);
|
||||
// Find missing keys
|
||||
$missingInEn = array_diff(array_keys($zhLang), array_keys($enLang));
|
||||
$missingInZh = array_diff(array_keys($enLang), array_keys($zhLang));
|
||||
$detail = "zh={$zhCount}, en={$enCount} (diff={$diff})";
|
||||
if (!empty($missingInEn)) {
|
||||
$detail .= " | Missing in en: " . implode(', ', array_slice($missingInEn, 0, 10));
|
||||
}
|
||||
if (!empty($missingInZh)) {
|
||||
$detail .= " | Missing in zh: " . implode(', ', array_slice($missingInZh, 0, 10));
|
||||
}
|
||||
test_fail("i18n key count mismatch", $detail);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 9. 路由无死链验证
|
||||
// ============================================================
|
||||
echo "=== 9. Route Dead Link Check ===\n";
|
||||
|
||||
$deadLinks = 0;
|
||||
foreach ($routes as $route) {
|
||||
$action = $route[2];
|
||||
if (strpos($action, '@') === false) continue;
|
||||
list($controllerClass, $methodName) = explode('@', $action);
|
||||
$controllerFile = $ROOT . '/' . str_replace('\\', '/', ltrim($controllerClass, '\\')) . '.php';
|
||||
|
||||
if (!file_exists($controllerFile)) {
|
||||
// Already reported in section 2
|
||||
$deadLinks++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($controllerFile);
|
||||
if (!preg_match('/function\s+' . preg_quote($methodName) . '\s*\(/', $content)) {
|
||||
// Already reported in section 2
|
||||
$deadLinks++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($deadLinks === 0) {
|
||||
test_pass("No dead route links: all " . count($routes) . " routes point to existing methods");
|
||||
} else {
|
||||
test_fail("Dead route links", "{$deadLinks} routes point to non-existent controllers/methods (see section 2 for details)");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 汇总输出
|
||||
// ============================================================
|
||||
echo "\n" . str_repeat('=', 60) . "\n";
|
||||
echo "TEST RESULTS\n";
|
||||
echo str_repeat('=', 60) . "\n\n";
|
||||
|
||||
foreach ($results as $r) {
|
||||
echo $r . "\n";
|
||||
}
|
||||
|
||||
$total = $pass + $fail;
|
||||
echo "\n" . str_repeat('-', 60) . "\n";
|
||||
echo "Total: {$total} tests | Pass: {$pass} | Fail: {$fail}\n";
|
||||
echo str_repeat('-', 60) . "\n";
|
||||
|
||||
if ($fail > 0) {
|
||||
echo "\n*** {$fail} FAILURE(S) DETECTED ***\n";
|
||||
exit(1);
|
||||
} else {
|
||||
echo "\n*** ALL TESTS PASSED ***\n";
|
||||
exit(0);
|
||||
}
|
||||