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 命名空间
This commit is contained in:
li
2026-03-27 18:41:06 +08:00
parent 48cfe4be04
commit 4a94fe36cf
87 changed files with 8681 additions and 646 deletions
+1 -1
View File
@@ -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,
+965
View File
@@ -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]);
}
}
}
+134 -2
View File
@@ -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);
+115 -77
View File
@@ -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) {
+22 -1
View File
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
+24 -2
View File
@@ -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';
}
+1 -1
View File
@@ -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);
}
}
+181
View File
@@ -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) {
// 审计失败不阻断主流程
}
}
}
+33 -3
View File
@@ -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;
}
+37 -23
View File
@@ -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());
+6 -4
View File
@@ -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' => '', // 客服链接(充值时跳转)
];
}
+245
View File
@@ -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']]);
}
}
}
+344
View File
@@ -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;
}
}
+146
View File
@@ -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' => '充值申请已提交'];
}
}
BIN
View File
Binary file not shown.
+444
View File
@@ -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>
+152
View File
@@ -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>
+306
View File
@@ -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>
+37 -1
View File
@@ -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"> &copy; <?=date('Y')?> PK10 极速赛车后台管理系统 </p>
<p class="text-sm text-gray-500"> &copy; <?=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>
+389 -46
View File
@@ -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>
<!-- 日明细 -->
<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; ?>
</tbody></table></div>
<?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>
<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="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 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>
<?php endif; ?>
</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>
+18
View File
@@ -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()"
+19
View File
@@ -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=>{
BIN
View File
Binary file not shown.
+131
View File
@@ -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>
+43 -3
View File
@@ -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>
+1 -1
View File
@@ -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">
+621 -164
View File
@@ -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">&#x2022;</span>
<span class="rank-badge" id="rankBadge<?=$i?>"></span>
</div>
</div>
<?php endfor; ?>
</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>
<!-- 底部投注操作栏(参考图 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 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 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 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>
</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;
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')?>'
}
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);})();
};
// 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>
+1 -1
View File
@@ -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';
+84 -3
View File
@@ -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>
+138
View File
@@ -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>
+7
View File
@@ -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`);
+47
View File
@@ -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;
+18
View File
@@ -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='充提申请记录';
+226
View File
@@ -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;
+4 -2
View File
@@ -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' => ' টি বাজি যোগ হয়েছে',
];
+38 -2
View File
@@ -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',
];
+4 -2
View File
@@ -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',
];
+4 -2
View File
@@ -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',
];
+4 -2
View File
@@ -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' => ' รายการเพิ่มแล้ว',
];
+4 -2
View File
@@ -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',
];
+38 -2
View File
@@ -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' => '大小计划',
];
+21 -2
View File
@@ -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('');
}
};
/*
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+96 -25
View File
@@ -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);
}
};
+333 -143
View File
@@ -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', () => {
+184
View File
@@ -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?'&#x1F525;':s.count>=3?'&#x1F4A8;':'';
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();});}
+127
View File
@@ -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);})();
+179
View File
@@ -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(){});}}
+33
View File
@@ -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();}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+67 -2
View File
@@ -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());
}
}
+8
View File
@@ -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;
+158 -30
View File
@@ -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,23 +62,31 @@ 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'],
@@ -55,7 +94,9 @@ return [
['POST', '/admin/settings/save', '\App\Controllers\Admin\SettingsController@save'],
['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'],
// ============================================================
// API - BotTelegram 机器人)
// ============================================================
['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'],
];
+541
View File
@@ -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);
}