Initial commit: 投注游戏平台初始化

This commit is contained in:
li
2026-02-25 01:26:58 +08:00
commit 77ca2cc8b3
275 changed files with 237479 additions and 0 deletions
Vendored Executable
BIN
View File
Binary file not shown.
Executable
+1
View File
@@ -0,0 +1 @@
.ace-tool/
Executable
+8
View File
@@ -0,0 +1,8 @@
<IfModule mod_rewrite.c>
Options +FollowSymlinks -Multiviews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php?/$1 [QSA,PT,L]
</IfModule>
Executable
+1
View File
@@ -0,0 +1 @@
open_basedir=/www/wwwroot/touzi/:/tmp/
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class AdminController extends AdminBaseController {
/**
* 构造函数 - 验证管理员权限
*/
public function __construct() {
$this->checkLogin();
$this->checkAdmin();
}
/**
* 管理员列表页面
*/
public function index() {
$db = new Database();
// 只查询管理员角色的用户
$admins = $db->select('users', '*', [
'role' => 'admin',
'ORDER' => ['id' => 'DESC']
]);
$this->render('Admin/admin.php', [
'admins' => $admins,
'title' => '管理员管理'
]);
}
/**
* 获取单个管理员数据(用于编辑和详情)
*/
public function get($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的管理员ID'
]);
return;
}
$db = new Database();
$admin = $db->get('users', '*', [
'id' => $id,
'role' => 'admin' // 确保只获取管理员
]);
if ($admin) {
// 移除密码字段,避免泄露
unset($admin['password']);
echo json_encode([
'success' => true,
'data' => $admin
]);
} else {
echo json_encode([
'success' => false,
'message' => '管理员不存在'
]);
}
}
public function update() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
echo json_encode([
'success' => false,
'message' => '未接收到数据'
]);
return;
}
$db = new Database();
$id = isset($data['id']) ? (int)$data['id'] : 0;
$isEditMode = $id > 0;
$username = trim($data['username'] ?? '');
$email = trim($data['email'] ?? '');
$status = isset($data['status']) ? (int)$data['status'] : 0;
if (empty($username) || empty($email)) {
echo json_encode([
'success' => false,
'message' => '用户名和邮箱不能为空'
]);
return;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo json_encode([
'success' => false,
'message' => '邮箱格式不正确'
]);
return;
}
// 统一查重(排除当前 ID
$exists = $db->get('users', '*', [
'AND' => [
'OR' => [
'username' => $username,
'email' => $email
],
'id[!]' => $id
]
]);
if ($exists) {
if ($exists['username'] === $username) {
$msg = '用户名已存在';
} elseif ($exists['email'] === $email) {
$msg = '邮箱已被使用';
} else {
$msg = '用户名或邮箱已被占用';
}
echo json_encode([
'success' => false,
'message' => $msg
]);
return;
}
// 准备数据 - 强制角色为 admin
$adminData = [
'username' => $username,
'email' => $email,
'role' => 'admin', // 强制设置为管理员角色
'status' => $status,
'updated_at' => date('Y-m-d H:i:s')
];
try {
if ($isEditMode) {
// 验证是否真的是管理员
$existingAdmin = $db->get('users', 'id', [
'id' => $id,
'role' => 'admin'
]);
if (!$existingAdmin) {
echo json_encode([
'success' => false,
'message' => '该用户不是管理员'
]);
return;
}
// 如果密码不为空,更新密码
if (!empty($data['password'])) {
if (strlen($data['password']) < 8) {
echo json_encode([
'success' => false,
'message' => '密码长度至少8位'
]);
return;
}
$adminData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
}
$db->update('users', $adminData, ['id' => $id]);
$message = '管理员更新成功';
} else {
// 新增时必须有密码
if (empty($data['password']) || strlen($data['password']) < 8) {
echo json_encode([
'success' => false,
'message' => '密码长度至少8位'
]);
return;
}
$adminData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
$adminData['created_at'] = date('Y-m-d H:i:s');
$id = $db->insert('users', $adminData);
$message = '管理员创建成功';
}
echo json_encode([
'success' => true,
'message' => $message,
'data' => ['id' => $id]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '操作失败:' . $e->getMessage()
]);
}
}
/**
* 删除管理员
*/
public function delete($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的管理员ID'
]);
return;
}
// 禁止删除自己
session_start();
if ($id == $_SESSION['user_id']) {
echo json_encode([
'success' => false,
'message' => '不能删除当前登录的管理员'
]);
return;
}
$db = new Database();
// 确保只删除管理员角色
$adminExists = $db->get('users', 'id', [
'id' => $id,
'role' => 'admin'
]);
if (!$adminExists) {
echo json_encode([
'success' => false,
'message' => '管理员不存在'
]);
return;
}
// 检查是否还有其他管理员
$adminCount = $db->count('users', [
'role' => 'admin'
]);
if ($adminCount <= 1) {
echo json_encode([
'success' => false,
'message' => '不能删除最后一个管理员'
]);
return;
}
try {
$db->delete('users', [
'id' => $id,
'role' => 'admin'
]);
echo json_encode([
'success' => true,
'message' => '管理员已删除'
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '删除失败:' . $e->getMessage()
]);
}
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class AgentController extends AdminBaseController {
private $db;
public function __construct(Database $db) { $this->db = $db; }
// 后台代理管理
public function index() {
$this->checkLogin(); $this->checkAdmin();
$agents = $this->db->select('agents', '*', ['ORDER' => ['id' => 'DESC']]);
foreach ($agents as &$a) {
$a['user'] = $this->db->get('users', ['username','balance'], ['id' => $a['user_id']]);
$a['player_count'] = $this->db->count('users', ['agent_id' => $a['id']]);
}
$this->render('Admin/agents.php', compact('agents'));
}
public function update() {
$this->checkLogin(); $this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
if ($id > 0) {
$this->db->update('agents', [
'commission_rate' => floatval($data['commission_rate'] ?? 0),
'rebate_rate' => floatval($data['rebate_rate'] ?? 0),
'status' => (int)($data['status'] ?? 1),
], ['id' => $id]);
} else {
// 创建代理:先创建用户,再创建代理记录
$userId = (int)($data['user_id'] ?? 0);
if (!$userId) { $this->json(['status'=>'error','message'=>'User ID required']); return; }
$code = strtoupper(substr(md5(uniqid()), 0, 8));
$this->db->insert('agents', [
'user_id' => $userId,
'parent_id' => !empty($data['parent_id']) ? (int)$data['parent_id'] : null,
'level' => !empty($data['parent_id']) ? 2 : 1,
'agent_code' => $code,
'commission_rate' => floatval($data['commission_rate'] ?? 0),
'rebate_rate' => floatval($data['rebate_rate'] ?? 0),
'status' => 1,
]);
$this->db->update('users', ['role' => 'agent'], ['id' => $userId]);
}
$this->json(['status' => 'success']);
}
public function delete($id) {
$this->checkLogin(); $this->checkAdmin();
$agent = $this->db->get('agents', '*', ['id' => (int)$id]);
if ($agent) {
$this->db->update('users', ['role' => 'user', 'agent_id' => null], ['agent_id' => $agent['id']]);
$this->db->delete('agents', ['id' => (int)$id]);
}
$this->json(['status' => 'success']);
}
// 代理赔率设置
public function odds($agentId) {
$this->checkLogin(); $this->checkAdmin();
$odds = $this->db->select('agent_odds', '*', ['agent_id' => (int)$agentId]);
$this->json(['status' => 'success', 'data' => $odds]);
}
public function updateOdds() {
$this->checkLogin(); $this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
$agentId = (int)($data['agent_id'] ?? 0);
$odds = $data['odds'] ?? [];
$gameId = (int)($data['game_id'] ?? 0);
foreach ($odds as $o) {
$existing = $this->db->get('agent_odds', 'id', [
'agent_id' => $agentId, 'game_id' => $gameId,
'bet_type' => $o['bet_type'], 'bet_target' => $o['bet_target']
]);
if ($existing) {
$this->db->update('agent_odds', ['odds' => floatval($o['odds'])], ['id' => $existing]);
} else {
$this->db->insert('agent_odds', [
'agent_id' => $agentId, 'game_id' => $gameId,
'bet_type' => $o['bet_type'], 'bet_target' => $o['bet_target'],
'odds' => floatval($o['odds']),
]);
}
}
$this->json(['status' => 'success']);
}
// 佣金记录
public function commissions($agentId) {
$this->checkLogin(); $this->checkAdmin();
$records = $this->db->select('agent_commissions', '*', [
'agent_id' => (int)$agentId, 'ORDER' => ['id' => 'DESC'], 'LIMIT' => 100
]);
$this->json(['status' => 'success', 'data' => $records]);
}
private function json($data) { header('Content-Type: application/json'); echo json_encode($data); }
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Controllers\admin;
use \Db\Database;
class AuthController {
public function loginPage($error = '') {
include __DIR__ . '/../../Views/Admin/login.php';
}
public function loginSubmit() {
header('Content-Type: application/json');
$rawData = file_get_contents('php://input');
$data = json_decode($rawData, true);
// 检查 JSON 格式
if (json_last_error() !== JSON_ERROR_NONE) {
echo json_encode([
'success' => false,
'message' => '提交的数据格式有误'
]);
return;
}
// 提取输入参数
$usernameOrEmail = trim($data['username_or_email'] ?? '');
$password = $data['password'] ?? '';
if (empty($usernameOrEmail) || empty($password)) {
echo json_encode([
'success' => false,
'message' => '请输入用户名/邮箱和密码'
]);
return;
}
$db = new Database();
// 查询用户(匹配用户名或邮箱,忽略邮箱大小写)
$user = $db->get('users', '*', [
'OR' => [
'username' => $usernameOrEmail,
'email' => strtolower($usernameOrEmail)
]
]);
if (!$user) {
echo json_encode([
'success' => false,
'message' => '用户不存在'
]);
return;
}
// 检查是否禁用(假设 status=0 表示禁用)
if (isset($user['status']) && $user['status'] == 0) {
echo json_encode([
'success' => false,
'message' => '该账户未启用,请联系管理员'
]);
return;
}
// 验证密码
if (!password_verify($password, $user['password'])) {
echo json_encode([
'success' => false,
'message' => '密码错误'
]);
return;
}
// 设置会话
session_start();
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
$_SESSION['last_activity'] = time();
$_SESSION['ip'] = $_SERVER['REMOTE_ADDR'];
$_SESSION['ua'] = $_SERVER['HTTP_USER_AGENT'];
echo json_encode([
'success' => true,
'message' => '登录成功,正在跳转...',
'redirect' => '/admin/dashboard'
]);
}
public function logout() {
session_start();
session_destroy();
header('Location: /admin/login');
exit;
}
public function registerSubmit() {
header('Content-Type: application/json');
$rawData = file_get_contents('php://input');
$data = json_decode($rawData, true);
$username = trim($data['username'] ?? '');
$email = strtolower(trim($data['email'] ?? '')); // 统一转小写
$password = $data['password'] ?? '';
$confirm = $data['confirm_password'] ?? '';
if (!$username || !$email || !$password || !$confirm) {
echo json_encode([
'success' => false,
'message' => '请完整填写注册信息'
]);
return;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo json_encode([
'success' => false,
'message' => '邮箱格式不正确'
]);
return;
}
if ($password !== $confirm) {
echo json_encode([
'success' => false,
'message' => '两次密码输入不一致'
]);
return;
}
$db = new Database();
// 检查用户名或邮箱是否已存在(邮箱忽略大小写)
$existing = $db->get('users', '*', [
'OR' => [
'username' => $username,
'email' => $email
]
]);
if ($existing) {
if ($existing['username'] === $username) {
$msg = '用户名已存在';
} elseif (strtolower($existing['email']) === $email) {
$msg = '邮箱已存在';
} else {
$msg = '用户名或邮箱已存在';
}
echo json_encode([
'success' => false,
'message' => $msg
]);
return;
}
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
$insertResult = $db->insert('users', [
'username' => $username,
'email' => $email, // 已转小写
'password' => $hashedPassword,
'role' => 'user', // 默认角色
'created_at' => date('Y-m-d H:i:s'),
]);
if ($insertResult) {
echo json_encode([
'success' => true,
'message' => '注册成功,请登录',
'redirect' => '/admin/login'
]);
} else {
echo json_encode([
'success' => false,
'message' => '注册失败,请稍后重试'
]);
}
}
}
@@ -0,0 +1,174 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
/**
* 自动开期设置控制器
*/
class AutoPeriodController extends AdminBaseController {
private $db;
public function __construct(Database $db) {
$this->db = $db;
}
/**
* 自动开期设置页面
*/
public function index() {
$this->checkLogin();
$this->checkAdmin();
// 获取所有游戏
$games = $this->db->select('games', '*', [
'status' => 1,
'ORDER' => ['id' => 'ASC']
]);
// 获取最近的自动开期日志
$logs = [];
try {
$logs = $this->db->select('auto_period_log', '*', [
'ORDER' => ['id' => 'DESC'],
'LIMIT' => 50
]);
} catch (\Throwable $e) {
// 表可能不存在
}
$this->render('Admin/auto_period.php', compact('games', 'logs'));
}
/**
* 更新游戏的自动开期设置
*/
public function update() {
$this->checkLogin();
$this->checkAdmin();
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data)) $data = $_POST;
$gameId = (int)($data['game_id'] ?? 0);
if ($gameId <= 0) {
echo json_encode(['success' => false, 'message' => '无效的游戏ID']);
return;
}
$game = $this->db->get('games', '*', ['id' => $gameId]);
if (!$game) {
echo json_encode(['success' => false, 'message' => '游戏不存在']);
return;
}
$updateData = [];
// 自动开期开关
if (isset($data['auto_period_enabled'])) {
$updateData['auto_period_enabled'] = (int)$data['auto_period_enabled'] ? 1 : 0;
}
// 每期时长(秒)
if (isset($data['period_duration'])) {
$duration = (int)$data['period_duration'];
if ($duration < 60) $duration = 60; // 最少1分钟
if ($duration > 3600) $duration = 3600; // 最多1小时
$updateData['period_duration'] = $duration;
}
// 封盘提前时间(秒)
if (isset($data['lock_before_end'])) {
$lockTime = (int)$data['lock_before_end'];
if ($lockTime < 5) $lockTime = 5;
if ($lockTime > 120) $lockTime = 120;
$updateData['lock_before_end'] = $lockTime;
}
if (empty($updateData)) {
echo json_encode(['success' => false, 'message' => '无更新内容']);
return;
}
$updateData['updated_at'] = date('Y-m-d H:i:s');
try {
$this->db->update('games', $updateData, ['id' => $gameId]);
echo json_encode([
'success' => true,
'message' => '设置已保存',
'data' => $updateData
]);
} catch (\Throwable $e) {
echo json_encode(['success' => false, 'message' => '保存失败: ' . $e->getMessage()]);
}
}
/**
* 批量启用/禁用
*/
public function toggleAll() {
$this->checkLogin();
$this->checkAdmin();
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$enabled = (int)($data['enabled'] ?? 0);
try {
$this->db->update('games', [
'auto_period_enabled' => $enabled,
'updated_at' => date('Y-m-d H:i:s')
], ['status' => 1]);
echo json_encode([
'success' => true,
'message' => $enabled ? '已全部启用自动开期' : '已全部关闭自动开期'
]);
} catch (\Throwable $e) {
echo json_encode(['success' => false, 'message' => '操作失败: ' . $e->getMessage()]);
}
}
/**
* 获取自动开期运行状态
*/
public function status() {
$this->checkLogin();
header('Content-Type: application/json');
$games = $this->db->select('games', ['id', 'name', 'type', 'auto_period_enabled', 'period_duration', 'lock_before_end'], [
'status' => 1,
'ORDER' => ['id' => 'ASC']
]);
// 每个游戏的当前期号
foreach ($games as &$g) {
$g['current_period'] = $this->db->get('periods', ['id', 'period_number', 'status', 'start_time', 'end_time'], [
'game_id' => $g['id'],
'status' => ['pending', 'locked', 'drawn'],
'ORDER' => ['id' => 'DESC']
]);
}
// 最近日志
$recentLogs = [];
try {
$recentLogs = $this->db->select('auto_period_log', '*', [
'ORDER' => ['id' => 'DESC'],
'LIMIT' => 10
]);
} catch (\Throwable $e) {}
echo json_encode([
'success' => true,
'data' => [
'games' => $games,
'logs' => $recentLogs,
'server_time' => date('Y-m-d H:i:s'),
]
]);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class BetController extends AdminBaseController {
public function __construct() {
$this->checkLogin();
$this->checkAdmin();
}
/**
* 投注记录列表页面
*/
public function index() {
$db = new Database();
// 分页参数
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$limit = 20;
$offset = ($page - 1) * $limit;
// 筛选参数
$where = [];
if (isset($_GET['user_id']) && !empty($_GET['user_id'])) {
$where['bets.user_id'] = $_GET['user_id'];
}
if (isset($_GET['period_number']) && !empty($_GET['period_number'])) {
$where['bets.period_number'] = $_GET['period_number'];
}
if (isset($_GET['status']) && !empty($_GET['status'])) {
$where['bets.status'] = $_GET['status'];
}
// 排序
$where['ORDER'] = ['bets.id' => 'DESC'];
$where['LIMIT'] = [$offset, $limit];
try {
// 获取投注记录,关联用户表和游戏表
$bets = $db->select('bets', [
'[>]users' => ['user_id' => 'id'],
'[>]games' => ['game_id' => 'id']
], [
'bets.id',
'bets.user_id',
'users.username',
'users.balance', // 当前余额
'games.name(game_name)', // 游戏名称
'bets.period_number',
'bets.bet_type',
'bets.bet_value',
'bets.amount',
'bets.odds',
'bets.win_amount',
'bets.status',
'bets.created_at',
'bets.settled_at'
], $where);
// 获取总记录数用于分页(移除LIMIT)
unset($where['LIMIT']);
$total = $db->count('bets', [
'[>]users' => ['user_id' => 'id'],
'[>]games' => ['game_id' => 'id']
], '*', $where);
$totalPages = ceil($total / $limit);
} catch (\Throwable $e) {
// 记录错误以便调试
error_log("BetController Error: " . $e->getMessage());
$bets = [];
$total = 0;
$totalPages = 0;
}
$this->render('Admin/bets.php', [
'bets' => $bets,
'currentPage' => $page,
'totalPages' => $totalPages,
'title' => '投注记录'
]);
}
}
+734
View File
@@ -0,0 +1,734 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class DicePeriodController extends AdminBaseController {
private $gameType = 'dice';
public function __construct() {
$this->checkLogin();
$this->checkAdmin();
}
/**
* 骰子游戏期号列表页面
*/
public function index() {
$db = new Database();
try {
// 获取骰子游戏列表
$gamesList = $db->select('games', ['id', 'name'], [
'type' => $this->gameType,
'status' => 1,
'ORDER' => ['id' => 'ASC']
]);
if (!is_array($gamesList)) {
$gamesList = [];
}
// 获取所有骰子游戏的game_id
$gameIds = array_column($gamesList, 'id');
// 获取骰子游戏的期号列表
$periods = [];
if (!empty($gameIds)) {
$periods = $db->select('periods', '*', [
'game_id' => $gameIds,
'ORDER' => ['id' => 'DESC'],
'LIMIT' => 100
]);
}
if (!is_array($periods)) {
$periods = [];
}
// 构建游戏名称映射
$games = [];
foreach ($gamesList as $game) {
$games[$game['id']] = $game['name'];
}
// 为每个骰子游戏获取当前期号
$currentPeriods = [];
foreach ($gamesList as $game) {
$currentPeriod = $db->get('periods', '*', [
'game_id' => $game['id'],
'ORDER' => ['id' => 'DESC']
]);
if ($currentPeriod) {
$currentPeriods[$game['id']] = $currentPeriod;
}
}
} catch (\Throwable $e) {
$periods = [];
$games = [];
$gamesList = [];
$currentPeriods = [];
}
$this->render('Admin/dice_periods.php', [
'currentPeriods' => $currentPeriods,
'periods' => $periods,
'games' => $games,
'gamesList' => $gamesList,
'title' => '骰子游戏期号管理'
]);
}
/**
* 获取单个期号数据
*/
public function get($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
try {
$period = $db->get('periods', '*', [
'id' => $id
]);
if ($period) {
// 验证是否为骰子游戏期号
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if ($game && $game['type'] === $this->gameType) {
echo json_encode([
'success' => true,
'data' => $period
]);
} else {
echo json_encode([
'success' => false,
'message' => '期号不属于骰子游戏'
]);
}
} else {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
}
} catch (\Throwable $e) {
echo json_encode([
'success' => false,
'message' => '获取数据失败:' . $e->getMessage()
]);
}
}
/**
* 录入开奖结果
*/
public function draw() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
if (empty($data)) {
echo json_encode([
'success' => false,
'message' => '未接收到数据'
]);
return;
}
$db = new Database();
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 验证是否为骰子游戏
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '期号不属于骰子游戏'
]);
return;
}
// 检查期号状态
if ($period['status'] === 'settled') {
echo json_encode([
'success' => false,
'message' => '该期号已结算,无法修改'
]);
return;
}
$auto = isset($data['auto']) ? (bool)$data['auto'] : false;
// 骰子游戏开奖逻辑
if ($auto) {
$dice1 = rand(1, 6);
$dice2 = rand(1, 6);
$dice3 = rand(1, 6);
} else {
$dice1 = isset($data['dice1']) ? (int)$data['dice1'] : 0;
$dice2 = isset($data['dice2']) ? (int)$data['dice2'] : 0;
$dice3 = isset($data['dice3']) ? (int)$data['dice3'] : 0;
// 验证骰子点数
if ($dice1 < 1 || $dice1 > 6 || $dice2 < 1 || $dice2 > 6 || $dice3 < 1 || $dice3 > 6) {
echo json_encode([
'success' => false,
'message' => '骰子点数必须在1-6之间'
]);
return;
}
}
// 计算总和
$total = $dice1 + $dice2 + $dice3;
// 计算结果
$result = $this->calculateResult($dice1, $dice2, $dice3, $total);
// 获取当前登录的管理员ID(审核人)
$approvedBy = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
// 更新期号数据
$updateData = [
'dice1' => $dice1,
'dice2' => $dice2,
'dice3' => $dice3,
'total' => $total,
'result' => $result,
'status' => 'drawn',
'draw_time' => date('Y-m-d H:i:s'),
'approved_by' => $approvedBy,
'updated_at' => date('Y-m-d H:i:s')
];
$responseData = [
'dice1' => $dice1,
'dice2' => $dice2,
'dice3' => $dice3,
'total' => $total,
'result' => $result
];
try {
$db->update('periods', $updateData, ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '开奖结果已录入',
'data' => $responseData
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '录入失败:' . $e->getMessage()
]);
}
}
/**
* 启动新一期(开始下注)
*/
public function start() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$gameId = isset($data['game_id']) ? (int)$data['game_id'] : 0;
if ($gameId <= 0) {
echo json_encode([
'success' => false,
'message' => '请选择游戏'
]);
return;
}
$db = new Database();
// 验证游戏类型
$game = $db->get('games', ['type', 'stream_url'], ['id' => $gameId]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '游戏不属于骰子游戏'
]);
return;
}
// 检查该游戏是否有未结束的期号
$activePeriod = $db->get('periods', '*', [
'game_id' => $gameId,
'status' => ['pending', 'locked', 'drawn']
]);
if ($activePeriod) {
echo json_encode([
'success' => false,
'message' => '该游戏当前有未结束的期号,无法开始新一轮'
]);
return;
}
// 生成期号
$periodNumber = $this->generatePeriodNumber($db, $gameId);
// 获取stream_url
$streamUrl = !empty($game['stream_url']) ? $game['stream_url'] : null;
$periodData = [
'period_number' => $periodNumber,
'status' => 'pending',
'game_id' => $gameId,
'stream_url' => $streamUrl,
'start_time' => date('Y-m-d H:i:s'),
'auto_generated' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
try {
$id = $db->insert('periods', $periodData);
echo json_encode([
'success' => true,
'message' => '新一期已启动,开始下注',
'data' => [
'id' => $id,
'period_number' => $periodNumber,
'start_time' => $periodData['start_time']
]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '启动失败:' . $e->getMessage()
]);
}
}
/**
* 确认开奖并结算
*/
public function settle() {
ob_clean();
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 验证是否为骰子游戏
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '期号不属于骰子游戏'
]);
return;
}
// 检查期号状态
if ($period['status'] !== 'drawn') {
echo json_encode([
'success' => false,
'message' => '该期号还未开奖,无法结算'
]);
return;
}
if ($period['status'] === 'settled') {
echo json_encode([
'success' => false,
'message' => '该期号已结算'
]);
return;
}
try {
// 开启事务
$db->medoo->pdo->beginTransaction();
// 获取本期所有待结算注单
$bets = $db->select('bets', '*', [
'period_id' => $id,
'status' => 'pending'
]);
// 遍历注单进行结算
if ($bets) {
foreach ($bets as $bet) {
$checkResult = $this->checkWin($bet, $period);
$isWin = $checkResult['win'];
$winAmount = $checkResult['amount'];
$now = date('Y-m-d H:i:s');
if ($isWin) {
$payout = $bet['amount'] + $winAmount;
// 更新注单状态
$db->update('bets', [
'status' => 'win',
'win_amount' => $winAmount,
'settled_at' => $now,
'updated_at' => $now
], ['id' => $bet['id']]);
// 更新用户余额
$db->update('users', [
'balance[+]' => $payout
], ['id' => $bet['user_id']]);
// 获取更新后的余额
$user = $db->get('users', ['balance'], ['id' => $bet['user_id']]);
$balanceAfter = $user['balance'];
$balanceBefore = $balanceAfter - $payout;
// 写入资金流水
$db->insert('transactions', [
'user_id' => $bet['user_id'],
'type' => 'win',
'amount' => $payout,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'related_id' => $bet['id'],
'description' => "中奖 - 期号: " . $period['period_number'],
'created_at' => $now
]);
} else {
// 未中奖
$db->update('bets', [
'status' => 'lose',
'win_amount' => 0,
'settled_at' => $now,
'updated_at' => $now
], ['id' => $bet['id']]);
}
}
}
// 更新期号状态为已结算
$db->update('periods', [
'status' => 'settled',
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
// 提交事务
$db->medoo->pdo->commit();
echo json_encode([
'success' => true,
'message' => '结算成功,请点击"开始下注"启动下一期',
'data' => [
'current_period_id' => $id
]
]);
} catch (\Exception $e) {
// 回滚事务
if (isset($db->medoo->pdo)) {
$db->medoo->pdo->rollBack();
}
echo json_encode([
'success' => false,
'message' => '结算失败:' . $e->getMessage()
]);
}
}
/**
* 封盘
*/
public function lock() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 验证是否为骰子游戏
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '期号不属于骰子游戏'
]);
return;
}
// 检查期号状态
if ($period['status'] !== 'pending') {
echo json_encode([
'success' => false,
'message' => '该期号状态不允许封盘'
]);
return;
}
try {
$db->update('periods', [
'status' => 'locked',
'end_time' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '封盘成功'
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '封盘失败:' . $e->getMessage()
]);
}
}
/**
* 生成期号
*/
private function generatePeriodNumber($db, $gameId) {
$now = new \DateTime();
$dateStr = $now->format('Ymd');
$prefix = "G{$gameId}{$dateStr}";
try {
$lastPeriod = $db->get('periods', 'period_number', [
'period_number[~]' => $prefix . '%',
'ORDER' => ['period_number' => 'DESC']
]);
if ($lastPeriod) {
$lastSeq = substr($lastPeriod, strlen($prefix));
$newSeq = (int)$lastSeq + 1;
$seqStr = str_pad((string)$newSeq, 4, '0', STR_PAD_LEFT);
} else {
$seqStr = '0001';
}
return $prefix . $seqStr;
} catch (\Throwable $e) {
return $prefix . time();
}
}
/**
* 检查注单是否中奖
*/
private function checkWin($bet, $period) {
$betType = $bet['bet_type'];
$betValue = $bet['bet_value'];
$betAmount = (float)$bet['amount'];
$gameId = $period['game_id'];
$db = new Database();
// 获取赔率配置
static $oddsCache = [];
if (!isset($oddsCache[$gameId])) {
$oddsData = $db->select('game_odds', '*', ['game_id' => $gameId]);
$oddsCache[$gameId] = [];
foreach ($oddsData as $odd) {
$key = $odd['type'] . '_' . $odd['target'];
$oddsCache[$gameId][$key] = (float)$odd['odds'];
}
}
$isWin = false;
$payoutMultiplier = 0;
// 获取赔率
$getOdds = function($type, $target = 'all') use ($oddsCache, $gameId) {
$key = $type . '_' . $target;
if (isset($oddsCache[$gameId][$key])) {
return $oddsCache[$gameId][$key];
}
$allKey = $type . '_all';
return $oddsCache[$gameId][$allKey] ?? 0;
};
// 骰子游戏结算逻辑
$dice1 = (int)$period['dice1'];
$dice2 = (int)$period['dice2'];
$dice3 = (int)$period['dice3'];
$total = (int)$period['total'];
switch ($betType) {
case 'xiu': // 小 (4-10)
if ($dice1 == $dice2 && $dice2 == $dice3) {
if ($dice1 <= 3) $isWin = true;
} else {
if ($total >= 4 && $total <= 10) $isWin = true;
}
$payoutMultiplier = $getOdds('xiu');
break;
case 'tai': // 大 (11-17)
if ($dice1 == $dice2 && $dice2 == $dice3) {
if ($dice1 >= 4) $isWin = true;
} else {
if ($total >= 11 && $total <= 17) $isWin = true;
}
$payoutMultiplier = $getOdds('tai');
break;
case 'chan': // 偶数
if ($total % 2 == 0) $isWin = true;
$payoutMultiplier = $getOdds('chan');
break;
case 'le': // 奇数
if ($total % 2 != 0) $isWin = true;
$payoutMultiplier = $getOdds('le');
break;
case 'number': // 单数字 (Sum)
if ($total == (int)$betValue) {
$isWin = true;
$payoutMultiplier = $getOdds('sum', $betValue);
}
break;
case 'dice': // 单个骰子
$count = 0;
if ($dice1 == (int)$betValue) $count++;
if ($dice2 == (int)$betValue) $count++;
if ($dice3 == (int)$betValue) $count++;
if ($count > 0) {
$isWin = true;
$baseOdds = $getOdds('dice', $betValue);
$payoutMultiplier = $baseOdds * $count;
}
break;
case 'combo': // 豹子
if ($betValue === 'any_triple') {
if ($dice1 == $dice2 && $dice2 == $dice3) {
$isWin = true;
$payoutMultiplier = $getOdds('combo', 'any_triple');
}
} else {
if ($dice1 == $dice2 && $dice2 == $dice3 && $total == (int)$betValue) {
$isWin = true;
$payoutMultiplier = $getOdds('combo', 'specific_triple');
}
}
break;
}
if ($isWin && $payoutMultiplier > 0) {
return [
'win' => true,
'amount' => $betAmount * $payoutMultiplier
];
}
return ['win' => false, 'amount' => 0];
}
/**
* 计算开奖结果
*/
private function calculateResult($dice1, $dice2, $dice3, $total) {
// 检查是否为爆子
if ($dice1 === $dice2 && $dice2 === $dice3) {
if ($dice1 <= 3) {
return 'Xỉu';
} else {
return 'Tài';
}
}
// 普通大小判断
if ($total >= 4 && $total <= 10) {
return 'Xỉu';
} else {
return 'Tài';
}
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class EmployeeController extends AdminBaseController {
private $db;
public function __construct(Database $db) { $this->db = $db; }
public function index() {
$this->checkLogin(); $this->checkAdmin();
$employees = $this->db->select('employees', '*', ['ORDER' => ['id' => 'DESC']]);
$this->render('Admin/employees.php', compact('employees'));
}
public function update() {
$this->checkLogin(); $this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
$fields = [
'emp_code' => trim($data['emp_code'] ?? ''),
'username' => trim($data['username'] ?? ''),
'real_name' => trim($data['real_name'] ?? ''),
'shift' => $data['shift'] ?? 'all',
'permissions' => json_encode($data['permissions'] ?? ['deposit','withdraw']),
'status' => (int)($data['status'] ?? 1),
];
if ($id > 0) {
if (!empty($data['password'])) $fields['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
$this->db->update('employees', $fields, ['id' => $id]);
} else {
if (empty($data['password'])) { $this->json(['status'=>'error','message'=>'Password required']); return; }
$fields['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
$this->db->insert('employees', $fields);
}
$this->json(['status' => 'success']);
}
public function delete($id) {
$this->checkLogin(); $this->checkAdmin();
$this->db->delete('employees', ['id' => (int)$id]);
$this->json(['status' => 'success']);
}
// 员工登录(独立入口)
public function loginPage() {
include ROOT_PATH . 'App/Views/Admin/employee_login.php';
}
public function loginSubmit() {
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$db = new Database();
$emp = $db->get('employees', '*', ['username' => trim($data['username'] ?? '')]);
if (!$emp || !password_verify($data['password'] ?? '', $emp['password'])) {
echo json_encode(['success' => false, 'message' => 'Invalid credentials']); return;
}
if (!$emp['status']) {
echo json_encode(['success' => false, 'message' => 'Account disabled']); return;
}
// 班次检查
$hour = (int)date('H');
if ($emp['shift'] === 'day' && ($hour < 8 || $hour >= 20)) {
echo json_encode(['success' => false, 'message' => 'Not your shift']); return;
}
if ($emp['shift'] === 'night' && ($hour >= 8 && $hour < 20)) {
echo json_encode(['success' => false, 'message' => 'Not your shift']); return;
}
if (session_status() === PHP_SESSION_NONE) session_start();
$_SESSION['emp_id'] = $emp['id'];
$_SESSION['emp_code'] = $emp['emp_code'];
$_SESSION['emp_permissions'] = json_decode($emp['permissions'], true) ?: [];
$_SESSION['emp_last_activity'] = time();
$db->update('employees', ['last_login' => date('Y-m-d H:i:s')], ['id' => $emp['id']]);
echo json_encode(['success' => true, 'redirect' => '/employee/dashboard']);
}
// 员工操作面板(仅上下分)
public function dashboard() {
$this->checkEmployee();
$users = $this->db->select('users', ['id','username','balance','is_virtual'], [
'is_virtual' => 0, 'ORDER' => ['username' => 'ASC']
]);
$logs = $this->db->select('employee_logs', '*', [
'emp_id' => $_SESSION['emp_id'],
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 50
]);
include ROOT_PATH . 'App/Views/Admin/employee_dashboard.php';
}
public function adjustBalance() {
$this->checkEmployee();
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$userId = (int)($data['user_id'] ?? 0);
$action = $data['action'] ?? ''; // deposit / withdraw
$amount = abs(floatval($data['amount'] ?? 0));
$remark = trim($data['remark'] ?? '');
$perms = $_SESSION['emp_permissions'] ?? [];
if (!in_array($action, ['deposit','withdraw']) || !in_array($action, $perms)) {
echo json_encode(['success' => false, 'message' => 'No permission']); return;
}
if ($amount <= 0 || $userId <= 0) {
echo json_encode(['success' => false, 'message' => 'Invalid input']); return;
}
try {
$this->db->medoo->pdo->beginTransaction();
$stmt = $this->db->medoo->pdo->prepare("SELECT balance FROM users WHERE id = :id FOR UPDATE");
$stmt->execute([':id' => $userId]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$user) { $this->db->medoo->pdo->rollBack(); echo json_encode(['success'=>false,'message'=>'User not found']); return; }
$old = (float)$user['balance'];
$new = $action === 'deposit' ? $old + $amount : $old - $amount;
if ($new < 0) { $this->db->medoo->pdo->rollBack(); echo json_encode(['success'=>false,'message'=>'Insufficient balance']); return; }
$this->db->update('users', ['balance' => $new], ['id' => $userId]);
$this->db->insert('transactions', [
'user_id' => $userId, 'type' => 'manual_' . $action,
'amount' => $action === 'deposit' ? $amount : -$amount,
'balance_before' => $old, 'balance_after' => $new,
'description' => "Employee {$_SESSION['emp_code']}: $remark",
'operator_id' => $_SESSION['emp_id'], 'operator_type' => 'employee',
'created_at' => date('Y-m-d H:i:s'),
]);
$this->db->insert('employee_logs', [
'emp_id' => $_SESSION['emp_id'], 'action' => $action,
'target_user_id' => $userId, 'amount' => $amount,
'remark' => $remark, 'created_at' => date('Y-m-d H:i:s'),
]);
$this->db->medoo->pdo->commit();
echo json_encode(['success' => true, 'new_balance' => $new]);
} catch (\Throwable $e) {
if ($this->db->medoo->pdo->inTransaction()) $this->db->medoo->pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'Error']);
}
}
private function checkEmployee() {
if (session_status() === PHP_SESSION_NONE) session_start();
if (empty($_SESSION['emp_id'])) { header('Location: /employee/login'); exit; }
if (time() - ($_SESSION['emp_last_activity'] ?? 0) > 7200) {
unset($_SESSION['emp_id']); header('Location: /employee/login'); exit;
}
$_SESSION['emp_last_activity'] = time();
}
private function json($data) { header('Content-Type: application/json'); echo json_encode($data); }
}
+357
View File
@@ -0,0 +1,357 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class FinanceController extends AdminBaseController {
public function __construct() {
$this->checkLogin();
$this->checkAdmin();
}
/**
* 财务管理页面
*/
public function index() {
$db = new Database();
try {
// 获取资金流水列表
$transactions = $db->select('transactions', [
'[>]users' => ['user_id' => 'id']
], [
'transactions.id',
'transactions.user_id',
'users.username',
'transactions.type',
'transactions.amount',
'transactions.balance_before',
'transactions.balance_after',
'transactions.related_id',
'transactions.description',
'transactions.created_at'
], [
'ORDER' => ['transactions.id' => 'DESC'],
'LIMIT' => 100
]);
if (!is_array($transactions)) {
$transactions = [];
}
// 计算统计信息
$stats = $this->calculateStats($db);
} catch (\Throwable $e) {
$transactions = [];
$stats = [
'total_deposit' => 0,
'total_withdraw' => 0,
'total_bet' => 0,
'total_win' => 0,
'today_deposit' => 0,
'today_withdraw' => 0
];
}
$this->render('Admin/finance.php', [
'transactions' => $transactions,
'stats' => $stats,
'title' => '财务管理'
]);
}
/**
* 获取资金流水详情
*/
public function get($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的流水ID'
]);
return;
}
$db = new Database();
try {
$transaction = $db->get('transactions', [
'[>]users' => ['user_id' => 'id']
], [
'transactions.id',
'transactions.user_id',
'users.username',
'transactions.type',
'transactions.amount',
'transactions.balance_before',
'transactions.balance_after',
'transactions.related_id',
'transactions.description',
'transactions.created_at'
], [
'transactions.id' => $id
]);
if ($transaction) {
echo json_encode([
'success' => true,
'data' => $transaction
]);
} else {
echo json_encode([
'success' => false,
'message' => '流水记录不存在'
]);
}
} catch (\Throwable $e) {
echo json_encode([
'success' => false,
'message' => '获取数据失败:' . $e->getMessage()
]);
}
}
/**
* 处理充值
*/
public function deposit() {
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;
}
$userId = isset($data['user_id']) ? (int)$data['user_id'] : 0;
$amount = isset($data['amount']) ? floatval($data['amount']) : 0;
$description = trim($data['description'] ?? '');
if ($userId <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的用户ID'
]);
return;
}
if ($amount <= 0) {
echo json_encode([
'success' => false,
'message' => '充值金额必须大于0'
]);
return;
}
$db = new Database();
try {
// 获取用户信息
$user = $db->get('users', ['id', 'balance', 'username'], ['id' => $userId]);
if (!$user) {
echo json_encode([
'success' => false,
'message' => '用户不存在'
]);
return;
}
$balanceBefore = floatval($user['balance']);
$balanceAfter = $balanceBefore + $amount;
// 更新用户余额
$db->update('users', [
'balance' => $balanceAfter,
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $userId]);
// 记录资金流水
$transactionId = $db->insert('transactions', [
'user_id' => $userId,
'type' => 'deposit',
'amount' => $amount,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'description' => $description ?: '管理员充值',
'created_at' => date('Y-m-d H:i:s')
]);
echo json_encode([
'success' => true,
'message' => '充值成功',
'data' => [
'transaction_id' => $transactionId,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter
]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '充值失败:' . $e->getMessage()
]);
}
}
/**
* 处理提现
*/
public function withdraw() {
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;
}
$userId = isset($data['user_id']) ? (int)$data['user_id'] : 0;
$amount = isset($data['amount']) ? floatval($data['amount']) : 0;
$description = trim($data['description'] ?? '');
if ($userId <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的用户ID'
]);
return;
}
if ($amount <= 0) {
echo json_encode([
'success' => false,
'message' => '提现金额必须大于0'
]);
return;
}
$db = new Database();
try {
// 获取用户信息
$user = $db->get('users', ['id', 'balance', 'username'], ['id' => $userId]);
if (!$user) {
echo json_encode([
'success' => false,
'message' => '用户不存在'
]);
return;
}
$balanceBefore = floatval($user['balance']);
// 检查余额是否充足
if ($balanceBefore < $amount) {
echo json_encode([
'success' => false,
'message' => '用户余额不足,当前余额:' . number_format($balanceBefore, 2)
]);
return;
}
$balanceAfter = $balanceBefore - $amount;
// 更新用户余额
$db->update('users', [
'balance' => $balanceAfter,
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $userId]);
// 记录资金流水
$transactionId = $db->insert('transactions', [
'user_id' => $userId,
'type' => 'withdraw',
'amount' => -$amount, // 提现为负数
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'description' => $description ?: '管理员提现',
'created_at' => date('Y-m-d H:i:s')
]);
echo json_encode([
'success' => true,
'message' => '提现成功',
'data' => [
'transaction_id' => $transactionId,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter
]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '提现失败:' . $e->getMessage()
]);
}
}
/**
* 计算财务统计
*/
private function calculateStats($db) {
try {
// 总充值 (包含普通充值和人工加款)
$totalDeposit = $db->sum('transactions', 'amount', [
'type' => ['deposit', 'manual_deposit']
]) ?: 0;
// 总提现(取绝对值,包含普通提现和人工扣款)
$totalWithdraw = abs($db->sum('transactions', 'amount', [
'type' => ['withdraw', 'manual_withdraw']
]) ?: 0);
// 总投注(取绝对值)
$totalBet = abs($db->sum('transactions', 'amount', [
'type' => 'bet'
]) ?: 0);
// 总中奖
$totalWin = $db->sum('transactions', 'amount', [
'type' => 'win'
]) ?: 0;
// 今日充值 (包含普通充值和人工加款)
$todayDeposit = $db->sum('transactions', 'amount', [
'type' => ['deposit', 'manual_deposit'],
'created_at[>=]' => date('Y-m-d 00:00:00')
]) ?: 0;
// 今日提现(取绝对值,包含普通提现和人工扣款)
$todayWithdraw = abs($db->sum('transactions', 'amount', [
'type' => ['withdraw', 'manual_withdraw'],
'created_at[>=]' => date('Y-m-d 00:00:00')
]) ?: 0);
return [
'total_deposit' => floatval($totalDeposit),
'total_withdraw' => floatval($totalWithdraw),
'total_bet' => floatval($totalBet),
'total_win' => floatval($totalWin),
'today_deposit' => floatval($todayDeposit),
'today_withdraw' => floatval($todayWithdraw)
];
} catch (\Throwable $e) {
return [
'total_deposit' => 0,
'total_withdraw' => 0,
'total_bet' => 0,
'total_win' => 0,
'today_deposit' => 0,
'today_withdraw' => 0
];
}
}
}
+414
View File
@@ -0,0 +1,414 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class GameController extends AdminBaseController {
public function __construct() {
$this->checkLogin();
$this->checkAdmin();
}
public function index() {
$db = new Database();
try {
$games = $db->select('games', '*', [
'ORDER' => ['id' => 'DESC']
]);
if (!is_array($games)) {
$games = [];
}
} catch (\Throwable $e) {
$games = [];
}
$this->render('Admin/games.php', [
'games' => $games,
'title' => '游戏管理'
]);
}
/**
* 获取单个游戏数据(用于编辑)
*/
public function get($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的游戏ID'
]);
return;
}
$db = new Database();
try {
$game = $db->get('games', '*', [
'id' => $id
]);
if ($game) {
echo json_encode([
'success' => true,
'data' => $game
]);
} else {
echo json_encode([
'success' => false,
'message' => '游戏不存在'
]);
}
} catch (\Throwable $e) {
echo json_encode([
'success' => false,
'message' => '获取数据失败:' . $e->getMessage()
]);
}
}
/**
* 创建或更新游戏
*/
public function update() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
echo json_encode([
'success' => false,
'message' => '未接收到数据'
]);
return;
}
$db = new Database();
$id = isset($data['id']) ? (int)$data['id'] : 0;
$isEditMode = $id > 0;
$name = trim($data['name'] ?? '');
$code = trim($data['code'] ?? '');
$streamUrl = trim($data['stream_url'] ?? '');
$type = trim($data['type'] ?? 'dice');
$sortOrder = isset($data['sort_order']) ? (int)$data['sort_order'] : 0;
$status = isset($data['status']) ? (int)$data['status'] : 1; // 默认启用
$featured = isset($data['featured']) ? (int)$data['featured'] : 1; // 默认显示在游戏大厅
$image = trim($data['image'] ?? '');
// 处理标签(复选框数组转逗号分隔字符串)
$tags = '';
if (isset($data['tags']) && is_array($data['tags'])) {
$tags = implode(',', array_map('trim', $data['tags']));
} elseif (isset($data['tags'])) {
$tags = trim($data['tags']);
}
if (empty($name)) {
echo json_encode([
'success' => false,
'message' => '游戏名称不能为空'
]);
return;
}
// 自动生成游戏标识(如果为空)
if (empty($code)) {
// 将中文转换为拼音或使用拼音首字母,这里简化为使用时间戳+随机数
// 实际可以使用拼音库,这里使用简化方式:去除特殊字符,转为小写,空格转下划线
$code = $this->generateGameCode($name, $db, $id);
}
// 检查code是否已存在(排除当前ID)
try {
$exists = $db->get('games', 'id', [
'code' => $code,
'id[!]' => $id
]);
if ($exists) {
// 如果已存在,添加随机后缀
$code = $code . '_' . time();
}
} catch (\Throwable $e) {
// 如果表不存在或其他错误,继续执行
}
// 准备数据
$gameData = [
'name' => $name,
'code' => $code,
'stream_url' => $streamUrl,
'type' => $type,
'tags' => $tags,
'sort_order' => $sortOrder,
'status' => $status,
'featured' => $featured,
'updated_at' => date('Y-m-d H:i:s')
];
// 如果提供了图片路径,则添加
if (!empty($image)) {
$gameData['image'] = $image;
}
try {
if ($isEditMode) {
$db->update('games', $gameData, ['id' => $id]);
$message = '游戏更新成功';
} else {
$gameData['created_at'] = date('Y-m-d H:i:s');
$id = $db->insert('games', $gameData);
$message = '游戏创建成功';
}
echo json_encode([
'success' => true,
'message' => $message,
'data' => ['id' => $id]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '操作失败:' . $e->getMessage()
]);
}
}
/**
* 删除游戏
*/
public function delete($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的游戏ID'
]);
return;
}
$db = new Database();
try {
$db->delete('games', ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '游戏已删除'
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '删除失败:' . $e->getMessage()
]);
}
}
/**
* 切换游戏启用状态
*/
public function toggleStatus($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的游戏ID'
]);
return;
}
$db = new Database();
try {
$game = $db->get('games', ['status'], ['id' => $id]);
if (!$game) {
echo json_encode([
'success' => false,
'message' => '游戏不存在'
]);
return;
}
$newStatus = $game['status'] ? 0 : 1;
$db->update('games', [
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
echo json_encode([
'success' => true,
'message' => $newStatus ? '游戏已启用' : '游戏已禁用',
'data' => ['status' => $newStatus]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '操作失败:' . $e->getMessage()
]);
}
}
/**
* 获取游戏赔率配置
*/
public function getOdds($gameId) {
header('Content-Type: application/json');
if (empty($gameId) || !is_numeric($gameId)) {
echo json_encode([
'success' => false,
'message' => '无效的游戏ID'
]);
return;
}
$db = new Database();
try {
// Check if game exists
$game = $db->get('games', ['id', 'name'], ['id' => $gameId]);
if (!$game) {
echo json_encode([
'success' => false,
'message' => '游戏不存在'
]);
return;
}
// Get odds
$odds = $db->select('game_odds', '*', [
'game_id' => $gameId
]);
// If no odds found, return defaults structure (or empty array)
// The frontend should handle empty/missing odds
echo json_encode([
'success' => true,
'data' => [
'game' => $game,
'odds' => $odds
]
]);
} catch (\Throwable $e) {
echo json_encode([
'success' => false,
'message' => '获取赔率失败:' . $e->getMessage()
]);
}
}
/**
* 更新游戏赔率
*/
public function updateOdds() {
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data)) {
$data = $_POST;
}
$gameId = isset($data['game_id']) ? (int)$data['game_id'] : 0;
$oddsData = isset($data['odds']) ? $data['odds'] : [];
if (empty($gameId) || empty($oddsData)) {
echo json_encode([
'success' => false,
'message' => '参数不完整'
]);
return;
}
$db = new Database();
try {
// Begin transaction if supported (PDO usually does)
// For simple database wrapper, we just loop update
foreach ($oddsData as $item) {
$type = $item['type'];
$target = $item['target'];
$odds = floatval($item['odds']);
// Check if exists
$exists = $db->has('game_odds', [
'game_id' => $gameId,
'type' => $type,
'target' => $target
]);
if ($exists) {
$db->update('game_odds', [
'odds' => $odds,
'updated_at' => date('Y-m-d H:i:s')
], [
'game_id' => $gameId,
'type' => $type,
'target' => $target
]);
} else {
$db->insert('game_odds', [
'game_id' => $gameId,
'type' => $type,
'target' => $target,
'odds' => $odds,
'created_at' => date('Y-m-d H:i:s')
]);
}
}
echo json_encode([
'success' => true,
'message' => '赔率配置已保存'
]);
} catch (\Throwable $e) {
echo json_encode([
'success' => false,
'message' => '保存失败:' . $e->getMessage()
]);
}
}
/**
* 自动生成游戏标识
* @param string $name 游戏名称
* @param Database $db 数据库实例
* @param int $excludeId 排除的ID(编辑时使用)
* @return string 生成的游戏标识
*/
private function generateGameCode($name, $db, $excludeId = 0) {
// 去除特殊字符,转为小写,空格和中文标点转下划线
$code = strtolower($name);
// 将中文字符转为拼音(简化处理:直接使用拼音首字母或时间戳)
// 这里使用简化的方式:去除所有非字母数字字符,替换为下划线
$code = preg_replace('/[^a-z0-9_]+/', '_', $code);
$code = preg_replace('/_+/', '_', $code); // 多个下划线合并为一个
$code = trim($code, '_'); // 去除首尾下划线
// 如果转换后为空,使用时间戳
if (empty($code)) {
$code = 'game_' . time();
}
// 限制长度
if (strlen($code) > 50) {
$code = substr($code, 0, 50);
}
// 检查是否已存在,如果存在则添加随机数
try {
$exists = $db->get('games', 'id', [
'code' => $code,
'id[!]' => $excludeId
]);
if ($exists) {
$code = $code . '_' . substr(time(), -6); // 添加时间戳后缀
}
} catch (\Throwable $e) {
// 忽略错误
}
return $code;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
use App\Core\PluginManager;
class HomeController extends AdminBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->checkLogin();
}
public function index() {
$plugins = $this->pluginManager->getPluginStatusList();
$stats = $this->calculateDashboardStats();
$this->render('Admin/dashboard.php', [
'plugins' => $plugins,
'stats' => $stats,
'title' => '控制台'
]);
}
private function calculateDashboardStats() {
$db = new Database();
$todayStart = date('Y-m-d 00:00:00');
try {
// 1. 今日投注总额
$todayBetAmount = $db->sum('bets', 'amount', [
'created_at[>=]' => $todayStart
]) ?: 0;
// 2. 今日已派彩金额 (已结算且中奖的)
$todayPayoutAmount = $db->sum('bets', 'win_amount', [
'settled_at[>=]' => $todayStart,
'status' => 'win'
]) ?: 0;
// 3. 待处理开奖 (已封盘 waiting for draw, 或 已开奖 waiting for settle)
// status: pending -> locked -> drawn -> settled
$pendingDrawCount = $db->count('periods', [
'status' => ['locked', 'drawn']
]);
// 4. 待处理提现 (目前没有提现申请表,暂定为0)
// 如果后续有 withdrawals 表或 transactions status 字段,需在此修改
$pendingWithdrawCount = 0;
return [
'today_bet_amount' => $todayBetAmount,
'today_payout_amount' => $todayPayoutAmount,
'pending_draw_count' => $pendingDrawCount,
'pending_withdraw_count' => $pendingWithdrawCount
];
} catch (\Throwable $e) {
return [
'today_bet_amount' => 0,
'today_payout_amount' => 0,
'pending_draw_count' => 0,
'pending_withdraw_count' => 0
];
}
}
}
+376
View File
@@ -0,0 +1,376 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database; // Medoo数据库实例
use \Exception;
class ImagesController extends AdminBaseController {
protected $db;
protected $uploadDir;
protected array $uploadConfig;
// 允许的图片MIME类型
const ALLOWED_MIME = [
'image/jpeg', 'image/png', 'image/gif',
'image/webp', 'image/svg+xml'
];
const MAX_UPLOAD_SIZE = 10485760; // 上传大小限制(10MB
const MAX_IMAGE_SIZE = 800; // 主图最大边长(等同缩略图大小)
public function __construct() {
$this->checkLogin(); // 登录验证
$this->db = new Database(); // 初始化数据库连接
$domain = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http")
. "://" . $_SERVER['HTTP_HOST'];
$this->uploadConfig = [
'upload_dir' => $_SERVER['DOCUMENT_ROOT'] . '/Storage/images/',
'url' => $domain . '/Storage/images/',
'max_size' => 10 * 1024 * 1024, // 10MB
'allowed_mimes' => [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/icon'
],
'min_quality' => 60,
'max_quality' => 100,
'default_quality' => 80
];
}
/**
* 图片管理首页(仅渲染前端页面,数据使用AJAX加载)
*/
public function index() {
// 渲染页面模板
$this->render('Admin/images.php', [
'title' => '图片管理'
]);
}
/**
* 图片列表查询
*/
public function list() {
try {
// 处理分页参数
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$limit = isset($_GET['limit']) ?
min(100, max(1, (int)$_GET['limit'])) : 20;
$offset = ($page - 1) * $limit;
// 查询总数
$total = $this->db->count('images');
// 查询当前页数据
$data = $this->db->select('images', [
'id', 'name', 'url',
'size', 'width', 'height', 'upload_time'
], [
'LIMIT' => [$offset, $limit],
'ORDER' => ['upload_time' => 'DESC']
]);
// 返回结果
echo json_encode([
'status' => 'success',
'page' => $page,
'total' => $total,
'data' => $data ?: []
]);
exit;
} catch (Exception $e) {
$this->jsonError('获取数据失败: ' . $e->getMessage());
}
}
/**
* 图片上传处理
*/
public function upload() {
if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
$this->jsonError('文件上传失败: ' . $this->getUploadError($_FILES['image']['error'] ?? -1));
}
$file = $_FILES['image'];
$params = [
'quality' => isset($_POST['quality']) ?
min($this->uploadConfig['max_quality'],
max($this->uploadConfig['min_quality'], (int)$_POST['quality'])) :
$this->uploadConfig['default_quality'],
'width' => isset($_POST['width']) && $_POST['width'] !== '' ? max(1, (int)$_POST['width']) : 0,
'height' => isset($_POST['height']) && $_POST['height'] !== '' ? max(1, (int)$_POST['height']) : 0
];
if ($file['size'] > $this->uploadConfig['max_size']) {
$this->jsonError("文件过大,最大支持" . $this->formatFileSize($this->uploadConfig['max_size']));
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $this->uploadConfig['allowed_mimes'])) {
$this->jsonError('不支持的文件类型,仅允许: ' . implode(', ', $this->uploadConfig['allowed_mimes']));
}
try {
$uploadDir = rtrim($this->uploadConfig['upload_dir'], '/') . '/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$originalName = basename($file['name']);
$originalExt = pathinfo($originalName, PATHINFO_EXTENSION);
$filename = date('YmdHis') . '_' . uniqid() . '.' . $originalExt;
$targetPath = $uploadDir . $filename;
list($origWidth, $origHeight) = getimagesize($file['tmp_name']);
list($newWidth, $newHeight, $srcX, $srcY, $cropW, $cropH) = $this->calculateDimensions(
$origWidth, $origHeight,
$params['width'], $params['height']
);
// 判断是否需要裁剪
if ($params['width'] > 0 && $params['height'] > 0) {
$success = $this->processImageWithCrop(
$file['tmp_name'], $mime, $targetPath,
$newWidth, $newHeight,
$srcX, $srcY, $cropW, $cropH,
$params['quality']
);
} else {
$success = $this->processImage(
$file['tmp_name'], $mime, $targetPath,
$newWidth, $newHeight, $params['quality']
);
}
if (!$success) {
throw new Exception('图片处理失败');
}
$record = [
'name' => $originalName,
'newname' => $filename,
'url' => rtrim($this->uploadConfig['url'], '/') . '/' . $filename,
'size' => filesize($targetPath),
'width' => $newWidth,
'height' => $newHeight,
'upload_time' => time()
];
$imageId = $this->db->insert('images', $record);
echo json_encode([
'status' => 'success',
'data' => $record + ['id' => $imageId]
]);
exit;
} catch (Exception $e) {
if (isset($targetPath) && file_exists($targetPath)) {
unlink($targetPath);
}
$this->jsonError($e->getMessage());
}
}
private function calculateDimensions($origW, $origH, $targetW, $targetH) {
if ($targetW <= 0 && $targetH <= 0) {
return [$origW, $origH, 0, 0, $origW, $origH];
}
// 只指定宽
if ($targetW > 0 && $targetH <= 0) {
$newW = $targetW;
$newH = (int)($origH * ($targetW / $origW));
return [$newW, $newH, 0, 0, $origW, $origH];
}
// 只指定高
if ($targetH > 0 && $targetW <= 0) {
$newH = $targetH;
$newW = (int)($origW * ($targetH / $origH));
return [$newW, $newH, 0, 0, $origW, $origH];
}
// 指定宽高 → 居中裁剪
$srcRatio = $origW / $origH;
$dstRatio = $targetW / $targetH;
if ($srcRatio > $dstRatio) {
$cropH = $origH;
$cropW = (int)($origH * $dstRatio);
$srcX = (int)(($origW - $cropW) / 2);
$srcY = 0;
} else {
$cropW = $origW;
$cropH = (int)($origW / $dstRatio);
$srcX = 0;
$srcY = (int)(($origH - $cropH) / 2);
}
return [$targetW, $targetH, $srcX, $srcY, $cropW, $cropH];
}
// 处理图片,带裁剪逻辑
private function processImageWithCrop($srcPath, $mime, $destPath, $width, $height, $srcX, $srcY, $cropW, $cropH, $quality) {
switch ($mime) {
case 'image/jpeg': $src = imagecreatefromjpeg($srcPath); break;
case 'image/png': $src = imagecreatefrompng($srcPath); imagesavealpha($src, true); break;
case 'image/gif': $src = imagecreatefromgif($srcPath); break;
case 'image/webp': $src = imagecreatefromwebp($srcPath); break;
default: throw new Exception("不支持的图片格式: {$mime}");
}
if (!$src) throw new Exception('无法解析图片内容');
$dest = imagecreatetruecolor($width, $height);
if ($mime === 'image/png' || $mime === 'image/gif') {
imagealphablending($dest, false);
imagesavealpha($dest, true);
$transparent = imagecolorallocatealpha($dest, 255, 255, 255, 127);
imagefilledrectangle($dest, 0, 0, $width, $height, $transparent);
}
imagecopyresampled(
$dest, $src,
0, 0,
$srcX, $srcY,
$width, $height,
$cropW, $cropH
);
$result = false;
switch ($mime) {
case 'image/jpeg': $result = imagejpeg($dest, $destPath, $quality); break;
case 'image/png': $result = imagepng($dest, $destPath); break;
case 'image/gif': $result = imagegif($dest, $destPath); break;
case 'image/webp': $result = imagewebp($dest, $destPath, $quality); break;
}
imagedestroy($src);
imagedestroy($dest);
return $result;
}
// 工具函数:图片处理(普通格式,不裁剪)
private function processImage($srcPath, $mime, $destPath, $width, $height, $quality) {
switch ($mime) {
case 'image/jpeg': $src = imagecreatefromjpeg($srcPath); break;
case 'image/png': $src = imagecreatefrompng($srcPath); imagesavealpha($src, true); break;
case 'image/gif': $src = imagecreatefromgif($srcPath); break;
case 'image/webp': $src = imagecreatefromwebp($srcPath); break;
default: throw new Exception("不支持的图片格式: {$mime}");
}
if (!$src) throw new Exception('无法解析图片内容');
$dest = imagecreatetruecolor($width, $height);
if ($mime === 'image/png' || $mime === 'image/gif') {
imagealphablending($dest, false);
imagesavealpha($dest, true);
$transparent = imagecolorallocatealpha($dest, 255, 255, 255, 127);
imagefilledrectangle($dest, 0, 0, $width, $height, $transparent);
}
imagecopyresampled($dest, $src, 0, 0, 0, 0, $width, $height, imagesx($src), imagesy($src));
$result = false;
switch ($mime) {
case 'image/jpeg': $result = imagejpeg($dest, $destPath, $quality); break;
case 'image/png': $result = imagepng($dest, $destPath); break;
case 'image/gif': $result = imagegif($dest, $destPath); break;
case 'image/webp': $result = imagewebp($dest, $destPath, $quality); break;
}
imagedestroy($src);
imagedestroy($dest);
return $result;
}
// 工具函数:上传错误信息
private function getUploadError($code) {
$errors = [
UPLOAD_ERR_INI_SIZE => '超过php.ini限制',
UPLOAD_ERR_FORM_SIZE => '超过表单限制',
UPLOAD_ERR_PARTIAL => '文件仅部分上传',
UPLOAD_ERR_NO_FILE => '未上传文件',
UPLOAD_ERR_NO_TMP_DIR => '缺少临时目录',
UPLOAD_ERR_CANT_WRITE => '写入文件失败',
UPLOAD_ERR_EXTENSION => '被扩展阻止'
];
return $errors[$code] ?? "未知错误(代码: {$code}";
}
// 工具函数:格式化文件大小
private function formatFileSize($bytes) {
if ($bytes < 1024) return $bytes . 'B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . 'KB';
return round($bytes / 1048576, 1) . 'MB';
}
// 工具函数:JSON错误响应
private function jsonError($message) {
echo json_encode([
'status' => 'error',
'message' => $message
]);
exit;
}
public function delete() {
header('Content-Type: application/json');
// 获取 JSON 输入
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['ids']) || !is_array($input['ids'])) {
echo json_encode(['status' => 'error', 'message' => '缺少参数']);
return;
}
// 将字符串数组转换为整数数组
$ids = array_map('intval', $input['ids']);
$ids = array_filter($ids); // 去除无效值
if (empty($ids)) {
echo json_encode(['status' => 'error', 'message' => '无效的 ID 列表']);
return;
}
try {
// 查询图片路径(如果你想同时删除图片文件)
$images = $this->db->select('images', ['id', 'newname'], [
'id' => $ids
]);
foreach ($images as $img) {
$file = $this->uploadConfig['upload_dir'] . $img['newname'];
if (file_exists($file)) {
@unlink($file);
}
}
// 删除数据库记录
$this->db->delete('images', ['id' => $ids]);
echo json_encode(['status' => 'success']);
} catch (Exception $e) {
echo json_encode(['status' => 'error', 'message' => '删除失败:' . $e->getMessage()]);
}
}
}
+776
View File
@@ -0,0 +1,776 @@
<?php
namespace App\Controllers\Admin;
class InstallController
{
protected $step;
protected $configFile = ROOT_PATH . "Db/config.php";
protected $installedLockFile = ROOT_PATH . "Db/installed.lock";
protected $db;
protected $message = '';
protected $messageType = ''; // success, error, warning
public function __construct()
{
// 关键修复:步骤4即使已安装也允许访问
$currentStep = $_POST['step'] ?? $_GET['step'] ?? 1;
// 只有非步骤4且已安装时才显示已安装页面
if ($currentStep != 4 && $this->isInstalled()) {
$this->step = 5; // 已安装状态
return;
}
// 处理消息提示
if (isset($_SESSION['install_message'])) {
$this->message = $_SESSION['install_message'];
$this->messageType = $_SESSION['install_message_type'];
unset($_SESSION['install_message'], $_SESSION['install_message_type']);
}
// 设置当前步骤
$this->step = $currentStep;
}
public function index()
{
// 已安装则显示提示(步骤4除外)
if ($this->step == 5) {
$this->renderHeader();
$this->alreadyInstalled();
$this->renderFooter();
return;
}
// 处理表单提交(在输出任何内容之前)
$this->handleSubmission();
// 输出页面
$this->renderHeader();
switch ($this->step) {
case 1:
$this->checkEnvironment();
break;
case 2:
$this->databaseForm();
break;
case 3:
$this->adminForm();
break;
case 4:
$this->finish();
break;
default:
$this->setMessage('非法步骤', 'error');
$this->showMessage();
}
$this->renderFooter();
}
// 处理表单提交
protected function handleSubmission()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
$currentStep = $_POST['step'] ?? 1;
switch ($currentStep) {
case 2:
$this->processDatabaseForm();
break;
case 3:
$this->processAdminForm();
break;
}
}
// 处理数据库表单提交
protected function processDatabaseForm()
{
$host = $_POST['db_host'] ?? '';
$name = $_POST['db_name'] ?? '';
$user = $_POST['db_user'] ?? '';
$pass = $_POST['db_pass'] ?? '';
try {
// 连接数据库服务器
$pdo = new \PDO("mysql:host=$host;charset=utf8mb4", $user, $pass);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
// 检查数据库是否存在
$stmt = $pdo->query("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'");
$dbExists = $stmt->fetchColumn() !== false;
// 如果数据库存在且不为空,提示用户
if ($dbExists) {
$stmt = $pdo->query("USE $name");
$stmt = $pdo->query("SHOW TABLES");
if ($stmt->fetchColumn() !== false) {
$this->setSessionMessage(
"警告:数据库 '$name' 已存在且包含表。继续安装可能会覆盖现有数据。",
'warning'
);
return;
}
} else {
// 创建数据库
$pdo->exec("CREATE DATABASE `$name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
}
// 连接到指定数据库
$this->db = new \PDO("mysql:host=$host;dbname=$name;charset=utf8mb4", $user, $pass);
$this->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
// 保存配置文件
$config = "<?php\nreturn [\n";
$config .= " 'db_host' => '" . addslashes($host) . "',\n";
$config .= " 'db_name' => '" . addslashes($name) . "',\n";
$config .= " 'db_user' => '" . addslashes($user) . "',\n";
$config .= " 'db_pass' => '" . addslashes($pass) . "',\n];\n";
if (file_put_contents($this->configFile, $config) === false) {
throw new \Exception("无法写入配置文件,请检查权限");
}
$this->setSessionMessage('数据库配置成功!即将进入管理员设置', 'success');
header("Location: ?step=3");
exit;
} catch (\Exception $e) {
$this->setSessionMessage('数据库配置失败:' . $e->getMessage(), 'error');
header("Location: ?step=2");
exit;
}
}
// 处理管理员表单提交
protected function processAdminForm()
{
try {
if (!file_exists($this->configFile)) {
throw new \Exception("数据库配置文件不存在,请先完成数据库设置");
}
$cfg = require $this->configFile;
$this->db = new \PDO(
"mysql:host={$cfg['db_host']};dbname={$cfg['db_name']};charset=utf8mb4",
$cfg['db_user'],
$cfg['db_pass']
);
$this->db->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
// 创建images表
$this->db->exec("
CREATE TABLE IF NOT EXISTS `images` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(255) NOT NULL COMMENT '文件原名',
`newname` varchar(255) NOT NULL COMMENT '新文件名',
`url` text NOT NULL COMMENT 'URL',
`size` int(11) NOT NULL COMMENT '文件大小(字节)',
`width` int(11) NOT NULL COMMENT '宽度(像素)',
`height` int(11) NOT NULL COMMENT '高度(像素)',
`upload_time` int(11) NOT NULL COMMENT '上传时间戳',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
// 创建plugins表
$this->db->exec("
CREATE TABLE IF NOT EXISTS `plugins` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '插件唯一标识,如 SamplePlugin',
`title` varchar(255) NOT NULL COMMENT '插件显示名称',
`description` text COMMENT '插件描述',
`version` varchar(20) DEFAULT NULL COMMENT '插件版本号',
`author` varchar(100) DEFAULT NULL COMMENT '作者',
`url` varchar(128) NOT NULL,
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '启用状态,1=启用,0=禁用',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='插件管理表';
");
// 创建users表
$this->db->exec("
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(255) NOT NULL,
`email` varchar(100) NOT NULL,
`avatar` VARCHAR(128) DEFAULT NULL,
`balance` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '用户余额',
`status` int(11) NOT NULL DEFAULT '0',
`role` varchar(20) NOT NULL DEFAULT 'user',
`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 `username` (`username`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$email = $_POST['email'] ?? ''; // 新增邮箱字段
// 验证逻辑更新
if (!$username) {
throw new \Exception("用户名不能为空");
}
if (!$password) {
throw new \Exception("密码不能为空");
}
if (strlen($password) < 8) {
throw new \Exception("密码长度不能少于8位");
}
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \Exception("请输入有效的邮箱地址");
}
// 检查用户名是否已存在
$stmt = $this->db->prepare("SELECT id FROM users WHERE username = ?");
$stmt->execute([$username]);
if ($stmt->fetchColumn()) {
throw new \Exception("用户名 '$username' 已存在,请选择其他用户名");
}
// 检查邮箱是否已存在
$stmt = $this->db->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetchColumn()) {
throw new \Exception("邮箱 '$email' 已被使用,请选择其他邮箱");
}
$hash = password_hash($password, PASSWORD_BCRYPT);
// 插入管理员数据(包含邮箱和管理员角色)
$stmt = $this->db->prepare("
INSERT INTO users (username, password, email, role, status)
VALUES (?, ?, ?, 'admin', 1)
");
$stmt->execute([$username, $hash, $email]);
// 创建临时标记文件
file_put_contents(ROOT_PATH . "Db/install_completed.tmp", date('Y-m-d H:i:s') . "\n");
$this->setSessionMessage('管理员账号创建成功!安装已完成', 'success');
header("Location: ?step=4");
exit;
} catch (\Exception $e) {
$this->setSessionMessage('管理员账号创建失败:' . $e->getMessage(), 'error');
header("Location: ?step=3");
exit;
}
}
// 渲染页面头部和样式
protected function renderHeader()
{
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统安装向导</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#10b981',
danger: '#ef4444',
warning: '#f59e0b',
neutral: '#f3f4f6',
},
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.step-active { @apply bg-primary text-white border-primary; }
.step-passed { @apply bg-secondary text-white border-secondary; }
.step-pending { @apply bg-gray-100 text-gray-400 border-gray-200; }
.card { @apply bg-white rounded-lg shadow-md overflow-hidden transition-all duration-300 hover:shadow-lg; }
.btn { @apply px-4 py-2 rounded-md font-medium transition-all duration-200; }
.btn-primary { @apply bg-primary text-white hover:bg-primary/90; }
.btn-secondary { @apply bg-gray-600 text-white hover:bg-gray-700; }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen font-sans">
<div class="container mx-auto px-4 py-8 max-w-3xl">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<i class="fa fa-cogs text-2xl"></i>
</div>
<h1 class="text-3xl font-bold text-gray-800">系统安装向导</h1>
<p class="text-gray-500 mt-2">请按照步骤完成系统安装</p>
</div>
<!-- 步骤指示器 -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div class="flex flex-col items-center">
<div class="w-10 h-10 rounded-full border-2 flex items-center justify-center
<?php echo $this->step == 1 ? 'step-active' : ($this->step > 1 ? 'step-passed' : 'step-pending'); ?>">
<i class="fa fa-check"></i>
</div>
<span class="mt-2 text-sm font-medium text-gray-600">环境检测</span>
</div>
<div class="flex-1 h-1 mx-4 bg-gray-200 relative">
<div class="absolute inset-0 bg-primary transition-all duration-500
<?php echo $this->step > 1 ? 'w-full' : 'w-0'; ?>"></div>
</div>
<div class="flex flex-col items-center">
<div class="w-10 h-10 rounded-full border-2 flex items-center justify-center
<?php echo $this->step == 2 ? 'step-active' : ($this->step > 2 ? 'step-passed' : 'step-pending'); ?>">
<i class="fa fa-database"></i>
</div>
<span class="mt-2 text-sm font-medium text-gray-600">数据库设置</span>
</div>
<div class="flex-1 h-1 mx-4 bg-gray-200 relative">
<div class="absolute inset-0 bg-primary transition-all duration-500
<?php echo $this->step > 2 ? 'w-full' : 'w-0'; ?>"></div>
</div>
<div class="flex flex-col items-center">
<div class="w-10 h-10 rounded-full border-2 flex items-center justify-center
<?php echo $this->step == 3 ? 'step-active' : ($this->step > 3 ? 'step-passed' : 'step-pending'); ?>">
<i class="fa fa-user"></i>
</div>
<span class="mt-2 text-sm font-medium text-gray-600">管理员设置</span>
</div>
<div class="flex-1 h-1 mx-4 bg-gray-200 relative">
<div class="absolute inset-0 bg-primary transition-all duration-500
<?php echo $this->step > 3 ? 'w-full' : 'w-0'; ?>"></div>
</div>
<div class="flex flex-col items-center">
<div class="w-10 h-10 rounded-full border-2 flex items-center justify-center
<?php echo $this->step == 4 ? 'step-active' : ($this->step > 4 ? 'step-passed' : 'step-pending'); ?>">
<i class="fa fa-check-circle"></i>
</div>
<span class="mt-2 text-sm font-medium text-gray-600">完成</span>
</div>
</div>
</div>
<!-- 消息提示 -->
<?php $this->showMessage(); ?>
<div class="card p-6">
<?php
}
// 渲染页面底部
protected function renderFooter()
{
?>
</div>
<div class="mt-8 text-center text-gray-500 text-sm">
<p>© 2025 系统安装向导</p>
</div>
</div>
</body>
</html>
<?php
}
// 显示消息提示
protected function showMessage()
{
if (empty($this->message)) {
return;
}
$icon = '';
$class = '';
switch ($this->messageType) {
case 'success':
$icon = 'fa-check-circle';
$class = 'bg-green-50 border-green-200 text-green-700';
break;
case 'error':
$icon = 'fa-exclamation-circle';
$class = 'bg-red-50 border-red-200 text-red-700';
break;
case 'warning':
$icon = 'fa-exclamation-triangle';
$class = 'bg-yellow-50 border-yellow-200 text-yellow-700';
break;
default:
$icon = 'fa-info-circle';
$class = 'bg-blue-50 border-blue-200 text-blue-700';
}
echo "<div class='mb-6 p-4 border rounded-lg $class'>
<i class='fa $icon mr-2'></i>$this->message
</div>";
}
// 设置消息
protected function setMessage($message, $type = 'info')
{
$this->message = $message;
$this->messageType = $type;
}
// 设置会话消息(用于跳转后显示)
protected function setSessionMessage($message, $type = 'info')
{
if (session_status() == PHP_SESSION_NONE) {
session_start();
}
$_SESSION['install_message'] = $message;
$_SESSION['install_message_type'] = $type;
}
// 检查是否已安装
protected function isInstalled()
{
return file_exists($this->installedLockFile);
}
// 已安装提示
protected function alreadyInstalled()
{
echo "<div class='text-center py-8'>
<div class='inline-flex items-center justify-center w-20 h-20 rounded-full bg-warning/10 text-warning mb-6'>
<i class='fa fa-exclamation-triangle text-3xl'></i>
</div>
<h2 class='text-2xl font-bold text-gray-800 mb-3'>系统已安装</h2>
<p class='text-gray-600 mb-8 max-w-md mx-auto'>
检测到系统已经安装,如需重新安装,请先删除安装锁定文件:<br>
<code class='bg-gray-100 px-2 py-1 rounded text-sm'>{$this->installedLockFile}</code>
</p>
<div class='flex justify-center'>
<a href='/admin/login' class='btn btn-primary inline-flex items-center justify-center'>
<i class='fa fa-sign-in mr-2'></i> 登录后台
</a>
</div>
</div>";
}
// 第一步:检测环境
protected function checkEnvironment()
{
$phpVersion = PHP_VERSION;
$extensions = [
'pdo_mysql' => 'MySQL PDO 驱动(必须)',
'mbstring' => '多字节字符串支持(必须)',
'json' => 'JSON 解析支持(必须)',
'openssl' => 'OpenSSL 加密扩展(必须)',
'fileinfo' => '文件信息扩展(必须)',
'pdo' => 'PDO 基础扩展(可选,一般随 pdo_mysql 自动启用)'
];
$writableDirs = [
ROOT_PATH . "Db" => "数据库配置目录",
ROOT_PATH . "Storage" => "文件存储目录"
];
// 检查PHP版本
$phpVersionOk = version_compare($phpVersion, '8.0.0', '>=');
// 检查扩展
$allExtensionsOk = true;
$extensionResults = [];
foreach ($extensions as $ext => $desc) {
$installed = extension_loaded($ext);
if (!$installed && $ext !== 'pdo') { // pdo 可选
$allExtensionsOk = false;
}
$extensionResults[] = [
'name' => $ext,
'desc' => $desc,
'installed' => $installed
];
}
// 检查目录写权限(真实写入测试)
$allDirsWritable = true;
$dirResults = [];
foreach ($writableDirs as $dir => $desc) {
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$testFile = $dir . '/.test';
$canWrite = @file_put_contents($testFile, 'test') !== false;
if ($canWrite) {
unlink($testFile);
} else {
$allDirsWritable = false;
}
$dirResults[] = [
'path' => $dir,
'desc' => $desc,
'writable' => $canWrite
];
}
// 检查 PHP 配置
$fileUploadsOk = ini_get('file_uploads');
echo "<h2 class='text-2xl font-bold text-gray-800 mb-6 flex items-center'>
<i class='fa fa-server text-primary mr-3'></i>环境检测
</h2>";
// PHP版本检查
echo "<div class='mb-4 p-4 border rounded-lg " . ($phpVersionOk ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50') . "'>
<div class='flex justify-between items-center'>
<div>
<h3 class='font-medium text-gray-800'>PHP版本</h3>
<p class='text-sm text-gray-600 mt-1'>需要PHP 8.0.0或更高版本</p>
</div>
<span class='text-lg " . ($phpVersionOk ? 'text-secondary' : 'text-danger') . "'>
" . ($phpVersionOk ? '✅' : '❌') . "
</span>
</div>
<p class='mt-2 text-sm text-gray-700'>当前版本: $phpVersion</p>
</div>";
// 扩展检查
echo "<div class='mb-4'>
<h3 class='font-medium text-gray-800 mb-3'>必要扩展</h3>
<div class='grid grid-cols-1 md:grid-cols-2 gap-3'>";
foreach ($extensionResults as $ext) {
echo "<div class='p-3 border rounded-lg " . ($ext['installed'] ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50') . "'>
<div class='flex justify-between items-center'>
<span>{$ext['desc']} ({$ext['name']})</span>
<span class='" . ($ext['installed'] ? 'text-secondary' : 'text-danger') . "'>
" . ($ext['installed'] ? '✅' : '❌') . "
</span>
</div>";
if (!$ext['installed']) {
echo "<p class='text-xs text-gray-500 mt-1'>Linux 安装命令示例: <code>apt install php-{$ext['name']}</code></p>";
}
echo "</div>";
}
echo "</div></div>";
// 文件权限检查
echo "<div class='mb-6'>
<h3 class='font-medium text-gray-800 mb-3'>文件权限</h3>
<div class='space-y-3'>";
foreach ($dirResults as $dir) {
echo "<div class='p-3 border rounded-lg " . ($dir['writable'] ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50') . "'>
<div class='flex justify-between items-center'>
<span class='text-sm truncate max-w-[70%]'>{$dir['desc']} ({$dir['path']})</span>
<span class='" . ($dir['writable'] ? 'text-secondary' : 'text-danger') . "'>
" . ($dir['writable'] ? '✅' : '❌') . "
</span>
</div>
</div>";
}
echo "</div></div>";
// PHP 配置检查
echo "<div class='mb-6 p-4 border rounded-lg " . ($fileUploadsOk ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50') . "'>
<div class='flex justify-between items-center'>
<span>文件上传 (file_uploads)</span>
<span class='" . ($fileUploadsOk ? 'text-secondary' : 'text-danger') . "'>
" . ($fileUploadsOk ? '✅' : '❌') . "
</span>
</div>
</div>";
// 下一步按钮
$allChecksPassed = $phpVersionOk && $allExtensionsOk && $allDirsWritable && $fileUploadsOk;
echo "<div class='flex justify-end mt-8'>";
if ($allChecksPassed) {
echo "<a href='?step=2' class='btn btn-primary flex items-center'>
<span>下一步:数据库设置</span>
<i class='fa fa-arrow-right ml-2'></i>
</a>";
} else {
echo "<button class='btn bg-gray-300 text-gray-500 cursor-not-allowed' disabled>
<span>请先解决所有问题</span>
<i class='fa fa-arrow-right ml-2'></i>
</button>";
}
echo "</div>";
}
// 第二步:数据库表单
protected function databaseForm()
{
echo "<h2 class='text-2xl font-bold text-gray-800 mb-6 flex items-center'>
<i class='fa fa-database text-primary mr-3'></i>数据库设置
</h2>";
echo '<form method="post" class="space-y-4">
<input type="hidden" name="step" value="2">
<div>
<label for="db_host" class="block text-sm font-medium text-gray-700 mb-1">数据库主机</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<i class="fa fa-server -mt-5"></i>
</div>
<input type="text" id="db_host" name="db_host" value="localhost" required
class="w-full pl-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all">
<p class="mt-1 text-xs text-gray-500">通常为 localhost 或 127.0.0.1</p>
</div>
</div>
<div>
<label for="db_name" class="block text-sm font-medium text-gray-700 mb-1">数据库名称</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<i class="fa fa-database"></i>
</div>
<input type="text" id="db_name" name="db_name" required
class="w-full pl-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all">
</div>
</div>
<div>
<label for="db_user" class="block text-sm font-medium text-gray-700 mb-1">数据库用户名</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<i class="fa fa-user"></i>
</div>
<input type="text" id="db_user" name="db_user" required
class="w-full pl-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all">
</div>
</div>
<div>
<label for="db_pass" class="block text-sm font-medium text-gray-700 mb-1">数据库密码</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<i class="fa fa-lock -mt-5"></i>
</div>
<input type="password" id="db_pass" name="db_pass"
class="w-full pl-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all">
<p class="mt-1 text-xs text-gray-500">如果数据库无密码,请留空</p>
</div>
</div>
<div class="flex justify-between mt-8">
<a href="?step=1" class="btn btn-secondary flex items-center">
<i class="fa fa-arrow-left mr-2"></i>
<span>上一步</span>
</a>
<button type="submit" class="btn btn-primary flex items-center">
<span>保存并测试</span>
<i class="fa fa-arrow-right ml-2"></i>
</button>
</div>
</form>';
}
// 第三步:创建管理员
protected function adminForm()
{
// 检查配置文件是否存在
if (!file_exists($this->configFile)) {
$this->setMessage('数据库配置文件不存在,请先完成数据库设置', 'error');
$this->showMessage();
echo "<div class='flex justify-start mt-8'>
<a href='?step=2' class='btn btn-secondary flex items-center'>
<i class='fa fa-arrow-left mr-2'></i>
<span>返回数据库设置</span>
</a>
</div>";
return;
}
echo '<h2 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fa fa-user text-primary mr-3"></i>创建管理员账号
</h2>';
echo '<form method="post" class="space-y-4">
<input type="hidden" name="step" value="3">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">管理员用户名</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<i class="fa fa-user-circle"></i>
</div>
<input type="text" id="username" name="username" required
class="w-full pl-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all">
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<i class="fa fa-user-circle"></i>
</div>
<input type="text" id="email" name="email" required
class="w-full pl-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all">
</div>
</div> <div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">管理员密码</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<i class="fa fa-shield"></i>
</div>
<input type="password" id="password" name="password" required
class="w-full pl-10 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all">
<p class="mt-1 text-xs text-gray-500">请设置强密码(至少8位,包含字母和数字)</p>
</div>
</div>
<div class="flex justify-between mt-8">
<a href="?step=2" class="btn btn-secondary flex items-center">
<i class="fa fa-arrow-left mr-2"></i>
<span>上一步</span>
</a>
<button type="submit" class="btn btn-primary flex items-center">
<span>创建管理员</span>
<i class="fa fa-check ml-2"></i>
</button>
</div>
</form>';
}
// 第四步:完成安装
protected function finish()
{
// 关键修复:在完成页面创建最终的安装锁定文件
if (file_exists(ROOT_PATH . "Db/install_completed.tmp")) {
rename(ROOT_PATH . "Db/install_completed.tmp", $this->installedLockFile);
}
echo "<div class='text-center py-8'>
<div class='inline-flex items-center justify-center w-20 h-20 rounded-full bg-secondary/10 text-secondary mb-6'>
<i class='fa fa-check-circle text-3xl'></i>
</div>
<h2 class='text-2xl font-bold text-gray-800 mb-3'>安装完成!</h2>
<p class='text-gray-600 mb-8 max-w-md mx-auto'>管理员账号已创建,系统安装成功,您可以登录后台开始使用了。</p>
<div class='flex flex-col sm:flex-row justify-center gap-4'>
<a href='/admin/login' class='btn btn-primary inline-flex items-center justify-center'>
<i class='fa fa-sign-in mr-2'></i> 登录后台
</a>
<a href='/' class='btn btn-secondary inline-flex items-center justify-center'>
<i class='fa fa-home mr-2'></i> 访问首页
</a>
</div>
</div>";
}
}
@@ -0,0 +1,308 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use App\Core\PK10Algorithm;
use Db\Database;
class PK10PeriodController extends AdminBaseController {
private $db;
public function __construct(Database $db) {
$this->db = $db;
}
public function index() {
$this->checkLogin();
$gameId = $this->getGameId();
$periods = $this->db->select('periods', '*', [
'game_id' => $gameId,
'ORDER' => ['id' => 'DESC'],
'LIMIT' => 50
]);
// 附加pk10_results
foreach ($periods as &$p) {
$p['pk10'] = $this->db->get('pk10_results', '*', ['period_id' => $p['id']]);
}
$current = $this->db->get('periods', '*', [
'game_id' => $gameId,
'status[!]' => 'settled',
'ORDER' => ['id' => 'DESC']
]);
if ($current) {
$current['pk10'] = $this->db->get('pk10_results', '*', ['period_id' => $current['id']]);
}
$stats = [
'total' => $this->db->count('periods', ['game_id' => $gameId]),
'pending' => $this->db->count('periods', ['game_id' => $gameId, 'status' => 'pending']),
'drawn' => $this->db->count('periods', ['game_id' => $gameId, 'status' => 'drawn']),
'settled' => $this->db->count('periods', ['game_id' => $gameId, 'status' => 'settled']),
];
$this->render('Admin/pk10_periods.php', compact('periods', 'current', 'stats', 'gameId'));
}
public function start() {
$this->checkLogin();
$gameId = $this->getGameId();
// 检查是否有未结算期号
$active = $this->db->get('periods', 'id', [
'game_id' => $gameId,
'status[!]' => 'settled'
]);
if ($active) {
$this->json(['status' => 'error', 'message' => 'There is an active period. Please settle it first.']);
return;
}
$periodNumber = PK10Algorithm::generatePeriodNumber($gameId);
$this->db->insert('periods', [
'game_id' => $gameId,
'period_number' => $periodNumber,
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
]);
$this->json(['status' => 'success', 'message' => 'New period started', 'period_number' => $periodNumber]);
}
public function lock() {
$this->checkLogin();
$data = json_decode(file_get_contents('php://input'), true);
$periodId = (int)($data['period_id'] ?? 0);
$period = $this->db->get('periods', '*', ['id' => $periodId]);
if (!$period || $period['status'] !== 'pending') {
$this->json(['status' => 'error', 'message' => 'Invalid period or status']);
return;
}
$this->db->update('periods', ['status' => 'locked'], ['id' => $periodId]);
$this->json(['status' => 'success', 'message' => 'Period locked']);
}
public function draw() {
$this->checkLogin();
$data = json_decode(file_get_contents('php://input'), true);
$periodId = (int)($data['period_id'] ?? 0);
$manual = $data['manual'] ?? false;
$period = $this->db->get('periods', '*', ['id' => $periodId]);
if (!$period || !in_array($period['status'], ['pending', 'locked'])) {
$this->json(['status' => 'error', 'message' => 'Invalid period or status']);
return;
}
$gameId = $period['game_id'];
if ($manual && !empty($data['result'])) {
// 手动补开奖
$result = array_map('intval', $data['result']);
if (count($result) !== 10 || count(array_unique($result)) !== 10) {
$this->json(['status' => 'error', 'message' => 'Result must be 10 unique numbers 1-10']);
return;
}
} else {
// 自动开奖(带放水机制)
$waterConfig = [];
$wc = $this->db->select('water_control', '*', ['game_id' => $gameId, 'enabled' => 1]);
foreach ($wc as $w) {
$waterConfig[$w['bet_type']] = (float)$w['win_rate_pct'];
}
$bets = $this->db->select('bets', '*', [
'period_id' => $periodId,
'status' => 'pending',
'is_virtual' => 0
]);
if (!empty($waterConfig) && !empty($bets)) {
$result = PK10Algorithm::generateControlledResult($bets, $waterConfig);
} else {
$result = PK10Algorithm::generateResult();
}
}
$sum = $result[0] + $result[1];
$resultJson = json_encode($result);
$this->db->medoo->pdo->beginTransaction();
try {
$this->db->update('periods', [
'status' => 'drawn',
'result' => $resultJson,
'draw_time' => date('Y-m-d H:i:s'),
], ['id' => $periodId]);
// 删除旧结果(如果手动修改)
$this->db->delete('pk10_results', ['period_id' => $periodId]);
$this->db->insert('pk10_results', [
'period_id' => $periodId,
'rank_1' => $result[0], 'rank_2' => $result[1], 'rank_3' => $result[2],
'rank_4' => $result[3], 'rank_5' => $result[4], 'rank_6' => $result[5],
'rank_7' => $result[6], 'rank_8' => $result[7], 'rank_9' => $result[8],
'rank_10' => $result[9], 'champion_sum' => $sum,
]);
$this->db->medoo->pdo->commit();
} catch (\Throwable $e) {
$this->db->medoo->pdo->rollBack();
$this->json(['status' => 'error', 'message' => 'Draw failed: ' . $e->getMessage()]);
return;
}
$this->json(['status' => 'success', 'message' => 'Draw completed', 'result' => $result, 'sum' => $sum]);
}
public function settle() {
$this->checkLogin();
$data = json_decode(file_get_contents('php://input'), true);
$periodId = (int)($data['period_id'] ?? 0);
$period = $this->db->get('periods', '*', ['id' => $periodId]);
if (!$period || $period['status'] !== 'drawn') {
$this->json(['status' => 'error', 'message' => 'Period must be drawn first']);
return;
}
$result = json_decode($period['result'], true);
if (!$result || count($result) !== 10) {
$this->json(['status' => 'error', 'message' => 'Invalid result data']);
return;
}
$bets = $this->db->select('bets', '*', ['period_id' => $periodId, 'status' => 'pending']);
if (empty($bets)) {
$this->db->update('periods', ['status' => 'settled'], ['id' => $periodId]);
$this->json(['status' => 'success', 'message' => 'No bets to settle', 'wins' => 0, 'losses' => 0]);
return;
}
$wins = 0; $losses = 0; $totalPayout = 0;
$this->db->medoo->pdo->beginTransaction();
try {
foreach ($bets as $bet) {
$isWin = PK10Algorithm::checkWin($result, $bet['bet_type'], $bet['bet_value']);
$betAmount = (float)$bet['amount'];
$odds = (float)$bet['odds'];
if ($isWin) {
$winAmount = $betAmount * $odds;
$payout = $betAmount + $winAmount;
$this->db->update('bets', [
'status' => 'win',
'win_amount' => $winAmount,
], ['id' => $bet['id']]);
// 非虚拟账户才更新余额
if (!(int)$bet['is_virtual']) {
$stmt = $this->db->medoo->pdo->prepare("SELECT balance FROM users WHERE id = :id FOR UPDATE");
$stmt->execute([':id' => $bet['user_id']]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
$newBalance = (float)$user['balance'] + $payout;
$this->db->update('users', ['balance' => $newBalance], ['id' => $bet['user_id']]);
$this->db->insert('transactions', [
'user_id' => $bet['user_id'],
'type' => 'win',
'amount' => $payout,
'balance_before' => $user['balance'],
'balance_after' => $newBalance,
'related_id' => $periodId,
'description' => 'PK10 Win - Period ' . $period['period_number'],
'is_virtual' => $bet['is_virtual'],
'created_at' => date('Y-m-d H:i:s'),
]);
$totalPayout += $payout;
}
$wins++;
} else {
$this->db->update('bets', ['status' => 'lose', 'win_amount' => 0], ['id' => $bet['id']]);
$losses++;
}
}
// 计算代理佣金
$this->settleAgentCommissions($bets, $periodId);
$this->db->update('periods', ['status' => 'settled'], ['id' => $periodId]);
$this->db->medoo->pdo->commit();
} catch (\Throwable $e) {
$this->db->medoo->pdo->rollBack();
$this->json(['status' => 'error', 'message' => 'Settle failed: ' . $e->getMessage()]);
return;
}
$this->json(['status' => 'success', 'message' => 'Settlement completed',
'wins' => $wins, 'losses' => $losses, 'total_payout' => $totalPayout]);
}
private function settleAgentCommissions(array $bets, int $periodId) {
$agentBets = [];
foreach ($bets as $bet) {
if ((int)$bet['is_virtual']) continue;
$user = $this->db->get('users', ['agent_id'], ['id' => $bet['user_id']]);
if (!$user || !$user['agent_id']) continue;
$agentId = $user['agent_id'];
if (!isset($agentBets[$agentId])) $agentBets[$agentId] = 0;
$agentBets[$agentId] += (float)$bet['amount'];
}
foreach ($agentBets as $agentId => $totalBet) {
$agent = $this->db->get('agents', '*', ['id' => $agentId, 'status' => 1]);
if (!$agent) continue;
$commission = $totalBet * $agent['commission_rate'] / 100;
if ($commission <= 0) continue;
$this->db->insert('agent_commissions', [
'agent_id' => $agentId,
'from_user_id' => 0,
'period_id' => $periodId,
'bet_amount' => $totalBet,
'commission' => $commission,
'type' => 'bet',
'created_at' => date('Y-m-d H:i:s'),
]);
// 佣金加到代理用户余额
$agentUserId = $agent['user_id'];
$stmt = $this->db->medoo->pdo->prepare("SELECT balance FROM users WHERE id = :id FOR UPDATE");
$stmt->execute([':id' => $agentUserId]);
$au = $stmt->fetch(\PDO::FETCH_ASSOC);
$newBal = (float)$au['balance'] + $commission;
$this->db->update('users', ['balance' => $newBal], ['id' => $agentUserId]);
$this->db->insert('transactions', [
'user_id' => $agentUserId, 'type' => 'commission',
'amount' => $commission, 'balance_before' => $au['balance'],
'balance_after' => $newBal, 'related_id' => $periodId,
'description' => 'Agent commission', 'created_at' => date('Y-m-d H:i:s'),
]);
}
}
public function get($id) {
$this->checkLogin();
$period = $this->db->get('periods', '*', ['id' => (int)$id]);
if ($period) {
$period['pk10'] = $this->db->get('pk10_results', '*', ['period_id' => $period['id']]);
}
$this->json(['status' => 'success', 'data' => $period]);
}
private function getGameId(): int {
$game = $this->db->get('games', 'id', ['code' => 'pk10']);
return $game ? (int)$game : 0;
}
private function json(array $data) {
header('Content-Type: application/json');
echo json_encode($data);
}
}
+957
View File
@@ -0,0 +1,957 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class PeriodController extends AdminBaseController {
public function __construct() {
$this->checkLogin();
$this->checkAdmin();
}
/**
* 期号列表页面
*/
public function index() {
$db = new Database();
try {
// 获取所有期号列表
$periods = $db->select('periods', '*', [
'ORDER' => ['id' => 'DESC'],
'LIMIT' => 100
]);
if (!is_array($periods)) {
$periods = [];
}
// 获取游戏列表(用于关联显示)
$games = [];
$gamesList = [];
try {
$gamesList = $db->select('games', ['id', 'name'], [
'status' => 1,
'ORDER' => ['id' => 'ASC']
]);
if (is_array($gamesList)) {
foreach ($gamesList as $game) {
$games[$game['id']] = $game['name'];
}
}
} catch (\Throwable $e) {
// 忽略错误
}
// 为每个游戏获取当前期号
$currentPeriods = [];
foreach ($gamesList as $game) {
$currentPeriod = $db->get('periods', '*', [
'game_id' => $game['id'],
'ORDER' => ['id' => 'DESC']
]);
if ($currentPeriod) {
$currentPeriods[$game['id']] = $currentPeriod;
}
}
} catch (\Throwable $e) {
$periods = [];
$games = [];
$gamesList = [];
$currentPeriods = [];
}
$this->render('Admin/periods.php', [
'currentPeriods' => $currentPeriods, // 改为多个游戏的当前期号
'periods' => $periods,
'games' => $games,
'gamesList' => $gamesList, // 完整的游戏列表
'title' => '期号管理'
]);
}
/**
* 获取单个期号数据
*/
public function get($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
try {
$period = $db->get('periods', '*', [
'id' => $id
]);
if ($period) {
echo json_encode([
'success' => true,
'data' => $period
]);
} else {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
}
} catch (\Throwable $e) {
echo json_encode([
'success' => false,
'message' => '获取数据失败:' . $e->getMessage()
]);
}
}
/**
* 创建期号
*/
public function create() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
echo json_encode([
'success' => false,
'message' => '未接收到数据'
]);
return;
}
$db = new Database();
$gameId = isset($data['game_id']) ? (int)$data['game_id'] : null;
// 如果关联了游戏,从游戏中获取直播流地址
$streamUrl = null;
if ($gameId) {
try {
$game = $db->get('games', ['stream_url'], ['id' => $gameId]);
if ($game && !empty($game['stream_url'])) {
$streamUrl = $game['stream_url'];
}
} catch (\Throwable $e) {
// 忽略错误,继续使用 null
}
}
// 获取当前登录的管理员ID
$createdBy = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
// 生成期号
$periodNumber = $this->generatePeriodNumber($db, $gameId);
// 准备数据
$periodData = [
'period_number' => $periodNumber,
'status' => 'pending',
'game_id' => $gameId ?: null,
'stream_url' => $streamUrl,
'start_time' => date('Y-m-d H:i:s'),
'created_by' => $createdBy,
'auto_generated' => 0, // 手动创建
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
try {
$id = $db->insert('periods', $periodData);
echo json_encode([
'success' => true,
'message' => '期号创建成功',
'data' => ['id' => $id, 'period_number' => $periodNumber]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '创建失败:' . $e->getMessage()
]);
}
}
/**
* 录入开奖结果
*/
public function draw() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
if (empty($data)) {
echo json_encode([
'success' => false,
'message' => '未接收到数据'
]);
return;
}
$db = new Database();
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 检查期号状态
if ($period['status'] === 'settled') {
echo json_encode([
'success' => false,
'message' => '该期号已结算,无法修改'
]);
return;
}
// 获取游戏类型
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
$gameType = $game['type'] ?? 'dice';
$auto = isset($data['auto']) ? (bool)$data['auto'] : false;
// 根据游戏类型处理开奖结果
if ($gameType === 'xocdia') {
// Xóc Đĩa 开奖逻辑
if ($auto) {
// 自动生成:4个硬币随机红/白
$coins = [];
for ($i = 0; $i < 4; $i++) {
$coins[] = (rand(0, 1) === 0) ? 'red' : 'white';
}
} else {
// 手动输入:从前端获取
$coins = isset($data['coins']) ? $data['coins'] : [];
if (!is_array($coins) || count($coins) !== 4) {
echo json_encode([
'success' => false,
'message' => 'Xóc Đĩa 必须提供4个硬币的颜色(red/white'
]);
return;
}
// 验证颜色
foreach ($coins as $coin) {
if (!in_array($coin, ['red', 'white'])) {
echo json_encode([
'success' => false,
'message' => '硬币颜色只能是 red 或 white'
]);
return;
}
}
}
// 计算结果
$redCount = count(array_filter($coins, fn($c) => $c === 'red'));
$result = $this->calculateXocdiaResult($redCount);
// 获取当前登录的管理员ID(审核人)
$approvedBy = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
// 更新期号数据
$updateData = [
'result' => json_encode($coins), // 存储JSON数组
'dice1' => $redCount, // 复用字段:红色数量
'dice2' => 4 - $redCount, // 复用字段:白色数量
'dice3' => null,
'total' => null,
'status' => 'drawn',
'draw_time' => date('Y-m-d H:i:s'),
'approved_by' => $approvedBy,
'updated_at' => date('Y-m-d H:i:s')
];
$responseData = [
'coins' => $coins,
'red_count' => $redCount,
'white_count' => 4 - $redCount,
'result' => $result
];
} else {
// 骰子游戏开奖逻辑(原逻辑)
if ($auto) {
$dice1 = rand(1, 6);
$dice2 = rand(1, 6);
$dice3 = rand(1, 6);
} else {
$dice1 = isset($data['dice1']) ? (int)$data['dice1'] : 0;
$dice2 = isset($data['dice2']) ? (int)$data['dice2'] : 0;
$dice3 = isset($data['dice3']) ? (int)$data['dice3'] : 0;
// 验证骰子点数
if ($dice1 < 1 || $dice1 > 6 || $dice2 < 1 || $dice2 > 6 || $dice3 < 1 || $dice3 > 6) {
echo json_encode([
'success' => false,
'message' => '骰子点数必须在1-6之间'
]);
return;
}
}
// 计算总和
$total = $dice1 + $dice2 + $dice3;
// 计算结果
$result = $this->calculateResult($dice1, $dice2, $dice3, $total);
// 获取当前登录的管理员ID(审核人)
$approvedBy = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
// 更新期号数据
$updateData = [
'dice1' => $dice1,
'dice2' => $dice2,
'dice3' => $dice3,
'total' => $total,
'result' => $result,
'status' => 'drawn',
'draw_time' => date('Y-m-d H:i:s'),
'approved_by' => $approvedBy,
'updated_at' => date('Y-m-d H:i:s')
];
$responseData = [
'dice1' => $dice1,
'dice2' => $dice2,
'dice3' => $dice3,
'total' => $total,
'result' => $result
];
}
try {
$db->update('periods', $updateData, ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '开奖结果已录入',
'data' => $responseData
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '录入失败:' . $e->getMessage()
]);
}
}
/**
* 启动新一期(开始下注)
*/
public function start() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$gameId = isset($data['game_id']) ? (int)$data['game_id'] : 0;
if ($gameId <= 0) {
echo json_encode([
'success' => false,
'message' => '请选择游戏'
]);
return;
}
$db = new Database();
// 检查该游戏是否有未结束的期号
$activePeriod = $db->get('periods', '*', [
'game_id' => $gameId,
'status' => ['pending', 'locked', 'drawn']
]);
if ($activePeriod) {
echo json_encode([
'success' => false,
'message' => '该游戏当前有未结束的期号,无法开始新一轮'
]);
return;
}
// 生成期号
$periodNumber = $this->generatePeriodNumber($db, $gameId);
// 从游戏配置取stream_url
$streamUrl = null;
$game = $db->get('games', ['stream_url'], ['id' => $gameId]);
if ($game && !empty($game['stream_url'])) {
$streamUrl = $game['stream_url'];
}
$periodData = [
'period_number' => $periodNumber,
'status' => 'pending',
'game_id' => $gameId,
'stream_url' => $streamUrl,
'start_time' => date('Y-m-d H:i:s'),
'auto_generated' => 0, // 手动点击开始
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
try {
$id = $db->insert('periods', $periodData);
echo json_encode([
'success' => true,
'message' => '新一期已启动,开始下注',
'data' => [
'id' => $id,
'period_number' => $periodNumber,
'start_time' => $periodData['start_time']
]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '启动失败:' . $e->getMessage()
]);
}
}
/**
* 确认开奖并结算(自动创建下一期)
*/
public function settle() {
ob_clean();
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 检查期号状态
if ($period['status'] !== 'drawn') {
echo json_encode([
'success' => false,
'message' => '该期号还未开奖,无法结算'
]);
return;
}
if ($period['status'] === 'settled') {
echo json_encode([
'success' => false,
'message' => '该期号已结算'
]);
return;
}
try {
// 开启事务
$db->medoo->pdo->beginTransaction();
// 1. 获取本期所有待结算注单
$bets = $db->select('bets', '*', [
'period_id' => $id,
'status' => 'pending'
]);
// 2. 遍历注单进行结算
if ($bets) {
foreach ($bets as $bet) {
$checkResult = $this->checkWin($bet, $period);
$isWin = $checkResult['win'];
$winAmount = $checkResult['amount']; // 纯赢金额
$now = date('Y-m-d H:i:s');
if ($isWin) {
$payout = $bet['amount'] + $winAmount; // 本金 + 盈利
// 更新注单状态
$db->update('bets', [
'status' => 'win',
'win_amount' => $winAmount,
'settled_at' => $now,
'updated_at' => $now
], ['id' => $bet['id']]);
// 更新用户余额
$db->update('users', [
'balance[+]' => $payout
], ['id' => $bet['user_id']]);
// 获取更新后的余额(用于记录流水)
$user = $db->get('users', ['balance'], ['id' => $bet['user_id']]);
$balanceAfter = $user['balance'];
$balanceBefore = $balanceAfter - $payout;
// 写入资金流水
$db->insert('transactions', [
'user_id' => $bet['user_id'],
'type' => 'win',
'amount' => $payout,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'related_id' => $bet['id'],
'description' => "中奖 - 期号: " . $period['period_number'],
'created_at' => $now
]);
} else {
// 未中奖
$db->update('bets', [
'status' => 'lose',
'win_amount' => 0,
'settled_at' => $now,
'updated_at' => $now
], ['id' => $bet['id']]);
}
}
}
// 更新期号状态为已结算
$db->update('periods', [
'status' => 'settled',
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
// 移除自动创建下一期的逻辑,改为由管理员手动点击"开始下注"
/*
// 自动创建下一期
$nextPeriodNumber = $this->generatePeriodNumber($db);
// 如果当前期号没有 stream_url,尝试从关联游戏中获取
$nextStreamUrl = $period['stream_url'];
if (empty($nextStreamUrl) && !empty($period['game_id'])) {
try {
$game = $db->get('games', ['stream_url'], ['id' => $period['game_id']]);
if ($game && !empty($game['stream_url'])) {
$nextStreamUrl = $game['stream_url'];
}
} catch (\Throwable $e) {
// 忽略错误,继续使用原值
}
}
$nextPeriodData = [
'period_number' => $nextPeriodNumber,
'status' => 'pending',
'game_id' => $period['game_id'],
'stream_url' => $nextStreamUrl,
'start_time' => date('Y-m-d H:i:s'),
'auto_generated' => 1, // 自动生成
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
$nextPeriodId = $db->insert('periods', $nextPeriodData);
*/
$nextPeriodId = 0;
$nextPeriodNumber = '';
// 提交事务
$db->medoo->pdo->commit();
echo json_encode([
'success' => true,
'message' => '结算成功,请点击"开始下注"启动下一期',
'data' => [
'current_period_id' => $id,
// 'next_period_id' => $nextPeriodId,
// 'next_period_number' => $nextPeriodNumber
]
]);
} catch (\Exception $e) {
// 回滚事务
if (isset($db->medoo->pdo)) {
$db->medoo->pdo->rollBack();
}
echo json_encode([
'success' => false,
'message' => '结算失败:' . $e->getMessage()
]);
}
}
/**
* 封盘
*/
public function lock() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 检查期号状态
if ($period['status'] !== 'pending') {
echo json_encode([
'success' => false,
'message' => '该期号状态不允许封盘'
]);
return;
}
try {
$db->update('periods', [
'status' => 'locked',
'end_time' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '封盘成功'
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '封盘失败:' . $e->getMessage()
]);
}
}
/**
* 生成期号
* 格式:G{GameID}{YYYYMMDD}{NNNN}
* 例如:G1202512240001
*/
private function generatePeriodNumber($db, $gameId = null) {
// 如果没有指定游戏ID,默认使用1
$gameId = $gameId ?: 1;
$now = new \DateTime();
$dateStr = $now->format('Ymd');
$prefix = "G{$gameId}{$dateStr}";
// 查询当天该游戏最大的期号
try {
$lastPeriod = $db->get('periods', 'period_number', [
'period_number[~]' => $prefix . '%',
'ORDER' => ['period_number' => 'DESC']
]);
if ($lastPeriod) {
// 提取序号并+1
$lastSeq = substr($lastPeriod, strlen($prefix));
$newSeq = (int)$lastSeq + 1;
$seqStr = str_pad((string)$newSeq, 4, '0', STR_PAD_LEFT);
} else {
// 当天第一期
$seqStr = '0001';
}
return $prefix . $seqStr;
} catch (\Throwable $e) {
// 发生错误时回退到时间戳随机数,防止阻塞
return $prefix . time();
}
}
/**
* 检查注单是否中奖
*/
private function checkWin($bet, $period) {
$betType = $bet['bet_type'];
$betValue = $bet['bet_value'];
$betAmount = (float)$bet['amount'];
$gameId = $period['game_id'] ?? 1;
$db = new Database();
// 获取游戏类型
$game = $db->get('games', ['type'], ['id' => $gameId]);
$gameType = $game['type'] ?? 'dice';
// 获取该游戏的所有赔率配置
static $oddsCache = [];
if (!isset($oddsCache[$gameId])) {
$oddsData = $db->select('game_odds', '*', ['game_id' => $gameId]);
$oddsCache[$gameId] = [];
foreach ($oddsData as $odd) {
$key = $odd['type'] . '_' . $odd['target'];
$oddsCache[$gameId][$key] = (float)$odd['odds'];
}
}
$isWin = false;
$payoutMultiplier = 0;
// 辅助函数:获取赔率
$getOdds = function($type, $target = 'all') use ($oddsCache, $gameId) {
$key = $type . '_' . $target;
// 如果找不到特定target的赔率,尝试查找 'all'
if (isset($oddsCache[$gameId][$key])) {
return $oddsCache[$gameId][$key];
}
$allKey = $type . '_all';
return $oddsCache[$gameId][$allKey] ?? 0;
};
// Xóc Đĩa 游戏结算逻辑
if ($gameType === 'xocdia') {
// 从 result 字段解析硬币颜色数组
$coins = json_decode($period['result'], true);
if (!is_array($coins) || count($coins) !== 4) {
return ['win' => false, 'amount' => 0];
}
$redCount = count(array_filter($coins, fn($c) => $c === 'red'));
$whiteCount = 4 - $redCount;
switch ($betType) {
case 'chan': // 双(偶): 4红 或 4白 或 2红2白
if (in_array($redCount, [0, 2, 4])) {
$isWin = true;
$payoutMultiplier = $getOdds('chan', $betValue);
}
break;
case 'le': // 单(奇): 3红1白 或 3白1红
if (in_array($redCount, [1, 3])) {
$isWin = true;
$payoutMultiplier = $getOdds('le', $betValue);
}
break;
case 'exact': // 精确颜色组合
switch ($betValue) {
case '4red':
if ($redCount === 4) $isWin = true;
break;
case '4white':
if ($redCount === 0) $isWin = true;
break;
case '3red1white':
if ($redCount === 3) $isWin = true;
break;
case '3white1red':
if ($redCount === 1) $isWin = true;
break;
}
if ($isWin) {
$payoutMultiplier = $getOdds('exact', $betValue);
}
break;
}
if ($isWin && $payoutMultiplier > 0) {
return [
'win' => true,
'amount' => $betAmount * $payoutMultiplier
];
}
return ['win' => false, 'amount' => 0];
}
// 骰子游戏结算逻辑(原逻辑)
$dice1 = (int)$period['dice1'];
$dice2 = (int)$period['dice2'];
$dice3 = (int)$period['dice3'];
$total = (int)$period['total'];
switch ($betType) {
case 'xiu': // 小 (4-10)
// 爆子处理:1-3为小,4-6为大
if ($dice1 == $dice2 && $dice2 == $dice3) {
if ($dice1 <= 3) $isWin = true;
} else {
if ($total >= 4 && $total <= 10) $isWin = true;
}
$payoutMultiplier = $getOdds('xiu');
break;
case 'tai': // 大 (11-17)
// 爆子处理:1-3为小,4-6为大
if ($dice1 == $dice2 && $dice2 == $dice3) {
if ($dice1 >= 4) $isWin = true;
} else {
if ($total >= 11 && $total <= 17) $isWin = true;
}
$payoutMultiplier = $getOdds('tai');
break;
case 'chan': // 偶数
if ($total % 2 == 0) $isWin = true;
$payoutMultiplier = $getOdds('chan');
break;
case 'le': // 奇数
if ($total % 2 != 0) $isWin = true;
$payoutMultiplier = $getOdds('le');
break;
case 'number': // 单数字 (Sum)
if ($total == (int)$betValue) {
$isWin = true;
$payoutMultiplier = $getOdds('sum', $betValue);
}
break;
case 'dice': // 单个骰子
$count = 0;
if ($dice1 == (int)$betValue) $count++;
if ($dice2 == (int)$betValue) $count++;
if ($dice3 == (int)$betValue) $count++;
if ($count > 0) {
$isWin = true;
// 单个骰子赔率通常是 1:1, 1:2, 1:3
// 但数据库中只存了基础赔率 (e.g. 0.97 or 1.0)
// 这里我们假设数据库存的是 1赔X 的 X
// 如果是双骰或三骰,通常规则是:
// 1个: 1倍
// 2个: 2倍
// 3个: 3倍
// 我们读取基础赔率,然后乘以数量
$baseOdds = $getOdds('dice', $betValue);
$payoutMultiplier = $baseOdds * $count;
}
break;
case 'combo': // 豹子 (Specific Triple)
// betValue 存储的是总和 (3, 6, ..., 18) 或 'any_triple'
if ($betValue === 'any_triple') {
if ($dice1 == $dice2 && $dice2 == $dice3) {
$isWin = true;
$payoutMultiplier = $getOdds('combo', 'any_triple');
}
} else {
if ($dice1 == $dice2 && $dice2 == $dice3 && $total == (int)$betValue) {
$isWin = true;
$payoutMultiplier = $getOdds('combo', 'specific_triple');
}
}
break;
}
if ($isWin && $payoutMultiplier > 0) {
return [
'win' => true,
'amount' => $betAmount * $payoutMultiplier
];
}
return ['win' => false, 'amount' => 0];
}
/**
* 计算开奖结果(骰子游戏)
* @param int $dice1 骰子1点数
* @param int $dice2 骰子2点数
* @param int $dice3 骰子3点数
* @param int $total 总和
* @return string 结果:Xỉu/Tài/Bão
*/
private function calculateResult($dice1, $dice2, $dice3, $total) {
// 检查是否为爆子(三个骰子相同)
if ($dice1 === $dice2 && $dice2 === $dice3) {
// 爆子根据点数判断:1-3为Xỉu,4-6为Tài
if ($dice1 <= 3) {
return 'Xỉu';
} else {
return 'Tài';
}
}
// 普通大小判断:4-10点为小(Xỉu),11-17点为大(Tài)
if ($total >= 4 && $total <= 10) {
return 'Xỉu';
} else {
return 'Tài';
}
}
/**
* 计算 Xóc Đĩa 开奖结果
* @param int $redCount 红色硬币数量
* @return string 结果描述
*/
private function calculateXocdiaResult($redCount) {
switch ($redCount) {
case 0:
return '4 Trắng (Chẵn)';
case 1:
return '3 Trắng 1 Đỏ (Lẻ)';
case 2:
return '2 Trắng 2 Đỏ (Chẵn)';
case 3:
return '3 Đỏ 1 Trắng (Lẻ)';
case 4:
return '4 Đỏ (Chẵn)';
default:
return 'Không xác định';
}
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
use App\Core\PluginManager;
class PluginController extends AdminBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->db = $this->pluginManager->getDB();
$this->checkLogin();
$this->checkAdmin();
}
public function manage() {
$plugins = $this->pluginManager->getPluginStatusList();
$icons = $this->pluginManager->getAllPluginIcons();
foreach ($plugins as &$plugin) {
$pluginName = $plugin['name']; // 获取插件名
$plugin['icon'] = $icons[$pluginName] ?? 'fa fa-plug';
}
unset($plugin);
$this->render('Admin/plugins.php', [
'plugins' => $plugins,
'title' => '插件管理'
]);
}
public function upload() {
header('Content-Type: application/json');
if (!isset($_FILES['plugin_zip']) || $_FILES['plugin_zip']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => '插件上传失败']);
return;
}
$zipPath = $_FILES['plugin_zip']['tmp_name'];
$fileName = basename($_FILES['plugin_zip']['name']);
$pluginName = pathinfo($fileName, PATHINFO_FILENAME);
$pluginDir = __DIR__ . '/../../Plugins/' . $pluginName;
if (is_dir($pluginDir)) {
http_response_code(409);
echo json_encode(['success' => false, 'message' => '插件已存在,请先删除或改名']);
return;
}
if (!mkdir($pluginDir, 0777, true) && !is_dir($pluginDir)) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => '无法创建插件目录']);
return;
}
$zip = new \ZipArchive;
if ($zip->open($zipPath) === TRUE) {
$zip->extractTo($pluginDir);
$zip->close();
} else {
http_response_code(500);
echo json_encode(['success' => false, 'message' => '解压失败']);
return;
}
if (!file_exists($pluginDir . '/mian.php')) {
$this->deleteFolder($pluginDir);
http_response_code(400);
echo json_encode(['success' => false, 'message' => '无效插件包,缺少 mian.php']);
return;
}
$this->pluginManager->clearCache();
$plugins = $this->pluginManager->scanPlugins();
if (!isset($plugins[$pluginName])) {
$this->deleteFolder($pluginDir);
http_response_code(400);
echo json_encode([
'success' => false,
'message' =>$this->pluginManager->getLastError() ?: "插件缺少必要信息,请检查插件是否规范!"
]);
return;
}
echo json_encode(['success' => true, 'message' => '插件上传成功']);
}
public function toggleStatus($name) {
header('Content-Type: application/json');
$installedPlugins = $this->pluginManager->getInstalledPlugins();
if (!isset($installedPlugins[$name])) {
echo json_encode(['success' => false, 'message' => "插件 {$name} 不存在"]);
return;
}
$currentStatus = $installedPlugins[$name]['status'];
$newStatus = $currentStatus ? 0 : 1;
$this->db->update('plugins', ['status' => $newStatus], ['name' => $name]);
$msg = $newStatus ? "插件 {$name} 已启用" : "插件 {$name} 已禁用";
echo json_encode(['success' => true, 'message' => $msg]);
}
public function install($name) {
header('Content-Type: application/json');
$allPlugins = $this->pluginManager->getAllPlugins();
if (!isset($allPlugins[$name])) {
echo json_encode(['success' => false, 'message' => "插件 {$name} 不存在"]);
return;
}
$result = $this->pluginManager->installPlugin($name);
if ($result !== true) {
echo json_encode(['success' => false, 'message' => $result]);
return;
}
$pluginFile = $allPlugins[$name]['path'] . '/mian.php';
if (file_exists($pluginFile)) {
$pluginInfo = require $pluginFile;
if (isset($pluginInfo['activate']) && is_callable($pluginInfo['activate'])) {
call_user_func($pluginInfo['activate'], $this->db);
}
}
echo json_encode(['success' => true, 'message' => "插件 {$name} 安装成功"]);
}
public function uninstall($name) {
header('Content-Type: application/json');
$allPlugins = $this->pluginManager->getAllPlugins();
if (!isset($allPlugins[$name])) {
echo json_encode(['success' => false, 'message' => "插件 {$name} 不存在"]);
return;
}
$pluginFile = $allPlugins[$name]['path'] . '/mian.php';
if (file_exists($pluginFile)) {
$pluginInfo = require $pluginFile;
if (isset($pluginInfo['deactivate']) && is_callable($pluginInfo['deactivate'])) {
call_user_func($pluginInfo['deactivate'], $this->db);
}
}
$this->pluginManager->uninstallPlugin($name);
echo json_encode(['success' => true, 'message' => "插件 {$name} 已卸载。"]);
}
public function delete($name) {
if (!$name) {
return json_encode(['success' => false, 'message' => '插件名称不能为空']);
}
$pluginDir = PLUGIN_PATH . $name;
// 判断插件目录是否存在
if (!is_dir($pluginDir)) {
return json_encode(['success' => false, 'message' => '插件目录不存在']);
}
// 尝试删除目录
if ($this->deleteFolder($pluginDir)) {
echo json_encode(['success' => true, 'message' => "插件 {$name} 已卸载并删除"]);
} else {
echo json_encode(['success' => false, 'message' => "插件 {$name} 删除失败,请检查权限或是否被占用"]);
}
}
private function deleteFolder($dir) {
if (!file_exists($dir)) {
return true; // 不存在当作删除成功
}
// 如果是文件或符号链接,直接删除
if (is_file($dir) || is_link($dir)) {
return @unlink($dir);
}
// 扫描目录内容
$items = array_diff(scandir($dir), ['.', '..']);
foreach ($items as $item) {
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
if (!$this->deleteFolder($path)) {
return false; // 子目录删除失败
}
} else {
if (!@unlink($path)) {
return false; // 删除文件失败
}
}
}
// 删除目录本身
return @rmdir($dir);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class ReportController extends AdminBaseController {
private $db;
public function __construct(Database $db) { $this->db = $db; }
public function index() {
$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;
// 总派奖
$totalWin = $this->db->sum('bets', 'win_amount', [
'is_virtual' => 0, 'status' => 'win', 'created_at[<>]' => $range
]) ?: 0;
// 平台利润
$profit = $totalBet - $totalWin;
// 总充值
$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),
];
}
// 代理报表
$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,
];
}
$this->render('Admin/reports.php', compact(
'dateFrom','dateTo','totalBet','totalWin','profit',
'totalDeposit','totalWithdraw','totalCommission','totalRebate',
'totalUsers','newUsers','dailyStats','agentStats'
));
}
private function json($data) { header('Content-Type: application/json'); echo json_encode($data); }
}
+273
View File
@@ -0,0 +1,273 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
use \Exception;
class SettingsController extends AdminBaseController {
protected $db;
protected $uploadConfig;
public function __construct() {
$this->checkLogin();
$this->db = new Database();
$this->initSettingsTable();
$this->uploadConfig = [
'upload_dir' => dirname(dirname(dirname(__DIR__))) . '/Static/img/',
'max_size' => 5 * 1024 * 1024, // 5MB
'allowed_mimes' => [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/x-icon', 'image/vnd.microsoft.icon'
]
];
}
/**
* 初始化系统设置表
*/
private function initSettingsTable() {
try {
$this->db->query("
CREATE TABLE IF NOT EXISTS `system_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`setting_key` varchar(100) NOT NULL,
`setting_value` text,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_setting_key` (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
} catch (Exception $e) {
// 表已存在或其他错误,忽略
}
}
/**
* 系统设置页面
*/
public function index() {
$settings = $this->getAllSettings();
$this->render('Admin/settings.php', [
'title' => '系统设置',
'settings' => $settings
]);
}
/**
* 获取所有设置
*/
public function get() {
header('Content-Type: application/json');
try {
$settings = $this->getAllSettings();
echo json_encode([
'status' => 'success',
'data' => $settings
]);
exit;
} catch (Exception $e) {
$this->jsonError('获取设置失败: ' . $e->getMessage());
}
}
/**
* 保存系统设置
*/
public function save() {
header('Content-Type: application/json');
$this->checkAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->jsonError('仅支持POST请求');
}
try {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
$data = $_POST;
}
// 处理logo上传(只支持site_logo和site_favicon
$logoTypes = ['site_logo', 'site_favicon'];
foreach ($logoTypes as $logoType) {
$fileKey = $logoType . '_file';
if (isset($_FILES[$fileKey]) && $_FILES[$fileKey]['error'] === UPLOAD_ERR_OK) {
$_POST['logo_type'] = $logoType;
$logoUrl = $this->handleLogoUpload($_FILES[$fileKey]);
$this->saveSetting($logoType, $logoUrl);
}
}
// 保存其他设置
$allowedKeys = [
'site_title', 'site_description', 'site_keywords',
'site_logo', 'site_favicon', 'site_copyright',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass',
'smtp_from', 'smtp_from_name', 'smtp_encryption',
];
foreach ($allowedKeys as $key) {
if (isset($data[$key])) {
$this->saveSetting($key, $data[$key]);
}
}
// 清除SettingsHelper缓存
\App\Core\SettingsHelper::clearCache();
echo json_encode([
'status' => 'success',
'message' => '设置保存成功',
'data' => $this->getAllSettings()
]);
exit;
} catch (Exception $e) {
$this->jsonError('保存设置失败: ' . $e->getMessage());
}
}
/**
* 处理logo上传
*/
private function handleLogoUpload($file) {
// 验证文件大小
if ($file['size'] > $this->uploadConfig['max_size']) {
throw new Exception('文件过大,最大支持5MB');
}
// 验证文件类型
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $this->uploadConfig['allowed_mimes'])) {
throw new Exception('不支持的文件类型,仅允许: JPG, PNG, GIF, WEBP, ICO');
}
// 确保上传目录存在
$uploadDir = rtrim($this->uploadConfig['upload_dir'], '/') . '/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
// 生成文件名
$originalName = basename($file['name']);
$originalExt = pathinfo($originalName, PATHINFO_EXTENSION);
$filename = 'logo_' . date('YmdHis') . '_' . uniqid() . '.' . $originalExt;
$targetPath = $uploadDir . $filename;
// 移动文件
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
throw new Exception('文件上传失败');
}
// 返回相对路径(不含域名,兼容任何域名/端口)
return '/Static/img/' . $filename;
}
/**
* 获取单个设置值
*/
private function getSetting($key, $default = '') {
try {
$setting = $this->db->get('system_settings', 'setting_value', [
'setting_key' => $key
]);
return $setting !== false ? $setting : $default;
} catch (Exception $e) {
return $default;
}
}
/**
* 保存单个设置
*/
private function saveSetting($key, $value) {
try {
$existing = $this->db->get('system_settings', 'id', [
'setting_key' => $key
]);
if ($existing) {
// 更新
$this->db->update('system_settings', [
'setting_value' => $value
], [
'setting_key' => $key
]);
} else {
// 插入
$this->db->insert('system_settings', [
'setting_key' => $key,
'setting_value' => $value
]);
}
} catch (Exception $e) {
throw new Exception('保存设置失败: ' . $e->getMessage());
}
}
/**
* 获取所有设置(以关联数组形式返回)
*/
private function getAllSettings() {
try {
$settings = $this->db->select('system_settings', ['setting_key', 'setting_value']);
$result = [];
foreach ($settings as $setting) {
$result[$setting['setting_key']] = $setting['setting_value'];
}
// 设置默认值
$defaults = [
'site_title' => 'Tài Xỉu Online - TM68',
'site_description' => 'Trang game tài xỉu đổi thưởng, nạp rút nhanh chóng',
'site_keywords' => 'tài xỉu, game online, đổi thưởng',
'site_logo' => '/Static/tm68/logo_tm68.png.png',
'site_favicon' => '/Static/css/favicon.ico',
'site_copyright' => '© 2024 TM68. All rights reserved.'
];
foreach ($defaults as $key => $default) {
if (!isset($result[$key])) {
$result[$key] = $default;
}
}
return $result;
} catch (Exception $e) {
return [];
}
}
/**
* SMTP 测试发信
*/
public function smtpTest() {
header('Content-Type: application/json');
$this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
$email = trim($data['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->jsonError('Invalid email');
}
\App\Core\SettingsHelper::clearCache();
$result = \App\Core\Mailer::test($email);
echo json_encode($result);
exit;
}
/**
* JSON错误响应
*/
private function jsonError($message) {
echo json_encode([
'status' => 'error',
'message' => $message
]);
exit;
}
}
+359
View File
@@ -0,0 +1,359 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class UserController extends AdminBaseController {
/**
* 构造函数 - 验证管理员权限
*/
public function __construct() {
$this->checkLogin();
}
/**
* 用户列表页面
*/
public function index() {
$this->checkAdmin();
$db = new Database();
// 只查询普通用户,排除管理员
$users = $db->select('users', '*', [
'role[!]' => 'admin',
'ORDER' => ['id' => 'DESC']
]);
$this->render('Admin/user.php', [
'users' => $users,
'title' => '用户管理'
]);
}
/**
* 获取单个用户数据(用于编辑和详情)
*/
public function get($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的用户ID'
]);
return;
}
$db = new Database();
$user = $db->get('users', '*', [
'id' => $id
]);
if ($user) {
// 移除密码字段,避免泄露
unset($user['password']);
echo json_encode([
'success' => true,
'data' => $user
]);
} else {
echo json_encode([
'success' => false,
'message' => '用户不存在'
]);
}
}
public function update() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
echo json_encode([
'success' => false,
'message' => '未接收到数据'
]);
return;
}
$db = new Database();
$id = isset($data['id']) ? (int)$data['id'] : 0;
$isEditMode = $id > 0;
$username = trim($data['username'] ?? '');
$email = trim($data['email'] ?? '');
$role = $data['role'] ?? 'user';
$status = isset($data['status']) ? (int)$data['status'] : 0;
if (empty($username) || empty($email)) {
echo json_encode([
'success' => false,
'message' => '用户名和邮箱不能为空'
]);
return;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo json_encode([
'success' => false,
'message' => '邮箱格式不正确'
]);
return;
}
// 统一查重(排除当前 ID
$exists = $db->get('users', '*', [
'AND' => [
'OR' => [
'username' => $username,
'email' => $email
],
'id[!]' => $id
]
]);
if ($exists) {
if ($exists['username'] === $username) {
$msg = '用户名已存在';
} elseif ($exists['email'] === $email) {
$msg = '邮箱已被使用';
} else {
$msg = '用户名或邮箱已被占用';
}
echo json_encode([
'success' => false,
'message' => $msg
]);
return;
}
// 准备数据
$userData = [
'username' => $username,
'email' => $email,
'role' => $role,
'status' => $status,
'updated_at' => date('Y-m-d H:i:s')
];
try {
if ($isEditMode) {
// 如果密码不为空,更新密码
if (!empty($data['password'])) {
if (strlen($data['password']) < 8) {
echo json_encode([
'success' => false,
'message' => '密码长度至少8位'
]);
return;
}
$userData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
}
$db->update('users', $userData, ['id' => $id]);
$message = '用户更新成功';
} else {
// 新增时必须有密码
if (empty($data['password']) || strlen($data['password']) < 8) {
echo json_encode([
'success' => false,
'message' => '密码长度至少8位'
]);
return;
}
$userData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
$userData['created_at'] = date('Y-m-d H:i:s');
$id = $db->insert('users', $userData);
$message = '用户创建成功';
}
echo json_encode([
'success' => true,
'message' => $message,
'data' => ['id' => $id]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '操作失败:' . $e->getMessage()
]);
}
}
/**
* 调整用户余额
*/
public function adjustBalance($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的用户ID'
]);
return;
}
$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;
}
$amount = isset($data['amount']) ? floatval($data['amount']) : 0;
$action = $data['action'] ?? ''; // 'increase' or 'decrease'
if ($amount <= 0) {
echo json_encode([
'success' => false,
'message' => '金额必须大于0'
]);
return;
}
if (!in_array($action, ['increase', 'decrease'])) {
echo json_encode([
'success' => false,
'message' => '无效的操作类型'
]);
return;
}
$db = new Database();
// 获取当前用户信息
$user = $db->get('users', ['id', 'balance', 'username'], [
'id' => $id,
'role[!]' => 'admin' // 只能调整普通用户的余额
]);
if (!$user) {
echo json_encode([
'success' => false,
'message' => '用户不存在或无法操作'
]);
return;
}
$currentBalance = floatval($user['balance'] ?? 0);
$newBalance = $action === 'increase'
? $currentBalance + $amount
: $currentBalance - $amount;
// 检查减少后余额不能为负数
if ($newBalance < 0) {
echo json_encode([
'success' => false,
'message' => '余额不足,当前余额:' . number_format($currentBalance, 2)
]);
return;
}
try {
$db->medoo->pdo->beginTransaction();
// 更新用户余额
$db->update('users', [
'balance' => $newBalance,
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
// 写入资金流水
// 修改这里:使用 manual_deposit 和 manual_withdraw 区分人工操作
$transactionType = $action === 'increase' ? 'manual_deposit' : 'manual_withdraw';
$transactionAmount = $action === 'increase' ? $amount : -$amount;
$description = $action === 'increase' ? '管理员手动增加余额' : '管理员手动减少余额';
$db->insert('transactions', [
'user_id' => $id,
'type' => $transactionType,
'amount' => $transactionAmount,
'balance_before' => $currentBalance,
'balance_after' => $newBalance,
'description' => $description,
'created_at' => date('Y-m-d H:i:s')
]);
$db->medoo->pdo->commit();
$actionText = $action === 'increase' ? '增加' : '减少';
echo json_encode([
'success' => true,
'message' => "余额{$actionText}成功",
'data' => [
'old_balance' => $currentBalance,
'new_balance' => $newBalance,
'amount' => $amount
]
]);
} catch (\Exception $e) {
$db->medoo->pdo->rollBack();
echo json_encode([
'success' => false,
'message' => '操作失败:' . $e->getMessage()
]);
}
}
/**
* 删除用户
*/
public function delete($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的用户ID'
]);
return;
}
// 禁止删除自己
session_start();
if ($id == $_SESSION['user_id']) {
echo json_encode([
'success' => false,
'message' => '不能删除当前登录用户'
]);
return;
}
$db = new Database();
$userExists = $db->get('users', 'id', [
'id' => $id
]);
if (!$userExists) {
echo json_encode([
'success' => false,
'message' => '用户不存在'
]);
return;
}
try {
$db->delete('users', [
'id' => $id
]);
echo json_encode([
'success' => true,
'message' => '用户已删除'
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '删除失败:' . $e->getMessage()
]);
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class VirtualAccountController extends AdminBaseController {
private $db;
public function __construct(Database $db) { $this->db = $db; }
public function index() {
$this->checkLogin(); $this->checkAdmin();
$virtuals = $this->db->select('users', '*', ['is_virtual' => 1, 'ORDER' => ['id' => 'DESC']]);
$this->render('Admin/virtual_accounts.php', compact('virtuals'));
}
public function create() {
$this->checkLogin(); $this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
$username = trim($data['username'] ?? '');
if (empty($username)) { $this->json(['status'=>'error','message'=>'Username required']); return; }
if ($this->db->get('users', 'id', ['username' => $username])) {
$this->json(['status'=>'error','message'=>'Username exists']); return;
}
$this->db->insert('users', [
'username' => $username,
'password' => password_hash($data['password'] ?? '123456', PASSWORD_DEFAULT),
'email' => $username . '@virtual.local',
'role' => 'user', 'status' => 1, 'is_virtual' => 1,
'email_verified' => 1,
'balance' => floatval($data['balance'] ?? 100000),
'created_at' => date('Y-m-d H:i:s'),
]);
$this->json(['status' => 'success']);
}
public function adjustBalance() {
$this->checkLogin(); $this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
$id = (int)($data['id'] ?? 0);
$amount = floatval($data['amount'] ?? 0);
$user = $this->db->get('users', '*', ['id' => $id, 'is_virtual' => 1]);
if (!$user) { $this->json(['status'=>'error','message'=>'Not found']); return; }
$new = (float)$user['balance'] + $amount;
$this->db->update('users', ['balance' => $new], ['id' => $id]);
$this->json(['status' => 'success', 'new_balance' => $new]);
}
public function delete($id) {
$this->checkLogin(); $this->checkAdmin();
$this->db->delete('users', ['id' => (int)$id, 'is_virtual' => 1]);
$this->json(['status' => 'success']);
}
private function json($data) { header('Content-Type: application/json'); echo json_encode($data); }
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class WaterController extends AdminBaseController {
private $db;
public function __construct(Database $db) { $this->db = $db; }
public function index() {
$this->checkLogin(); $this->checkAdmin();
$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'));
}
public function updateWater() {
$this->checkLogin(); $this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
foreach ($data['items'] ?? [] as $item) {
$existing = $this->db->get('water_control', 'id', [
'game_id' => (int)$item['game_id'], 'bet_type' => $item['bet_type']
]);
$fields = [
'win_rate_pct' => floatval($item['win_rate_pct']),
'enabled' => (int)($item['enabled'] ?? 0),
];
if ($existing) {
$this->db->update('water_control', $fields, ['id' => $existing]);
} else {
$fields['game_id'] = (int)$item['game_id'];
$fields['bet_type'] = $item['bet_type'];
$this->db->insert('water_control', $fields);
}
}
$this->json(['status' => 'success']);
}
public function updateLimits() {
$this->checkLogin(); $this->checkAdmin();
$data = json_decode(file_get_contents('php://input'), true);
foreach ($data['items'] ?? [] as $item) {
$existing = $this->db->get('bet_limits', 'id', [
'game_id' => (int)$item['game_id'], 'bet_type' => $item['bet_type']
]);
$fields = [
'min_amount' => floatval($item['min_amount']),
'max_amount' => floatval($item['max_amount']),
'max_per_period' => floatval($item['max_per_period'] ?? 500000),
];
if ($existing) {
$this->db->update('bet_limits', $fields, ['id' => $existing]);
} else {
$fields['game_id'] = (int)$item['game_id'];
$fields['bet_type'] = $item['bet_type'];
$this->db->insert('bet_limits', $fields);
}
}
$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);
}
private function json($d) { header('Content-Type: application/json'); echo json_encode($d); }
}
+714
View File
@@ -0,0 +1,714 @@
<?php
namespace App\Controllers\Admin;
use App\Core\AdminBaseController;
use Db\Database;
class XocdiaPeriodController extends AdminBaseController {
private $gameType = 'xocdia';
public function __construct() {
$this->checkLogin();
$this->checkAdmin();
}
/**
* Xóc Đĩa 游戏期号列表页面
*/
public function index() {
$db = new Database();
try {
// 获取 Xóc Đĩa 游戏列表
$gamesList = $db->select('games', ['id', 'name'], [
'type' => $this->gameType,
'status' => 1,
'ORDER' => ['id' => 'ASC']
]);
if (!is_array($gamesList)) {
$gamesList = [];
}
// 获取所有 Xóc Đĩa 游戏的game_id
$gameIds = array_column($gamesList, 'id');
// 获取 Xóc Đĩa 游戏的期号列表
$periods = [];
if (!empty($gameIds)) {
$periods = $db->select('periods', '*', [
'game_id' => $gameIds,
'ORDER' => ['id' => 'DESC'],
'LIMIT' => 100
]);
}
if (!is_array($periods)) {
$periods = [];
}
// 构建游戏名称映射
$games = [];
foreach ($gamesList as $game) {
$games[$game['id']] = $game['name'];
}
// 为每个 Xóc Đĩa 游戏获取当前期号
$currentPeriods = [];
foreach ($gamesList as $game) {
$currentPeriod = $db->get('periods', '*', [
'game_id' => $game['id'],
'ORDER' => ['id' => 'DESC']
]);
if ($currentPeriod) {
$currentPeriods[$game['id']] = $currentPeriod;
}
}
} catch (\Throwable $e) {
$periods = [];
$games = [];
$gamesList = [];
$currentPeriods = [];
}
$this->render('Admin/xocdia_periods.php', [
'currentPeriods' => $currentPeriods,
'periods' => $periods,
'games' => $games,
'gamesList' => $gamesList,
'title' => 'Xóc Đĩa 游戏期号管理'
]);
}
/**
* 获取单个期号数据
*/
public function get($id) {
header('Content-Type: application/json');
if (empty($id) || !is_numeric($id)) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
try {
$period = $db->get('periods', '*', [
'id' => $id
]);
if ($period) {
// 验证是否为 Xóc Đĩa 游戏期号
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if ($game && $game['type'] === $this->gameType) {
echo json_encode([
'success' => true,
'data' => $period
]);
} else {
echo json_encode([
'success' => false,
'message' => '期号不属于 Xóc Đĩa 游戏'
]);
}
} else {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
}
} catch (\Throwable $e) {
echo json_encode([
'success' => false,
'message' => '获取数据失败:' . $e->getMessage()
]);
}
}
/**
* 录入开奖结果
*/
public function draw() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
if (empty($data)) {
echo json_encode([
'success' => false,
'message' => '未接收到数据'
]);
return;
}
$db = new Database();
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 验证是否为 Xóc Đĩa 游戏
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '期号不属于 Xóc Đĩa 游戏'
]);
return;
}
// 检查期号状态
if ($period['status'] === 'settled') {
echo json_encode([
'success' => false,
'message' => '该期号已结算,无法修改'
]);
return;
}
$auto = isset($data['auto']) ? (bool)$data['auto'] : false;
// Xóc Đĩa 开奖逻辑
if ($auto) {
// 自动生成:4个硬币随机红/白
$coins = [];
for ($i = 0; $i < 4; $i++) {
$coins[] = (rand(0, 1) === 0) ? 'red' : 'white';
}
} else {
// 手动输入:从前端获取
$coins = isset($data['coins']) ? $data['coins'] : [];
if (!is_array($coins) || count($coins) !== 4) {
echo json_encode([
'success' => false,
'message' => 'Xóc Đĩa 必须提供4个硬币的颜色(red/white'
]);
return;
}
// 验证颜色
foreach ($coins as $coin) {
if (!in_array($coin, ['red', 'white'])) {
echo json_encode([
'success' => false,
'message' => '硬币颜色只能是 red 或 white'
]);
return;
}
}
}
// 计算结果
$redCount = count(array_filter($coins, fn($c) => $c === 'red'));
$result = $this->calculateXocdiaResult($redCount);
// 获取当前登录的管理员ID(审核人)
$approvedBy = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
// 更新期号数据
$updateData = [
'result' => json_encode($coins),
'dice1' => $redCount,
'dice2' => 4 - $redCount,
'dice3' => null,
'total' => null,
'status' => 'drawn',
'draw_time' => date('Y-m-d H:i:s'),
'approved_by' => $approvedBy,
'updated_at' => date('Y-m-d H:i:s')
];
$responseData = [
'coins' => $coins,
'red_count' => $redCount,
'white_count' => 4 - $redCount,
'result' => $result
];
try {
$db->update('periods', $updateData, ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '开奖结果已录入',
'data' => $responseData
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '录入失败:' . $e->getMessage()
]);
}
}
/**
* 启动新一期(开始下注)
*/
public function start() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$gameId = isset($data['game_id']) ? (int)$data['game_id'] : 0;
if ($gameId <= 0) {
echo json_encode([
'success' => false,
'message' => '请选择游戏'
]);
return;
}
$db = new Database();
// 验证游戏类型
$game = $db->get('games', ['type', 'stream_url'], ['id' => $gameId]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '游戏不属于 Xóc Đĩa 游戏'
]);
return;
}
// 检查该游戏是否有未结束的期号
$activePeriod = $db->get('periods', '*', [
'game_id' => $gameId,
'status' => ['pending', 'locked', 'drawn']
]);
if ($activePeriod) {
echo json_encode([
'success' => false,
'message' => '该游戏当前有未结束的期号,无法开始新一轮'
]);
return;
}
// 生成期号
$periodNumber = $this->generatePeriodNumber($db, $gameId);
// 获取stream_url
$streamUrl = !empty($game['stream_url']) ? $game['stream_url'] : null;
$periodData = [
'period_number' => $periodNumber,
'status' => 'pending',
'game_id' => $gameId,
'stream_url' => $streamUrl,
'start_time' => date('Y-m-d H:i:s'),
'auto_generated' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
try {
$id = $db->insert('periods', $periodData);
echo json_encode([
'success' => true,
'message' => '新一期已启动,开始下注',
'data' => [
'id' => $id,
'period_number' => $periodNumber,
'start_time' => $periodData['start_time']
]
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '启动失败:' . $e->getMessage()
]);
}
}
/**
* 确认开奖并结算
*/
public function settle() {
ob_clean();
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 验证是否为 Xóc Đĩa 游戏
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '期号不属于 Xóc Đĩa 游戏'
]);
return;
}
// 检查期号状态
if ($period['status'] !== 'drawn') {
echo json_encode([
'success' => false,
'message' => '该期号还未开奖,无法结算'
]);
return;
}
if ($period['status'] === 'settled') {
echo json_encode([
'success' => false,
'message' => '该期号已结算'
]);
return;
}
try {
// 开启事务
$db->medoo->pdo->beginTransaction();
// 获取本期所有待结算注单
$bets = $db->select('bets', '*', [
'period_id' => $id,
'status' => 'pending'
]);
// 遍历注单进行结算
if ($bets) {
foreach ($bets as $bet) {
$checkResult = $this->checkWin($bet, $period);
$isWin = $checkResult['win'];
$winAmount = $checkResult['amount'];
$now = date('Y-m-d H:i:s');
if ($isWin) {
$payout = $bet['amount'] + $winAmount;
// 更新注单状态
$db->update('bets', [
'status' => 'win',
'win_amount' => $winAmount,
'settled_at' => $now,
'updated_at' => $now
], ['id' => $bet['id']]);
// 更新用户余额
$db->update('users', [
'balance[+]' => $payout
], ['id' => $bet['user_id']]);
// 获取更新后的余额
$user = $db->get('users', ['balance'], ['id' => $bet['user_id']]);
$balanceAfter = $user['balance'];
$balanceBefore = $balanceAfter - $payout;
// 写入资金流水
$db->insert('transactions', [
'user_id' => $bet['user_id'],
'type' => 'win',
'amount' => $payout,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'related_id' => $bet['id'],
'description' => "中奖 - 期号: " . $period['period_number'],
'created_at' => $now
]);
} else {
// 未中奖
$db->update('bets', [
'status' => 'lose',
'win_amount' => 0,
'settled_at' => $now,
'updated_at' => $now
], ['id' => $bet['id']]);
}
}
}
// 更新期号状态为已结算
$db->update('periods', [
'status' => 'settled',
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
// 提交事务
$db->medoo->pdo->commit();
echo json_encode([
'success' => true,
'message' => '结算成功,请点击"开始下注"启动下一期',
'data' => [
'current_period_id' => $id
]
]);
} catch (\Exception $e) {
// 回滚事务
if (isset($db->medoo->pdo)) {
$db->medoo->pdo->rollBack();
}
echo json_encode([
'success' => false,
'message' => '结算失败:' . $e->getMessage()
]);
}
}
/**
* 封盘
*/
public function lock() {
header('Content-Type: application/json');
$data = $_POST;
if (empty($data)) {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
}
$id = isset($data['id']) ? (int)$data['id'] : 0;
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => '无效的期号ID'
]);
return;
}
$db = new Database();
// 获取期号信息
$period = $db->get('periods', '*', ['id' => $id]);
if (!$period) {
echo json_encode([
'success' => false,
'message' => '期号不存在'
]);
return;
}
// 验证是否为 Xóc Đĩa 游戏
$game = $db->get('games', ['type'], ['id' => $period['game_id']]);
if (!$game || $game['type'] !== $this->gameType) {
echo json_encode([
'success' => false,
'message' => '期号不属于 Xóc Đĩa 游戏'
]);
return;
}
// 检查期号状态
if ($period['status'] !== 'pending') {
echo json_encode([
'success' => false,
'message' => '该期号状态不允许封盘'
]);
return;
}
try {
$db->update('periods', [
'status' => 'locked',
'end_time' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
], ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '封盘成功'
]);
} catch (\Exception $e) {
echo json_encode([
'success' => false,
'message' => '封盘失败:' . $e->getMessage()
]);
}
}
/**
* 生成期号
*/
private function generatePeriodNumber($db, $gameId) {
$now = new \DateTime();
$dateStr = $now->format('Ymd');
$prefix = "G{$gameId}{$dateStr}";
try {
$lastPeriod = $db->get('periods', 'period_number', [
'period_number[~]' => $prefix . '%',
'ORDER' => ['period_number' => 'DESC']
]);
if ($lastPeriod) {
$lastSeq = substr($lastPeriod, strlen($prefix));
$newSeq = (int)$lastSeq + 1;
$seqStr = str_pad((string)$newSeq, 4, '0', STR_PAD_LEFT);
} else {
$seqStr = '0001';
}
return $prefix . $seqStr;
} catch (\Throwable $e) {
return $prefix . time();
}
}
/**
* 检查注单是否中奖
*/
private function checkWin($bet, $period) {
$betType = $bet['bet_type'];
$betValue = $bet['bet_value'];
$betAmount = (float)$bet['amount'];
$gameId = $period['game_id'];
$db = new Database();
// 获取赔率配置
static $oddsCache = [];
if (!isset($oddsCache[$gameId])) {
$oddsData = $db->select('game_odds', '*', ['game_id' => $gameId]);
$oddsCache[$gameId] = [];
foreach ($oddsData as $odd) {
$key = $odd['type'] . '_' . $odd['target'];
$oddsCache[$gameId][$key] = (float)$odd['odds'];
}
}
$isWin = false;
$payoutMultiplier = 0;
// 获取赔率
$getOdds = function($type, $target = 'all') use ($oddsCache, $gameId) {
$key = $type . '_' . $target;
if (isset($oddsCache[$gameId][$key])) {
return $oddsCache[$gameId][$key];
}
$allKey = $type . '_all';
return $oddsCache[$gameId][$allKey] ?? 0;
};
// Xóc Đĩa 游戏结算逻辑
$coins = json_decode($period['result'], true);
if (!is_array($coins) || count($coins) !== 4) {
return ['win' => false, 'amount' => 0];
}
$redCount = count(array_filter($coins, fn($c) => $c === 'red'));
$whiteCount = 4 - $redCount;
switch ($betType) {
case 'chan': // 双(偶): 4红 或 4白 或 2红2白
if (in_array($redCount, [0, 2, 4])) {
$isWin = true;
$payoutMultiplier = $getOdds('chan', $betValue);
}
break;
case 'le': // 单(奇): 3红1白 或 3白1红
if (in_array($redCount, [1, 3])) {
$isWin = true;
$payoutMultiplier = $getOdds('le', $betValue);
}
break;
case 'exact': // 精确颜色组合
switch ($betValue) {
case '4red':
if ($redCount === 4) $isWin = true;
break;
case '4white':
if ($redCount === 0) $isWin = true;
break;
case '3red1white':
if ($redCount === 3) $isWin = true;
break;
case '3white1red':
if ($redCount === 1) $isWin = true;
break;
}
if ($isWin) {
$payoutMultiplier = $getOdds('exact', $betValue);
}
break;
}
if ($isWin && $payoutMultiplier > 0) {
return [
'win' => true,
'amount' => $betAmount * $payoutMultiplier
];
}
return ['win' => false, 'amount' => 0];
}
/**
* 计算 Xóc Đĩa 开奖结果
*/
private function calculateXocdiaResult($redCount) {
switch ($redCount) {
case 0:
return '4 Trắng (Chẵn)';
case 1:
return '3 Trắng 1 Đỏ (Lẻ)';
case 2:
return '2 Trắng 2 Đỏ (Chẵn)';
case 3:
return '3 Đỏ 1 Trắng (Lẻ)';
case 4:
return '4 Đỏ (Chẵn)';
default:
return 'Không xác định';
}
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
class ApiController {
public function data() {
$db = Database::get();
$data = $db->select('sample_table', '*');
header('Content-Type: application/json');
echo json_encode($data);
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Controllers\Web;
use App\Core\WebBaseController;
use App\Core\I18n;
use Db\Database;
class AgentController extends WebBaseController {
private function getAgent() {
$db = new Database();
$agent = $db->get('agents', '*', ['user_id' => $this->getCurrentUserId(), 'status' => 1]);
if (!$agent) { header('Location: /'); exit; }
return $agent;
}
// 代理后台主页
public function dashboard() {
$this->checkWebLogin();
$agent = $this->getAgent();
I18n::init();
$db = new Database();
$user = $db->get('users', '*', ['id' => $this->getCurrentUserId()]);
$players = $db->select('users', ['id','username','balance','created_at','status'], [
'agent_id' => $agent['id'], 'is_virtual' => 0, 'ORDER' => ['id' => 'DESC']
]);
$today = date('Y-m-d');
$todayRange = [$today . ' 00:00:00', $today . ' 23:59:59'];
$todayComm = $db->sum('agent_commissions', 'commission', [
'agent_id' => $agent['id'], 'created_at[<>]' => $todayRange
]) ?: 0;
$totalComm = $db->sum('agent_commissions', 'commission', ['agent_id' => $agent['id']]) ?: 0;
// 玩家总投注额
$totalBets = $db->sum('bets', 'amount', ['agent_id' => $agent['id'], 'is_virtual' => 0]) ?: 0;
// 下级代理
$subAgents = [];
if ($agent['level'] == 1) {
$subAgents = $db->select('agents', '*', ['parent_id' => $agent['id']]);
foreach ($subAgents as &$sa) {
$sa['user'] = $db->get('users', ['username','balance'], ['id' => $sa['user_id']]);
$sa['player_count'] = $db->count('users', ['agent_id' => $sa['id']]);
}
}
$page = 'home';
extract(compact('agent','user','players','todayComm','totalComm','totalBets','subAgents','page'));
include __DIR__ . '/../../Views/Web/agent_dashboard.php';
}
// 赔率管理页
public function odds() {
$this->checkWebLogin();
$agent = $this->getAgent();
I18n::init();
$db = new Database();
$user = $db->get('users', '*', ['id' => $this->getCurrentUserId()]);
$game = $db->get('games', '*', ['code' => 'pk10']);
// 系统赔率
$sysOdds = $db->select('game_odds', '*', ['game_id' => $game['id']]);
$sysMap = [];
foreach ($sysOdds as $s) $sysMap[$s['type'].'_'.$s['target']] = (float)$s['odds'];
// 上级赔率
$parentMap = $sysMap;
if ($agent['parent_id']) {
$po = $db->select('agent_odds', '*', ['agent_id' => $agent['parent_id'], 'game_id' => $game['id']]);
foreach ($po as $p) $parentMap[$p['bet_type'].'_'.$p['bet_target']] = (float)$p['odds'];
}
// 当前代理赔率
$myOdds = $db->select('agent_odds', '*', ['agent_id' => $agent['id'], 'game_id' => $game['id']]);
$myMap = [];
foreach ($myOdds as $m) $myMap[$m['bet_type'].'_'.$m['bet_target']] = (float)$m['odds'];
$page = 'odds';
extract(compact('agent','user','game','sysMap','parentMap','myMap','page'));
include __DIR__ . '/../../Views/Web/agent_dashboard.php';
}
// 保存赔率
public function setOdds() {
$this->checkWebLogin();
$agent = $this->getAgent();
header('Content-Type: application/json');
$db = new Database();
$data = json_decode(file_get_contents('php://input'), true);
$gameId = (int)($data['game_id'] ?? 0);
// 上级赔率限制
$parentOdds = [];
if ($agent['parent_id']) {
$po = $db->select('agent_odds', '*', ['agent_id' => $agent['parent_id'], 'game_id' => $gameId]);
foreach ($po as $p) $parentOdds[$p['bet_type'].'_'.$p['bet_target']] = (float)$p['odds'];
}
if (empty($parentOdds)) {
$so = $db->select('game_odds', '*', ['game_id' => $gameId]);
foreach ($so as $s) $parentOdds[$s['type'].'_'.$s['target']] = (float)$s['odds'];
}
foreach ($data['odds'] ?? [] as $o) {
$key = $o['bet_type'].'_'.$o['bet_target'];
$newOdds = floatval($o['odds']);
if (isset($parentOdds[$key]) && $newOdds > $parentOdds[$key]) {
echo json_encode(['success'=>false,'message'=>"赔率不能超过上级: $key 上限 ".$parentOdds[$key]]);
return;
}
$existing = $db->get('agent_odds', 'id', [
'agent_id'=>$agent['id'],'game_id'=>$gameId,'bet_type'=>$o['bet_type'],'bet_target'=>$o['bet_target']
]);
if ($existing) {
$db->update('agent_odds', ['odds'=>$newOdds], ['id'=>$existing]);
} else {
$db->insert('agent_odds', [
'agent_id'=>$agent['id'],'game_id'=>$gameId,
'bet_type'=>$o['bet_type'],'bet_target'=>$o['bet_target'],'odds'=>$newOdds
]);
}
}
echo json_encode(['success' => true, 'message' => '赔率已更新']);
}
// 玩家投注记录
public function bets() {
$this->checkWebLogin();
$agent = $this->getAgent();
I18n::init();
$db = new Database();
$user = $db->get('users', '*', ['id' => $this->getCurrentUserId()]);
$betRecords = $db->select('bets', '*', [
'agent_id' => $agent['id'], 'is_virtual' => 0,
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 200
]);
foreach ($betRecords as &$b) {
$b['username'] = $db->get('users', 'username', ['id' => $b['user_id']]) ?: '?';
}
$page = 'bets';
extract(compact('agent','user','betRecords','page'));
include __DIR__ . '/../../Views/Web/agent_dashboard.php';
}
// 佣金明细
public function commissions() {
$this->checkWebLogin();
$agent = $this->getAgent();
I18n::init();
$db = new Database();
$user = $db->get('users', '*', ['id' => $this->getCurrentUserId()]);
$records = $db->select('agent_commissions', '*', [
'agent_id' => $agent['id'], 'ORDER' => ['id' => 'DESC'], 'LIMIT' => 200
]);
foreach ($records as &$r) {
$r['username'] = $db->get('users', 'username', ['id' => $r['from_user_id']]) ?: '?';
}
$page = 'commissions';
extract(compact('agent','user','records','page'));
include __DIR__ . '/../../Views/Web/agent_dashboard.php';
}
// 佣金APIJSON
public function commissionsApi() {
$this->checkWebLogin();
$agent = $this->getAgent();
header('Content-Type: application/json');
$db = new Database();
$records = $db->select('agent_commissions', '*', [
'agent_id' => $agent['id'], 'ORDER' => ['id' => 'DESC'], 'LIMIT' => 100
]);
echo json_encode(['success' => true, 'data' => $records]);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Controllers\Web;
use Db\Database;
use App\Core\WebBaseController;
use App\Core\I18n;
class AuthController extends WebBaseController {
public function loginPage() {
if ($this->isLoggedIn()) { header('Location: /'); exit; }
I18n::init();
include __DIR__ . '/../../Views/Web/login.php';
}
public function loginSubmit() {
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
$remember = $data['remember'] ?? false;
if (empty($username) || empty($password)) {
echo json_encode(['success' => false, 'message' => 'Please fill in all fields']);
return;
}
$db = new Database();
$user = $db->get('users', '*', ['username' => $username]);
if (!$user || !password_verify($password, $user['password'])) {
echo json_encode(['success' => false, 'message' => 'Invalid username or password']);
return;
}
if (isset($user['status']) && $user['status'] == 0) {
echo json_encode(['success' => false, 'message' => 'Account disabled']);
return;
}
// 邮箱验证检查(虚拟账户跳过)
if (empty($user['is_virtual']) && isset($user['email_verified']) && !$user['email_verified']) {
echo json_encode(['success' => false, 'message' => 'Please verify your email first', 'need_verify' => true]);
return;
}
if (session_status() === PHP_SESSION_NONE) session_start();
$_SESSION['web_user_id'] = $user['id'];
$_SESSION['web_username'] = $user['username'];
$_SESSION['web_role'] = $user['role'] ?? 'user';
$_SESSION['web_last_activity'] = time();
$_SESSION['web_ip'] = $_SERVER['REMOTE_ADDR'];
$_SESSION['web_ua'] = $_SERVER['HTTP_USER_AGENT'];
if ($remember) $_SESSION['web_remember'] = true;
if (!empty($user['lang'])) $_SESSION['lang'] = $user['lang'];
echo json_encode(['success' => true, 'message' => 'Login successful', 'redirect' => '/']);
}
public function registerSubmit() {
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
$confirmPassword = $data['confirm_password'] ?? '';
$email = trim($data['email'] ?? '');
$inviteCode = trim($data['invite_code'] ?? '');
$verifyCode = trim($data['verify_code'] ?? '');
if (empty($username) || empty($password) || empty($email)) {
echo json_encode(['success' => false, 'message' => 'Please fill in all required fields']);
return;
}
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
echo json_encode(['success' => false, 'message' => 'Username: 3-20 chars, letters/numbers/underscore only']);
return;
}
if (strlen($password) < 6) {
echo json_encode(['success' => false, 'message' => 'Password must be at least 6 characters']);
return;
}
if ($password !== $confirmPassword) {
echo json_encode(['success' => false, 'message' => 'Passwords do not match']);
return;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo json_encode(['success' => false, 'message' => 'Invalid email address']);
return;
}
$db = new Database();
// 唯一性检查
if ($db->get('users', 'id', ['username' => $username])) {
echo json_encode(['success' => false, 'message' => 'Username already exists']);
return;
}
if ($db->get('users', 'id', ['email' => $email])) {
echo json_encode(['success' => false, 'message' => 'Email already registered']);
return;
}
// 验证邮箱验证码
$ev = $db->get('email_verifications', '*', [
'email' => $email, 'code' => $verifyCode, 'used' => 0,
'expires_at[>]' => date('Y-m-d H:i:s'),
'ORDER' => ['id' => 'DESC']
]);
if (!$ev) {
echo json_encode(['success' => false, 'message' => 'Invalid or expired verification code']);
return;
}
// 处理邀请码 → 代理关联
$agentId = null;
if (!empty($inviteCode)) {
$agent = $db->get('agents', '*', ['agent_code' => $inviteCode, 'status' => 1]);
if ($agent) $agentId = $agent['id'];
}
$db->insert('users', [
'username' => $username,
'password' => password_hash($password, PASSWORD_DEFAULT),
'email' => $email,
'role' => 'user',
'status' => 1,
'email_verified' => 1,
'agent_id' => $agentId,
'lang' => 'en',
'created_at' => date('Y-m-d H:i:s'),
]);
// 标记验证码已用
$db->update('email_verifications', ['used' => 1], ['id' => $ev['id']]);
echo json_encode(['success' => true, 'message' => 'Registration successful', 'redirect' => '/login']);
}
public function sendVerifyCode() {
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$email = trim($data['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo json_encode(['success' => false, 'message' => 'Invalid email']);
return;
}
// 频率限制: 60秒内只能发一次
$db = new Database();
$recent = $db->get('email_verifications', '*', [
'email' => $email,
'created_at[>]' => date('Y-m-d H:i:s', time() - 60),
]);
if ($recent) {
echo json_encode(['success' => false, 'message' => 'Please wait 60 seconds']);
return;
}
$code = str_pad(mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);
$db->insert('email_verifications', [
'email' => $email,
'code' => $code,
'expires_at' => date('Y-m-d H:i:s', time() + 600), // 10分钟
'created_at' => date('Y-m-d H:i:s'),
]);
// 发送邮件
$subject = 'Email Verification Code';
$body = "Your verification code is: <b>$code</b><br>Valid for 10 minutes.";
$sent = \App\Core\Mailer::send($email, $subject, $body);
echo json_encode(['success' => true, 'message' => 'Verification code sent']);
}
public function logout() {
if (session_status() === PHP_SESSION_NONE) session_start();
$keys = ['web_user_id','web_username','web_role','web_last_activity','web_ip','web_ua','web_remember','lang'];
foreach ($keys as $k) unset($_SESSION[$k]);
header('Location: /login');
exit;
}
private function isLoggedIn(): bool {
if (session_status() === PHP_SESSION_NONE) session_start();
return isset($_SESSION['web_user_id']);
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Controllers\Web;
use App\Core\WebBaseController;
use Db\Database;
class BetController extends WebBaseController {
public function placeBet() {
$this->checkWebLogin();
header('Content-Type: application/json');
$userId = $this->getCurrentUserId();
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['bets']) || empty($data['period_number']) || empty($data['game_id'])) {
echo json_encode(['success' => false, 'message' => 'Missing required fields']);
return;
}
$gameId = (int)$data['game_id'];
$periodNumber = $data['period_number'];
$bets = $data['bets'];
$db = new Database();
// 验证期号
$period = $db->get('periods', '*', ['period_number' => $periodNumber, 'game_id' => $gameId]);
if (!$period || $period['status'] !== 'pending') {
echo json_encode(['success' => false, 'message' => 'Period is not open for betting']);
return;
}
// 赔率映射
$oddsList = $db->select('game_odds', '*', ['game_id' => $gameId]);
$oddsMap = [];
foreach ($oddsList as $o) {
$oddsMap[$o['type'] . '_' . $o['target']] = (float)$o['odds'];
}
// 用户信息
$user = $db->get('users', '*', ['id' => $userId]);
$isVirtual = (int)($user['is_virtual'] ?? 0);
// 代理专属赔率覆盖
if (!empty($user['agent_id'])) {
$agentOdds = $db->select('agent_odds', '*', ['agent_id' => $user['agent_id'], 'game_id' => $gameId]);
foreach ($agentOdds as $ao) {
$oddsMap[$ao['bet_type'] . '_' . $ao['bet_target']] = (float)$ao['odds'];
}
}
// 限额
$limitsRaw = $db->select('bet_limits', '*', ['game_id' => $gameId]);
$limits = [];
foreach ($limitsRaw as $l) { $limits[$l['bet_type']] = $l; }
// 验证投注
$totalAmount = 0;
$validBets = [];
foreach ($bets as $bet) {
$type = $bet['type'] ?? '';
$target = $bet['value'] ?? $bet['target'] ?? '';
$amount = floatval($bet['amount'] ?? 0);
if ($amount <= 0) continue;
$key = $type . '_' . $target;
$odds = $oddsMap[$key] ?? null;
if ($odds === null) {
echo json_encode(['success' => false, 'message' => "Invalid bet: $key"]);
return;
}
// 限额检查
if (isset($limits[$type])) {
if ($amount < (float)$limits[$type]['min_amount']) {
echo json_encode(['success' => false, 'message' => "Min bet for $type: " . $limits[$type]['min_amount']]);
return;
}
if ($amount > (float)$limits[$type]['max_amount']) {
echo json_encode(['success' => false, 'message' => "Max bet for $type: " . $limits[$type]['max_amount']]);
return;
}
}
$totalAmount += $amount;
$validBets[] = ['type' => $type, 'target' => $target, 'amount' => $amount, 'odds' => $odds];
}
if (empty($validBets)) {
echo json_encode(['success' => false, 'message' => 'No valid bets']);
return;
}
// 事务
try {
$db->medoo->pdo->beginTransaction();
$stmt = $db->medoo->pdo->prepare("SELECT balance FROM users WHERE id = :id FOR UPDATE");
$stmt->execute([':id' => $userId]);
$userRow = $stmt->fetch(\PDO::FETCH_ASSOC);
$currentBalance = (float)$userRow['balance'];
if ($currentBalance < $totalAmount) {
$db->medoo->pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'Insufficient balance']);
return;
}
$newBalance = $currentBalance - $totalAmount;
$db->update('users', ['balance' => $newBalance], ['id' => $userId]);
$db->insert('transactions', [
'user_id' => $userId, 'type' => 'bet', 'amount' => -$totalAmount,
'balance_before' => $currentBalance, 'balance_after' => $newBalance,
'related_id' => $period['id'], 'description' => ($db->get('games', 'name', ['id' => $gameId]) ?: 'Game') . ' Bet - ' . $periodNumber,
'is_virtual' => $isVirtual, 'created_at' => date('Y-m-d H:i:s'),
]);
foreach ($validBets as $vb) {
$db->insert('bets', [
'user_id' => $userId, 'game_id' => $gameId,
'period_number' => $periodNumber, 'period_id' => $period['id'],
'bet_type' => $vb['type'], 'bet_value' => $vb['target'],
'amount' => $vb['amount'], 'odds' => $vb['odds'],
'status' => 'pending', 'is_virtual' => $isVirtual,
'agent_id' => $user['agent_id'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
$db->medoo->pdo->commit();
echo json_encode(['success' => true, 'message' => 'Bet placed successfully', 'new_balance' => $newBalance]);
} catch (\Throwable $e) {
if ($db->medoo->pdo->inTransaction()) $db->medoo->pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'System error']);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Controllers\Web;
use App\Core\WebBaseController;
use App\Core\GameFactory;
use App\Core\I18n;
use Db\Database;
class GameController extends WebBaseController {
public function play($code) {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$userId = $this->getCurrentUserId();
$user = $db->get('users', ['id','username','balance','lang'], ['id' => $userId]);
$game = $db->get('games', '*', ['code' => $code, 'status' => 1]);
if (!$game) { header('Location: /'); return; }
$isAgent = !!$db->get('agents', 'id', ['user_id' => $userId, 'status' => 1]);
extract([
'user' => $user ?: ['id' => $userId, 'username' => $this->getCurrentUsername(), 'balance' => 0],
'game' => $game, 'isAgent' => $isAgent,
]);
$viewFile = __DIR__ . "/../../Views/Web/{$code}.php";
if (!file_exists($viewFile)) $viewFile = __DIR__ . "/../../Views/Web/game_generic.php";
include $viewFile;
}
public function stats($code) {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$game = $db->get('games', '*', ['code' => $code, 'status' => 1]);
if (!$game) { header('Location: /'); return; }
$gameType = $game['type'] ?? $code;
$algoClass = GameFactory::getAlgorithm($gameType);
$resultTable = $algoClass::getResultTable();
$gameId = $game['id'];
$periods = $db->select('periods', ['id','period_number'], [
'game_id' => $gameId, 'status' => 'settled',
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 100
]);
$results = [];
foreach ($periods as $p) {
if ($resultTable) {
$row = $db->get($resultTable, '*', ['period_id' => $p['id']]);
} else {
$row = $db->get('periods', '*', ['id' => $p['id']]);
}
if ($row) { $row['period_number'] = $p['period_number']; $results[] = $row; }
}
extract(compact('game', 'results'));
$viewFile = __DIR__ . "/../../Views/Web/{$code}_stats.php";
if (!file_exists($viewFile)) $viewFile = __DIR__ . "/../../Views/Web/game_stats_generic.php";
include $viewFile;
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Controllers\Web;
use App\Core\WebBaseController;
use App\Core\I18n;
use Db\Database;
class HomeController extends WebBaseController {
public function index() {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$userId = $this->getCurrentUserId();
$user = $db->get('users', ['id','username','balance','lang'], ['id' => $userId]);
$game = $db->get('games', '*', ['code' => 'pk10', 'status' => 1]);
extract([
'user' => $user ?: ['id' => $userId, 'username' => $this->getCurrentUsername(), 'balance' => 0],
'game' => $game,
]);
include __DIR__ . '/../../Views/Web/home.php';
}
public function pk10Game() {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$userId = $this->getCurrentUserId();
$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]);
extract([
'user' => $user ?: ['id' => $userId, 'username' => $this->getCurrentUsername(), 'balance' => 0],
'game' => $game,
'isAgent' => $isAgent,
]);
include __DIR__ . '/../../Views/Web/pk10.php';
}
public function profile() {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$userId = $this->getCurrentUserId();
$user = $db->get('users', '*', ['id' => $userId]);
// 最近交易
$transactions = $db->select('transactions', '*', [
'user_id' => $userId, 'is_virtual' => 0,
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 20
]);
// 最近投注
$bets = $db->select('bets', '*', [
'user_id' => $userId,
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 20
]);
extract(compact('user', 'transactions', 'bets'));
include __DIR__ . '/../../Views/Web/profile.php';
}
public function bindUsdt() {
$this->checkWebLogin();
header('Content-Type: application/json');
$db = new Database();
$userId = $this->getCurrentUserId();
$data = json_decode(file_get_contents('php://input'), true);
$address = trim($data['usdt_address'] ?? '');
// TRC20地址验证: 以T开头, 34字符, base58
if (!preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $address)) {
echo json_encode(['success' => false, 'message' => 'Invalid TRC20 address']);
return;
}
// 唯一性
$exists = $db->get('users', 'id', ['usdt_address' => $address, 'id[!]' => $userId]);
if ($exists) {
echo json_encode(['success' => false, 'message' => 'Address already bound to another account']);
return;
}
$db->update('users', ['usdt_address' => $address, 'usdt_chain' => 'TRC20'], ['id' => $userId]);
echo json_encode(['success' => true, 'message' => 'Address bound successfully']);
}
public function setLang() {
header('Content-Type: application/json');
$data = json_decode(file_get_contents('php://input'), true);
$lang = $data['lang'] ?? 'en';
if (isset(I18n::LANGUAGES[$lang])) {
$_SESSION['lang'] = $lang;
setcookie('lang', $lang, time() + 86400 * 365, '/');
$db = new Database();
$userId = $this->getCurrentUserId();
if ($userId) $db->update('users', ['lang' => $lang], ['id' => $userId]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'message' => 'Invalid language']);
}
}
public function lottery() {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$gameCode = $_GET['game'] ?? 'pk10';
$game = $db->get('games', '*', ['code' => $gameCode, 'status' => 1]);
if (!$game) $game = $db->get('games', '*', ['code' => 'pk10', 'status' => 1]);
$gameId = $game['id'] ?? 0;
$gameType = $game['type'] ?? 'pk10';
$algoClass = \App\Core\GameFactory::getAlgorithm($gameType);
$resultTable = $algoClass::getResultTable();
$periods = $db->select('periods', '*', [
'game_id' => $gameId, 'status' => 'settled',
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 50
]);
foreach ($periods as &$p) {
if ($resultTable) {
$pk = $db->get($resultTable, '*', ['period_id' => $p['id']]);
if ($pk) { foreach ($pk as $k => $v) $p[$k] = $v; }
}
}
unset($p);
extract(compact('game', 'periods'));
include __DIR__ . '/../../Views/Web/lottery.php';
}
public function pk10Stats() {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$game = $db->get('games', '*', ['code' => 'pk10', 'status' => 1]);
$gameId = $game['id'] ?? 0;
// 最近100期已结算结果
$periods = $db->select('periods', ['id','period_number'], [
'game_id' => $gameId, 'status' => 'settled',
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 100
]);
$results = [];
foreach ($periods as $p) {
$pk = $db->get('pk10_results', '*', ['period_id' => $p['id']]);
if ($pk) {
$pk['period_number'] = $p['period_number'];
$results[] = $pk;
}
}
extract(compact('game', 'results'));
include __DIR__ . '/../../Views/Web/pk10_stats.php';
}
public function details() {
$this->checkWebLogin();
I18n::init();
$db = new Database();
$userId = $this->getCurrentUserId();
$settled = $db->select('bets', '*', [
'user_id' => $userId, 'status[!]' => 'pending',
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 50
]);
$pending = $db->select('bets', '*', [
'user_id' => $userId, 'status' => 'pending',
'ORDER' => ['id' => 'DESC'], 'LIMIT' => 50
]);
extract(compact('settled', 'pending'));
include __DIR__ . '/../../Views/Web/details.php';
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace App\Controllers\Web;
use App\Core\WebBaseController;
use App\Core\GameFactory;
use Db\Database;
class PeriodController extends WebBaseController {
public function getCurrent() {
header('Content-Type: application/json');
$db = new Database();
$gameId = (int)($_GET['game_id'] ?? 0);
if (!$gameId) {
$gameId = (int)($db->get('games', 'id', ['code' => 'pk10']) ?: 0);
}
if (!$gameId) {
echo json_encode(['success' => false, 'message' => 'Game not found']);
return;
}
// 获取游戏类型和算法
$game = $db->get('games', ['id', 'type', 'period_duration', 'lock_before_end'], ['id' => $gameId]);
$gameType = $game['type'] ?? 'pk10';
$algoClass = GameFactory::getAlgorithm($gameType);
$resultTable = $algoClass::getResultTable();
$period = $db->get('periods', '*', [
'game_id' => $gameId,
'ORDER' => ['id' => 'DESC']
]);
$now = time();
$countdown = 0;
$lockCountdown = 0;
if ($period && in_array($period['status'], ['pending', 'locked'])) {
$start = strtotime($period['start_time'] ?? $period['created_at']);
$countdown = max(0, !empty($period['end_time']) ? strtotime($period['end_time']) - $now : 300 - ($now - $start));
$periodDuration = (int)($game['period_duration'] ?? 300);
$lockBefore = (int)($game['lock_before_end'] ?? 30);
$lockCountdown = max(0, ($periodDuration - $lockBefore) - ($now - $start));
}
// 获取结果的通用方法
$getResult = function($periodRow) use ($db, $resultTable, $algoClass) {
if (!$periodRow) return null;
if ($resultTable) {
return $db->get($resultTable, '*', ['period_id' => $periodRow['id']]);
}
// dice/xocdia: 结果在 periods 表中
return $periodRow;
};
// ====== 最近开奖结果 ======
$lastResult = null;
$lastResultStatus = null;
$lastDrawn = $db->get('periods', '*', [
'game_id' => $gameId,
'status' => ['drawn', 'settled'],
'ORDER' => ['id' => 'DESC']
]);
if ($lastDrawn) {
$pk10 = $getResult($lastDrawn);
if ($pk10) {
$lastResult = $pk10;
$lastResult['period_number'] = $lastDrawn['period_number'];
$lastResultStatus = $lastDrawn['status'];
}
}
// ====== locked 状态预开奖结果 ======
$raceResult = null;
if ($period && $period['status'] === 'locked' && !empty($period['result'])) {
$pk10Current = $getResult($period);
if ($pk10Current) {
$raceResult = $pk10Current;
$raceResult['period_number'] = $period['period_number'];
}
}
// history
$history = [];
$recentPeriods = $db->select('periods', ['id','period_number','result','status'], [
'game_id' => $gameId,
'status' => ['drawn', 'settled'],
'ORDER' => ['id' => 'DESC'],
'LIMIT' => 10
]);
foreach ($recentPeriods as $s) {
$pk = $getResult($s);
if ($pk) { $pk['period_number'] = $s['period_number']; $history[] = $pk; }
}
// user bets + balance
$myBets = [];
$balance = null;
$userId = $this->getWebUserId();
if ($userId && $period) {
$betsRaw = $db->select('bets', ['id', 'bet_type', 'bet_value', 'amount', 'odds', 'status', 'win_amount'], [
'user_id' => $userId,
'period_id' => $period['id'],
'ORDER' => ['id' => 'DESC']
]);
$myBets = $betsRaw ?: [];
$userRow = $db->get('users', ['balance'], ['id' => $userId]);
$balance = $userRow ? (float)$userRow['balance'] : null;
}
$lastMyBets = [];
if ($userId && $lastDrawn && $lastDrawn['status'] === 'settled') {
$lastBetsRaw = $db->select('bets', ['id', 'bet_type', 'bet_value', 'amount', 'odds', 'status', 'win_amount'], [
'user_id' => $userId,
'period_id' => $lastDrawn['id'],
'ORDER' => ['id' => 'DESC']
]);
$lastMyBets = $lastBetsRaw ?: [];
}
echo json_encode([
'success' => true,
'data' => [
'id' => $period['id'] ?? 0,
'period_number' => $period['period_number'] ?? '',
'status' => $period['status'] ?? 'none',
'remaining_seconds' => $countdown,
'lock_countdown' => $lockCountdown,
'auto_generated' => (int)($period['auto_generated'] ?? 0),
'server_time' => date('Y-m-d H:i:s'),
],
'last_result' => $lastResult,
'last_result_status' => $lastResultStatus,
'race_result' => $raceResult,
'history' => $history,
'my_bets' => $myBets,
'last_my_bets' => $lastMyBets,
'balance' => $balance,
]);
}
private function getWebUserId(): int {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return (int)($_SESSION['web_user_id'] ?? 0);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Core;
class AdminBaseController extends BaseController
{
public function __construct()
{
}
// 检测是否是管理员
protected function checkAdmin()
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
if (
!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'
) {
header('Content-Type: application/json');
echo json_encode([
'status' => 'error',
'message' => '您没有权限访问该功能'
]);
} else {
echo "<script>alert('您没有权限访问该功能');history.back();</script>";
}
exit;
}
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Core;
class BaseController {
/**
* 渲染视图
* @param string $view 视图名,支持如 admin.dashboard(映射为 app/views/admin/dashboard.php
* @param array $data 传递给视图的数据
*/
protected function render($viewPath, $data = []) {
if (strpos($viewPath, '/') === 0 || preg_match('/^[a-zA-Z]:\\\\/', $viewPath)) {
$fullPath = $viewPath;
} else {
$viewsDir = __DIR__ . '/../Views/';
$fullPath = $viewsDir . $viewPath;
}
if (!file_exists($fullPath)) {
throw new \Exception("视图文件不存在: {$fullPath}", 500);
}
extract($data);
if (
!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'
) {
include $fullPath;
} else {
ob_start();
include $fullPath;
$Content = ob_get_clean();
include __DIR__ . '/../Views/Admin/index.php'; // 主后台模板
}
}
/**
* 检查用户登录
*/
protected function checkLogin() {
session_start();
$timeout = 7200;
// 提取 IP 前三段(IPv4
function get_ip_prefix($ip, $segments = 3) {
$parts = explode('.', $ip);
return implode('.', array_slice($parts, 0, $segments));
}
if (!isset($_SESSION['username'])) {
header("Location: /admin/login");
exit;
}
// 宽松 IP 检查(只比对前三段,例如 192.168.1.xxx
$current_ip_prefix = get_ip_prefix($_SERVER['REMOTE_ADDR'], 3);
$session_ip_prefix = get_ip_prefix($_SESSION['ip'] ?? '', 3);
if ($current_ip_prefix !== $session_ip_prefix) {
session_destroy();
header("Location: /admin/login");
exit;
}
if ($_SESSION['ua'] !== $_SERVER['HTTP_USER_AGENT']) {
session_destroy();
header("Location: /admin/login");
exit;
}
if (time() - ($_SESSION['last_activity'] ?? 0) > $timeout) {
session_destroy();
header("Location: /admin/login");
exit;
}
$_SESSION['last_activity'] = time();
}
/**
* 显示 404 页面
*/
public function show404($msg = '') {
http_response_code(404);
echo "<h1>404 Not Found</h1>";
if ($msg) echo "<p>$msg</p>";
exit;
}
protected function showError($errorMessage) {
// 错误视图文件路径(根据实际项目目录调整)
$errorViewPath = __DIR__ . '/../Views/Web/error.php';
// 检查错误视图文件是否存在
if (!file_exists($errorViewPath)) {
die("错误:找不到错误视图文件,请检查路径是否正确");
}
// 传递错误信息到视图
$error = $errorMessage;
// 加载错误视图(通过include将变量传入视图)
include $errorViewPath;
// 终止后续代码执行
exit;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Core;
class DiceAlgorithm implements GameAlgorithmInterface {
public static function generateResult(): array {
return [rand(1, 6), rand(1, 6), rand(1, 6)];
}
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100): array {
if (empty($bets) || empty($waterConfig)) return self::generateResult();
$bestResult = null;
$bestProfit = PHP_INT_MIN;
for ($i = 0; $i < $attempts; $i++) {
$result = self::generateResult();
$profit = self::calculatePlatformProfit($result, $bets);
if ($profit > $bestProfit) { $bestProfit = $profit; $bestResult = $result; }
}
return $bestResult;
}
public static function checkWin(array $result, string $betType, string $betTarget): bool {
$total = array_sum($result);
switch ($betType) {
case 'big_small':
return ($betTarget === 'big' && $total >= 11) || ($betTarget === 'small' && $total <= 10);
case 'odd_even':
return ($betTarget === 'odd' && $total % 2 === 1) || ($betTarget === 'even' && $total % 2 === 0);
case 'sum':
return (int)$betTarget === $total;
default: return false;
}
}
public static function calculatePlatformProfit(array $result, array $bets): float {
$totalBet = 0; $totalPayout = 0;
foreach ($bets as $bet) {
$amount = (float)$bet['amount'];
$totalBet += $amount;
if (self::checkWin($result, $bet['bet_type'], $bet['bet_target'] ?? $bet['bet_value'] ?? '')) {
$totalPayout += $amount + $amount * (float)$bet['odds'];
}
}
return $totalBet - $totalPayout;
}
public static function getResultTable(): ?string { return null; }
public static function formatResultForStorage(array $result, int $periodId): array {
return [
'dice1' => $result[0], 'dice2' => $result[1], 'dice3' => $result[2],
'total' => array_sum($result),
];
}
public static function parseResultFromDb(array $row): array {
return [(int)($row['dice1'] ?? 0), (int)($row['dice2'] ?? 0), (int)($row['dice3'] ?? 0)];
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Core;
interface GameAlgorithmInterface {
public static function generateResult(): array;
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100): array;
public static function checkWin(array $result, string $betType, string $betTarget): bool;
public static function calculatePlatformProfit(array $result, array $bets): float;
public static function getResultTable(): ?string;
public static function formatResultForStorage(array $result, int $periodId): array;
public static function parseResultFromDb(array $row): array;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Core;
class GameFactory {
private static $map = [
'pk10' => PK10Algorithm::class,
'dice' => DiceAlgorithm::class,
'xocdia' => XocDiaAlgorithm::class,
];
public static function getAlgorithm(string $gameType): string {
$class = self::$map[$gameType] ?? null;
if (!$class) throw new \RuntimeException("Unknown game type: {$gameType}");
return $class;
}
public static function register(string $gameType, string $class): void {
self::$map[$gameType] = $class;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Core;
class I18n {
private static $lang = 'en';
private static $translations = [];
private static $fallback = [];
private static $loaded = false;
// 支持的语言列表
const LANGUAGES = [
'en' => 'English',
'th' => 'ไทย',
'vi' => 'Tiếng Việt',
'zh' => '中文',
'ms' => 'Bahasa Melayu',
'fil' => 'Filipino',
'bn' => 'বাংলা',
];
public static function init($db = null) {
if (self::$loaded) return;
// 优先级: URL参数 > Session > Cookie > 浏览器 > 默认en
if (!empty($_GET['lang']) && isset(self::LANGUAGES[$_GET['lang']])) {
self::$lang = $_GET['lang'];
} elseif (!empty($_SESSION['lang'])) {
self::$lang = $_SESSION['lang'];
} elseif (!empty($_COOKIE['lang'])) {
self::$lang = $_COOKIE['lang'];
} else {
self::$lang = self::detectBrowserLang();
}
$_SESSION['lang'] = self::$lang;
setcookie('lang', self::$lang, time() + 86400 * 365, '/');
self::loadFromFile();
self::$loaded = true;
}
private static function detectBrowserLang(): string {
$accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
foreach (self::LANGUAGES as $code => $name) {
if (stripos($accept, $code) !== false) return $code;
}
return 'en';
}
private static function loadFromFile() {
$file = ROOT_PATH . 'Lang/' . self::$lang . '.php';
if (file_exists($file)) {
self::$translations = require $file;
}
// 始终加载英文作为fallback
$enFile = ROOT_PATH . 'Lang/en.php';
if (file_exists($enFile)) {
self::$fallback = require $enFile;
}
}
public static function t(string $key, array $params = []): string {
$text = self::$translations[$key] ?? self::$fallback[$key] ?? $key;
foreach ($params as $k => $v) {
$text = str_replace(':' . $k, $v, $text);
}
return $text;
}
public static function getLang(): string { return self::$lang; }
public static function setLang(string $lang) {
if (isset(self::LANGUAGES[$lang])) {
self::$lang = $lang;
self::$loaded = false;
self::init();
}
}
public static function getLanguages(): array { return self::LANGUAGES; }
}
// 全局快捷函数
function __($key, $params = []) { return I18n::t($key, $params); }
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Core;
use Db\Database;
class Mailer {
public static function send(string $to, string $subject, string $htmlBody): bool {
$cfg = self::getConfig();
if (empty($cfg['smtp_host']) || empty($cfg['smtp_user'])) {
// fallback to mail()
$headers = "MIME-Version: 1.0\r\nContent-type:text/html;charset=UTF-8\r\nFrom: {$cfg['smtp_from']}\r\n";
return @mail($to, $subject, $htmlBody, $headers);
}
$host = $cfg['smtp_host'];
$port = (int)($cfg['smtp_port'] ?: 465);
$user = $cfg['smtp_user'];
$pass = $cfg['smtp_pass'];
$from = $cfg['smtp_from'] ?: $user;
$fromName = $cfg['smtp_from_name'] ?: 'System';
$encryption = $cfg['smtp_encryption'] ?: 'ssl';
try {
$target = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$sock = @fsockopen($target, $port, $errno, $errstr, 10);
if (!$sock) throw new \RuntimeException("Connect failed: {$errstr}");
self::readLine($sock);
self::cmd($sock, "EHLO localhost");
// STARTTLS for tls mode
if ($encryption === 'tls') {
self::cmd($sock, "STARTTLS");
stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
self::cmd($sock, "EHLO localhost");
}
// AUTH LOGIN
self::cmd($sock, "AUTH LOGIN");
self::cmd($sock, base64_encode($user));
self::cmd($sock, base64_encode($pass));
self::cmd($sock, "MAIL FROM:<{$from}>");
self::cmd($sock, "RCPT TO:<{$to}>");
self::cmd($sock, "DATA");
$msg = "From: {$fromName} <{$from}>\r\n"
. "To: {$to}\r\n"
. "Subject: {$subject}\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/html; charset=UTF-8\r\n"
. "\r\n"
. $htmlBody . "\r\n.\r\n";
fwrite($sock, $msg);
self::readLine($sock);
self::cmd($sock, "QUIT");
fclose($sock);
return true;
} catch (\Throwable $e) {
error_log("Mailer error: " . $e->getMessage());
return false;
}
}
private static function cmd($sock, string $cmd): string {
fwrite($sock, $cmd . "\r\n");
return self::readLine($sock);
}
private static function readLine($sock): string {
$resp = '';
while ($line = fgets($sock, 512)) {
$resp .= $line;
if (isset($line[3]) && $line[3] === ' ') break;
}
return $resp;
}
public static function getConfig(): array {
$defaults = [
'smtp_host' => '', 'smtp_port' => '465', 'smtp_user' => '',
'smtp_pass' => '', 'smtp_from' => '', 'smtp_from_name' => 'PK10',
'smtp_encryption' => 'ssl',
];
try {
$db = new Database();
$rows = $db->select('system_settings', ['setting_key', 'setting_value'], [
'setting_key[~]' => 'smtp_%'
]);
foreach ($rows as $r) $defaults[$r['setting_key']] = $r['setting_value'];
} catch (\Throwable $e) {}
return $defaults;
}
public static function test(string $to): array {
$ok = self::send($to, 'SMTP Test', '<h2>SMTP configuration is working!</h2><p>Time: ' . date('Y-m-d H:i:s') . '</p>');
return ['success' => $ok, 'message' => $ok ? 'Test email sent' : 'Failed to send, check SMTP settings'];
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace App\Core;
class PK10Algorithm implements GameAlgorithmInterface {
/**
* 生成随机开奖结果 (1-10的排列)
*/
public static function generateResult(): array {
$cars = range(1, 10);
shuffle($cars);
return $cars; // index 0=冠军, 1=亚军, ..., 9=第十名
}
/**
* 带放水机制的开奖结果生成
* @param array $bets 当期所有投注 [{bet_type, bet_target, amount, odds}, ...]
* @param array $waterConfig [{bet_type => win_rate_pct}, ...]
* @param int $attempts 最大尝试次数
*/
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100): array {
if (empty($bets) || empty($waterConfig)) {
return self::generateResult();
}
$bestResult = null;
$bestProfit = PHP_INT_MIN;
for ($i = 0; $i < $attempts; $i++) {
$result = self::generateResult();
$profit = self::calculatePlatformProfit($result, $bets);
// 选择平台利润最高的结果
if ($profit > $bestProfit) {
$bestProfit = $profit;
$bestResult = $result;
}
}
return $bestResult;
}
/**
* 计算某个开奖结果下平台的利润
*/
public static function calculatePlatformProfit(array $result, array $bets): float {
$totalBet = 0;
$totalPayout = 0;
foreach ($bets as $bet) {
$amount = (float)$bet['amount'];
$totalBet += $amount;
if (self::checkWin($result, $bet['bet_type'], $bet['bet_target'])) {
$totalPayout += $amount + $amount * (float)$bet['odds'];
}
}
return $totalBet - $totalPayout;
}
/**
* 核心中奖判定
* @param array $result 开奖排名 [冠军车号, 亚军车号, ..., 第十名车号]
* @param string $betType 投注类型: rank/bs/oe/dt/sum/sum_bs
* @param string $betTarget 投注目标: rank1_5/rank1_big/dt1_dragon/sum_11/sum_big...
*/
public static function checkWin(array $result, string $betType, string $betTarget): bool {
switch ($betType) {
case 'rank': return self::checkRank($result, $betTarget);
case 'bs': return self::checkBigSmall($result, $betTarget);
case 'oe': return self::checkOddEven($result, $betTarget);
case 'dt': return self::checkDragonTiger($result, $betTarget);
case 'sum': return self::checkSum($result, $betTarget);
case 'sum_bs': return self::checkSumBigSmall($result, $betTarget);
default: return false;
}
}
// 名次投注: rank{N}_{carNo} 如 rank1_5 = 冠军是5号车
private static function checkRank(array $r, string $target): bool {
if (!preg_match('/^rank(\d+)_(\d+)$/', $target, $m)) return false;
$pos = (int)$m[1] - 1; // 0-indexed
$car = (int)$m[2];
return isset($r[$pos]) && $r[$pos] === $car;
}
// 大小: rank{N}_big/small, 车号>=6为大, <=5为小
private static function checkBigSmall(array $r, string $target): bool {
if (!preg_match('/^rank(\d+)_(big|small)$/', $target, $m)) return false;
$pos = (int)$m[1] - 1;
if (!isset($r[$pos])) return false;
$car = $r[$pos];
return $m[2] === 'big' ? $car >= 6 : $car <= 5;
}
// 单双: rank{N}_odd/even
private static function checkOddEven(array $r, string $target): bool {
if (!preg_match('/^rank(\d+)_(odd|even)$/', $target, $m)) return false;
$pos = (int)$m[1] - 1;
if (!isset($r[$pos])) return false;
$car = $r[$pos];
return $m[2] === 'odd' ? ($car % 2 === 1) : ($car % 2 === 0);
}
// 龙虎: dt{N}_dragon/tiger, N=1-5, 对应 1vs10, 2vs9, 3vs8, 4vs7, 5vs6
private static function checkDragonTiger(array $r, string $target): bool {
if (!preg_match('/^dt(\d)_(dragon|tiger)$/', $target, $m)) return false;
$pair = (int)$m[1]; // 1-5
$frontPos = $pair - 1; // 0,1,2,3,4
$backPos = 10 - $pair; // 9,8,7,6,5
if (!isset($r[$frontPos], $r[$backPos])) return false;
$front = $r[$frontPos];
$back = $r[$backPos];
if ($front === $back) return false; // 和局(PK10不会出现)
return $m[2] === 'dragon' ? $front > $back : $front < $back;
}
// 冠亚和值: sum_{3-19}
private static function checkSum(array $r, string $target): bool {
if (!preg_match('/^sum_(\d+)$/', $target, $m)) return false;
$sumVal = $r[0] + $r[1]; // 冠军+亚军
return $sumVal === (int)$m[1];
}
// 冠亚和大小单双: sum_big/small/odd/even, 和>=12为大, <=11为小
private static function checkSumBigSmall(array $r, string $target): bool {
$sumVal = $r[0] + $r[1];
switch ($target) {
case 'sum_big': return $sumVal >= 12;
case 'sum_small': return $sumVal <= 11;
case 'sum_odd': return $sumVal % 2 === 1;
case 'sum_even': return $sumVal % 2 === 0;
default: return false;
}
}
/**
* 获取名次中文/英文名称
*/
public static function getRankName(int $pos): string {
$names = [1=>'champion',2=>'runner_up',3=>'rank_n',4=>'rank_n',5=>'rank_n',
6=>'rank_n',7=>'rank_n',8=>'rank_n',9=>'rank_n',10=>'rank_n'];
return $names[$pos] ?? 'rank_n';
}
/**
* 龙虎对应关系
*/
public static function getDragonTigerPairs(): array {
return [
1 => [1, 10], // 第1名 vs 第10名
2 => [2, 9],
3 => [3, 8],
4 => [4, 7],
5 => [5, 6],
];
}
/**
* 期号生成
*/
public static function generatePeriodNumber(int $gameId): string {
return 'PK' . $gameId . date('Ymd') . str_pad(mt_rand(1, 9999), 4, '0', STR_PAD_LEFT);
}
public static function getResultTable(): ?string { return 'pk10_results'; }
public static function formatResultForStorage(array $result, int $periodId): array {
return [
'period_id' => $periodId,
'rank_1' => $result[0], 'rank_2' => $result[1], 'rank_3' => $result[2],
'rank_4' => $result[3], 'rank_5' => $result[4], 'rank_6' => $result[5],
'rank_7' => $result[6], 'rank_8' => $result[7], 'rank_9' => $result[8],
'rank_10' => $result[9], 'champion_sum' => $result[0] + $result[1],
];
}
public static function parseResultFromDb(array $row): array {
$r = [];
for ($i = 1; $i <= 10; $i++) $r[] = (int)($row['rank_' . $i] ?? 0);
return $r;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Core;
class PluginBaseController extends BaseController {
/**
* 渲染插件视图文件
*
* @param string $pluginName 插件名称(必须与插件目录一致)
* @param string $viewFile 视图相对路径,如 'Admin/list.php'
* @param array $data 传递给视图的变量数组
*/
protected function renderPluginView($pluginName, $viewFile, $data = []) {
$viewPath = PLUGIN_PATH . $pluginName . '/Views/' . $viewFile;
// 调用基类的渲染方法
$this->render($viewPath, $data);
}
}
+682
View File
@@ -0,0 +1,682 @@
<?php
namespace App\Core;
class PluginManager {
protected $db;
protected $pluginDir;
protected $loadedPlugins = [];
protected $router;
protected $logFile;
protected $installedPluginsCache = null;
protected $registeredRoutes = [];
protected $plugins = null;
protected $enabledPlugins = [];
protected $systemRoutes = [];
protected $enabledRoutes = [];
private $lastError = '';
private function setError(string $msg) {
$this->lastError = $msg;
$this->log("[ERROR] $msg");
}
public function getLastError(): string {
return $this->lastError;
}
/**
* 构造函数
* @param object $db 数据库对象
* @param object $router 路由对象
* @param string $pluginDir 插件目录
*/
public function __construct($db, $router, string $pluginDir) {
$this->db = $db;
$this->router = $router;
$this->pluginDir = rtrim($pluginDir, '/');
$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';
// 检查日志文件大小并自动清理(大于2MB时)
$this->cleanupLogFile(2); // 传入最大允许的MB数
}
/**
* 清理日志日志文件清理
* @param int $maxSizeMB 最大允许的文件大小(MB)
*/
private function cleanupLogFile(int $maxSizeMB) {
// 检查文件是否存在
if (!file_exists($this->logFile)) {
return;
}
// 转换MB为字节
$maxSizeBytes = $maxSizeMB * 1024 * 1024;
// 获取当前文件大小
$currentSize = filesize($this->logFile);
// 如果文件大小超过限制,清空文件
if ($currentSize > $maxSizeBytes) {
// 先备份当前日志内容(可选)
$backupFile = $this->logFile . '.bak_' . date('YmdHis');
copy($this->logFile, $backupFile);
// 清空日志文件
file_put_contents($this->logFile, '');
// 记录清理日志
$message = "[" . date('Y-m-d H:i:s') . "] 日志文件超过{$maxSizeMB}MB,已自动清理\n";
file_put_contents($this->logFile, $message, FILE_APPEND);
}
}
/**
* 设置系统核心路由
* @param array $routes 格式: [['GET', '/path', 'handler'], ...]
*/
public function setSystemRoutes(array $routes): void {
$this->systemRoutes = [];
foreach ($routes as $route) {
if (count($route) < 2) continue;
[$method, $path] = $route;
$key = strtoupper(trim($method)) . ' ' . trim($path);
$this->systemRoutes[$key] = true;
}
}
/**
* 获取所有已注册路由(系统+已启用插件)
* @return array 路由键名数组
*/
public function getAllRegisteredRoutes(): array {
return array_merge(
array_keys($this->systemRoutes),
array_keys($this->enabledRoutes)
);
}
public function getDB() {
return $this->db;
}
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);
} else {
error_log("PluginManager log directory not writable: $logDir");
}
} catch (\Throwable $e) {
error_log("Failed to write plugin log: " . $e->getMessage());
}
}
/**
* 获取所有扫描到的插件信息
* @return array
*/
public function getAllPlugins(): array {
$this->scanPlugins();
return $this->plugins;
}
public function scanPlugins() {
if ($this->plugins !== null) {
return $this->plugins;
}
$this->log("scanPlugins called");
$this->plugins = [];
if (!is_dir($this->pluginDir)) {
mkdir($this->pluginDir, 0755, true);
$this->log("Plugin directory created: {$this->pluginDir}");
return $this->plugins;
}
$dirs = scandir($this->pluginDir);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') continue;
$pluginPath = $this->pluginDir . '/' . $dir;
$pluginFile = $pluginPath . '/mian.php';
if (is_dir($pluginPath) && is_file($pluginFile)) {
$pluginConfig = $this->getPluginInfo($pluginFile, $dir);
if (empty($pluginConfig)) {
$this->log("[WARN] 插件 {$dir} 不规范,已跳过");
continue;
}
$this->plugins[$dir] = array_merge([
'dir' => $dir,
'path' => $pluginPath,
], $pluginConfig);
$this->log("Plugin found: $dir ({$pluginConfig['name']})");
}
}
return $this->plugins;
}
public function getAllPluginIcons(): array
{
$pluginDirs = array_filter(scandir(PLUGIN_PATH . '/'), function($dir) {
return $dir !== '.' && $dir !== '..';
});
$icons = [];
foreach ($pluginDirs as $pluginDir) {
$pluginFile = PLUGIN_PATH . "/{$pluginDir}/mian.php";
if (file_exists($pluginFile)) {
$info = include $pluginFile;
$icons[$pluginDir] = $info['menus'][0]['icon'] ?? 'fa fa-plug';
}
}
return $icons;
}
/**
* 从插件文件头部注释获取插件信息
*/
private function getPluginInfo(string $pluginFile, string $pluginDirName): array {
$info = [];
$arrayConfig = @include $pluginFile;
if (!is_array($arrayConfig)) {
$this->setError("[ERROR] Plugin file {$pluginFile} must return an array");
return [];
}
$lines = file($pluginFile);
if (!$lines) {
$this->setError("[ERROR] Cannot read plugin file: {$pluginFile}");
return [];
}
// 读取前 30 行,兼容大部分注释头
$header = implode('', array_slice($lines, 0, 30));
//$this->log("Header of {$pluginFile}:\n" . $header);
if (preg_match_all('/^\s*\*\s*([A-Za-z ]+):\s*(.+)$/m', $header, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$key = strtolower(str_replace(' ', '_', trim($match[1])));
$value = trim($match[2]);
$info[$key] = $value;
}
}
// 必填字段列表
$requiredFields = ['plugin_name', 'version', 'description', 'author', 'plugin_url'];
// 检查必要字段是否存在且非空
foreach ($requiredFields as $field) {
if (empty($info[$field])) {
$this->setError("[ERROR] Plugin {$pluginFile} 缺少必要字段或为空: {$field}");
return [];
}
}
// 插件文件夹名必须和插件名一致
if ($pluginDirName !== $info['plugin_name']) {
$this->setError("[ERROR] 插件目录名 {$pluginDirName} 与插件名 {$info['plugin_name']} 不一致");
return [];
}
if (isset($info['plugin_name'])) {
$arrayConfig['name'] = $info['plugin_name'];
}
if (isset($info['description'])) {
$arrayConfig['description'] = $info['description'];
}
if (isset($info['version'])) {
$arrayConfig['version'] = $info['version'];
}
if (isset($info['author'])) {
$arrayConfig['author'] = $info['author'];
}
if (isset($info['plugin_url'])) {
$arrayConfig['url'] = $info['plugin_url'];
}
return $arrayConfig;
}
public function clearCache() {
$this->plugins = null;
}
public function getEnabledPluginMenus(): array {
$menus = [];
foreach ($this->enabledPlugins as $plugin) {
if (isset($plugin['menus']) && is_array($plugin['menus'])) {
$menus = array_merge($menus, $plugin['menus']);
}
}
return $menus;
}
/**
* 获取已安装插件列表
* @return array
*/
public function getInstalledPlugins(): array {
// 如果数据库对象不存在或未初始化,直接返回空数组
if (!$this->db || !$this->db->medoo) {
return [];
}
if ($this->installedPluginsCache !== null) {
return $this->installedPluginsCache;
}
try {
$installed = $this->db->select('plugins', '*');
if (!is_array($installed)) {
$installed = [];
}
} catch (\Exception $e) {
// 捕获数据库异常,返回空数组
$installed = [];
}
$installedPlugins = [];
foreach ($installed as $row) {
$installedPlugins[$row['name']] = $row;
}
$this->installedPluginsCache = $installedPlugins;
return $installedPlugins;
}
/**
* 获取所有插件状态信息
* @return array
*/
public function getPluginStatusList(): array {
$this->scanPlugins();
$installedPlugins = $this->getInstalledPlugins();
$result = [];
foreach ($this->plugins as $name => $plugin) {
$installed = isset($installedPlugins[$name]);
$status = $installed ? (int)$installedPlugins[$name]['status'] : 0;
$result[] = [
'name' => $name,
'installed' => $installed ? 1 : 0,
'status' => $status,
'path' => $plugin['path'],
'title' => $installed ? $installedPlugins[$name]['title'] : ($plugin['title'] ?? $name),
'version' => $installed ? $installedPlugins[$name]['version'] : ($plugin['version'] ?? ''),
'description' => $installed ? $installedPlugins[$name]['description'] : ($plugin['description'] ?? ''),
'author' => $installed ? $installedPlugins[$name]['author'] : ($plugin['author'] ?? ''),
'url' => $installed ? $installedPlugins[$name]['url'] : ($plugin['url'] ?? ''),
];
}
return $result;
}
/**
* 获取启用的插件名称列表
* @return array
*/
public function getEnabledPlugins(): array {
$enabled = [];
$installed = $this->getInstalledPlugins();
foreach ($this->plugins as $name => $plugin) {
if (isset($installed[$name]) && $installed[$name]['status'] == 1) {
$enabled[] = $name;
}
}
return $enabled;
}
/**
* 递归加载控制器目录内所有PHP文件
* @param string $dir
*/
protected function loadControllersRecursively(string $dir): void {
if (!is_dir($dir)) {
return;
}
$files = scandir($dir);
foreach ($files as $file) {
if ($file === '.' || $file === '..') continue;
$fullPath = $dir . DIRECTORY_SEPARATOR . $file;
if (is_dir($fullPath)) {
$this->loadControllersRecursively($fullPath);
} elseif (is_file($fullPath) && pathinfo($file, PATHINFO_EXTENSION) === 'php') {
require_once $fullPath;
$this->log("Loaded controller: $fullPath");
}
}
}
private function isAssoc(array $arr): bool {
return array_keys($arr) !== range(0, count($arr) - 1);
}
public function loadEnabledPlugins(): void {
static $loaded = false;
if ($loaded) {
return;
}
$loaded = true;
$this->scanPlugins();
$installed = $this->getInstalledPlugins();
$this->enabledPlugins = [];
$this->registeredRoutes = [];
$this->enabledRoutes = [];
foreach ($this->plugins as $pluginName => $pluginData) {
if (isset($installed[$pluginName]) && $installed[$pluginName]['status'] == 1) {
// 激活插件(调用init
if (!empty($pluginData['init']) && is_callable($pluginData['init'])) {
try {
call_user_func($pluginData['init']);
//$this->log("Initialized plugin $pluginName");
} catch (\Throwable $e) {
$this->log("[ERROR] Exception in init of plugin $pluginName: " . $e->getMessage());
}
}
// 注册普通路由
if (!empty($pluginData['routes']) && is_array($pluginData['routes'])) {
foreach ($pluginData['routes'] as $route) {
if (count($route) < 3) {
$this->log("[WARNING] Invalid route config for plugin $pluginName");
continue;
}
[$method, $path, $handler] = $route;
$method = strtoupper($method);
$routeKey = $method . ' ' . $path;
if (in_array($routeKey, $this->registeredRoutes, true)) {
$this->log("[WARNING] Route conflict: [$method] $path");
continue;
}
$this->router->add($method, $path, $handler);
$this->registeredRoutes[] = $routeKey;
$this->enabledRoutes[$routeKey] = true;
//$this->log("Registered route [$method] $path for plugin $pluginName");
}
}
// 注册路由组
if (!empty($pluginData['route_group'])) {
$groups = $this->isAssoc($pluginData['route_group'])
? [$pluginData['route_group']]
: $pluginData['route_group'];
foreach ($groups as $group) {
if (isset($group['prefix'], $group['namespace'], $group['routes']) && is_array($group['routes'])) {
$this->router->group(
$group['prefix'],
$group['namespace'],
$group['routes']
);
//$this->log("Registered route group [prefix={$group['prefix']}] for plugin $pluginName");
} else {
$this->log("[WARNING] Invalid route_group config in plugin $pluginName");
}
}
}
$this->enabledPlugins[] = $pluginData;
}
}
}
/**
* 安装插件
* @param string $name
* @return bool
*/
public function installPlugin(string $name): bool|string {
if (!isset($this->plugins[$name])) {
$this->log("Install failed: plugin $name not found");
return false;
}
$plugin = $this->plugins[$name];
// 1. 路由冲突检测
if (!empty($plugin['route_group']) && is_array($plugin['route_group'])) {
$prefixes = [];
foreach ($plugin['route_group'] as $group) {
if (isset($group['prefix'])) {
$prefixes[] = $group['prefix'];
}
}
$prefixes = array_unique($prefixes);
if (!$this->checkRouteConflicts($prefixes)) {
return false; // 路由冲突阻止安装
}
}
// 2. 表冲突检测
if (!empty($plugin['tables']) && is_array($plugin['tables'])) {
$conflictTables = [];
foreach ($plugin['tables'] as $table) {
$stmt = $this->db->query("SHOW TABLES LIKE '{$table}'");
if ($stmt && $stmt->fetch()) {
$conflictTables[] = $table;
}
}
if (!empty($conflictTables)) {
$conflictList = implode(', ', $conflictTables);
$errorMsg = "插件 {$name} 安装失败:以下数据表已存在 -> {$conflictList}";
$this->log("[ERROR] $errorMsg");
return $errorMsg; // 返回错误消息字符串阻止安装
}
}
// 3. 插件元信息写入数据库
$pluginData = [
'name' => $name,
'title' => $plugin['title'] ?? $name,
'description' => $plugin['description'] ?? '',
'version' => $plugin['version'] ?? '',
'author' => $plugin['author'] ?? '',
'url' => $plugin['url'] ?? '',
'status' => 1,
];
$count = $this->db->count('plugins', '*', ['name' => $name]);
if ($count > 0) {
$this->db->update('plugins', $pluginData, ['name' => $name]);
} else {
$this->db->insert('plugins', $pluginData);
}
$this->installedPluginsCache = null;
return true;
}
/**
* 路由冲突检测
*/
public function checkRouteConflicts(array $newPrefixes): bool {
$installedPlugins = $this->getInstalledPlugins();
$installedPrefixes = [];
foreach ($this->plugins as $pluginName => $plugin) {
if (!isset($installedPlugins[$pluginName])) continue;
if (!empty($plugin['route_group']) && is_array($plugin['route_group'])) {
if (isset($plugin['route_group'][0]) && is_array($plugin['route_group'][0])) {
foreach ($plugin['route_group'] as $group) {
$prefix = $group['prefix'] ?? '';
if ($prefix) $installedPrefixes[] = $prefix;
}
} else {
$prefix = $plugin['route_group']['prefix'] ?? '';
if ($prefix) $installedPrefixes[] = $prefix;
}
}
}
$installedPrefixes = array_unique($installedPrefixes);
$conflictNewPrefixes = [];
$conflictInstalledPrefixes = [];
foreach ($newPrefixes as $newPrefix) {
foreach ($installedPrefixes as $installedPrefix) {
if ($this->isPrefixConflict($newPrefix, $installedPrefix)) {
$conflictNewPrefixes[] = $newPrefix;
$conflictInstalledPrefixes[] = $installedPrefix;
}
}
}
if (!empty($conflictNewPrefixes)) {
echo json_encode([
'success' => false,
'message' => "路由前缀冲突:新插件的前缀(" . implode(', ', array_unique($conflictNewPrefixes)) . ")与已安装插件的前缀(" . implode(', ', array_unique($conflictInstalledPrefixes)) . ")重复,请修改后再安装。"
]);
exit;
}
return true;
}
/**
* 判断两个路由前缀是否冲突
*/
protected function isPrefixConflict(string $prefixA, string $prefixB): bool {
$prefixA = rtrim(trim($prefixA), '/');
$prefixB = rtrim(trim($prefixB), '/');
// 两个都是空字符串,视为冲突
if ($prefixA === '' && $prefixB === '') {
return true;
}
// 完全相等才算冲突
if ($prefixA === $prefixB) {
return true;
}
// 不再判断包含关系为冲突,直接返回不冲突
return false;
}
/**
* 卸载插件
* @param string $name
* @return bool
*/
public function uninstallPlugin(string $name): bool {
if (!isset($this->plugins[$name])) {
$this->log("Uninstall failed: plugin $name not found");
return false;
}
$plugin = $this->plugins[$name];
// 删除插件相关的数据库表
if (!empty($plugin['tables']) && is_array($plugin['tables'])) {
foreach ($plugin['tables'] as $table) {
$exists = $this->db->query("SHOW TABLES LIKE '{$table}'")->fetch();
if ($exists) {
try {
$this->db->query("DROP TABLE IF EXISTS `{$table}`");
$this->log("Dropped table: {$table}");
} catch (\Throwable $e) {
$this->log("[ERROR] Failed to drop table {$table}: " . $e->getMessage());
}
}
}
}
// 删除插件记录
$this->db->delete('plugins', ['name' => $name]);
// 清除已安装插件缓存
$this->installedPluginsCache = null;
$this->log("Uninstalled plugin {$name}");
return true;
}
/**
* 启用插件
* @param string $name
* @return bool
*/
public function enablePlugin(string $name): bool {
$count = $this->db->count('plugins', '*', ['name' => $name]);
if ($count == 0) {
$this->log("Enable failed: plugin $name not installed");
return false;
}
$this->db->update('plugins', ['status' => 1], ['name' => $name]);
$this->installedPluginsCache = null;
$this->log("Enabled plugin $name");
return true;
}
/**
* 禁用插件
* @param string $name
* @return bool
*/
public function disablePlugin(string $name): bool {
$count = $this->db->count('plugins', '*', ['name' => $name]);
if ($count == 0) {
$this->log("Disable failed: plugin $name not installed");
return false;
}
$this->db->update('plugins', ['status' => 0], ['name' => $name]);
$this->installedPluginsCache = null;
$this->log("Disabled plugin $name");
return true;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace App\Core;
class Router {
private array $routes = [];
private array $dependencies = [];
private function handle404() {
$controller = new BaseController();
$controller->show404();
}
/**
* 添加路由
* @param string $method 请求方法 GET/POST 等
* @param string $path 路径,必须以 / 开头,尾部无 /
* @param callable|string $callback 处理器,格式:Controller@method 或回调函数
*/
public function add($method, $path, $callback) {
$methods = explode('|', strtoupper($method)); // 支持多方法
$normalizedPath = $this->normalizePath($path);
foreach ($methods as $m) {
$this->routes[$m][$normalizedPath] = $callback;
}
}
public function get($path, $callback) {
$this->add('GET', $path, $callback);
}
public function post($path, $callback) {
$this->add('POST', $path, $callback);
}
public function hasRoute($method, $path): bool {
$method = strtoupper($method);
$normalizedPath = $this->normalizePath($path);
return isset($this->routes[$method][$normalizedPath]);
}
public function setDependencies(array $deps) {
$this->dependencies = $deps;
}
/**
* 路由分发
* @param string $method 请求方法
* @param string $uri 请求路径
* @param array $dependencies 依赖注入数组,键为类名,值为实例(可选)
*/
public function dispatch($method, $uri, $dependencies = []) {
$dependencies = array_merge($this->dependencies, $dependencies);
$method = strtoupper($method);
$normalizedPath = $this->normalizePath($uri);
if (!isset($this->routes[$method])) {
header("HTTP/1.1 404 Not Found");
echo "请求方法 [$method] 无任何注册路由。";
return;
}
// 精确匹配
if (isset($this->routes[$method][$normalizedPath])) {
$this->callHandler($this->routes[$method][$normalizedPath], [], $dependencies);
return;
}
// 模糊匹配带参数路由
foreach ($this->routes[$method] as $routePath => $callback) {
$pattern = $this->convertToRegex($routePath);
if (preg_match($pattern, $normalizedPath, $matches)) {
array_shift($matches); // 去掉完整匹配
$this->callHandler($callback, $matches, $dependencies);
return;
}
}
// 404
$this->handle404();
}
public function group(string $prefixUri, string $controllerNamespace, array $routes)
{
foreach ($routes as $route) {
if (count($route) < 3) {
throw new \InvalidArgumentException("每个子路由必须包含 method、uri、handler");
}
[$method, $subUri, $handler] = $route;
// 构造完整 URI
$uri = rtrim($prefixUri, '/') . '/' . ltrim($subUri, '/');
// 构造完整处理器(加命名空间)
if (strpos($handler, '@') !== false) {
[$controller, $action] = explode('@', $handler);
$fullHandler = $controllerNamespace . '\\' . $controller . '@' . $action;
} else {
$fullHandler = $controllerNamespace . '\\' . $handler;
}
$this->add($method, $uri, $fullHandler);
}
}
/**
* 调用处理器,支持构造函数依赖注入
* @param callable|string $callback
* @param array $params 传给方法的参数
* @param array $dependencies 依赖注入映射,key: 类名,value: 实例
*/
private function callHandler($callback, $params = [], $dependencies = []) {
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? '未知请求方法';
$requestUri = $_SERVER['REQUEST_URI'] ?? '未知请求路径';
if (is_string($callback) && strpos($callback, '@') !== false) {
list($class, $method) = explode('@', $callback);
if (class_exists($class) && method_exists($class, $method)) {
try {
$reflection = new \ReflectionClass($class);
$instance = null;
$constructor = $reflection->getConstructor();
if ($constructor) {
$ctorParams = $constructor->getParameters();
$args = [];
foreach ($ctorParams as $param) {
$paramType = $param->getType();
if ($paramType && !$paramType->isBuiltin()) {
$paramClassName = $paramType->getName();
if (isset($dependencies[$paramClassName])) {
$args[] = $dependencies[$paramClassName];
} elseif ($param->isDefaultValueAvailable()) {
$args[] = $param->getDefaultValue();
} else {
throw new \Exception("依赖注入失败:未提供 {$paramClassName} 实例");
}
} else {
$args[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
}
}
$instance = $reflection->newInstanceArgs($args);
} else {
$instance = new $class();
}
// ⚠️ 此处加入详细异常捕获
try {
call_user_func_array([$instance, $method], $params);
} catch (\Throwable $e) {
http_response_code(500);
echo "处理错误:<br>";
echo "<strong>" . htmlspecialchars($e->getMessage()) . "</strong><br>";
echo "文件:" . $e->getFile() . "" . $e->getLine() . " 行<br>";
echo "<pre>" . $e->getTraceAsString() . "</pre>";
exit;
}
return;
} catch (\Throwable $e) {
http_response_code(500);
echo "控制器初始化错误:<br>";
echo "<strong>" . htmlspecialchars($e->getMessage()) . "</strong><br>";
echo "文件:" . $e->getFile() . "" . $e->getLine() . " 行<br>";
echo "<pre>" . $e->getTraceAsString() . "</pre>";
exit;
}
}
http_response_code(500);
echo "处理错误:类 <strong>" . htmlspecialchars($class) . "</strong> 或方法 <strong>" . htmlspecialchars($method) . "</strong> 未找到。<br>";
echo "请求方法:<strong>{$requestMethod}</strong><br>";
echo "请求路径:<strong>{$requestUri}</strong><br>";
return;
}
if (is_callable($callback)) {
try {
call_user_func_array($callback, $params);
} catch (\Throwable $e) {
http_response_code(500);
echo "回调执行错误:<br>";
echo "<strong>" . htmlspecialchars($e->getMessage()) . "</strong><br>";
echo "文件:" . $e->getFile() . "" . $e->getLine() . " 行<br>";
echo "<pre>" . $e->getTraceAsString() . "</pre>";
exit;
}
return;
}
http_response_code(500);
echo "无效的路由处理器。<br>";
echo "请求方法:<strong>{$requestMethod}</strong><br>";
echo "请求路径:<strong>{$requestUri}</strong><br>";
}
/**
* 规范化路径,保证统一格式:
* - 开头带 /
* - 尾部无 /
* - 根路径保持 /
*/
private function normalizePath($path) {
$path = trim($path);
if ($path === '' || $path === '/') {
return '/';
}
return '/' . trim($path, '/');
}
/**
* 将路由路径转为正则表达式,支持 {param} 动态参数
* @param string $routePath
* @return string 正则表达式
*/
private function convertToRegex($routePath) {
$pattern = preg_replace('/\{[a-zA-Z0-9_]+\}/', '([^/]+)', $routePath);
return '/^' . str_replace('/', '\/', $pattern) . '$/';
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Core;
use Db\Database;
class SettingsHelper {
private static $settings = null;
/**
* 获取所有系统设置
*/
public static function getAll() {
if (self::$settings !== null) {
return self::$settings;
}
try {
$db = new Database();
// 尝试查询设置(如果表不存在会抛出异常,被catch捕获)
try {
$settings = $db->select('system_settings', ['setting_key', 'setting_value']);
} catch (\Exception $e) {
// 表不存在或其他错误,返回默认值
self::$settings = self::getDefaults();
return self::$settings;
}
$result = [];
foreach ($settings as $setting) {
$result[$setting['setting_key']] = $setting['setting_value'];
}
// 合并默认值
$defaults = self::getDefaults();
foreach ($defaults as $key => $value) {
if (!isset($result[$key])) {
$result[$key] = $value;
}
}
self::$settings = $result;
return $result;
} catch (\Exception $e) {
// 出错时返回默认值
return self::getDefaults();
}
}
/**
* 获取单个设置值
*/
public static function get($key, $default = '') {
$settings = self::getAll();
return isset($settings[$key]) ? $settings[$key] : $default;
}
/**
* 获取默认设置值
*/
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_logo' => '/Static/images/logo.png',
'site_favicon' => '/Static/css/favicon.ico',
'site_copyright' => '© 2025 PK10 Racing. All rights reserved.'
];
}
/**
* 清除缓存(当设置更新后调用)
*/
public static function clearCache() {
self::$settings = null;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Core;
class WebBaseController extends BaseController {
protected function render($viewPath, $data = []) {
// 如果是绝对路径,直接使用
if (strpos($viewPath, '/') === 0 || preg_match('/^[a-zA-Z]:\\\\/', $viewPath)) {
$fullPath = $viewPath;
} else {
// 相对路径,按默认视图目录拼接
$viewsDir = __DIR__ . '/../views/';
$fullPath = $viewsDir . $viewPath;
}
if (!file_exists($fullPath)) {
throw new Exception("视图文件不存在: $fullPath");
}
extract($data);
if (
!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'
) {
include $fullPath;
} else {
ob_start();
include $fullPath;
$Content = ob_get_clean();
include __DIR__ . '/../views/Web/index.php'; // 主后台模板
}
}
/**
* 检查前台用户登录状态
* 如果未登录,重定向到登录页面
*/
protected function checkWebLogin() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$timeout = isset($_SESSION['web_remember']) && $_SESSION['web_remember'] ? 604800 : 7200; // 记住我:7天,否则2小时
// 检查是否有用户ID
if (!isset($_SESSION['web_user_id'])) {
$this->redirectToLogin();
return;
}
// 提取 IP 前三段(IPv4
function get_ip_prefix($ip, $segments = 3) {
if (empty($ip)) return '';
$parts = explode('.', $ip);
if (count($parts) < 4) return $ip; // IPv6 或其他格式,直接返回
return implode('.', array_slice($parts, 0, $segments));
}
// 宽松 IP 检查(只比对前三段,例如 192.168.1.xxx
$currentIp = $_SERVER['REMOTE_ADDR'] ?? '';
$sessionIp = $_SESSION['web_ip'] ?? '';
if (!empty($currentIp) && !empty($sessionIp)) {
$current_ip_prefix = get_ip_prefix($currentIp, 3);
$session_ip_prefix = get_ip_prefix($sessionIp, 3);
if ($current_ip_prefix !== $session_ip_prefix) {
session_destroy();
$this->redirectToLogin();
return;
}
}
// User Agent 检查
$currentUa = $_SERVER['HTTP_USER_AGENT'] ?? '';
$sessionUa = $_SESSION['web_ua'] ?? '';
if (!empty($currentUa) && !empty($sessionUa) && $currentUa !== $sessionUa) {
session_destroy();
$this->redirectToLogin();
return;
}
// 会话超时检查
$lastActivity = $_SESSION['web_last_activity'] ?? 0;
if (time() - $lastActivity > $timeout) {
session_destroy();
$this->redirectToLogin();
return;
}
// 更新最后活动时间
$_SESSION['web_last_activity'] = time();
}
/**
* 重定向到登录页面
*/
private function redirectToLogin() {
$currentUrl = $_SERVER['REQUEST_URI'] ?? '/';
$loginUrl = '/login?redirect=' . urlencode($currentUrl);
if (
!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'
) {
// AJAX 请求,返回 JSON
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'redirect' => $loginUrl,
'message' => 'Phiên đăng nhập đã hết hạn, vui lòng đăng nhập lại!'
]);
} else {
// 普通请求,重定向
header('Location: ' . $loginUrl);
}
exit;
}
/**
* 获取当前登录用户ID
*/
protected function getCurrentUserId() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return $_SESSION['web_user_id'] ?? null;
}
/**
* 获取当前登录用户名
*/
protected function getCurrentUsername() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
return $_SESSION['web_username'] ?? null;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Core;
class XocDiaAlgorithm implements GameAlgorithmInterface {
public static function generateResult(): array {
$coins = [];
for ($i = 0; $i < 4; $i++) $coins[] = (rand(0, 1) === 0) ? 'red' : 'white';
return $coins;
}
public static function generateControlledResult(array $bets, array $waterConfig, int $attempts = 100): array {
if (empty($bets) || empty($waterConfig)) return self::generateResult();
$bestResult = null;
$bestProfit = PHP_INT_MIN;
for ($i = 0; $i < $attempts; $i++) {
$result = self::generateResult();
$profit = self::calculatePlatformProfit($result, $bets);
if ($profit > $bestProfit) { $bestProfit = $profit; $bestResult = $result; }
}
return $bestResult;
}
public static function checkWin(array $result, string $betType, string $betTarget): bool {
$redCount = count(array_filter($result, fn($c) => $c === 'red'));
switch ($betTarget) {
case 'even': return ($redCount % 2 === 0);
case 'odd': return ($redCount % 2 === 1);
case '4red': return ($redCount === 4);
case '4white': return ($redCount === 0);
case '3red1white': return ($redCount === 3);
case '1red3white': return ($redCount === 1);
default: return false;
}
}
public static function calculatePlatformProfit(array $result, array $bets): float {
$totalBet = 0; $totalPayout = 0;
foreach ($bets as $bet) {
$amount = (float)$bet['amount'];
$totalBet += $amount;
if (self::checkWin($result, $bet['bet_type'], $bet['bet_target'] ?? $bet['bet_value'] ?? '')) {
$totalPayout += $amount + $amount * (float)$bet['odds'];
}
}
return $totalBet - $totalPayout;
}
public static function getResultTable(): ?string { return null; }
public static function formatResultForStorage(array $result, int $periodId): array {
$redCount = count(array_filter($result, fn($c) => $c === 'red'));
return [
'dice1' => ($result[0] === 'red') ? 1 : 0,
'dice2' => ($result[1] === 'red') ? 1 : 0,
'dice3' => ($result[2] === 'red') ? 1 : 0,
'total' => $redCount,
];
}
public static function parseResultFromDb(array $row): array {
return [
((int)($row['dice1'] ?? 0)) ? 'red' : 'white',
((int)($row['dice2'] ?? 0)) ? 'red' : 'white',
((int)($row['dice3'] ?? 0)) ? 'red' : 'white',
// 第4个硬币从 result JSON 恢复,或根据 total 推断
'white', // fallback
];
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
namespace Plugins\SoUrl\Controllers\Admin;
use App\Core\PluginBaseController;
class SoUrlController extends PluginBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->db = $this->pluginManager->getDB();
}
public function index() {
$this->checkLogin(); // 登录保护
// 获取配置信息(带默认值,避免空值)
$settings = $this->db->get('sourl_settings', '*', [
"ORDER" => ["id" => "ASC"],
"LIMIT" => 1
]) ?: []; // 如果没有数据,返回空数组
// 处理域名逻辑
$currentDomain = $_SERVER['HTTP_HOST'] ?? '';
$useDomain = !empty($settings['domain']) ? $settings['domain'] : $currentDomain;
$protocol = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
$fullDomain = "{$protocol}://{$useDomain}";
// 传数据给视图(统一封装在data中)
$this->renderPluginView('SoUrl', 'Admin/index.php', [
'data' => [
'settings' => $settings, // 配置信息
'domain' => $fullDomain // 带协议的完整域名
],
'title' => '短链管理中心'
]);
}
public function list() {
$this->checkLogin(); // 登录保护
// 获取分页参数
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$pageSize = isset($_GET['page_size']) ? max(1, min(100, intval($_GET['page_size']))) : 10;
$offset = ($page - 1) * $pageSize;
// 1. 获取总记录数
$totalItems = $this->db->count('sourl_list', '*');
// 2. 获取当前页数据
$shortlinks = $this->db->select('sourl_list', '*', [
'ORDER' => ['id' => 'DESC'],
'LIMIT' => [$offset, $pageSize]
]);
// 3. 计算分页信息
$totalPages = max(1, ceil($totalItems / $pageSize));
// 4. 返回JSON格式数据
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'data' => [
'items' => $shortlinks,
'current_page' => $page,
'total_pages' => $totalPages,
'total_items' => $totalItems,
'page_size' => $pageSize,
'has_prev' => $page > 1,
'has_next' => $page < $totalPages
]
], JSON_UNESCAPED_UNICODE);
exit;
}
public function get($id) {
$this->checkLogin();
// 设置响应内容类型为JSON
header('Content-Type: application/json');
$row = $this->db->get('sourl_list', '*', ['id' => $id]);
if ($row) {
// 成功响应,包含状态和数据
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'message' => '无效的ID或短链不存在'
]);
}
exit;
}
public function update() {
// 设置JSON响应头
header('Content-Type: application/json');
$this->checkLogin();
// 获取表单数据
$id = intval($_POST['id'] ?? 0);
$url = trim($_POST['url'] ?? '');
$name = trim($_POST['name'] ?? '');
$code = trim($_POST['code'] ?? '');
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
try {
// 有ID则更新
if ($id > 0) {
$data = [
'url' => $url,
'name' => $name,
'description' => $description,
'is_active' => $isActive,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->update('sourl_list', $data, ['id' => $id]);
if ($result) {
$qrcode = $this->db->get('sourl_list', '*', ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '短链更新成功',
'data' => $qrcode,
'shortUrl' => '/so/' . $qrcode['code']
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '更新失败,请稍后重试'
]);
}
} else {
$insertId = $this->db->insert('sourl_list', [
'code' => $code,
'url' => $url,
'name' => $name,
'description' => $description,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if ($insertId) {
$shortUrl = '/so/' . $code;
echo json_encode([
'success' => true,
'message' => '短链创建成功',
'data' => [
'id' => $insertId,
'code' => $code,
'url' => $url,
'name' => $name,
'description' => $description,
'is_active' => $isActive
],
'shortUrl' => $shortUrl
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '创建失败,请稍后重试'
]);
}
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '操作失败: ' . $e->getMessage()
]);
}
exit;
}
public function settings() {
$this->checkLogin();
header('Content-Type: application/json');
$id = intval($_REQUEST['id'] ?? 0);
try {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// 获取第一条设置记录
$row = $this->db->get('sourl_settings', '*', [
"ORDER" => ["id" => "ASC"]
]);
if ($row) {
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'message' => '设置不存在'
]);
}
} else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 获取提交数据
$domain = trim($_POST['domain'] ?? '');
$isActive = isset($_POST['is_active']) ? 1 : 0;
/* if (empty($domain)) {
echo json_encode([
'success' => false,
'message' => '域名不能为空'
]);
exit;
}*/
if ($id > 0) {
// 更新
$data = [
'domain' => $domain,
'is_active' => $isActive,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->update('sourl_settings', $data, ['id' => $id]);
if ($result->rowCount() > 0) {
$setting = $this->db->get('sourl_settings', '*', ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '设置更新成功',
'data' => $setting
]);
} else {
echo json_encode([
'success' => false,
'message' => '更新失败或数据无变化'
]);
}
} else {
// 新增
$insertId = $this->db->insert('sourl_settings', [
'domain' => $domain,
'is_active' =>1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if ($insertId) {
$setting = $this->db->get('sourl_settings', '*');
echo json_encode([
'success' => true,
'message' => '设置创建成功',
'data' => $setting
]);
} else {
echo json_encode([
'success' => false,
'message' => '创建失败,请稍后重试'
]);
}
}
} else {
http_response_code(405);
echo json_encode([
'success' => false,
'message' => '只支持 GET 和 POST 请求'
]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '操作失败: ' . $e->getMessage()
]);
}
exit;
}
public function deletelink($id) {
$this->checkLogin();
header('Content-Type: application/json');
try {
// 检查是否存在
$row = $this->db->get('sourl_list', '*', ['id' => $id]);
if (!$row) {
echo json_encode([
'success' => false,
'message' => '要删除的短链不存在'
]);
exit;
}
// 执行删除
$result = $this->db->delete('sourl_list', ['id' => $id]);
if ($result->rowCount() > 0) {
echo json_encode([
'success' => true,
'message' => '短链已删除'
]);
} else {
echo json_encode([
'success' => false,
'message' => '删除失败,请稍后再试'
]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '删除失败: ' . $e->getMessage()
]);
}
exit;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Plugins\SoUrl\Controllers\Web;
use App\Core\WebBaseController;
class SoUrlController extends WebBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->db = $this->pluginManager->getDB();
}
public function redirect($code) {
$row = $this->db->get('sourl_list', '*', ['code' => $code]);
if ($row) {
// 检查链接是否处于激活状态
if ($row['is_active'] != 1) {
$this->showError($code . ' 此链接已被停用!');
exit;
}
// 若激活,则更新访问量并跳转
$update = $this->db->update('sourl_list', ['views[+]' => 1 ], ['id' => $row['id'] ]);
if ($update->rowCount() > 0) {
header("Location: " . $row['url']);
exit;
}
} else {
$this->showError( $code . ' 此链接不存在!');
}
}
}
+596
View File
@@ -0,0 +1,596 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fa fa-chain text-primary mr-3"></i>
短链管理中心
</h1>
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<!-- 消息提示框 -->
<div id="message" class="mb-4 px-4 py-3 rounded-lg hidden"></div>
<!-- 选项卡导航 -->
<div class="border-b border-gray-200 mb-6">
<ul class="flex flex-wrap -mb-px" id="tabs" role="tablist">
<li class="mr-2" role="presentation">
<button id="list-tab" class="inline-block py-4 px-5 border-b-2 border-primary text-sm font-medium text-primary" onclick="switchTab('list')" aria-selected="true">
短链列表
</button>
</li>
<li class="mr-2" role="presentation">
<button id="settings-tab" class="inline-block py-4 px-5 border-b-2 border-transparent text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300" onclick="switchTab('settings')" aria-selected="false">
系统设置
</button>
</li>
</ul>
</div>
<!-- 短链列表内容 -->
<div id="list-content" class="tab-content">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-700">短链列表</h2>
<!-- 创建新短链按钮 -->
<button id="openFormBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center">
<i class="fa fa-plus mr-2"></i>
<span>新增短链</span>
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full bg-white rounded-xl shadow-md overflow-hidden">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">名称</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">短码</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">跳转链接</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">访问</th>
<th scope="col" 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" id="shortlinkList">
<!-- 内容将通过JavaScript动态生成 -->
</tbody>
</table>
<div id="loading" class="hidden py-10 text-center"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>
<div id="empty" class="hidden">
<tr>
<td colspan="5" class="text-center py-12">没有找到短链</td>
</tr>
</div>
</div>
<div id="pagination" class="flex justify-between items-center mt-6 hidden">
<div class="text-sm text-gray-500">
显示 <span id="showingRange">0-0</span> 条,共 <span id="totalItems">0</span>
</div>
<div class="flex space-x-2">
<button id="prevPage" class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50" disabled>上一页</button>
<div id="pageNumbers" class="flex space-x-1"></div>
<button id="nextPage" class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50" disabled>下一页</button>
</div>
</div>
</div>
<!-- 设置选项卡内容 -->
<div id="settings-content" class="tab-content hidden">
<h2 class="text-xl font-semibold text-gray-700 mb-6">系统设置</h2>
<form id="settingsForm" class="space-y-6">
<input type="hidden" id="settingsId" name="id">
<div class="bg-gray-50 p-5 rounded-lg">
<h3 class="text-lg font-medium text-gray-800 mb-4 flex items-center">
<i class="fa fa-link text-primary mr-2"></i>短链接设置
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="default_domain" class="block text-sm font-medium text-gray-700 mb-1">默认域名</label>
<input type="text" id="default_domain" name="domain" 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.cn 如果为空则为当前主域名">
<p class="mt-1 text-xs text-gray-500">域名需要解析到当前网站才可使用</p>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button type="button" id="resetSettingsBtn" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
重置
</button>
<button type="button" id="saveSettingsBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存设置
</button>
</div>
</form>
</div>
</div>
<!-- 表单弹窗背景 -->
<div id="formBackdrop" class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div>
<!-- 短链表单弹窗 -->
<div id="formModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 id="formTitle" class="text-xl font-bold text-gray-800 flex items-center">
<i class="fa fa-plus-circle text-primary mr-2"></i>
创建新短链
</h3>
<button id="closeFormBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fa fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="shortlinkForm" class="space-y-5">
<input type="hidden" id="shortlinkId" name="id">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">短链名称 <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required 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="请输入短链名称">
</div>
<div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">目标链接 <span class="text-red-500">*</span></label>
<!-- 移除 textarea type 属性,因为它不适用 -->
<textarea id="url" name="url" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary transition transition-colors resize-none"></textarea>
</div>
<div>
<label for="code" class="block text-sm font-medium text-gray-700 mb-1">自定义短码(可选)</label>
<div class="flex">
<span class="inline-flex items-center px-3 rounded-l-lg border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
/so/
</span>
<input type="text" id="code" name="code" class="flex-1 px-4 py-2 border border-gray-300 rounded-r-lg focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors" placeholder="留空则自动生成">
</div>
<p class="mt-1 text-xs text-gray-500">仅支持字母、数字和短横线,不超过20个字符</p>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">描述(可选)</label>
<textarea id="description" name="description" rows="3" 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 resize-none" placeholder="请输入短链描述信息"></textarea>
</div>
<div>
<label class="flex items-center">
<!-- 移除默认的checked属性,避免覆盖JS设置的状态 -->
<input type="checkbox" id="is_active" name="is_active" value="1" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-700">启用状态</span>
</label>
</div>
</form>
</div>
<div class="border-t border-gray-100 px-6 py-4 flex justify-end gap-3">
<button id="cancelBtn" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
取消
</button>
<button id="submitBtn" type="button" class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存短链
</button>
</div>
</div>
</div>
<script type="text/javascript">
// 分页相关功能
const PAGE_SIZE = 10;
let currentPage = 1,
totalPages = 1;
// 分页DOM元素
const listEl = document.getElementById('shortlinkList');
const [paginationEl, prevBtn, nextBtn, pageNumbers] = ['pagination', 'prevPage', 'nextPage', 'pageNumbers'].map(id => document.getElementById(id));
const [rangeEl, totalEl, loadingEl, emptyEl] = ['showingRange', 'totalItems', 'loading', 'empty'].map(id => document.getElementById(id));
// 初始化分页
document.addEventListener('DOMContentLoaded', () => {
loadPage(1);
prevBtn.onclick = () => currentPage > 1 && loadPage(currentPage - 1);
nextBtn.onclick = () => currentPage < totalPages && loadPage(currentPage + 1);
});
// 加载分页数据
async function loadPage(page) {
// 显示加载状态
loadingEl.classList.remove('hidden');
listEl.innerHTML = '';
paginationEl.classList.add('hidden');
emptyEl.classList.add('hidden');
try {
// 请求数据
const res = await fetch(`/admin/sourl/list?page=${page}&page_size=${PAGE_SIZE}`);
const { data } = await res.json();
// 更新分页信息
currentPage = data.current_page;
totalPages = data.total_pages;
totalEl.textContent = data.total_items;
// 渲染列表
if (data.items.length) {
data.items.forEach(link => {
const statusClass = link.is_active == 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = link.is_active == 1 ? '启用' : '停用';
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 transition-colors';
tr.innerHTML = `
<!-- 短链信息单元格 - 包含名称和描述 -->
<td class="px-4 py-4 whitespace-nowrap">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 truncate">${escapeHtml(link.name ?? '未命名')}</div>
<div class="text-xs text-gray-500 truncate max-w-xs">
${escapeHtml(link.description ?? '无描述')}
</div>
</div>
</td>
<!-- 短码单元格 -->
<td class="px-4 py-4 whitespace-nowrap">
<button class="copy-code-btn text-primary hover:text-primary/80 hover:underline font-medium text-sm flex items-center"
data-code="${link.code}"
title="点击或按Enter复制短码"
tabindex="0"
onclick="copyToClipboard('<?= htmlspecialchars($data['domain'] ?? '', ENT_QUOTES) ?>/so/${escapeHtml(link.code)}', '短码')"
<span>${escapeHtml(link.code)}</span>
<i class="fa fa-copy ml-1 opacity-70 text-primary"></i>
</button>
</td>
<!-- 跳转链接 - 小屏幕隐藏 -->
<td class="px-4 py-4 whitespace-nowrap hidden sm:table-cell">
<div class="text-sm text-gray-500 truncate max-w-xs">
${escapeHtml(link.url)}
</div>
</td>
<!-- 状态 -->
<td class="px-4 py-4 whitespace-nowrap">
<span class="inline-block px-2 py-1 text-xs rounded-full ${statusClass}">
${statusText}
</span>
</td>
<!-- 访问计数 -->
<td class="px-4 py-4 whitespace-nowrap">
<span class="inline-block px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
${link.views} 次
</span>
</td>
<!-- 操作按钮 -->
<td class="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<button class="edit-btn text-gray-500 hover:text-blue-500"
data-id="${link.id}" title="编辑">
<i class="fa fa-pencil"></i>
</button>
<button class="delete-btn text-gray-500 hover:text-red-500"
data-id="${link.id}" title="删除">
<i class="fa fa-trash"></i>
</button>
</div>
</td>
`;
listEl.appendChild(tr);
});
} else {
listEl.innerHTML = `<tr><td colspan="5" class="text-center py-12">没有查到短链;请创建后查看!</td></tr>`;
}
// 更新分页控件
rangeEl.textContent = `${(page-1)*PAGE_SIZE+1}-${Math.min(page*PAGE_SIZE, data.total_items)}`;
renderPageNumbers();
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
paginationEl.classList.remove('hidden');
} catch (e) {
listEl.innerHTML = `<tr><td colspan="5" class="text-center py-12">加载失败: ${e.message}</td></tr>`;
} finally {
loadingEl.classList.add('hidden');
}
}
// 渲染页码按钮
function renderPageNumbers() {
pageNumbers.innerHTML = '';
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, start + 4);
for (let i = start; i <= end; i++) {
const btn = document.createElement('button');
btn.className = `px-3 py-1 rounded ${i === currentPage ? 'bg-primary text-white' : 'border'}`;
btn.textContent = i;
btn.onclick = () => loadPage(i);
pageNumbers.appendChild(btn);
}
}
// HTML转义函数
function escapeHtml(str) {
return str ? str.toString().replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
} [c])) : '';
}
function generateCode(length = 8) {
const chars = '1234567890ACDEFGHIJKLMNOPQRSTUVWXYZ';
let code = '';
for (let i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
// 选项卡切换功能
function switchTab(tabName) {
document.getElementById('list-content').classList.add('hidden');
document.getElementById('settings-content').classList.add('hidden');
document.getElementById('list-tab').classList.remove('border-primary', 'text-primary');
document.getElementById('list-tab').classList.add('border-transparent', 'text-gray-500');
document.getElementById('settings-tab').classList.remove('border-primary', 'text-primary');
document.getElementById('settings-tab').classList.add('border-transparent', 'text-gray-500');
document.getElementById(`${tabName}-content`).classList.remove('hidden');
document.getElementById(`${tabName}-tab`).classList.remove('border-transparent', 'text-gray-500');
document.getElementById(`${tabName}-tab`).classList.add('border-primary', 'text-primary');
if (tabName === 'settings' && !window.settingsLoaded) {
loadSettings();
window.settingsLoaded = true;
}
}
// 主功能逻辑
document.addEventListener('DOMContentLoaded', function() {
const formModal = document.getElementById('formModal');
const formBackdrop = document.getElementById('formBackdrop');
const openFormBtn = document.getElementById('openFormBtn');
const closeFormBtn = document.getElementById('closeFormBtn');
const cancelBtn = document.getElementById('cancelBtn');
const submitBtn = document.getElementById('submitBtn');
const formTitle = document.getElementById('formTitle');
const shortlinkForm = document.getElementById('shortlinkForm');
const shortlinkList = document.getElementById('shortlinkList');
const settingsForm = document.getElementById('settingsForm');
const saveSettingsBtn = document.getElementById('saveSettingsBtn');
window.settingsLoaded = false;
// 检查必要元素
function checkElements() {
const elements = [formModal, formBackdrop, openFormBtn, closeFormBtn, cancelBtn, submitBtn];
const missing = elements.filter(el => !el);
if (missing.length > 0) {
console.error('缺少必要的DOM元素,弹窗功能无法正常工作');
return false;
}
return true;
}
// 打开表单弹窗
function openFormModal() {
if (!checkElements()) return;
resetForm();
formModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.add('scale-100');
formBackdrop.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
void formModal.offsetWidth; // 强制重绘
document.getElementById('code').value = generateCode();
}
// 关闭表单弹窗
function closeFormModal() {
if (!checkElements()) return;
formModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.remove('scale-100');
formBackdrop.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
// 重置表单
function resetForm() {
shortlinkForm.reset();
document.getElementById('shortlinkId').value = '';
formTitle.innerHTML = '<i class="fa fa-plus-circle text-primary mr-2"></i> 创建新短链';
submitBtn.innerHTML = '保存短链';
submitBtn.disabled = false;
}
// 加载设置
window.loadSettings = async function() {
try {
const response = await fetch('/admin/sourl/settings');
if (!response.ok) throw new Error('获取设置失败');
const data = await response.json();
if (data.success && data.data) {
const settings = data.data;
document.getElementById('settingsId').value = settings.id || '';
document.getElementById('default_domain').value = settings.domain || '';
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 表单验证
function validateForm(formElement) {
if (formElement.id === 'shortlinkForm') {
const name = formElement.querySelector('#name').value.trim();
const url = formElement.querySelector('#url').value.trim();
if (!name) {
showMessage('请输入短链名称', 'error');
return false;
}
if (!url) {
showMessage('请输入目标链接', 'error');
return false;
}
// 简单URL验证
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
if (!urlPattern.test(url)) {
showMessage('请输入有效的URL地址', 'error');
return false;
}
} else if (formElement.id === 'settingsForm') {
// 设置表单验证
const domain = formElement.querySelector('#default_domain').value.trim();
if (!domain) {
showMessage('请输入默认域名', 'error');
return false;
}
}
return true;
}
// 表单提交
async function submitFormData(url, formElement, successMsg) {
if (!validateForm(formElement)) return;
const submitButton = formElement.id === 'shortlinkForm' ? submitBtn : document.getElementById('saveSettingsBtn');
const originalText = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 保存中...';
try {
const formData = new FormData(formElement);
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.success) {
showMessage(data.message, 'info');
closeFormModal();
setTimeout(() => location.reload(), 1000);
} else {
throw new Error(data.message || '操作失败');
}
} catch (e) {
showMessage(e.message, 'error');
} finally {
submitButton.disabled = false;
submitButton.innerHTML = originalText;
}
}
// 加载短链数据(编辑用)
async function loadShortlinkData(id) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 加载中...';
try {
const response = await fetch(`/admin/sourl/get/${id}`);
if (!response.ok) throw new Error('获取数据失败');
const data = await response.json();
if (data.success && data.data) {
const { id, name, url, code, description, is_active } = data.data;
document.getElementById('shortlinkId').value = id;
document.getElementById('name').value = name || '';
document.getElementById('url').value = url || '';
document.getElementById('code').value = code || '';
document.getElementById('description').value = description || '';
document.getElementById('is_active').checked = Boolean(Number(is_active));
formTitle.innerHTML = '<i class="fa fa-pencil text-primary mr-2"></i> 编辑短链';
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (e) {
showMessage(e.message, 'error');
closeFormModal();
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '保存短链';
}
}
// 删除短链
async function deleteLink(id) {
if (!confirm('确定要删除该短链吗?此操作不可恢复!')) return;
try {
const response = await fetch(`/admin/sourl/delete/${id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showMessage('短链已删除', 'info');
const row = document.querySelector(`tr[data-id="${id}"]`);
if (row) {
// 添加删除动画
row.classList.add('opacity-0', 'transform', 'translate-x-4', 'transition-all', 'duration-300');
setTimeout(() => {
row.remove();
const rows = shortlinkList.querySelectorAll('tr:not(:last-child)');
if (rows.length === 0) {
shortlinkList.innerHTML = `
<tr>
<td colspan="5" class="px-6 py-12 text-center">
<div class="flex flex-col items-center">
<i class="fa fa-link 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">尝试调整筛选条件或添加新短链</p>
<button class="mt-4 bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center" id="openFormBtn">
<i class="fa fa-plus mr-2"></i>
<span>新增短链</span>
</button>
</div>
</td>
</tr>`;
}
}, 300);
}
} else {
throw new Error(data.message || '删除失败');
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 绑定事件
if (checkElements()) {
// 打开表单
openFormBtn.addEventListener('click', openFormModal);
// 关闭表单
closeFormBtn.addEventListener('click', closeFormModal);
cancelBtn.addEventListener('click', closeFormModal);
formBackdrop.addEventListener('click', closeFormModal);
// 短链表单提交
submitBtn.addEventListener('click', function() {
submitFormData('/admin/sourl/update', shortlinkForm, '短链保存成功');
});
// 设置表单提交
saveSettingsBtn.addEventListener('click', function() {
submitFormData('/admin/sourl/settings', settingsForm, '设置保存成功');
});
// 列表操作事件委托
shortlinkList.addEventListener('click', function(e) {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
if (editBtn) {
const id = editBtn.getAttribute('data-id');
if (id) {
openFormModal();
setTimeout(() => loadShortlinkData(id), 300);
}
} else if (deleteBtn) {
const id = deleteBtn.getAttribute('data-id');
if (id) deleteLink(id);
}
});
// ESC键关闭弹窗
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !formModal.classList.contains('invisible')) {
closeFormModal();
}
});
// 阻止表单默认提交
shortlinkForm.addEventListener('submit', function(e) {
e.preventDefault();
});
}
});
</script>
+80
View File
@@ -0,0 +1,80 @@
<?php
/**
* Plugin Name: SoUrl
* Description: 用来缩短网址链接或者说是跳转到新地址的插件。
* Version: 1.0.0
* Author: JuheDev
* Plugin URL: https://plugins.juhe.me/sourl
*/
return [
'menus' => [
[
'title' => '缩短链接',
'icon' => 'fa fa-chain',
'path' => '/admin/sourl',
],
],
'route_group' => [
[
'prefix' => '/so',
'namespace' => 'Plugins\SoUrl\Controllers\Web',
'routes' => [
['GET', '/{code}', 'SoUrlController@redirect'],
],
],
[
'prefix' => '/admin/sourl',
'namespace' => 'Plugins\SoUrl\Controllers\Admin',
'routes' => [
['GET', '/', 'SoUrlController@index'],
['GET', '/get/{id}', 'SoUrlController@get'],
['GET', '/list', 'SoUrlController@list'],
['GET', '/delete/{id}', 'SoUrlController@deletelink'],
['POST', '/update', 'SoUrlController@update'],
['GET|POST', '/settings', 'SoUrlController@settings'],
],
],
],
// 插件所需表
'tables' => ['sourl_list', 'sourl_settings'],
'init' => function () {
// 插件初始化,可选
// require_once __DIR__ . '/helpers.php';
},
'activate' => function ($db) {
$db->query("
CREATE TABLE IF NOT EXISTS `sourl_list` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`code` varchar(64) NOT NULL,
`url` text NOT NULL,
`name` varchar(255) NOT NULL DEFAULT '',
`description` text,
`views` int(6) DEFAULT 0,
`is_active` tinyint(1) NOT NULL DEFAULT '1',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->query("
CREATE TABLE IF NOT EXISTS `sourl_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`domain` text NOT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT '1',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
},
'deactivate' => function ($db) {
// 插件被停用时执行
},
];
+407
View File
@@ -0,0 +1,407 @@
<?php
namespace Plugins\WxGCode\Controllers\Admin;
use App\Core\PluginBaseController;
class WxGCodeController extends PluginBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->db = $this->pluginManager->getDB();
}
public function index() {
$this->checkLogin(); // 登录保护
// 获取配置信息(带默认值,避免空值)
$settings = $this->db->get('wxgcode_settings', '*') ?: []; // 如果没有数据,返回空数组
// 传数据给视图(统一封装在data中)
$this->renderPluginView('WxGCode', 'Admin/index.php', [
'data' => [
'settings' => '', // 配置信息
'domain' => '' // 带协议的完整域名
],
'title' => '扫一扫管理中心'
]);
}
public function list() {
$this->checkLogin(); // 登录保护
// 获取分页参数
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$pageSize = isset($_GET['page_size']) ? max(1, min(100, intval($_GET['page_size']))) : 10;
$offset = ($page - 1) * $pageSize;
// 1. 获取总记录数
$totalItems = $this->db->count('wxgcode_list', '*');
// 2. 获取当前页数据
$shortlinks = $this->db->select('wxgcode_list', '*', [
'ORDER' => ['id' => 'DESC'],
'LIMIT' => [$offset, $pageSize]
]);
// 3. 计算分页信息
$totalPages = max(1, ceil($totalItems / $pageSize));
// 4. 返回JSON格式数据
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'data' => [
'items' => $shortlinks,
'current_page' => $page,
'total_pages' => $totalPages,
'total_items' => $totalItems,
'page_size' => $pageSize,
'has_prev' => $page > 1,
'has_next' => $page < $totalPages
]
], JSON_UNESCAPED_UNICODE);
exit;
}
public function get($id) {
$this->checkLogin();
// 设置响应内容类型为JSON
header('Content-Type: application/json');
$row = $this->db->get('wxgcode_list', '*', ['id' => $id]);
if ($row) {
// 成功响应,包含状态和数据
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'message' => '无效的ID或记录不存在'
]);
}
exit;
}
public function update() {
// 设置JSON响应头
header('Content-Type: application/json');
$this->checkLogin();
// 获取表单数据(使用前端页面对应的字段名)
$id = intval($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$wx_group_name = trim($_POST['wx_group_name'] ?? '');
$qrcode_url = trim($_POST['qrcode_url'] ?? '');
$code = trim($_POST['code'] ?? '');
$max_scans = intval($_POST['max_scans'] ?? 0);
$max_members = intval($_POST['max_members'] ?? 0);
$description = trim($_POST['description'] ?? '');
$status = isset($_POST['status']) ? 1 : 0;
try {
// 数据验证
if (empty($name)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => '请输入活码名称'
]);
exit;
}
if (empty($wx_group_name)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => '请输入微信群名称'
]);
exit;
}
if (empty($qrcode_url)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => '请输入群二维码URL'
]);
exit;
}
if (empty($code)) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => '活码编码不能为空'
]);
exit;
}
// 有ID则更新
if ($id > 0) {
$data = [
'name' => $name,
'wx_group_name' => $wx_group_name,
'qrcode_url' => $qrcode_url,
'code' => $code,
'max_scans' => $max_scans,
'max_members' => $max_members,
'description' => $description,
'status' => $status,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->update('wxgcode_list', $data, ['id' => $id]);
if ($result) {
$qrcode = $this->db->get('wxgcode_list', '*', ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '更新成功',
'data' => $qrcode
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '更新失败,请稍后重试'
]);
}
}
// 无ID则新增
else {
// 检查编码是否已存在
$exists = $this->db->has('wxgcode_list', ['code' => $code]);
if ($exists) {
http_response_code(400);
echo json_encode([
'success' => false,
'message' => '活码编码已存在,请更换'
]);
exit;
}
$insertId = $this->db->insert('wxgcode_list', [
'name' => $name,
'wx_group_name' => $wx_group_name,
'qrcode_url' => $qrcode_url,
'code' => $code,
'max_scans' => $max_scans,
'max_members' => $max_members,
'description' => $description,
'status' => $status,
'total_views' => 0, // 对应前端显示的访问量
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if ($insertId) {
echo json_encode([
'success' => true,
'message' => '创建成功',
'data' => [
'id' => $insertId,
'name' => $name,
'wx_group_name' => $wx_group_name,
'qrcode_url' => $qrcode_url,
'code' => $code,
'max_scans' => $max_scans,
'max_members' => $max_members,
'description' => $description,
'status' => $status,
'total_views' => 0
]
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '创建失败,请稍后重试'
]);
}
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '操作失败: ' . $e->getMessage()
]);
}
exit;
}
public function settings() {
$this->checkLogin();
header('Content-Type: application/json');
$id = intval($_REQUEST['id'] ?? 0);
try {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// 获取第一条设置记录
$row = $this->db->get('wxgcode_settings', '*');
if ($row) {
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'message' => '设置不存在'
]);
}
} else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 获取提交数据
$wechat_name = trim($_POST['wechat_name'] ?? '');
$wechat_account = trim($_POST['wechat_account'] ?? '');
$appid = trim($_POST['appid'] ?? '');
$appsecret = trim($_POST['appsecret'] ?? '');
$token = trim($_POST['token'] ?? '');
$encoding_aes_key = trim($_POST['encoding_aes_key'] ?? '');
$qrcode_url = trim($_POST['qrcode_url'] ?? '');
$wechat_type = trim($_POST['wechat_type'] ?? 'service');
$status = isset($_POST['status']) ? 1 : 0;
// 验证必填项
if (empty($wechat_name) || empty($wechat_account) || empty($appid) || empty($appsecret)) {
echo json_encode([
'success' => false,
'message' => '公众号名称、原始ID、AppID和AppSecret为必填项'
]);
exit;
}
if ($id > 0) {
// 更新
$data = [
'wechat_name' => $wechat_name,
'wechat_account' => $wechat_account,
'appid' => $appid,
'appsecret' => $appsecret,
'token' => $token,
'encoding_aes_key' => $encoding_aes_key,
'qrcode_url' => $qrcode_url,
'wechat_type' => $wechat_type,
'status' => $status,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->update('wxgcode_settings', $data, ['id' => $id]);
if ($result) {
$setting = $this->db->get('wxgcode_settings', '*', ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '设置更新成功',
'data' => $setting
]);
} else {
echo json_encode([
'success' => false,
'message' => '更新失败或数据无变化'
]);
}
} else {
// 新增
$insertId = $this->db->insert('wxgcode_settings', [
'wechat_name' => $wechat_name,
'wechat_account' => $wechat_account,
'appid' => $appid,
'appsecret' => $appsecret,
'token' => $token,
'encoding_aes_key' => $encoding_aes_key,
'qrcode_url' => $qrcode_url,
'wechat_type' => $wechat_type,
'status' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if ($insertId) {
$setting = $this->db->get('wxgcode_settings', '*');
echo json_encode([
'success' => true,
'message' => '设置创建成功',
'data' => $setting
]);
} else {
echo json_encode([
'success' => false,
'message' => '创建失败,请稍后重试'
]);
}
}
} else {
http_response_code(405);
echo json_encode([
'success' => false,
'message' => '只支持 GET 和 POST 请求'
]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '操作失败: ' . $e->getMessage()
]);
}
exit;
}
public function delete($id) {
$this->checkLogin();
header('Content-Type: application/json');
try {
// 检查是否存在
$row = $this->db->get('wxgcode_list', '*', ['id' => $id]);
if (!$row) {
echo json_encode([
'success' => false,
'message' => '要删除的记录不存在'
]);
exit;
}
// 执行删除
$result = $this->db->delete('wxgcode_list', ['id' => $id]);
if ($result) {
echo json_encode([
'success' => true,
'message' => '记录已删除'
]);
} else {
echo json_encode([
'success' => false,
'message' => '删除失败,请稍后再试'
]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '删除失败: ' . $e->getMessage()
]);
}
exit;
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace Plugins\WxGCode\Controllers\Web;
use App\Core\WebBaseController;
class WxGCodeController extends WebBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->db = $this->pluginManager->getDB();
}
public function index($code) {
$code = $code;
include __DIR__ . '/../../Views/Web/index.php';
}
public function get($code) {
// 设置响应内容类型为JSON
header('Content-Type: application/json');
// 验证code格式 (假设code是字母数字组合)
if (empty($code) || !preg_match('/^[A-Za-z0-9]+$/', $code)) {
echo json_encode([
'success' => false,
'message' => '无效的活码编码'
]);
exit;
}
// 查询有效的活码记录 (只查询启用状态的)
$row = $this->db->get('wxgcode_list', '*', [
'AND' => [
'code' => $code,
'status' => 1 // 只显示启用状态的活码
]
]);
if ($row) {
// 记录访问量
$this->increaseViewCount($row['id']);
// 检查是否需要切换到备用活码 (如果当前活码达到最大扫码次数)
if ($row['max_scans'] > 0 && $row['total_scans'] >= $row['max_scans'] && !empty($row['backup_id'])) {
$backupRow = $this->db->get('wxgcode_list', '*', [
'AND' => [
'id' => $row['backup_id'],
'status' => 1
]
]);
if ($backupRow) {
$row = $backupRow;
}
}
// 格式化数据
if (isset($row['created_at'])) {
$row['created_at'] = date('Y-m-d', strtotime($row['created_at']));
}
// 返回活码信息
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'message' => '活码不存在或已被禁用'
]);
}
exit;
}
/**
* 增加活码访问量
* @param int $id 活码ID
*/
protected function increaseViewCount($id) {
// 增加扫码次数
$this->db->update('wxgcode_list', [
'total_views[+]' => 1
], ['id' => $id]);
}
/**
* 刷新二维码
* @param string $code 活码编码
*/
public function refreshQrcode($code) {
header('Content-Type: application/json');
$row = $this->db->get('wxgcode_list', ['id', 'qrcode_url', 'code'], [
'AND' => [
'code' => $code,
'status' => 1
]
]);
if ($row) {
// 这里可以添加调用微信接口生成新二维码的逻辑
// 示例:$newQrcodeUrl = $this->generateNewQrcode($row['id']);
// 简单模拟刷新(实际项目中应替换为真实逻辑)
$newQrcodeUrl = $row['qrcode_url'] . '?t=' . time();
// 更新数据库中的二维码URL
$this->db->update('wxgcode_list', [
'qrcode_url' => $newQrcodeUrl,
'update_time' => date('Y-m-d H:i:s')
], ['id' => $row['id']]);
echo json_encode([
'success' => true,
'data' => [
'qrcode_url' => $newQrcodeUrl
]
]);
} else {
echo json_encode([
'success' => false,
'message' => '刷新失败,活码不存在或已被禁用'
]);
}
exit;
}
}
+1
View File
@@ -0,0 +1 @@
<?php exit();?>{"expire_time":1754852111,"access_token":"95_rXIZ_RT-sfFba-lFUL3IyhSMbDb32bqoFpYzJCYhxLI-7HzWv6v6GqxIfn2V_8qJ1nkGFA-9RtcLNdiEIWHGwNFuuUZntG8CM_OmB4F2tb0teS2QI8EArS4VlOAZRSbAEANFG"}
+1
View File
@@ -0,0 +1 @@
<?php exit();?>{"expire_time":1754852112,"jsapi_ticket":"LIKLckvwlJT9cWIhEQTwfMAhJFYw3_TwrJw6wWpdGhRHwl7AqiNprXTimrsImS13T-xXctQR4na76SuT9Pkxgg"}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace Plugins\WxGCode\Controllers\lib;
class JSSDK {
private $appId;
private $appSecret;
// 缓存文件路径(使用绝对路径避免问题)
private $cacheDir;
public function __construct($appId, $appSecret) {
$this->appId = $appId;
$this->appSecret = $appSecret;
// 初始化缓存目录(与jssdk.php同目录)
$this->cacheDir = dirname(__FILE__) . '/';
// 确保缓存目录可写
$this->checkCacheDir();
}
// 检查缓存目录是否存在且可写
private function checkCacheDir() {
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
if (!is_writable($this->cacheDir)) {
throw new \Exception("缓存目录不可写:{$this->cacheDir}");
}
}
// 检查并创建缓存文件
private function checkCacheFile($filename) {
$filePath = $this->cacheDir . $filename;
// 如果文件不存在则创建并初始化
if (!file_exists($filePath)) {
$initialData = json_encode([
'expire_time' => 0,
'access_token' => '',
'jsapi_ticket' => ''
]);
$this->set_php_file($filename, $initialData);
}
return $filePath;
}
public function getSignPackage() {
$jsapiTicket = $this->getJsApiTicket();
// 动态获取当前URL
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
$url = "$protocol$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";
$timestamp = time();
$nonceStr = $this->createNonceStr();
// 按ASCII码升序排序
$string = "jsapi_ticket=$jsapiTicket&noncestr=$nonceStr&timestamp=$timestamp&url=$url";
$signature = sha1($string);
return [
"appId" => $this->appId,
"nonceStr" => $nonceStr,
"timestamp" => $timestamp,
"url" => $url,
"signature" => $signature,
"rawString" => $string
];
}
private function createNonceStr($length = 16) {
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
$str = "";
for ($i = 0; $i < $length; $i++) {
$str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
}
return $str;
}
private function getJsApiTicket() {
// 先检查并创建缓存文件
$this->checkCacheFile("jsapi_ticket.php");
$data = json_decode($this->get_php_file("jsapi_ticket.php"));
// 处理空数据或过期情况
if (empty($data) || $data->expire_time < time()) {
$accessToken = $this->getAccessToken();
$url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi&access_token=$accessToken";
$res = json_decode($this->httpGet($url));
if (isset($res->ticket)) {
$data = new \stdClass(); // 初始化空对象
$data->expire_time = time() + 7000;
$data->jsapi_ticket = $res->ticket;
$this->set_php_file("jsapi_ticket.php", json_encode($data));
} else {
throw new \Exception("获取jsapi_ticket失败: " . json_encode($res));
}
}
return $data->jsapi_ticket ?? '';
}
private function getAccessToken() {
// 先检查并创建缓存文件
$this->checkCacheFile("access_token.php");
$data = json_decode($this->get_php_file("access_token.php"));
// 处理空数据或过期情况
if (empty($data) || $data->expire_time < time()) {
$url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=$this->appId&secret=$this->appSecret";
$res = json_decode($this->httpGet($url));
if (isset($res->access_token)) {
$data = new \stdClass(); // 初始化空对象
$data->expire_time = time() + 7000;
$data->access_token = $res->access_token;
$this->set_php_file("access_token.php", json_encode($data));
} else {
throw new \Exception("获取access_token失败: " . json_encode($res));
}
}
return $data->access_token ?? '';
}
private function httpGet($url) {
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_TIMEOUT, 500);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, true);
curl_setopt($curl, CURLOPT_URL, $url);
$res = curl_exec($curl);
curl_close($curl);
return $res;
}
private function get_php_file($filename) {
$filePath = $this->cacheDir . $filename;
return trim(substr(file_get_contents($filePath), 15));
}
private function set_php_file($filename, $content) {
$filePath = $this->cacheDir . $filename;
$fp = fopen($filePath, "w");
fwrite($fp, "<?php exit();?>" . $content);
fclose($fp);
}
}
+754
View File
@@ -0,0 +1,754 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fa fa-qrcode text-primary mr-3"></i>
微信群活码管理中心
</h1>
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<!-- 消息提示框 -->
<div id="message" class="mb-4 px-4 py-3 rounded-lg hidden"></div>
<!-- 选项卡导航 -->
<div class="border-b border-gray-200 mb-6">
<ul class="flex flex-wrap -mb-px" id="tabs" role="tablist">
<li class="mr-2" role="presentation">
<button id="list-tab" class="inline-block py-4 px-5 border-b-2 border-primary text-sm font-medium text-primary" onclick="switchTab('list')" aria-selected="true">
活码列表
</button>
</li>
<li class="mr-2" role="presentation">
<button id="settings-tab" class="inline-block py-4 px-5 border-b-2 border-transparent text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300" onclick="switchTab('settings')" aria-selected="false">
系统设置
</button>
</li>
</ul>
</div>
<!-- 活码列表内容 -->
<div id="list-content" class="tab-content">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-700">活码列表</h2>
<!-- 创建新活码按钮 -->
<button id="openFormBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center">
<i class="fa fa-plus mr-2"></i>
<span>新增群活码</span>
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full bg-white rounded-xl shadow-md overflow-hidden">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">活码名称</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">活码编码</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">微信群名称</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">访问量</th>
<th scope="col" 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" id="shortlinkList">
<!-- 内容将通过JavaScript动态生成 -->
</tbody>
</table>
<div id="loading" class="hidden py-10 text-center"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>
<div id="empty" class="hidden">
<tr>
<td colspan="5" class="text-center py-12">没有找到活码</td>
</tr>
</div>
</div>
<!-- 分页和状态控件 -->
<div id="pagination" class="flex justify-between items-center mt-6 hidden">
<div class="text-sm text-gray-500">
显示 <span id="showingRange">0-0</span> 条,共 <span id="totalItems">0</span>
</div>
<div class="flex space-x-2">
<button id="prevPage" class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50" disabled>上一页</button>
<div id="pageNumbers" class="flex space-x-1"></div>
<button id="nextPage" class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50" disabled>下一页</button>
</div>
</div>
</div>
<!-- 设置选项卡内容 -->
<div id="settings-content" class="tab-content hidden">
<h2 class="text-xl font-semibold text-gray-700 mb-6">系统设置</h2>
<form id="settingsForm" class="space-y-6">
<input type="hidden" id="settingsId" name="id">
<!-- 公众号基本信息 -->
<div class="bg-gray-50 p-5 rounded-lg">
<h3 class="text-lg font-medium text-gray-800 mb-4 flex items-center">
<i class="fa fa-wechat text-primary mr-2"></i>公众号基本信息
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="wechat_name" class="block text-sm font-medium text-gray-700 mb-1">公众号名称 <span class="text-red-500">*</span></label>
<input type="text" id="wechat_name" name="wechat_name" required 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="请输入公众号名称">
</div>
<div>
<label for="wechat_account" class="block text-sm font-medium text-gray-700 mb-1">公众号原始ID <span class="text-red-500">*</span></label>
<input type="text" id="wechat_account" name="wechat_account" required 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="格式为gh_xxxx">
</div>
<div>
<label for="wechat_type" class="block text-sm font-medium text-gray-700 mb-1">公众号类型 <span class="text-red-500">*</span></label>
<select id="wechat_type" name="wechat_type" required 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">
<option value="subscription">订阅号</option>
<option value="service" selected>服务号</option>
<option value="enterprise">企业号</option>
<option value="test">测试号</option>
</select>
</div>
<div>
<label for="qrcode_url" class="block text-sm font-medium text-gray-700 mb-1">公众号二维码URL</label>
<div class="flex items-center">
<input type="url" id="qrcode_url" name="qrcode_url"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
placeholder="https://example.com/share.jpg">
<div class="relative">
<button type="button"
class="w-10 h-10 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center justify-center"
onclick="OpenGallery('qrcode_url', 'setting_qrimg')">
<img src="https://cdn-icons-png.flaticon.com/128/10054/10054290.png" alt="预览图" class=" object-cover rounded">
</button>
</div>
</div>
</div>
<div>
<img class="w-[120px] h-[120px] object-contain" id='setting_qrimg' src="" alt="公众号二维码预览" />
</div>
<div>
<label class="flex items-center">
<input type="checkbox" id="setting_status" name="status" value="1" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-700">启用当前公众号配置</span>
</label>
</div>
</div>
</div>
<!-- 公众号接口配置 -->
<div class="bg-gray-50 p-5 rounded-lg">
<h3 class="text-lg font-medium text-gray-800 mb-4 flex items-center">
<i class="fa fa-plug text-primary mr-2"></i>接口配置信息
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="appid" class="block text-sm font-medium text-gray-700 mb-1">AppID <span class="text-red-500">*</span></label>
<input type="text" id="appid" name="appid" required 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="公众号的AppID">
</div>
<div>
<label for="appsecret" class="block text-sm font-medium text-gray-700 mb-1">AppSecret <span class="text-red-500">*</span></label>
<input type="text" id="appsecret" name="appsecret" required 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="公众号的AppSecret">
</div>
<div>
<label for="token" class="block text-sm font-medium text-gray-700 mb-1">Token</label>
<input type="text" id="token" name="token" 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="用于接口调用的Token">
<p class="mt-1 text-xs text-gray-500">由开发者自定义,用于生成签名</p>
</div>
<div>
<label for="encoding_aes_key" class="block text-sm font-medium text-gray-700 mb-1">EncodingAESKey</label>
<input type="text" id="encoding_aes_key" name="encoding_aes_key" 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="消息加密密钥">
<p class="mt-1 text-xs text-gray-500">消息加解密时使用,43位字符</p>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button type="button" id="saveSettingsBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存设置
</button>
</div>
</form>
</div>
</div>
<!-- 表单弹窗背景 -->
<div id="formBackdrop" class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div>
<!-- 活码表单弹窗 -->
<div id="formModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 id="formTitle" class="text-xl font-bold text-gray-800 flex items-center">
<i class="fa fa-plus-circle text-primary mr-2"></i>
创建新活码
</h3>
<button id="closeFormBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fa fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="shortlinkForm" class="space-y-5">
<input type="hidden" id="shortlinkId" name="id">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">活码名称 <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required 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="请输入活码名称(如:技术交流群)">
</div>
<div>
<label for="wx_group_name" class="block text-sm font-medium text-gray-700 mb-1">微信群名称 <span class="text-red-500">*</span></label>
<input type="text" id="wx_group_name" name="wx_group_name" required 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="请输入微信群名称">
</div>
<div>
<label for="form_qrcode_url" class="block text-sm font-medium text-gray-700 mb-1">群二维码URL <span class="text-red-500">*</span></label>
<div class="flex gap-2">
<input type="url" id="form_qrcode_url" name="qrcode_url"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
placeholder="https://example.com/share.jpg">
<div class="relative">
<button type="button"
class="bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center gap-2"
onclick="OpenGallery('form_qrcode_url', 'image-preview')">
<img id="image-preview" src="" alt="预览图" class="w-10 h-10 object-cover rounded">
</button>
</div>
</div>
</div>
<div>
<label for="code" class="block text-sm font-medium text-gray-700 mb-1">活码编码 <span class="text-red-500">*</span></label>
<input type="text" id="code" name="code" required 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" value="" readonly>
</div>
<div>
<label for="max_scans" class="block text-sm font-medium text-gray-700 mb-1">最大扫码次数(0为无限制)</label>
<input type="number" id="max_scans" name="max_scans" min="0" value="0" 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="0">
</div>
<div>
<label for="max_members" class="block text-sm font-medium text-gray-700 mb-1">群最大人数</label>
<input type="number" id="max_members" name="max_members" min="1" 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="如:200">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">描述(可选)</label>
<textarea id="description" name="description" rows="3" 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 resize-none" placeholder="请输入活码描述(如:技术交流一群,满200人自动切换)"></textarea>
</div>
<div>
<label class="flex items-center">
<input type="checkbox" id="form_status" name="status" value="1" checked class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-700">启用状态</span>
</label>
</div>
</form>
</div>
<div class="border-t border-gray-100 px-6 py-4 flex justify-end gap-3">
<button id="cancelBtn" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
取消
</button>
<button id="submitBtn" type="button" class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存活码
</button>
</div>
</div>
</div>
<div id="qrCodeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white p-6 rounded-lg shadow-xl max-w-xs w-full mx-4">
<h3 class="text-lg font-medium text-gray-900 mb-4 text-center">扫码分享</h3>
<div class="flex justify-center mb-4" id="qrCodeContainer"></div>
<p onclick="copyToClipboard(this.innerText, '分享链接')"id="textId" class="text-sm text-gray-500 text-center mb-4"></p>
<button onclick="hideQrCode()" class="w-full bg-gray-100 hover:bg-gray-200 text-gray-800 py-2 px-4 rounded transition-colors">
关闭
</button>
</div>
</div>
<script type="text/javascript">
// 分页相关功能
const PAGE_SIZE = 10;
let currentPage = 1,
totalPages = 1;
// 分页DOM元素
const listEl = document.getElementById('shortlinkList');
const [paginationEl, prevBtn, nextBtn, pageNumbers] = ['pagination', 'prevPage', 'nextPage', 'pageNumbers'].map(id => document.getElementById(id));
const [rangeEl, totalEl, loadingEl, emptyEl] = ['showingRange', 'totalItems', 'loading', 'empty'].map(id => document.getElementById(id));
// 初始化分页
document.addEventListener('DOMContentLoaded', () => {
loadPage(1);
prevBtn.onclick = () => currentPage > 1 && loadPage(currentPage - 1);
nextBtn.onclick = () => currentPage < totalPages && loadPage(currentPage + 1);
});
// 加载分页数据
async function loadPage(page) {
// 显示加载状态
loadingEl.classList.remove('hidden');
listEl.innerHTML = '';
paginationEl.classList.add('hidden');
emptyEl.classList.add('hidden');
try {
const res = await fetch(`/admin/wxgcode/list?page=${page}&page_size=${PAGE_SIZE}`);
const { data } = await res.json();
// 更新分页信息
currentPage = data.current_page;
totalPages = data.total_pages;
totalEl.textContent = data.total_items;
// 渲染列表
if (data.items.length) {
data.items.forEach(link => {
const statusClass = link.status == 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = link.status == 1 ? '启用' : '停用';
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 transition-colors';
tr.setAttribute('data-id', link.id);
tr.innerHTML = `
<!-- 活码名称和描述 -->
<td class="px-4 py-4 whitespace-nowrap">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 truncate">${escapeHtml(link.name ?? '未命名')}</div>
<div class="text-xs text-gray-500 truncate max-w-xs">
${escapeHtml(link.description ?? '无描述')}
</div>
</div>
</td>
<!-- 活码编码 -->
<td class="px-4 py-4 whitespace-nowrap">
<div class="text-sm text-primary truncate max-w-md cursor-pointer" onclick="showQrCode('${escapeHtml(getQrcodeUrl(link.code))}')">
${link.code}
<i class="fa fa-qrcode ml-1 opacity-70"></i>
</div>
</td>
<!-- 微信群名称(小屏幕隐藏) -->
<td class="px-4 py-4 whitespace-nowrap hidden sm:table-cell">
<div class="text-sm text-gray-500 truncate max-w-md">
${escapeHtml(link.wx_group_name ?? '未设置')}
</div>
</td>
<!-- 状态 -->
<td class="px-4 py-4 whitespace-nowrap">
<span class="inline-block px-2 py-1 text-xs rounded-full ${statusClass}">
${statusText}
</span>
</td>
<!-- 访问量 -->
<td class="px-4 py-4 whitespace-nowrap">
<span class="inline-block px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
${link.total_views} 次
</span>
</td>
<!-- 操作按钮 -->
<td class="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<button class="edit-btn text-gray-500 hover:text-blue-500"
data-id="${link.id}" title="编辑">
<i class="fa fa-pencil"></i>
</button>
<button class="delete-btn text-gray-500 hover:text-red-500"
data-id="${link.id}" title="删除">
<i class="fa fa-trash"></i>
</button>
</div>
</td>
`;
listEl.appendChild(tr);
});
} else {
listEl.innerHTML = `<tr><td colspan="5" class="text-center py-12">没有查到活码;请创建后查看!</td></tr>`;
}
// 更新分页控件
rangeEl.textContent = `${(page-1)*PAGE_SIZE+1}-${Math.min(page*PAGE_SIZE, data.total_items)}`;
renderPageNumbers();
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
paginationEl.classList.remove('hidden');
} catch (e) {
listEl.innerHTML = `<tr><td colspan="5" class="text-center py-12">加载失败: ${e.message}</td></tr>`;
} finally {
loadingEl.classList.add('hidden');
}
}
// 生成活码编码
function generateCode(length = 8) {
const chars = '1234567890ACDEFGHIJKLMNOPQRSTUVWXYZ';
let code = '';
for (let i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
// 生成活码访问链接
function getQrcodeUrl(code) {
const protocol = window.location.protocol;
const host = window.location.host;
return `${protocol}//${host}/wxgcode/${code}`;
}
// 渲染页码按钮
function renderPageNumbers() {
pageNumbers.innerHTML = '';
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, start + 4);
for (let i = start; i <= end; i++) {
const btn = document.createElement('button');
btn.className = `px-3 py-1 rounded ${i === currentPage ? 'bg-primary text-white' : 'border'}`;
btn.textContent = i;
btn.onclick = () => loadPage(i);
pageNumbers.appendChild(btn);
}
}
// HTML转义函数
function escapeHtml(str) {
return str ? str.toString().replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
} [c])) : '';
}
// 二维码相关功能
function showQrCode(url) {
// 清空之前的二维码
document.getElementById('qrCodeContainer').innerHTML = '';
// 生成新的二维码
QRCode.toCanvas(url, {
width: 200,
margin: 1
}, function(error, canvas) {
if (error) {
console.error('生成二维码失败:', error);
return;
}
document.getElementById('qrCodeContainer').appendChild(canvas);
});
// 显示弹窗
document.getElementById('qrCodeModal').classList.remove('hidden');
document.getElementById('textId').textContent = url;
// 阻止页面滚动
document.body.style.overflow = 'hidden';
}
function hideQrCode() {
document.getElementById('qrCodeModal').classList.add('hidden');
// 恢复页面滚动
document.body.style.overflow = '';
}
// 点击弹窗外部关闭
document.addEventListener('DOMContentLoaded', function() {
// 二维码弹窗事件绑定
const qrCodeModal = document.getElementById('qrCodeModal');
if (qrCodeModal) {
qrCodeModal.addEventListener('click', function(e) {
if (e.target === this) {
hideQrCode();
}
});
}
});
// 选项卡切换功能
function switchTab(tabName) {
document.getElementById('list-content').classList.add('hidden');
document.getElementById('settings-content').classList.add('hidden');
document.getElementById('list-tab').classList.remove('border-primary', 'text-primary');
document.getElementById('list-tab').classList.add('border-transparent', 'text-gray-500');
document.getElementById('settings-tab').classList.remove('border-primary', 'text-primary');
document.getElementById('settings-tab').classList.add('border-transparent', 'text-gray-500');
document.getElementById(`${tabName}-content`).classList.remove('hidden');
document.getElementById(`${tabName}-tab`).classList.remove('border-transparent', 'text-gray-500');
document.getElementById(`${tabName}-tab`).classList.add('border-primary', 'text-primary');
if (tabName === 'settings' && !window.settingsLoaded) {
loadSettings();
window.settingsLoaded = true;
}
}
// 主功能逻辑
document.addEventListener('DOMContentLoaded', function() {
const formModal = document.getElementById('formModal');
const formBackdrop = document.getElementById('formBackdrop');
const openFormBtn = document.getElementById('openFormBtn');
const closeFormBtn = document.getElementById('closeFormBtn');
const cancelBtn = document.getElementById('cancelBtn');
const submitBtn = document.getElementById('submitBtn');
const formTitle = document.getElementById('formTitle');
const shortlinkForm = document.getElementById('shortlinkForm');
const shortlinkList = document.getElementById('shortlinkList');
const settingsForm = document.getElementById('settingsForm');
const saveSettingsBtn = document.getElementById('saveSettingsBtn');
window.settingsLoaded = false;
// 检查必要元素
function checkElements() {
const elements = [formModal, formBackdrop, openFormBtn, closeFormBtn, cancelBtn, submitBtn];
const missing = elements.filter(el => !el);
if (missing.length > 0) {
console.error('缺少必要的DOM元素,弹窗功能无法正常工作');
return false;
}
return true;
}
// 打开表单弹窗
function openFormModal() {
if (!checkElements()) return;
resetForm();
formModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.add('scale-100');
formBackdrop.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
void formModal.offsetWidth; // 强制重绘
document.getElementById('code').value = generateCode();
}
// 关闭表单弹窗
function closeFormModal() {
if (!checkElements()) return;
formModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.remove('scale-100');
formBackdrop.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
// 重置表单
function resetForm() {
shortlinkForm.reset();
document.getElementById('shortlinkId').value = '';
document.getElementById('image-preview').src = 'https://cdn-icons-png.flaticon.com/128/10054/10054290.png';
formTitle.innerHTML = '<i class="fa fa-plus-circle text-primary mr-2"></i> 创建新活码';
submitBtn.innerHTML = '保存活码';
submitBtn.disabled = false;
}
// 加载设置
window.loadSettings = async function() {
try {
const response = await fetch('/admin/wxgcode/settings');
if (!response.ok) throw new Error('获取设置失败');
const data = await response.json();
if (data.success && data.data) {
const settings = data.data;
// 回填公众号基本信息
document.getElementById('settingsId').value = settings.id || '';
document.getElementById('wechat_name').value = settings.wechat_name || '';
document.getElementById('wechat_account').value = settings.wechat_account || '';
document.getElementById('wechat_type').value = settings.wechat_type || 'service';
document.getElementById('setting_qrcode_url').value = settings.qrcode_url || '';
document.getElementById('setting_qrimg').src = settings.qrcode_url || '';
document.getElementById('setting_status').checked = Boolean(Number(settings.status));
// 回填接口配置信息
document.getElementById('appid').value = settings.appid || '';
document.getElementById('appsecret').value = settings.appsecret || '';
document.getElementById('token').value = settings.token || '';
document.getElementById('encoding_aes_key').value = settings.encoding_aes_key || '';
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 表单验证
function validateForm(formElement) {
if (formElement.id === 'shortlinkForm') {
const name = formElement.querySelector('#name').value.trim();
const wxGroupName = formElement.querySelector('#wx_group_name').value.trim();
const qrcodeUrl = formElement.querySelector('#form_qrcode_url').value.trim();
const code = formElement.querySelector('#code').value.trim();
if (!name) {
showMessage('请输入活码名称', 'error');
return false;
}
if (!wxGroupName) {
showMessage('请输入微信群名称', 'error');
return false;
}
if (!qrcodeUrl) {
showMessage('请输入群二维码URL', 'error');
return false;
}
// 验证二维码URL格式
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
if (!urlPattern.test(qrcodeUrl)) {
showMessage('请输入有效的群二维码URL', 'error');
return false;
}
} else if (formElement.id === 'settingsForm') {
// 验证实际必填项
const wechatName = formElement.querySelector('#wechat_name').value.trim();
const wechatAccount = formElement.querySelector('#wechat_account').value.trim();
const appid = formElement.querySelector('#appid').value.trim();
const appsecret = formElement.querySelector('#appsecret').value.trim();
if (!wechatName) { showMessage('请输入公众号名称', 'error'); return false; }
if (!wechatAccount) { showMessage('请输入公众号原始ID', 'error'); return false; }
if (!appid) { showMessage('请输入AppID', 'error'); return false; }
if (!appsecret) { showMessage('请输入AppSecret', 'error'); return false; }
}
return true;
}
// 表单提交
async function submitFormData(url, formElement, successMsg) {
if (!validateForm(formElement)) return;
const submitButton = formElement.id === 'shortlinkForm' ? submitBtn : document.getElementById('saveSettingsBtn');
const originalText = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 保存中...';
try {
const formData = new FormData(formElement);
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.success) {
showMessage(data.message || successMsg, 'success');
closeFormModal();
setTimeout(() => location.reload(), 1000);
} else {
throw new Error(data.message || '操作失败');
}
} catch (e) {
showMessage(e.message, 'error');
} finally {
submitButton.disabled = false;
submitButton.innerHTML = originalText;
}
}
// 加载活码数据(编辑用)
async function loadShortlinkData(id) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 加载中...';
try {
const response = await fetch(`/admin/wxgcode/get/${id}`);
if (!response.ok) throw new Error('获取数据失败');
const data = await response.json();
if (data.success && data.data) {
// 活码表字段回填
const { id, name, code, wx_group_name, qrcode_url, max_scans, max_members, description, status } = data.data;
document.getElementById('shortlinkId').value = id;
document.getElementById('name').value = name || '';
document.getElementById('code').value = code || '';
document.getElementById('wx_group_name').value = wx_group_name || '';
document.getElementById('form_qrcode_url').value = qrcode_url || '';
document.getElementById('image-preview').src = qrcode_url || '';
document.getElementById('max_scans').value = max_scans || 0;
document.getElementById('max_members').value = max_members || '';
document.getElementById('description').value = description || '';
document.getElementById('form_status').checked = Boolean(Number(status));
formTitle.innerHTML = '<i class="fa fa-pencil text-primary mr-2"></i> 编辑活码';
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (e) {
showMessage(e.message, 'error');
closeFormModal();
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '保存活码';
}
}
// 删除活码
async function deleteLink(id) {
if (!confirm('确定要删除该活码吗?此操作不可恢复!')) return;
try {
const response = await fetch(`/admin/wxgcode/delete/${id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showMessage('活码已删除', 'success');
setTimeout(() => location.reload(), 1000);
} else {
throw new Error(data.message || '删除失败');
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 绑定事件
if (checkElements()) {
// 打开表单
openFormBtn.addEventListener('click', openFormModal);
// 关闭表单
closeFormBtn.addEventListener('click', closeFormModal);
cancelBtn.addEventListener('click', closeFormModal);
formBackdrop.addEventListener('click', closeFormModal);
// 活码表单提交
submitBtn.addEventListener('click', function() {
submitFormData('/admin/wxgcode/update', shortlinkForm, '活码保存成功');
});
// 设置表单提交
saveSettingsBtn.addEventListener('click', function() {
submitFormData('/admin/wxgcode/settings', settingsForm, '设置保存成功');
});
// 列表操作事件委托
shortlinkList.addEventListener('click', function(e) {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
if (editBtn) {
const id = editBtn.getAttribute('data-id');
if (id) {
openFormModal();
// 监听动画结束后加载数据
const loadData = () => {
loadShortlinkData(id);
formModal.removeEventListener('transitionend', loadData);
};
formModal.addEventListener('transitionend', loadData, { once: true });
}
} else if (deleteBtn) {
const id = deleteBtn.getAttribute('data-id');
if (id) deleteLink(id);
}
});
// ESC键关闭弹窗
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !formModal.classList.contains('invisible')) {
closeFormModal();
}
});
// 阻止表单默认提交
shortlinkForm.addEventListener('submit', function(e) {
e.preventDefault();
});
}
// 复制到剪贴板功能
window.copyToClipboard = function(text, message) {
navigator.clipboard.writeText(text).then(() => {
showMessage('已复制: ' + message, 'success');
}).catch(err => {
showMessage('复制失败: ' + err.message, 'error');
});
}
});
</script>
+55
View File
@@ -0,0 +1,55 @@
<?php
// 检查是否有 data 参数
if (isset($_POST['data'])) {
$scanResult = htmlspecialchars($_POST['data']);
} else {
$scanResult = '未获取到扫描结果';
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>扫描结果</title>
<style>
body {font-family: Arial, sans-serif; display: flex; flex-direction: column; height: 100vh; margin: 0; background-color: #f4f4f4; }
.result-container {background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); text-align: center; width: 90%; max-width: 400px; margin: auto; cursor: pointer; margin-top: 200px; }
.back-button-container {position: fixed; bottom: 0; width: 100%; text-align: center; padding: 20px 0; background-color: #007BFF; color: white; cursor: pointer; transition: background-color 0.3s ease; } .back-button-container:hover {background-color: #0056b3; }
.copy-toast {position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.7); color: white; padding: 10px 20px; border-radius: 4px; opacity: 0; transition: opacity 0.3s ease; }
.result-container p:nth-child(2) {word-wrap: break-word; word-break: break-all; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="result-container" onclick="copyResult()">
<p id="scan-result">扫描结果:</p>
<p> <?php echo $scanResult; ?></p>
</div>
<div class="back-button-container" onclick="history.back()">
扫一扫
</div>
<div id="copy-toast" class="copy-toast">复制成功</div>
<script>
function copyResult() {
const resultElement = document.querySelector('.result-container p:nth-child(2)');
const textToCopy = resultElement.textContent;
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
const toast = document.getElementById('copy-toast');
toast.style.opacity = 1;
setTimeout(() => {
toast.style.opacity = 0;
}, 2000);
}
</script>
</body>
</html>
+518
View File
@@ -0,0 +1,518 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>加入我们的微信群</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#10b981',
neutral: '#f3f4f6'
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.content-auto {
content-visibility: auto;
}
.card-shadow {
box-shadow: 0 10px 25px -5px rgba(59, 130, 246, 0.1), 0 8px 10px -6px rgba(59, 130, 246, 0.05);
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0px); }
}
}
</style>
</head>
<body class="bg-gradient-to-b from-blue-50 to-white min-h-screen">
<main class="container mx-auto px-4 py-8 md:py-16">
<!-- 加载状态 -->
<div id="loadingContainer" class="max-w-4xl mx-auto py-16 text-center">
<i class="fa fa-spinner fa-spin text-primary text-3xl mb-4"></i>
<p class="text-gray-600">加载中,请稍候...</p>
</div>
<!-- 错误状态 -->
<div id="errorContainer" class="max-w-4xl mx-auto py-16 text-center hidden">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-50 mb-4">
<i class="fa fa-exclamation-triangle text-2xl text-red-400"></i>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-1">加载失败</h3>
<p class="text-gray-500 max-w-md mx-auto mb-4" id="errorMessage">无法加载群组数据,请稍后重试</p>
<button id="retryBtn" class="px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded-lg shadow hover:shadow-md transition-all duration-200">
重试
</button>
</div>
<!-- 内容区域 (默认隐藏) -->
<div id="contentContainer" class="hidden">
<!-- 页面标题 -->
<div class="text-center mb-12">
<h1 class="text-[clamp(1.8rem,5vw,3rem)] font-bold text-gray-800 mb-4">加入我们的微信群</h1>
<p class="text-gray-600 max-w-2xl mx-auto text-lg">扫码加入感兴趣的群组,与志同道合的朋友交流互动</p>
</div>
<!-- 活码展示区 -->
<div class="max-w-4xl mx-auto">
<!-- 主要活码卡片 -->
<div id="mainQrcodeCard" class="bg-white rounded-2xl p-6 md:p-8 card-shadow mb-10 transform transition-all duration-300 hover:scale-[1.01]">
<div class="flex flex-col md:flex-row items-center gap-8">
<!-- 二维码区域 -->
<div class="w-full md:w-1/3 flex justify-center">
<div class="bg-white p-4 rounded-xl border border-gray-100 shadow-md animate-float">
<div id="qrcodeContainer" class="w-56 h-56 mx-auto">
<!-- 二维码将通过JS动态生成 -->
</div>
<p class="text-center mt-3 text-sm text-gray-500">扫码加入群聊</p>
</div>
</div>
<!-- 群信息区域 -->
<div class="w-full md:w-2/3">
<div class="flex items-center mb-4">
<span id="groupStatusBadge" class="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium mr-3">
<i class="fa fa-check-circle mr-1"></i> 活跃中
</span>
<span class="text-gray-500 text-sm"><i class="fa fa-eye mr-1"></i> 已被查看 <span id="viewCount">1,234</span> </span>
</div>
<h2 id="groupName" class="text-2xl font-bold text-gray-800 mb-3">技术交流微信群</h2>
<p id="groupDescription" class="text-gray-600 mb-6">
这是一个技术爱好者交流群,欢迎大家分享编程经验、解决技术难题,一起学习进步。群内禁止广告和无关话题。
</p>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div class="bg-neutral rounded-lg p-3 text-center">
<p class="text-gray-500 text-sm">群人数</p>
<p id="memberCount" class="font-semibold text-gray-800">186</p>
</div>
<div class="bg-neutral rounded-lg p-3 text-center">
<p class="text-gray-500 text-sm">创建时间</p>
<p id="createTime" class="font-semibold text-gray-800">2023-05-12</p>
</div>
<div class="bg-neutral rounded-lg p-3 text-center">
<p class="text-gray-500 text-sm">最大人数</p>
<p id="maxMembers" class="font-semibold text-gray-800">200</p>
</div>
<div class="bg-neutral rounded-lg p-3 text-center">
<p class="text-gray-500 text-sm">今日新增</p>
<p id="todayNew" class="font-semibold text-gray-800">8</p>
</div>
</div>
<div class="flex flex-wrap hidden gap-3">
<button id="refreshQrcodeBtn" class="px-5 py-2.5 bg-primary hover:bg-primary/90 text-white rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center">
<i class="fa fa-refresh mr-2"></i> 刷新二维码
</button>
<button id="shareBtn" class="px-5 py-2.5 border border-gray-300 hover:bg-gray-50 text-gray-700 rounded-lg transition-all duration-200 flex items-center">
<i class="fa fa-share-alt mr-2"></i> 分享
</button>
</div>
</div>
</div>
</div>
<!-- 群规则说明 -->
<div class="bg-blue-50 rounded-2xl p-6 mb-10">
<h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
<i class="fa fa-info-circle text-primary mr-2"></i> 入群须知
</h3>
<ul class="space-y-2 text-gray-600">
<li class="flex items-start">
<i class="fa fa-check-circle text-secondary mt-1 mr-2"></i>
<span>请遵守群规,文明交流,友善互动</span>
</li>
<li class="flex items-start">
<i class="fa fa-check-circle text-secondary mt-1 mr-2"></i>
<span>禁止发布广告、色情、暴力等违规内容</span>
</li>
<li class="flex items-start">
<i class="fa fa-check-circle text-secondary mt-1 mr-2"></i>
<span>本群二维码有效期为7天,过期请重新获取</span>
</li>
<li class="flex items-start">
<i class="fa fa-check-circle text-secondary mt-1 mr-2"></i>
<span>群满200人后将自动切换至新群,请重新扫码</span>
</li>
</ul>
</div>
<!-- 推荐群组 -->
<div class="mb-10 hidden">
<h3 class="text-xl font-semibold text-gray-800 mb-6 flex items-center">
<i class="fa fa-th-large text-primary mr-2"></i> 推荐群组
</h3>
<div id="recommendedGroups" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- 推荐群将通过JS动态生成 -->
</div>
</div>
</div>
</div>
</main>
<!-- 分享弹窗 -->
<div id="shareModal" class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6 transform transition-transform duration-300 scale-95">
<div class="flex justify-between items-center mb-5">
<h3 class="text-xl font-bold text-gray-800">分享群二维码</h3>
<button id="closeShareModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fa fa-times text-xl"></i>
</button>
</div>
<div class="space-y-4">
<div class="p-3 bg-gray-50 rounded-lg text-center">
<p class="text-gray-600 mb-2">通过以下方式分享</p>
<div class="flex justify-center space-x-6">
<a href="#" class="flex flex-col items-center text-gray-600 hover:text-green-500 transition-colors">
<i class="fa fa-weixin text-2xl mb-1"></i>
<span class="text-sm">微信</span>
</a>
<a href="#" class="flex flex-col items-center text-gray-600 hover:text-blue-500 transition-colors">
<i class="fa fa-qq text-2xl mb-1"></i>
<span class="text-sm">QQ</span>
</a>
<a href="#" class="flex flex-col items-center text-gray-600 hover:text-red-500 transition-colors">
<i class="fa fa-weibo text-2xl mb-1"></i>
<span class="text-sm">微博</span>
</a>
<a href="#" class="flex flex-col items-center text-gray-600 hover:text-gray-800 transition-colors">
<i class="fa fa-link text-2xl mb-1"></i>
<span class="text-sm">复制链接</span>
</a>
</div>
</div>
<div class="bg-blue-50 p-4 rounded-lg">
<p class="text-gray-600 text-sm">
<i class="fa fa-info-circle text-primary mr-1"></i>
分享后,好友可以通过您分享的链接加入相同的群组
</p>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.1/build/qrcode.min.js"></script>
<script>
// 全局变量存储群组数据
let groupData = null;
// API基础地址 - 请根据实际情况修改
const API_BASE_URL = '';
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
// 从URL获取活码编码(假设URL格式为 ...?code=XXX
const urlParams = new URLSearchParams(window.location.search);
const code = "<?= $code ?>";
if (!code) {
showError('未找到活码编码,请检查链接是否正确');
return;
}
// 加载群组数据
loadGroupData(code);
// 绑定按钮事件
document.getElementById('refreshQrcodeBtn').addEventListener('click', refreshQrcode);
document.getElementById('shareBtn').addEventListener('click', openShareModal);
document.getElementById('closeShareModal').addEventListener('click', closeShareModal);
document.getElementById('retryBtn').addEventListener('click', () => loadGroupData(code));
// 点击分享弹窗外部关闭
document.getElementById('shareModal').addEventListener('click', function(e) {
if (e.target === this) {
closeShareModal();
}
});
});
// 从API加载群组数据
function loadGroupData(code) {
showLoading();
// 构建API请求URL
const apiUrl = `${API_BASE_URL}/wxgcode/get/${code}`;
fetch(apiUrl)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP错误,状态码: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success && data.data) {
// 保存数据
groupData = data.data;
// 渲染页面
renderPage();
// 显示内容
showContent();
} else {
showError(data.message || '获取群组数据失败');
}
})
.catch(error => {
console.error('加载群组数据失败:', error);
showError('网络请求失败,请稍后重试');
});
}
// 渲染页面内容
function renderPage() {
if (!groupData) return;
// 更新页面标题
document.title = `${groupData.name || '微信群'} - 加入我们的微信群`;
// 填充群组信息
document.getElementById('groupName').textContent = groupData.name || '微信群';
document.getElementById('groupDescription').textContent = groupData.description || '暂无群组描述';
document.getElementById('viewCount').textContent = formatNumber(groupData.total_views || 0);
document.getElementById('memberCount').textContent = groupData.current_members ? `${groupData.current_members}人` : '未知';
document.getElementById('createTime').textContent = groupData.created_at || '未知时间';
document.getElementById('maxMembers').textContent = groupData.max_members ? `${groupData.max_members}人` : '无限制';
document.getElementById('todayNew').textContent = groupData.today_new || '0人';
// 更新状态标签
const statusBadge = document.getElementById('groupStatusBadge');
if (groupData.status === 0) {
statusBadge.className = 'px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm font-medium mr-3';
statusBadge.innerHTML = '<i class="fa fa-times-circle mr-1"></i> 已禁用';
} else if (groupData.is_full === 1) {
statusBadge.className = 'px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-sm font-medium mr-3';
statusBadge.innerHTML = '<i class="fa fa-exclamation-circle mr-1"></i> 已满员';
}
// 生成二维码
generateQrcode(groupData.qrcode_url);
// 渲染推荐群组
renderRecommendedGroups(groupData.recommended || []);
}
// 生成二维码
function generateQrcode(qrcodeUrl) {
const container = document.getElementById('qrcodeContainer');
container.innerHTML = '';
if (qrcodeUrl) {
// 如果有提供二维码URL,直接使用
const img = document.createElement('img');
img.src = qrcodeUrl;
img.alt = `${groupData.name || '微信群'}的二维码`;
img.className = 'w-full h-full object-contain';
container.appendChild(img);
} else {
// 否则生成当前页面URL的二维码
const url = window.location.href;
QRCode.toCanvas(url, {
width: 220,
margin: 1,
color: {
dark: '#333333',
light: '#ffffff'
}
}, function(error, canvas) {
if (error) {
console.error('生成二维码失败:', error);
container.innerHTML = '<p class="text-center text-red-500 py-10">生成二维码失败</p>';
return;
}
container.appendChild(canvas);
});
}
}
// 渲染推荐群组
function renderRecommendedGroups(groups) {
const container = document.getElementById('recommendedGroups');
container.innerHTML = '';
if (groups.length === 0) {
container.innerHTML = '<p class="col-span-full text-center text-gray-500 py-6">暂无推荐群组</p>';
return;
}
groups.forEach(group => {
const groupCard = document.createElement('div');
groupCard.className = 'bg-white rounded-xl overflow-hidden shadow-md transition-all duration-300 hover:shadow-lg hover:-translate-y-1';
groupCard.innerHTML = `
<div class="p-5">
<div class="flex justify-between items-start mb-3">
<h4 class="font-semibold text-gray-800">${group.name || '未命名群组'}</h4>
<span class="px-2 py-0.5 bg-${getCategoryColor(group.category)}-100 text-${getCategoryColor(group.category)}-800 rounded-full text-xs">${group.category || '其他'}</span>
</div>
<p class="text-gray-600 text-sm mb-4 line-clamp-2">${group.description || '暂无群组描述'}</p>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-500"><i class="fa fa-users mr-1"></i> ${group.member_count || 0}人</span>
<a href="${group.url || '#'}" class="text-primary hover:text-primary/80 transition-colors">查看 <i class="fa fa-arrow-right ml-1"></i></a>
</div>
</div>
`;
container.appendChild(groupCard);
});
}
// 刷新二维码
function refreshQrcode() {
if (!groupData || !groupData.code) {
showNotification('无法获取活码信息,刷新失败', 'error');
return;
}
const btn = document.getElementById('refreshQrcodeBtn');
const originalText = btn.innerHTML;
// 显示加载状态
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 刷新中...';
// 发送请求刷新二维码
fetch(`${API_BASE_URL}/wxgcode/refresh/${groupData.code}`)
.then(response => response.json())
.then(data => {
if (data.success && data.data && data.data.qrcode_url) {
// 更新本地数据
groupData.qrcode_url = data.data.qrcode_url;
// 更新二维码
generateQrcode(data.data.qrcode_url);
// 显示成功提示
showNotification('二维码已刷新', 'success');
} else {
// 显示错误信息
showNotification(data.message || '刷新二维码失败', 'error');
}
})
.catch(error => {
console.error('刷新二维码错误:', error);
showNotification('网络错误,刷新失败', 'error');
})
.finally(() => {
// 恢复按钮状态
btn.disabled = false;
btn.innerHTML = originalText;
});
}
// 打开分享弹窗
function openShareModal() {
const modal = document.getElementById('shareModal');
modal.classList.remove('opacity-0', 'pointer-events-none');
modal.querySelector('div').classList.remove('scale-95');
modal.querySelector('div').classList.add('scale-100');
document.body.style.overflow = 'hidden';
}
// 关闭分享弹窗
function closeShareModal() {
const modal = document.getElementById('shareModal');
modal.classList.add('opacity-0', 'pointer-events-none');
modal.querySelector('div').classList.remove('scale-100');
modal.querySelector('div').classList.add('scale-95');
document.body.style.overflow = '';
}
// 显示通知消息
function showNotification(message, type = 'info') {
// 创建通知元素
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-x-full`;
// 设置通知类型样式
if (type === 'success') {
notification.classList.add('bg-green-50', 'text-green-800', 'border', 'border-green-200');
notification.innerHTML = `<i class="fa fa-check-circle mr-2"></i>${message}`;
} else if (type === 'error') {
notification.classList.add('bg-red-50', 'text-red-800', 'border', 'border-red-200');
notification.innerHTML = `<i class="fa fa-exclamation-circle mr-2"></i>${message}`;
} else {
notification.classList.add('bg-blue-50', 'text-blue-800', 'border', 'border-blue-200');
notification.innerHTML = `<i class="fa fa-info-circle mr-2"></i>${message}`;
}
// 添加到页面
document.body.appendChild(notification);
// 显示通知
setTimeout(() => {
notification.classList.remove('translate-x-full');
}, 100);
// 3秒后隐藏通知
setTimeout(() => {
notification.classList.add('translate-x-full');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
// 显示加载状态
function showLoading() {
document.getElementById('loadingContainer').classList.remove('hidden');
document.getElementById('contentContainer').classList.add('hidden');
document.getElementById('errorContainer').classList.add('hidden');
}
// 显示内容
function showContent() {
document.getElementById('loadingContainer').classList.add('hidden');
document.getElementById('contentContainer').classList.remove('hidden');
document.getElementById('errorContainer').classList.add('hidden');
}
// 显示错误状态
function showError(message) {
document.getElementById('loadingContainer').classList.add('hidden');
document.getElementById('contentContainer').classList.add('hidden');
document.getElementById('errorContainer').classList.remove('hidden');
document.getElementById('errorMessage').textContent = message;
}
// 格式化数字(添加千位分隔符)
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
// 根据分类获取颜色
function getCategoryColor(category) {
const colorMap = {
'技术': 'blue',
'产品': 'purple',
'商业': 'amber',
'设计': 'pink',
'教育': 'green',
'生活': 'teal'
};
return colorMap[category] || 'gray';
}
</script>
</body>
</html>
+113
View File
@@ -0,0 +1,113 @@
<?php
class JSSDK {
private $appId;
private $appSecret;
public function __construct($appId, $appSecret) {
$this->appId = $appId;
$this->appSecret = $appSecret;
}
public function getSignPackage() {
$jsapiTicket = $this->getJsApiTicket();
// 注意 URL 一定要动态获取,不能 hardcode.
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
$url = "$protocol$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";
$timestamp = time();
$nonceStr = $this->createNonceStr();
// 这里参数的顺序要按照 key 值 ASCII 码升序排序
$string = "jsapi_ticket=$jsapiTicket&noncestr=$nonceStr&timestamp=$timestamp&url=$url";
$signature = sha1($string);
$signPackage = array(
"appId" => $this->appId,
"nonceStr" => $nonceStr,
"timestamp" => $timestamp,
"url" => $url,
"signature" => $signature,
"rawString" => $string
);
return $signPackage;
}
private function createNonceStr($length = 16) {
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
$str = "";
for ($i = 0; $i < $length; $i++) {
$str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
}
return $str;
}
private function getJsApiTicket() {
// jsapi_ticket 应该全局存储与更新,以下代码以写入到文件中做示例
$data = json_decode($this->get_php_file("jsapi_ticket.php"));
if ($data->expire_time < time()) {
$accessToken = $this->getAccessToken();
// 如果是企业号用以下 URL 获取 ticket
// $url = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=$accessToken";
$url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi&access_token=$accessToken";
$res = json_decode($this->httpGet($url));
$ticket = $res->ticket;
if ($ticket) {
$data->expire_time = time() + 7000;
$data->jsapi_ticket = $ticket;
$this->set_php_file("jsapi_ticket.php", json_encode($data));
}
} else {
$ticket = $data->jsapi_ticket;
}
return $ticket;
}
private function getAccessToken() {
// access_token 应该全局存储与更新,以下代码以写入到文件中做示例
$data = json_decode($this->get_php_file("access_token.php"));
if ($data->expire_time < time()) {
// 如果是企业号用以下URL获取access_token
// $url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=$this->appId&corpsecret=$this->appSecret";
$url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=$this->appId&secret=$this->appSecret";
$res = json_decode($this->httpGet($url));
$access_token = $res->access_token;
if ($access_token) {
$data->expire_time = time() + 7000;
$data->access_token = $access_token;
$this->set_php_file("access_token.php", json_encode($data));
}
} else {
$access_token = $data->access_token;
}
return $access_token;
}
private function httpGet($url) {
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_TIMEOUT, 500);
// 为保证第三方服务器与微信服务器之间数据传输的安全性,所有微信接口采用https方式调用,必须使用下面2行代码打开ssl安全校验。
// 如果在部署过程中代码在此处验证失败,请到 http://curl.haxx.se/ca/cacert.pem 下载新的证书判别文件。
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, true);
curl_setopt($curl, CURLOPT_URL, $url);
$res = curl_exec($curl);
curl_close($curl);
return $res;
}
private function get_php_file($filename) {
return trim(substr(file_get_contents($filename), 15));
}
private function set_php_file($filename, $content) {
$fp = fopen($filename, "w");
fwrite($fp, "<?php exit();?>" . $content);
fclose($fp);
}
}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>扫码结果</title>
<style>
body { font-family: Arial, sans-serif; display: flex; flex-direction: column; height: 100vh; margin: 0; background-color: #f4f4f4; }
.result-container { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); text-align: center; width: 90%; max-width: 400px; margin: auto; cursor: pointer; margin-top: 200px; }
.back-button-container { position: fixed; bottom: 0; width: 100%; text-align: center; padding: 20px 0; background-color: #007BFF; color: white; cursor: pointer; transition: background-color 0.3s ease; }
.back-button-container:hover { background-color: #0056b3; }
.copy-toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.7); color: white; padding: 10px 20px; border-radius: 4px; opacity: 0; transition: opacity 0.3s ease; }
.result-container p:nth-child(2) { word-wrap: break-word; word-break: break-all; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="result-container" onclick="copyResult()">
<p id="scan-result">扫描结果:</p>
<p><?php echo $scanResult; ?></p>
</div>
<div class="back-button-container" onclick="history.back()">扫一扫</div>
<div id="copy-toast" class="copy-toast">复制成功</div>
<script>
function copyResult() {
const result = document.querySelector('.result-container p:nth-child(2)');
const text = result.textContent;
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
const toast = document.getElementById('copy-toast');
toast.style.opacity = 1;
setTimeout(() => { toast.style.opacity = 0; }, 2000);
}
</script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* Plugin Name: WxGCode
* Description: 用来创建微信群活码或者其它活码的插件。
* Version: 1.0.0
* Author: JuheDev
* Plugin URL: https://plugins.juhe.me/wxgcode
*/
return [
'menus' => [
[
'title' => '微信活码',
'icon' => 'fa fa-qrcode',
'path' => '/admin/wxgcode/',
],
],
'route_group' => [
[
'prefix' => '/wxgcode',
'namespace' => 'Plugins\WxGCode\Controllers\Web',
'routes' => [
['GET', '/get/{code}', 'WxGCodeController@get'],
['POST', '/data', 'WxGCodeController@data'],
['GET', '/{code}', 'WxGCodeController@index'],
],
],
[
'prefix' => '/admin/wxgcode',
'namespace' => 'Plugins\WxGCode\Controllers\Admin',
'routes' => [
['GET', '/', 'WxGCodeController@index'],
['GET', '/get/{id}', 'WxGCodeController@get'],
['GET', '/list', 'WxGCodeController@list'],
['GET', '/delete/{id}', 'WxGCodeController@delete'],
['POST', '/update', 'WxGCodeController@update'],
['GET|POST', '/settings', 'WxGCodeController@settings'],
],
],
],
'tables' => ['wxgcode_list', 'wxgcode_settings'],
'init' => function () {},
'activate' => function ($db) {
$db->query("
CREATE TABLE IF NOT EXISTS `wxgcode_list` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) NOT NULL COMMENT '活码名称(如“技术交流群活码”)',
`code` varchar(30) NOT NULL COMMENT '活码唯一标识(用于生成访问链接)',
`qrcode_url` varchar(500) NOT NULL COMMENT '微信群二维码图片URL',
`wx_group_name` varchar(100) NOT NULL COMMENT '微信群名称',
`wx_group_id` varchar(50) DEFAULT NULL COMMENT '微信群ID(可选)',
`total_views` int(11) NOT NULL DEFAULT 0 COMMENT '总访问次数',
`total_scans` int(11) NOT NULL DEFAULT 0 COMMENT '总扫码次数',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1-启用,0-禁用',
`max_scans` int(11) DEFAULT 0 COMMENT '最大扫码次数(0为无限制)',
`max_members` int(11) DEFAULT NULL COMMENT '群最大人数',
`current_members` int(11) DEFAULT 0 COMMENT '当前群人数',
`is_full` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否满人:1-是,0-否',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间(NULL为永久有效)',
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序值(用于多群排序)',
`description` text DEFAULT NULL COMMENT '备注描述',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`) COMMENT '活码标识唯一索引',
KEY `idx_status_full` (`status`,`is_full`) COMMENT '状态和满人状态索引,用于快速筛选可用群码'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='URL管理表,存储系统中所有需要管理的URL信息';
");
$db->query("
CREATE TABLE IF NOT EXISTS `wxgcode_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`wechat_name` varchar(100) NOT NULL COMMENT '公众号名称',
`wechat_account` varchar(50) NOT NULL COMMENT '公众号原始ID(gh_xxxx格式)',
`appid` varchar(50) NOT NULL COMMENT '公众号AppID',
`appsecret` varchar(100) NOT NULL COMMENT '公众号AppSecret',
`token` varchar(100) DEFAULT NULL COMMENT '接口调用Token',
`encoding_aes_key` varchar(100) DEFAULT NULL COMMENT '消息加密密钥',
`qrcode_url` varchar(255) DEFAULT NULL COMMENT '公众号二维码URL',
`wechat_type` enum('subscription','service','enterprise','test') NOT NULL DEFAULT 'service' COMMENT '公众号类型',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1-启用,0-禁用',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_appid` (`appid`),
UNIQUE KEY `uk_wechat_account` (`wechat_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公众号设置表,存储公众号相关ID和密钥信息';
");
},
'deactivate' => function ($db) {},
];
+371
View File
@@ -0,0 +1,371 @@
<?php
namespace Plugins\WxShare\Controllers\Admin;
use App\Core\PluginBaseController;
class WxShareController extends PluginBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->db = $this->pluginManager->getDB();
}
public function index() {
$this->checkLogin(); // 登录保护
// 获取配置信息(带默认值,避免空值)
$settings = $this->db->get('wxshare_settings', '*') ?: []; // 如果没有数据,返回空数组
// 传数据给视图(统一封装在data中)
$this->renderPluginView('WxShare', 'Admin/index.php', [
'data' => [
'settings' => '', // 配置信息
'domain' => '' // 带协议的完整域名
],
'title' => '微信卡片分享管理中心'
]);
}
public function list() {
$this->checkLogin(); // 登录保护
// 获取分页参数
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$pageSize = isset($_GET['page_size']) ? max(1, min(100, intval($_GET['page_size']))) : 10;
$offset = ($page - 1) * $pageSize;
// 1. 获取总记录数
$totalItems = $this->db->count('wxshare_list', '*');
// 2. 获取当前页数据
$shortlinks = $this->db->select('wxshare_list', '*', [
'ORDER' => ['id' => 'DESC'],
'LIMIT' => [$offset, $pageSize]
]);
// 3. 计算分页信息
$totalPages = max(1, ceil($totalItems / $pageSize));
// 4. 返回JSON格式数据
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'data' => [
'items' => $shortlinks,
'current_page' => $page,
'total_pages' => $totalPages,
'total_items' => $totalItems,
'page_size' => $pageSize,
'has_prev' => $page > 1,
'has_next' => $page < $totalPages
]
], JSON_UNESCAPED_UNICODE);
exit;
}
public function get($id) {
$this->checkLogin();
// 设置响应内容类型为JSON
header('Content-Type: application/json');
$row = $this->db->get('wxshare_list', '*', ['id' => $id]);
if ($row) {
// 成功响应,包含状态和数据
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'message' => '无效的ID或记录不存在'
]);
}
exit;
}
public function update() {
// 设置JSON响应头
header('Content-Type: application/json');
$this->checkLogin();
// 获取表单数据(使用表中定义的字段)
$id = intval($_POST['id'] ?? 0);
$share_link = trim($_POST['share_link'] ?? '');
$code = trim($_POST['code'] ?? '');
$share_title = trim($_POST['share_title'] ?? '');
$share_desc = trim($_POST['share_desc'] ?? '');
$share_img = trim($_POST['share_img'] ?? '');
$status = isset($_POST['status']) ? 1 : 0;
// 验证参数
$errors = [];
if (!$share_title) $errors[] = '分享标题不能为空';
if (!$share_link) $errors[] = '分享链接不能为空';
if (!$code) $errors[] = 'code不能为空';
if (!$share_desc) $errors[] = '分享描述不能为空';
if (!$share_img) $errors[] = '分享封面图不能为空';
if (!empty($errors)) {
// 返回 JSON 错误信息
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'status' => 'error',
'message' => implode('; ', $errors)
]);
exit;
}
try {
// 有ID则更新
if ($id > 0) {
$data = [
'share_link' => $share_link, // 原url改为share_link
'name' => $share_link,
'code' => $code,
'share_title' => $share_title, // 新增字段
'share_desc' => $share_desc, // 原description改为share_desc
'share_img' => $share_img, // 新增字段
'status' => $status,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->update('wxshare_list', $data, ['id' => $id]);
if ($result) {
$qrcode = $this->db->get('wxshare_list', '*', ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '更新成功',
'data' => $qrcode
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '更新失败,请稍后重试'
]);
}
}
// 无ID则新增
else {
$insertId = $this->db->insert('wxshare_list', [
'share_link' => $share_link, // 原url改为share_link
'name' => $share_link,
'code' => $code,
'share_title' => $share_title, // 新增字段
'share_desc' => $share_desc, // 原description改为share_desc
'share_img' => $share_img, // 新增字段
'status' => 1,
'views' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if ($insertId) {
echo json_encode([
'success' => true,
'message' => '创建成功',
'data' => [
'id' => $insertId,
'share_link' => $share_link,
'name' => $share_link,
'code' => $code,
'share_title' => $share_title,
'share_desc' => $share_desc,
'share_img' => $share_img,
'status' => $status,
'views' => 0
]
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '创建失败,请稍后重试'
]);
}
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '操作失败: ' . $e->getMessage()
]);
}
exit;
}
public function settings() {
$this->checkLogin();
header('Content-Type: application/json');
$id = intval($_REQUEST['id'] ?? 0);
try {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// 获取第一条设置记录
$row = $this->db->get('wxshare_settings', '*');
if ($row) {
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'message' => '设置不存在'
]);
}
} else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 获取提交数据
$wechat_name = trim($_POST['wechat_name'] ?? '');
$wechat_account = trim($_POST['wechat_account'] ?? '');
$appid = trim($_POST['appid'] ?? '');
$appsecret = trim($_POST['appsecret'] ?? '');
$token = trim($_POST['token'] ?? '');
$encoding_aes_key = trim($_POST['encoding_aes_key'] ?? '');
$qrcode_url = trim($_POST['qrcode_url'] ?? '');
$wechat_type = trim($_POST['wechat_type'] ?? 'service');
$status = isset($_POST['status']) ? 1 : 0;
// 验证必填项
if (empty($wechat_name) || empty($wechat_account) || empty($appid) || empty($appsecret)) {
echo json_encode([
'success' => false,
'message' => '公众号名称、原始ID、AppID和AppSecret为必填项'
]);
exit;
}
if ($id > 0) {
// 更新
$data = [
'wechat_name' => $wechat_name,
'wechat_account' => $wechat_account,
'appid' => $appid,
'appsecret' => $appsecret,
'token' => $token,
'encoding_aes_key' => $encoding_aes_key,
'qrcode_url' => $qrcode_url,
'wechat_type' => $wechat_type,
'status' => $status,
'updated_at' => date('Y-m-d H:i:s')
];
$result = $this->db->update('wxshare_settings', $data, ['id' => $id]);
if ($result) {
$setting = $this->db->get('wxshare_settings', '*', ['id' => $id]);
echo json_encode([
'success' => true,
'message' => '设置更新成功',
'data' => $setting
]);
} else {
echo json_encode([
'success' => false,
'message' => '更新失败或数据无变化'
]);
}
} else {
// 新增
$insertId = $this->db->insert('wxshare_settings', [
'wechat_name' => $wechat_name,
'wechat_account' => $wechat_account,
'appid' => $appid,
'appsecret' => $appsecret,
'token' => $token,
'encoding_aes_key' => $encoding_aes_key,
'qrcode_url' => $qrcode_url,
'wechat_type' => $wechat_type,
'status' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if ($insertId) {
$setting = $this->db->get('wxshare_settings', '*');
echo json_encode([
'success' => true,
'message' => '设置创建成功',
'data' => $setting
]);
} else {
echo json_encode([
'success' => false,
'message' => '创建失败,请稍后重试'
]);
}
}
} else {
http_response_code(405);
echo json_encode([
'success' => false,
'message' => '只支持 GET 和 POST 请求'
]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '操作失败: ' . $e->getMessage()
]);
}
exit;
}
public function delete($id) {
$this->checkLogin();
header('Content-Type: application/json');
try {
$row = $this->db->get('wxshare_list', '*', ['id' => $id]);
if (!$row) {
echo json_encode([
'success' => false,
'message' => '要删除的记录不存在'
]);
exit;
}
// 执行删除
$result = $this->db->delete('wxshare_list', ['id' => $id]);
if ($result) {
echo json_encode([
'success' => true,
'message' => '记录已删除'
]);
} else {
echo json_encode([
'success' => false,
'message' => '删除失败,请稍后再试'
]);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => '删除失败: ' . $e->getMessage()
]);
}
exit;
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Plugins\WxShare\Controllers\Web;
use App\Core\WebBaseController;
class WxShareController extends WebBaseController {
protected $pluginManager;
protected $db;
public function __construct() {
global $pluginManager;
$this->pluginManager = $pluginManager;
$this->db = $this->pluginManager->getDB();
}
public function index($code) {
// 引入 JSSDK 类文件
$jssdkPath = dirname(__FILE__) . '/../../../WxShare/Controllers/lib/jssdk.php';
if (!file_exists($jssdkPath)) {
$this->showError("JSSDK 文件不存在:{$jssdkPath}");
}
require_once($jssdkPath);
try {
// 1. 从wxshare_settings表获取公众号配置(假设系统中只有一条配置记录)
$settings = $this->db->get('wxshare_settings', '*');
if (empty($settings)) {
$this->showError('未找到公众号配置,请先在后台完成设置');
}
// 2. 验证必要配置是否存在
$requiredFields = ['appid', 'appsecret'];
foreach ($requiredFields as $field) {
if (empty($settings[$field])) {
$this->showError("公众号配置不完整,缺少:{$field}");
}
}
// 3. 从wxshare_list表获取token与URL的映射关系
$share = $this->db->get('wxshare_list', '*', ['code' => $code] );
// 2. 判断是否存在该类型记录
if (empty($share)) {
$this->showError('未找到' . $code . '的URL配置,请先添加');
exit; // 无此类型记录,停止执行
}
// 3. 检查该记录是否启用(status = 1)
if ($share['status'] != 1) {
$this->showError('当前' . $code . '的URL未启用,请启用后再使用');
exit;
}
if (isset($_GET['rep'])) {
// 更新访问次数
$this->db->update('wxshare_list', [
'views[+]' => 1
], [
'code' => $code,
'status' => 1
]);
// 跳转
header("Location: " . $share['share_link']);
exit;
}
$url = $share['share_link'];
$jssdk = new \Plugins\WxShare\Controllers\lib\JSSDK($settings['appid'], $settings['appsecret']);
$signPackage = $jssdk->GetSignPackage();
} catch (Exception $e) {
echo '<script>alert("错误: '. addslashes($e->getMessage()). '"); window.close();</script>';
exit;
}
// 传数据给视图
include __DIR__ . '/../../Views/Web/index.php';
}
public function redirect($code) {
$row = $this->db->get('wxshare_list', '*', ['code' => $code]);
if ($row) {
// 检查链接是否处于激活状态
if ($row['is_active'] != 1) {
$this->showError($code . ' 此链接已被停用!');
exit;
}
// 若激活,则更新访问量并跳转
$update = $this->db->update('wxshare_list', ['views[+]' => 1 ], ['id' => $row['id'] ]);
if ($update->rowCount() > 0) {
header("Location: " . $row['share_link']);
exit;
}
} else {
$this->showError( $code . ' 此链接不存在!');
}
}
}
+1
View File
@@ -0,0 +1 @@
<?php exit();?>{"expire_time":1755715644,"access_token":"95_eDc_CzaW9yYbHNhPFs5_UmwQIjx-j25vgX2a-m5scHEaxnFNg5d3FufMFvGS2eegNbhRhh01duV_i23i5zpz96UKWyM7f-rlohmxzqSjqRuYhhwYSZqRKCqZ1zsIRIhAHAXGX"}
+1
View File
@@ -0,0 +1 @@
<?php exit();?>{"expire_time":1755715644,"jsapi_ticket":"7mo9kzLF0zXvfXKd2ScDpJkaYOCMtHVFZ1MqnrYmJLj66DEsamUjaZ0-iUq3MJWpwWiTm_vt903SF5b7Y9dB2w"}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Plugins\WxShare\Controllers\lib;
class JSSDK {
private $appId;
private $appSecret;
// 缓存文件路径(使用绝对路径避免问题)
private $cacheDir;
public function __construct($appId, $appSecret) {
$this->appId = $appId;
$this->appSecret = $appSecret;
// 初始化缓存目录(与jssdk.php同目录)
$this->cacheDir = dirname(__FILE__) . '/';
// 确保缓存目录可写
$this->checkCacheDir();
}
// 检查缓存目录是否存在且可写
private function checkCacheDir() {
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
if (!is_writable($this->cacheDir)) {
echo "缓存目录不可写:{$this->cacheDir}.需要手动改写权限 777";
exit();
}
}
// 检查并创建缓存文件
private function checkCacheFile($filename) {
$filePath = $this->cacheDir . $filename;
// 如果文件不存在则创建并初始化
if (!file_exists($filePath)) {
$initialData = json_encode([
'expire_time' => 0,
'access_token' => '',
'jsapi_ticket' => ''
]);
$this->set_php_file($filename, $initialData);
}
return $filePath;
}
public function getSignPackage() {
$jsapiTicket = $this->getJsApiTicket();
// 动态获取当前URL
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
$url = "$protocol$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";
$timestamp = time();
$nonceStr = $this->createNonceStr();
// 按ASCII码升序排序
$string = "jsapi_ticket=$jsapiTicket&noncestr=$nonceStr&timestamp=$timestamp&url=$url";
$signature = sha1($string);
return [
"appId" => $this->appId,
"nonceStr" => $nonceStr,
"timestamp" => $timestamp,
"url" => $url,
"signature" => $signature,
"rawString" => $string
];
}
private function createNonceStr($length = 16) {
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
$str = "";
for ($i = 0; $i < $length; $i++) {
$str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
}
return $str;
}
private function getJsApiTicket() {
// 先检查并创建缓存文件
$this->checkCacheFile("jsapi_ticket.php");
$data = json_decode($this->get_php_file("jsapi_ticket.php"));
// 处理空数据或过期情况
if (empty($data) || $data->expire_time < time()) {
$accessToken = $this->getAccessToken();
$url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi&access_token=$accessToken";
$res = json_decode($this->httpGet($url));
if (isset($res->ticket)) {
$data = new \stdClass(); // 初始化空对象
$data->expire_time = time() + 7000;
$data->jsapi_ticket = $res->ticket;
$this->set_php_file("jsapi_ticket.php", json_encode($data));
} else {
throw new \Exception("获取jsapi_ticket失败: " . json_encode($res));
}
}
return $data->jsapi_ticket ?? '';
}
private function getAccessToken() {
// 先检查并创建缓存文件
$this->checkCacheFile("access_token.php");
$data = json_decode($this->get_php_file("access_token.php"));
// 处理空数据或过期情况
if (empty($data) || $data->expire_time < time()) {
$url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=$this->appId&secret=$this->appSecret";
$res = json_decode($this->httpGet($url));
if (isset($res->access_token)) {
$data = new \stdClass(); // 初始化空对象
$data->expire_time = time() + 7000;
$data->access_token = $res->access_token;
$this->set_php_file("access_token.php", json_encode($data));
} else {
throw new \Exception("获取access_token失败: " . json_encode($res));
}
}
return $data->access_token ?? '';
}
private function httpGet($url) {
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_TIMEOUT, 500);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, true);
curl_setopt($curl, CURLOPT_URL, $url);
$res = curl_exec($curl);
curl_close($curl);
return $res;
}
private function get_php_file($filename) {
$filePath = $this->cacheDir . $filename;
return trim(substr(file_get_contents($filePath), 15));
}
private function set_php_file($filename, $content) {
$filePath = $this->cacheDir . $filename;
$fp = fopen($filePath, "w");
fwrite($fp, "<?php exit();?>" . $content);
fclose($fp);
}
}
+740
View File
@@ -0,0 +1,740 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fa fa-share-alt text-primary mr-3"></i>
微信分享管理中心
</h1>
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<!-- 消息提示框 -->
<div id="message" class="mb-4 px-4 py-3 rounded-lg hidden"></div>
<!-- 选项卡导航 -->
<div class="border-b border-gray-200 mb-6">
<ul class="flex flex-wrap -mb-px" id="tabs" role="tablist">
<li class="mr-2" role="presentation">
<button id="list-tab" class="inline-block py-4 px-5 border-b-2 border-primary text-sm font-medium text-primary" onclick="switchTab('list')" aria-selected="true">
微信分享列表
</button>
</li>
<li class="mr-2" role="presentation">
<button id="settings-tab" class="inline-block py-4 px-5 border-b-2 border-transparent text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300" onclick="switchTab('settings')" aria-selected="false">
系统设置
</button>
</li>
</ul>
</div>
<!-- 分享列表内容 -->
<div id="list-content" class="tab-content">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-700">分享列表</h2>
<!-- 创建新分享按钮 -->
<button id="openFormBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center">
<i class="fa fa-plus mr-2"></i>
<span>新增分享</span>
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full bg-white rounded-xl shadow-md overflow-hidden">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">名称</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">分享编码</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">跳转地址</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">访问</th>
<th scope="col" 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" id="shortlinkList">
<!-- 内容将通过JavaScript动态生成 -->
</tbody>
</table>
<div id="loading" class="hidden py-10 text-center"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>
<div id="empty" class="hidden">
<tr>
<td colspan="5" class="text-center py-12">没有找到分享</td>
</tr>
</div>
</div>
<!-- 分页和状态控件 -->
<div id="pagination" class="flex justify-between items-center mt-6 hidden">
<div class="text-sm text-gray-500">
显示 <span id="showingRange">0-0</span> 条,共 <span id="totalItems">0</span>
</div>
<div class="flex space-x-2">
<button id="prevPage" class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50" disabled>上一页</button>
<div id="pageNumbers" class="flex space-x-1"></div>
<button id="nextPage" class="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50" disabled>下一页</button>
</div>
</div>
</div>
<!-- 设置选项卡内容 -->
<div id="settings-content" class="tab-content hidden">
<h2 class="text-xl font-semibold text-gray-700 mb-6">系统设置</h2>
<form id="settingsForm" class="space-y-6">
<input type="hidden" id="settingsId" name="id">
<!-- 公众号基本信息 -->
<div class="bg-gray-50 p-5 rounded-lg">
<h3 class="text-lg font-medium text-gray-800 mb-4 flex items-center">
<i class="fab fa-weixin text-primary mr-2"></i>公众号基本信息
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="wechat_name" class="block text-sm font-medium text-gray-700 mb-1">公众号名称 <span class="text-red-500">*</span></label>
<input type="text" id="wechat_name" name="wechat_name" required 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="请输入公众号名称">
</div>
<div>
<label for="wechat_account" class="block text-sm font-medium text-gray-700 mb-1">公众号原始ID <span class="text-red-500">*</span></label>
<input type="text" id="wechat_account" name="wechat_account" required 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="格式为gh_xxxx">
</div>
<div>
<label for="wechat_type" class="block text-sm font-medium text-gray-700 mb-1">公众号类型 <span class="text-red-500">*</span></label>
<select id="wechat_type" name="wechat_type" required 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">
<option value="subscription">订阅号</option>
<option value="service" selected>服务号</option>
<option value="enterprise">企业号</option>
<option value="test">测试号</option>
</select>
</div>
<div>
<label for="qrcode_url" class="block text-sm font-medium text-gray-700 mb-1">公众号二维码URL</label>
<div class="flex items-center">
<input type="url" id="qrcode_url" name="qrcode_url"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
placeholder="https://example.com/share.jpg">
<div class="relative">
<button type="button"
class="w-10 h-10 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center justify-center"
onclick="OpenGallery('qrcode_url', 'qrimg')">
<img src="https://cdn-icons-png.flaticon.com/128/10054/10054290.png" alt="预览图" class=" object-cover rounded">
</button>
</div>
</div>
</div>
<div>
<img class="w-50 h-50" id='qrimg' src="" />
</div>
<div>
<label class="flex items-center">
<input type="checkbox" id="statuss" name="status" value="1" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-700">启用当前公众号配置</span>
</label>
</div>
</div>
</div>
<!-- 公众号接口配置 -->
<div class="bg-gray-50 p-5 rounded-lg">
<h3 class="text-lg font-medium text-gray-800 mb-4 flex items-center">
<i class="fa fa-plug text-primary mr-2"></i>接口配置信息
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="appid" class="block text-sm font-medium text-gray-700 mb-1">AppID <span class="text-red-500">*</span></label>
<input type="text" id="appid" name="appid" required 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="公众号的AppID">
</div>
<div>
<label for="appsecret" class="block text-sm font-medium text-gray-700 mb-1">AppSecret <span class="text-red-500">*</span></label>
<input type="text" id="appsecret" name="appsecret" required 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="公众号的AppSecret">
</div>
<div>
<label for="token" class="block text-sm font-medium text-gray-700 mb-1">Token</label>
<input type="text" id="token" name="token" 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="用于接口调用的Token">
<p class="mt-1 text-xs text-gray-500">由开发者自定义,用于生成签名</p>
</div>
<div>
<label for="encoding_aes_key" class="block text-sm font-medium text-gray-700 mb-1">EncodingAESKey</label>
<input type="text" id="encoding_aes_key" name="encoding_aes_key" 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="消息加密密钥">
<p class="mt-1 text-xs text-gray-500">消息加解密时使用,43位字符</p>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button type="button" id="saveSettingsBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存设置
</button>
</div>
</form>
</div>
</div>
<!-- 表单弹窗背景 -->
<div id="formBackdrop" class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div>
<!-- 分享表单弹窗 -->
<div id="formModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 id="formTitle" class="text-xl font-bold text-gray-800 flex items-center">
<i class="fa fa-plus-circle text-primary mr-2"></i>
创建新分享
</h3>
<button id="closeFormBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fa fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="shortlinkForm" class="space-y-5">
<input type="hidden" id="shortlinkId" name="id">
<input type="hidden" id="code" name="code" >
<div>
<label for="share_title" class="block text-sm font-medium text-gray-700 mb-1">分享标题</label>
<input type="text" id="share_title" name="share_title"
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="请输入分享时显示的标题">
</div>
<div>
<label for="share_desc" class="block text-sm font-medium text-gray-700 mb-1">分享简介</label>
<textarea id="share_desc" name="share_desc" rows="3"
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 resize-none"
placeholder="请输入详细描述信息"></textarea>
</div>
<div>
<label for="share_img" class="block text-sm font-medium text-gray-700 mb-1">分享封面图URL</label>
<div class="flex gap-2">
<input type="url" id="share_img" name="share_img"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
placeholder="https://example.com/share.jpg">
<div class="relative">
<button type="button"
class="bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center gap-2"
onclick="OpenGallery('share_img', 'image-preview')">
<img id="image-preview" src="https://cdn-icons-png.flaticon.com/128/10054/10054290.png" alt="预览图" class="w-10 h-10 object-cover rounded">
</button>
</div>
</div>
</div>
<div>
<label for="share_link" class="block text-sm font-medium text-gray-700 mb-1">跳转地址 </label>
<input type="url" id="share_link" name="share_link" required
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://example.com">
</div>
<div>
<label class="flex items-center">
<input type="checkbox" id="status" name="status" value="1" checked
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-700">启用状态</span>
</label>
</div>
</form>
</div>
<div class="border-t border-gray-100 px-6 py-4 flex justify-end gap-3">
<button id="cancelBtn" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
取消
</button>
<button id="submitBtn" type="button" class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存分享
</button>
</div>
</div>
</div>
<div id="qrCodeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white p-6 rounded-lg shadow-xl max-w-xs w-full mx-4">
<h3 class="text-lg font-medium text-gray-900 mb-4 text-center">扫码分享</h3>
<div class="flex justify-center mb-4" id="qrCodeContainer"></div>
<p onclick="copyToClipboard(this.innerText, '分享链接')"id="textId" class="text-sm text-gray-500 text-center mb-4"></p>
<button onclick="hideQrCode()" class="w-full bg-gray-100 hover:bg-gray-200 text-gray-800 py-2 px-4 rounded transition-colors">
关闭
</button>
</div>
</div>
<script type="text/javascript">
// 分页相关功能
const PAGE_SIZE = 10;
let currentPage = 1,
totalPages = 1;
// 分页DOM元素
const listEl = document.getElementById('shortlinkList');
const [paginationEl, prevBtn, nextBtn, pageNumbers] = ['pagination', 'prevPage', 'nextPage', 'pageNumbers'].map(id => document.getElementById(id));
const [rangeEl, totalEl, loadingEl, emptyEl] = ['showingRange', 'totalItems', 'loading', 'empty'].map(id => document.getElementById(id));
// 初始化分页
document.addEventListener('DOMContentLoaded', () => {
loadPage(1);
prevBtn.onclick = () => currentPage > 1 && loadPage(currentPage - 1);
nextBtn.onclick = () => currentPage < totalPages && loadPage(currentPage + 1);
});
// 加载分页数据
async function loadPage(page) {
// 显示加载状态
loadingEl.classList.remove('hidden');
listEl.innerHTML = '';
paginationEl.classList.add('hidden');
emptyEl.classList.add('hidden');
try {
// 请求数据
const res = await fetch(`/admin/wxshare/list?page=${page}&page_size=${PAGE_SIZE}`);
const { data } = await res.json();
// 更新分页信息
currentPage = data.current_page;
totalPages = data.total_pages;
totalEl.textContent = data.total_items;
// 渲染列表
if (data.items.length) {
data.items.forEach(link => {
const statusClass = link.status == 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = link.status == 1 ? '启用' : '停用';
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50 transition-colors';
tr.setAttribute('data-id', link.id);
tr.innerHTML = `
<!-- 分享信息单元格 - 包含名称和描述 -->
<td class="px-4 py-4 whitespace-nowrap">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 truncate">${escapeHtml(link.share_title)}</div>
<div class="text-xs text-gray-500 truncate max-w-xs">
${escapeHtml(link.share_desc)}
</div>
</div>
</td>
<!-- 分享编码 -->
<td class="px-4 py-4 whitespace-nowrap ">
<div class="text-sm text-primary truncate max-w-md cursor-pointer" onclick="showQrCode('${escapeHtml(getTypeName(link.code))}')">
${link.code}
<i class="fa fa-qrcode ml-1 opacity-70"></i>
</div>
</td>
<!-- 跳转地址 - 小屏幕隐藏 -->
<td class="px-4 py-4 whitespace-nowrap hidden sm:table-cell">
<div class="text-sm text-gray-500 truncate max-w-md">
${escapeHtml(link.share_link)}
</div>
</td>
<!-- 状态 -->
<td class="px-4 py-4 whitespace-nowrap">
<span class="inline-block px-2 py-1 text-xs rounded-full ${statusClass}">
${statusText}
</span>
</td>
<!-- 访问计数 -->
<td class="px-4 py-4 whitespace-nowrap">
<span class="inline-block px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
${link.views} 次
</span>
</td>
<!-- 操作按钮 -->
<td class="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<button class="edit-btn text-gray-500 hover:text-blue-500"
data-id="${link.id}" title="编辑">
<i class="fa fa-pencil"></i>
</button>
<button class="delete-btn text-gray-500 hover:text-red-500"
data-id="${link.id}" title="删除">
<i class="fa fa-trash"></i>
</button>
</div>
</td>
`;
listEl.appendChild(tr);
});
} else {
listEl.innerHTML = `<tr><td colspan="5" class="text-center py-12">没有查到分享;请创建后查看!</td></tr>`;
}
// 更新分页控件
rangeEl.textContent = `${(page-1)*PAGE_SIZE+1}-${Math.min(page*PAGE_SIZE, data.total_items)}`;
renderPageNumbers();
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
paginationEl.classList.remove('hidden');
} catch (e) {
listEl.innerHTML = `<tr><td colspan="5" class="text-center py-12">加载失败: ${e.message}</td></tr>`;
} finally {
loadingEl.classList.add('hidden');
}
}
// 获取类型名称
function generateCode(length = 8) {
const chars = '1234567890ACDEFGHIJKLMNOPQRSTUVWXYZ';
let code = '';
for (let i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
function getTypeName(code) {
const protocol = window.location.protocol;
const host = window.location.host;
return `${protocol}//${host}/wxshare/${code}`;
}
// 渲染页码按钮
function renderPageNumbers() {
pageNumbers.innerHTML = '';
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, start + 4);
for (let i = start; i <= end; i++) {
const btn = document.createElement('button');
btn.className = `px-3 py-1 rounded ${i === currentPage ? 'bg-primary text-white' : 'border'}`;
btn.textContent = i;
btn.onclick = () => loadPage(i);
pageNumbers.appendChild(btn);
}
}
// HTML转义函数
function escapeHtml(str) {
return str ? str.toString().replace(/[&<>"']/g, c => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
} [c])) : '';
}
// 二维码相关功能
function showQrCode(url) {
// 清空之前的二维码
document.getElementById('qrCodeContainer').innerHTML = '';
// 生成新的二维码
QRCode.toCanvas(url, {
width: 200,
margin: 1
}, function(error, canvas) {
if (error) {
console.error('生成二维码失败:', error);
return;
}
document.getElementById('qrCodeContainer').appendChild(canvas);
});
// 显示弹窗
document.getElementById('qrCodeModal').classList.remove('hidden');
document.getElementById('textId').textContent = url;
// 阻止页面滚动
document.body.style.overflow = 'hidden';
}
function hideQrCode() {
console.log('ok');
document.getElementById('qrCodeModal').classList.add('hidden');
// 恢复页面滚动
document.body.style.overflow = '';
}
// 点击弹窗外部关闭
document.addEventListener('DOMContentLoaded', function() {
// 二维码弹窗事件绑定
const qrCodeModal = document.getElementById('qrCodeModal');
if (qrCodeModal) {
qrCodeModal.addEventListener('click', function(e) {
if (e.target === this) {
hideQrCode();
}
});
}
});
// 选项卡切换功能
function switchTab(tabName) {
document.getElementById('list-content').classList.add('hidden');
document.getElementById('settings-content').classList.add('hidden');
document.getElementById('list-tab').classList.remove('border-primary', 'text-primary');
document.getElementById('list-tab').classList.add('border-transparent', 'text-gray-500');
document.getElementById('settings-tab').classList.remove('border-primary', 'text-primary');
document.getElementById('settings-tab').classList.add('border-transparent', 'text-gray-500');
document.getElementById(`${tabName}-content`).classList.remove('hidden');
document.getElementById(`${tabName}-tab`).classList.remove('border-transparent', 'text-gray-500');
document.getElementById(`${tabName}-tab`).classList.add('border-primary', 'text-primary');
if (tabName === 'settings' && !window.settingsLoaded) {
loadSettings();
window.settingsLoaded = true;
}
}
// 主功能逻辑
document.addEventListener('DOMContentLoaded', function() {
const formModal = document.getElementById('formModal');
const formBackdrop = document.getElementById('formBackdrop');
const openFormBtn = document.getElementById('openFormBtn');
const closeFormBtn = document.getElementById('closeFormBtn');
const cancelBtn = document.getElementById('cancelBtn');
const submitBtn = document.getElementById('submitBtn');
const formTitle = document.getElementById('formTitle');
const shortlinkForm = document.getElementById('shortlinkForm');
const shortlinkList = document.getElementById('shortlinkList');
const settingsForm = document.getElementById('settingsForm');
const saveSettingsBtn = document.getElementById('saveSettingsBtn');
window.settingsLoaded = false;
// 检查必要元素
function checkElements() {
const elements = [formModal, formBackdrop, openFormBtn, closeFormBtn, cancelBtn, submitBtn];
const missing = elements.filter(el => !el);
if (missing.length > 0) {
console.error('缺少必要的DOM元素,弹窗功能无法正常工作');
return false;
}
return true;
}
// 打开表单弹窗
function openFormModal() {
if (!checkElements()) return;
resetForm();
formModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.add('scale-100');
formBackdrop.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
void formModal.offsetWidth; // 强制重绘
document.getElementById('code').value = generateCode();
}
// 关闭表单弹窗
function closeFormModal() {
if (!checkElements()) return;
formModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.remove('scale-100');
formBackdrop.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
// 重置表单
function resetForm() {
shortlinkForm.reset();
document.getElementById('shortlinkId').value = '';
document.getElementById('image-preview').src = 'https://cdn-icons-png.flaticon.com/128/10054/10054290.png';
formTitle.innerHTML = '<i class="fa fa-plus-circle text-primary mr-2"></i> 创建新分享';
submitBtn.innerHTML = '保存分享';
submitBtn.disabled = false;
}
// 加载设置
window.loadSettings = async function() {
try {
const response = await fetch('/admin/wxshare/settings');
if (!response.ok) throw new Error('获取设置失败');
const data = await response.json();
if (data.success && data.data) {
const settings = data.data;
// 回填公众号基本信息
document.getElementById('settingsId').value = settings.id || '';
document.getElementById('wechat_name').value = settings.wechat_name || '';
document.getElementById('wechat_account').value = settings.wechat_account || '';
document.getElementById('wechat_type').value = settings.wechat_type || 'service';
document.getElementById('qrcode_url').value = settings.qrcode_url || '';
document.getElementById('qrimg').src = settings.qrcode_url || '';
document.getElementById('statuss').checked = Boolean(Number(settings.status));
// 回填接口配置信息
document.getElementById('appid').value = settings.appid || '';
document.getElementById('appsecret').value = settings.appsecret || '';
document.getElementById('token').value = settings.token || '';
document.getElementById('encoding_aes_key').value = settings.encoding_aes_key || '';
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 表单验证
function validateForm(formElement) {
if (formElement.id === 'shortlinkForm') {
const name = formElement.querySelector('#name').value.trim();
const shareLink = formElement.querySelector('#share_link').value.trim();
const code = formElement.querySelector('#code').value.trim();
if (!name) {
showMessage('请输入分享名称', 'error');
return false;
}
if (!shareLink) {
showMessage('请输入跳转地址', 'error');
return false;
}
// 简单URL验证
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
if (!urlPattern.test(shareLink)) {
showMessage('请输入有效的URL地址', 'error');
return false;
}
} else if (formElement.id === 'settingsForm') {
// 验证实际必填项
const wechatName = formElement.querySelector('#wechat_name').value.trim();
const wechatAccount = formElement.querySelector('#wechat_account').value.trim();
const appid = formElement.querySelector('#appid').value.trim();
const appsecret = formElement.querySelector('#appsecret').value.trim();
if (!wechatName) { showMessage('请输入公众号名称', 'error'); return false; }
if (!wechatAccount) { showMessage('请输入公众号原始ID', 'error'); return false; }
if (!appid) { showMessage('请输入AppID', 'error'); return false; }
if (!appsecret) { showMessage('请输入AppSecret', 'error'); return false; }
}
return true;
}
// 表单提交
async function submitFormData(url, formElement, successMsg) {
if (!validateForm(formElement)) return;
const submitButton = formElement.id === 'shortlinkForm' ? submitBtn : document.getElementById('saveSettingsBtn');
const originalText = submitButton.innerHTML;
submitButton.disabled = true;
submitButton.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 保存中...';
try {
const formData = new FormData(formElement);
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.success) {
showMessage(data.message || successMsg, 'success');
closeFormModal();
setTimeout(() => location.reload(), 1000);
} else {
throw new Error(data.message || '操作失败');
}
} catch (e) {
showMessage(e.message, 'error');
} finally {
submitButton.disabled = false;
submitButton.innerHTML = originalText;
}
}
// 加载分享数据(编辑用)
async function loadShortlinkData(id) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 加载中...';
try {
const response = await fetch(`/admin/wxshare/get/${id}`);
if (!response.ok) throw new Error('获取数据失败');
const data = await response.json();
if (data.success && data.data) {
const { id, name, share_link, code, share_desc, share_title, share_img, status } = data.data;
document.getElementById('shortlinkId').value = id;
document.getElementById('share_link').value = share_link || '';
document.getElementById('code').value = code || '';
document.getElementById('share_desc').value = share_desc || '';
document.getElementById('share_title').value = share_title || '';
document.getElementById('share_img').value = share_img || '';
document.getElementById('image-preview').src = share_img || '';
document.getElementById('status').checked = Boolean(Number(status));
formTitle.innerHTML = '<i class="fa fa-pencil text-primary mr-2"></i> 编辑分享';
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (e) {
showMessage(e.message, 'error');
closeFormModal();
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '保存分享';
}
}
// 删除分享
async function deleteLink(id) {
if (!confirm('确定要删除该分享吗?此操作不可恢复!')) return;
try {
const response = await fetch(`/admin/wxshare/delete/${id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showMessage('分享已删除', 'success');
setTimeout(() => location.reload(), 1000);
} else {
throw new Error(data.message || '删除失败');
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 绑定事件
if (checkElements()) {
// 打开表单
openFormBtn.addEventListener('click', openFormModal);
// 关闭表单
closeFormBtn.addEventListener('click', closeFormModal);
cancelBtn.addEventListener('click', closeFormModal);
formBackdrop.addEventListener('click', closeFormModal);
// 分享表单提交
submitBtn.addEventListener('click', function() {
submitFormData('/admin/wxshare/update', shortlinkForm, '分享保存成功');
});
// 设置表单提交
saveSettingsBtn.addEventListener('click', function() {
submitFormData('/admin/wxshare/settings', settingsForm, '设置保存成功');
});
// 列表操作事件委托
shortlinkList.addEventListener('click', function(e) {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
if (editBtn) {
const id = editBtn.getAttribute('data-id');
if (id) {
openFormModal();
// 监听动画结束后加载数据
const loadData = () => {
loadShortlinkData(id);
formModal.removeEventListener('transitionend', loadData);
};
formModal.addEventListener('transitionend', loadData, { once: true });
}
} else if (deleteBtn) {
const id = deleteBtn.getAttribute('data-id');
if (id) deleteLink(id);
}
});
// ESC键关闭弹窗
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !formModal.classList.contains('invisible')) {
closeFormModal();
}
});
// 阻止表单默认提交
shortlinkForm.addEventListener('submit', function(e) {
e.preventDefault();
});
}
});
</script>
+142
View File
@@ -0,0 +1,142 @@
<?php
$title = htmlspecialchars($share['share_title']);
$desc = htmlspecialchars($share['share_desc']);
$image = htmlspecialchars($share['share_img']);
$link = htmlspecialchars($share['share_link']);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>微信卡片分享预览</title>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<style>
/* 基础样式重置 */
* {margin: 0; padding: 0; box-sizing: border-box; }
body {font-family: "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif; padding: 15px; background-color: #f7f7f7; color: #333; position: relative; min-height: 100vh; }
h1 {text-align: center; margin: 20px 0 25px; font-weight: 500; font-size: 18px; color: #333; }
.status-info {text-align: center; color: #666; font-size: 15px; padding: 10px; margin: 0 auto 20px; line-height: 1.5; }
.blog-list-container {max-width: 640px; margin: 0 auto; }
.blog-card {display: flex; max-width: 500px; margin: 0 auto; flex-direction: row; background-color: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 15px; transition: transform 0.2s, box-shadow 0.2s; height: 120px;}
.blog-card:hover {transform: translateY(-2px); box-shadow: 0 3px 8px rgba(0,0,0,0.12); }
.blog-image {width: 120px; height: 100%; flex-shrink: 0; object-fit: cover; }
.blog-content {flex: 1;padding: 8px 18px; display: flex; flex-direction: column; justify-content: center; }
.blog-title {font-size: 18px; color: #333; line-height: 1.5; margin-bottom: 5px; max-height: 48px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.blog-desc {font-size: 15px; color: #666; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
.blog-meta {display: table; justify-content: space-between; align-items: center;margin:0 auto; }
.blog-link {font-size: 12px; color: #999; }
.share-guide {position: fixed; top: -25px;right: 0px; z-index: 999; pointer-events: none; }
.share-guide img {width: 120px;height: auto; }
@media screen and (max-width: 375px) {.blog-card {height: 100px; } .blog-image {width: 110px; } .blog-content {padding: 8px 15px; } .blog-title {font-size: 15px; margin-bottom: 3px; } .blog-desc {font-size: 12px; } .share-guide img {width: 100px;} }
</style>
</head>
<body>
<!-- 分享引导GIF动画 -->
<div class="share-guide">
<!-- 使用指向右上角的引导动画GIF -->
<img src="https://yanxuan.nosdn.127.net/c1bf3641f8cc21cc05a65bc978cf819b.gif" alt="点击分享引导">
</div>
<h1>卡片分享预览</h1>
<div class="blog-list-container">
<div class="blog-card">
<img src="<?= $image ?>" class="blog-image" alt="分享图片">
<div class="blog-content">
<div class="blog-title"><?= $title ?></div>
<div class="blog-desc"><?= $desc ?></div>
</div>
</div>
<div class="blog-meta">
<div class="blog-link">跳转到:<a target="_blank" href="<?= $link ?>" ><?= $link ?></a></div>
</div>
</div>
<div class="status-info">
提示:点击右上角菜单选择分享
</div>
<script>
// 从后端获取签名配置
const signPackage = {
appId: "<?php echo $signPackage['appId']?>",
timestamp: "<?php echo $signPackage['timestamp']?>",
nonceStr: "<?php echo $signPackage['nonceStr']?>",
signature: "<?php echo $signPackage['signature']?>",
url: "<?php echo $signPackage['url']?>"
};
// 配置微信JS-SDK
wx.config({
debug: false,
appId: signPackage.appId,
timestamp: signPackage.timestamp,
nonceStr: signPackage.nonceStr,
signature: signPackage.signature,
jsApiList: [
'updateAppMessageShareData',
'updateTimelineShareData',
'onMenuShareAppMessage',
'onMenuShareTimeline'
]
});
// 生成带参数的分享链接
function getShareLink(baseUrl) {
const param = '?rep';
if (baseUrl && baseUrl.includes('?')) {
return baseUrl + '&' + param.substring(1);
}
return (baseUrl || window.location.href) + param;
}
// 分享配置参数
const shareConfig = {
title: '<?= $title ?>',
desc: '<?= $desc ?>',
link: getShareLink(signPackage.url),
imgUrl: '<?= $image ?: 'https://picsum.photos/400/300' ?>'
};
// JS-SDK初始化成功回调
wx.ready(function() {
// 新接口配置
wx.updateAppMessageShareData({
title: shareConfig.title,
desc: shareConfig.desc,
link: shareConfig.link,
imgUrl: shareConfig.imgUrl,
success: function() {
console.log('分享给朋友配置成功');
}
});
wx.updateTimelineShareData({
title: shareConfig.title,
link: shareConfig.link,
imgUrl: shareConfig.imgUrl,
success: function() {
console.log('分享到朋友圈配置成功');
}
});
// 兼容旧版本接口
if (wx.onMenuShareAppMessage) {
wx.onMenuShareAppMessage(shareConfig);
}
if (wx.onMenuShareTimeline) {
wx.onMenuShareTimeline({
title: shareConfig.title,
link: shareConfig.link,
imgUrl: shareConfig.imgUrl
});
}
});
// JS-SDK配置失败回调
wx.error(function(res) {
console.error('微信JS-SDK配置失败:', res.errMsg);
});
</script>
</body>
</html>
+113
View File
@@ -0,0 +1,113 @@
<?php
class JSSDK {
private $appId;
private $appSecret;
public function __construct($appId, $appSecret) {
$this->appId = $appId;
$this->appSecret = $appSecret;
}
public function getSignPackage() {
$jsapiTicket = $this->getJsApiTicket();
// 注意 URL 一定要动态获取,不能 hardcode.
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
$url = "$protocol$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";
$timestamp = time();
$nonceStr = $this->createNonceStr();
// 这里参数的顺序要按照 key 值 ASCII 码升序排序
$string = "jsapi_ticket=$jsapiTicket&noncestr=$nonceStr&timestamp=$timestamp&url=$url";
$signature = sha1($string);
$signPackage = array(
"appId" => $this->appId,
"nonceStr" => $nonceStr,
"timestamp" => $timestamp,
"url" => $url,
"signature" => $signature,
"rawString" => $string
);
return $signPackage;
}
private function createNonceStr($length = 16) {
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
$str = "";
for ($i = 0; $i < $length; $i++) {
$str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
}
return $str;
}
private function getJsApiTicket() {
// jsapi_ticket 应该全局存储与更新,以下代码以写入到文件中做示例
$data = json_decode($this->get_php_file("jsapi_ticket.php"));
if ($data->expire_time < time()) {
$accessToken = $this->getAccessToken();
// 如果是企业号用以下 URL 获取 ticket
// $url = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=$accessToken";
$url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi&access_token=$accessToken";
$res = json_decode($this->httpGet($url));
$ticket = $res->ticket;
if ($ticket) {
$data->expire_time = time() + 7000;
$data->jsapi_ticket = $ticket;
$this->set_php_file("jsapi_ticket.php", json_encode($data));
}
} else {
$ticket = $data->jsapi_ticket;
}
return $ticket;
}
private function getAccessToken() {
// access_token 应该全局存储与更新,以下代码以写入到文件中做示例
$data = json_decode($this->get_php_file("access_token.php"));
if ($data->expire_time < time()) {
// 如果是企业号用以下URL获取access_token
// $url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=$this->appId&corpsecret=$this->appSecret";
$url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=$this->appId&secret=$this->appSecret";
$res = json_decode($this->httpGet($url));
$access_token = $res->access_token;
if ($access_token) {
$data->expire_time = time() + 7000;
$data->access_token = $access_token;
$this->set_php_file("access_token.php", json_encode($data));
}
} else {
$access_token = $data->access_token;
}
return $access_token;
}
private function httpGet($url) {
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_TIMEOUT, 500);
// 为保证第三方服务器与微信服务器之间数据传输的安全性,所有微信接口采用https方式调用,必须使用下面2行代码打开ssl安全校验。
// 如果在部署过程中代码在此处验证失败,请到 http://curl.haxx.se/ca/cacert.pem 下载新的证书判别文件。
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, true);
curl_setopt($curl, CURLOPT_URL, $url);
$res = curl_exec($curl);
curl_close($curl);
return $res;
}
private function get_php_file($filename) {
return trim(substr(file_get_contents($filename), 15));
}
private function set_php_file($filename, $content) {
$fp = fopen($filename, "w");
fwrite($fp, "<?php exit();?>" . $content);
fclose($fp);
}
}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>扫码结果</title>
<style>
body { font-family: Arial, sans-serif; display: flex; flex-direction: column; height: 100vh; margin: 0; background-color: #f4f4f4; }
.result-container { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); text-align: center; width: 90%; max-width: 400px; margin: auto; cursor: pointer; margin-top: 200px; }
.back-button-container { position: fixed; bottom: 0; width: 100%; text-align: center; padding: 20px 0; background-color: #007BFF; color: white; cursor: pointer; transition: background-color 0.3s ease; }
.back-button-container:hover { background-color: #0056b3; }
.copy-toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.7); color: white; padding: 10px 20px; border-radius: 4px; opacity: 0; transition: opacity 0.3s ease; }
.result-container p:nth-child(2) { word-wrap: break-word; word-break: break-all; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="result-container" onclick="copyResult()">
<p id="scan-result">扫描结果:</p>
<p><?php echo $scanResult; ?></p>
</div>
<div class="back-button-container" onclick="history.back()">扫一扫</div>
<div id="copy-toast" class="copy-toast">复制成功</div>
<script>
function copyResult() {
const result = document.querySelector('.result-container p:nth-child(2)');
const text = result.textContent;
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
const toast = document.getElementById('copy-toast');
toast.style.opacity = 1;
setTimeout(() => { toast.style.opacity = 0; }, 2000);
}
</script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* Plugin Name: WxShare
* Description: 微信分享卡片的创建。可动态调态分享后的卡片跳转的地址。
* Version: 1.0.0
* Author: JuheDev
* Plugin URL: https://plugins.juhe.me/wxshare
*/
return [
'menus' => [
[
'title' => '微信分享',
'icon' => 'fa fa-share-alt',
'path' => '/admin/wxshare/',
],
],
'route_group' => [
[
'prefix' => '/wxshare',
'namespace' => 'Plugins\WxShare\Controllers\Web',
'routes' => [
['GET', '/{code}', 'WxShareController@index'],
],
],
[
'prefix' => '/admin/wxshare',
'namespace' => 'Plugins\WxShare\Controllers\Admin',
'routes' => [
['GET', '/', 'WxShareController@index'],
['GET', '/get/{id}', 'WxShareController@get'],
['GET', '/list', 'WxShareController@list'],
['POST', '/delete/{id}', 'WxShareController@delete'],
['POST', '/update', 'WxShareController@update'],
['GET|POST', '/settings', 'WxShareController@settings'],
],
],
],
'tables' => ['wxshare_list', 'wxshare_settings'],
'init' => function () {},
'activate' => function ($db) {
$db->query("
CREATE TABLE IF NOT EXISTS `wxshare_list` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` varchar(100) NOT NULL COMMENT '名称',
`code` varchar(20) NOT NULL COMMENT '分享编码',
`views` int(11) NOT NULL DEFAULT 0 COMMENT '访问次数',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1-启用,0-禁用',
`share_title` varchar(255) DEFAULT NULL COMMENT '分享标题',
`share_desc` varchar(255) DEFAULT NULL COMMENT '分享描述',
`share_img` varchar(500) DEFAULT NULL COMMENT '分享封面图URL',
`share_link` text NOT NULL COMMENT '跳转地址',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信分享链接管理表';
");
$db->query("
CREATE TABLE IF NOT EXISTS `wxshare_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`wechat_name` varchar(100) NOT NULL COMMENT '公众号名称',
`wechat_account` varchar(50) NOT NULL COMMENT '公众号原始ID(gh_xxxx格式)',
`appid` varchar(50) NOT NULL COMMENT '公众号AppID',
`appsecret` varchar(100) NOT NULL COMMENT '公众号AppSecret',
`token` varchar(100) DEFAULT NULL COMMENT '接口调用Token',
`encoding_aes_key` varchar(100) DEFAULT NULL COMMENT '消息加密密钥',
`qrcode_url` varchar(255) DEFAULT NULL COMMENT '公众号二维码URL',
`wechat_type` enum('subscription','service','enterprise','test') NOT NULL DEFAULT 'service' COMMENT '公众号类型',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1-启用,0-禁用',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_appid` (`appid`),
UNIQUE KEY `uk_wechat_account` (`wechat_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公众号设置表,存储公众号相关ID和密钥信息';
");
},
'deactivate' => function ($db) {},
];
Vendored Executable
BIN
View File
Binary file not shown.
+584
View File
@@ -0,0 +1,584 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fa fa-user-shield text-primary mr-3"></i>
管理员管理中心
</h1>
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<!-- 搜索和操作区 -->
<div class="flex justify-between items-center mb-6 gap-4">
<h2 class="text-xl font-semibold text-gray-700 whitespace-nowrap">管理员列表</h2>
<div class="flex gap-3">
<!-- 新增管理员按钮 -->
<button id="openFormBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center whitespace-nowrap">
<i class="fa fa-plus mr-2"></i>
<span>新增管理员</span>
</button>
</div>
</div>
<!-- 管理员列表表格 -->
<div class="overflow-x-auto">
<table class="w-full bg-white rounded-xl shadow-md overflow-hidden">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">管理员信息</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">注册时间</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th scope="col" 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" id="adminList">
<?php if (!empty($admins) && is_array($admins)): ?>
<?php foreach ($admins as $admin): ?>
<tr class="hover:bg-gray-50 transition-colors" data-id="<?php echo $admin['id']; ?>">
<!-- 用户名和头像单元格 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap">
<div class="flex items-center gap-3">
<img src="<?php
if (!empty($admin['avatar'])) {
echo htmlspecialchars($admin['avatar']);
} else {
echo "https://robohash.org/admin" . $admin['id'] . "?size=40x40";
}
?>"
alt="管理员头像" class="w-10 h-10 rounded-full object-cover border border-gray-200 flex-shrink-0">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 truncate"><?php echo htmlspecialchars($admin['username']); ?></div>
<div class="text-xs text-gray-500 truncate"><?php echo htmlspecialchars($admin['email']); ?></div>
</div>
</div>
</td>
<!-- 创建时间 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap hidden sm:table-cell">
<div class="text-sm text-gray-500"><?php echo date('Y-m-d H:i', strtotime($admin['created_at'])); ?></div>
</td>
<!-- 状态 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap">
<?php
$statusClass = $admin['status'] ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
$statusText = $admin['status'] ? '启用' : '停用';
?>
<span class="inline-block px-2 py-1 text-xs rounded-full <?php echo $statusClass; ?>">
<?php echo $statusText; ?>
</span>
</td>
<!-- 操作按钮 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<button class="view-btn text-gray-500 hover:text-purple-500"
data-id="<?php echo $admin['id']; ?>" title="查看详情">
<i class="fa fa-eye"></i>
</button>
<button class="edit-btn text-gray-500 hover:text-blue-500"
data-id="<?php echo $admin['id']; ?>" title="编辑">
<i class="fa fa-pencil"></i>
</button>
<button class="delete-btn text-gray-500 hover:text-red-500"
data-id="<?php echo $admin['id']; ?>" title="删除">
<i class="fa fa-trash"></i>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="4" class="px-6 py-12 text-center">
<div class="flex flex-col items-center">
<i class="fa fa-user-shield 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">尝试调整筛选条件或添加新管理员</p>
<button class="mt-4 bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center"
onclick="openFormModal()">
<i class="fa fa-plus mr-2"></i>
<span>添加新管理员</span>
</button>
</div>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="flex justify-between items-center mt-6">
<p class="text-sm text-gray-500">显示 1 至 <?php echo min(10, count($admins ?? [])); ?> 条,共 <?php echo count($admins ?? []); ?> 条</p>
</div>
</div>
<!-- 管理员表单弹窗背景 -->
<div id="formBackdrop" class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div>
<!-- 管理员表单弹窗 -->
<div id="formModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-events-none pointer-none transition transition-all duration-300 scale-95">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 id="formTitle" class="text-xl font-bold text-gray-800 flex items-center">
<i class="fa fa-plus-circle text-primary mr-2"></i>
创建新管理员
</h3>
<button id="closeFormBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fa fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="adminForm" class="space-y-5">
<input type="hidden" id="adminId" name="id">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">用户名 <span class="text-red-500">*</span></label>
<input type="text" id="username" name="username" required
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="请输入用户名">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">邮箱 <span class="text-red-500">*</span></label>
<input type="email" id="email" name="email" required
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="请输入邮箱地址">
</div>
<!-- 管理员表单弹窗中密码字段部分 -->
<div id="passwordField">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
密码 <span class="text-red-500">*</span>
</label>
<input type="password" id="password" name="password" required
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="请输入密码">
<p class="mt-1 text-xs text-gray-500">密码长度至少8位,包含字母和数字</p>
</div>
<div>
<label class="flex items-center">
<input type="checkbox" id="status" name="status" value="1" checked
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-700">启用管理员</span>
</label>
</div>
</form>
</div>
<div class="border-t border-gray-100 px-6 py-4 flex justify-end gap-3">
<button id="cancelBtn" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
取消
</button>
<button id="submitBtn" type="button"
class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存管理员
</button>
</div>
</div>
</div>
<!-- 管理员详情弹窗 -->
<div id="detailModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fa fa-user-shield text-primary mr-2"></i>
管理员详情
</h3>
<button id="closeDetailBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fa fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-100px)]">
<div class="flex flex-col items-center mb-6">
<img id="detailAvatar" src="https://picsum.photos/seed/admin/100/100" alt="管理员头像" class="w-24 h-24 rounded-full mb-4">
<h4 id="detailUsername" class="text-xl font-bold text-gray-800">管理员名</h4>
<p id="detailRole" class="mt-1 px-3 py-1 text-sm rounded-full bg-red-100 text-red-800">管理员</p>
</div>
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">ID</span>
<span id="detailId" class="col-span-2 text-gray-800">--</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">邮箱</span>
<span id="detailEmail" class="col-span-2 text-gray-800">--</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">状态</span>
<span id="detailStatus" class="col-span-2">
<span class="inline-block px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">启用</span>
</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">创建时间</span>
<span id="detailCreatedAt" class="col-span-2 text-gray-800">--</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">最后登录</span>
<span id="detailLastLogin" class="col-span-2 text-gray-800">--</span>
</div>
</div>
</div>
<div class="border-t border-gray-100 px-6 py-4 flex justify-end">
<button id="closeDetailBtn2" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
关闭
</button>
</div>
</div>
</div>
<script>
console.log('管理员管理JS加载完成');
document.addEventListener('DOMContentLoaded', function() {
// 缓存DOM元素
const formModal = document.getElementById('formModal');
const formBackdrop = document.getElementById('formBackdrop');
const detailModal = document.getElementById('detailModal');
const openFormBtn = document.getElementById('openFormBtn');
const closeFormBtn = document.getElementById('closeFormBtn');
const cancelBtn = document.getElementById('cancelBtn');
const submitBtn = document.getElementById('submitBtn');
const formTitle = document.getElementById('formTitle');
const adminForm = document.getElementById('adminForm');
const adminList = document.getElementById('adminList');
const passwordField = document.getElementById('passwordField');
const closeDetailBtn = document.getElementById('closeDetailBtn');
const closeDetailBtn2 = document.getElementById('closeDetailBtn2');
// 检查元素是否存在
function checkElements() {
const elements = [
formModal, formBackdrop, openFormBtn,
closeFormBtn, cancelBtn, submitBtn
];
const missing = elements.filter(el => !el);
if (missing.length > 0) {
console.error('缺少必要的DOM元素,功能无法正常工作');
return false;
}
return true;
}
// 显示表单弹窗
function openFormModal() {
if (!checkElements()) return;
resetForm();
formModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.add('scale-100');
formBackdrop.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
void formModal.offsetWidth; // 强制重绘
}
// 隐藏表单弹窗
function closeFormModal() {
if (!checkElements()) return;
formModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.remove('scale-100');
formBackdrop.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
// 显示详情弹窗
function openDetailModal() {
detailModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
detailModal.classList.add('scale-100');
formBackdrop.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
void detailModal.offsetWidth;
}
// 隐藏详情弹窗
function closeDetailModal() {
detailModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
detailModal.classList.remove('scale-100');
formBackdrop.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
// 重置表单(新增模式)
function resetForm() {
adminForm.reset();
document.getElementById('adminId').value = '';
formTitle.innerHTML = '<i class="fa fa-plus-circle text-primary mr-2"></i> 创建新管理员';
// 新增模式:密码必填设置
const passwordLabel = document.querySelector('#passwordField label');
const passwordInput = document.getElementById('password');
passwordLabel.innerHTML = '密码 <span class="text-red-500">*</span>';
passwordInput.required = true;
passwordInput.placeholder = '请输入密码';
passwordField.style.display = 'block';
submitBtn.innerHTML = '保存管理员';
submitBtn.disabled = false;
}
// 加载管理员数据(编辑模式)
async function loadAdminData(id) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 加载中...';
try {
const response = await fetch(`/admin/admins/${id}`);
if (!response.ok) throw new Error('获取数据失败');
const data = await response.json();
if (data.success && data.data) {
const { id, username, email, status } = data.data;
document.getElementById('adminId').value = id;
document.getElementById('username').value = username || '';
document.getElementById('email').value = email || '';
document.getElementById('status').checked = status == 1;
formTitle.innerHTML = '<i class="fa fa-pencil text-primary mr-2"></i> 编辑管理员';
// 编辑模式:密码可选设置
const passwordLabel = document.querySelector('#passwordField label');
const passwordInput = document.getElementById('password');
passwordLabel.innerHTML = '密码(不填则不修改)';
passwordInput.required = false;
passwordInput.placeholder = '不修改密码请留空';
passwordField.style.display = 'block';
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (e) {
showMessage(e.message, 'error');
closeFormModal();
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '保存管理员';
}
}
// 加载管理员详情
async function loadAdminDetail(id) {
try {
const response = await fetch(`/admin/admins/${id}`);
if (!response.ok) throw new Error('获取详情失败');
const data = await response.json();
if (data.success && data.data) {
const { id, username, email, status, created_at, last_login, avatar } = data.data;
// 填充详情数据
document.getElementById('detailId').textContent = id;
document.getElementById('detailUsername').textContent = username || '未知管理员';
document.getElementById('detailEmail').textContent = email || '未设置';
document.getElementById('detailCreatedAt').textContent = created_at ? new Date(created_at).toLocaleString() : '未知';
document.getElementById('detailLastLogin').textContent = last_login ? new Date(last_login).toLocaleString() : '从未登录';
document.getElementById('detailAvatar').src = avatar || `https://robohash.org/admin${id}?size=100x100`;
// 设置角色标签样式(管理员固定为管理员)
document.getElementById('detailRole').className = 'mt-1 px-3 py-1 text-sm rounded-full bg-red-100 text-red-800';
document.getElementById('detailRole').textContent = '管理员';
// 设置状态标签样式
const statusClass = status ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = status ? '启用' : '禁用';
document.getElementById('detailStatus').innerHTML =
`<span class="inline-block px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>`;
openDetailModal();
} else {
throw new Error(data.message || '获取详情失败');
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 表单验证
function validateForm() {
const username = document.getElementById('username').value.trim();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value.trim();
const isEditMode = !!document.getElementById('adminId').value;
if (!username) {
showMessage('请输入用户名', 'error');
return false;
}
if (!email) {
showMessage('请输入邮箱地址', 'error');
return false;
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
showMessage('请输入有效的邮箱地址', 'error');
return false;
}
// 仅在新增或编辑时填写了密码的情况下验证长度
if ((!isEditMode || password) && password.length < 8) {
showMessage('密码长度至少8位', 'error');
return false;
}
return true;
}
// 提交表单(创建/更新)
async function submitFormData() {
if (!validateForm()) return;
const formData = new FormData(adminForm);
const isEditMode = !!document.getElementById('adminId').value;
const statusCheckbox = document.getElementById('status');
formData.delete('status'); // 先删除可能存在的旧值
formData.append('status', statusCheckbox.checked ? '1' : '0');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 保存中...';
try {
const response = await fetch('/admin/admins/update', {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.success) {
showMessage(isEditMode ? '管理员更新成功' : '管理员创建成功');
closeFormModal();
setTimeout(() => location.reload(), 1000);
} else {
throw new Error(data.message || (isEditMode ? '更新失败' : '创建失败'));
}
} catch (e) {
showMessage(e.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '保存管理员';
}
}
// 删除管理员
async function deleteAdmin(id) {
if (!confirm('确定要删除该管理员吗?此操作不可恢复!')) return;
try {
const response = await fetch(`/admin/admins/delete/${id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showMessage('管理员已删除');
// 移除DOM元素
const row = document.querySelector(`tr[data-id="${id}"]`);
if (row) {
row.remove();
// 检查是否还有数据行
const rows = adminList.querySelectorAll('tr:not(:last-child)');
if (rows.length === 0) {
adminList.innerHTML = `
<tr>
<td colspan="4" class="px-6 py-10 text-center text-gray-500 border border-dashed border-gray-200">
<div>
<i class="fa fa-info-circle text-2xl mb-2 text-gray-300"></i>
<p>暂无管理员数据</p>
</div>
</td>
</tr>`;
}
}
} else {
throw new Error(data.message || '删除失败');
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 绑定事件
if (checkElements()) {
// 打开表单
openFormBtn.addEventListener('click', openFormModal);
// 关闭表单
closeFormBtn.addEventListener('click', closeFormModal);
cancelBtn.addEventListener('click', closeFormModal);
formBackdrop.addEventListener('click', () => {
if (!formModal.classList.contains('invisible')) closeFormModal();
if (!detailModal.classList.contains('invisible')) closeDetailModal();
});
// 关闭详情
closeDetailBtn.addEventListener('click', closeDetailModal);
closeDetailBtn2.addEventListener('click', closeDetailModal);
// 提交表单
submitBtn.addEventListener('click', submitFormData);
// 编辑、删除、查看事件委托
adminList.addEventListener('click', function(e) {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
const viewBtn = e.target.closest('.view-btn');
if (editBtn) {
const id = editBtn.getAttribute('data-id');
if (id) {
openFormModal();
setTimeout(() => loadAdminData(id), 300);
}
} else if (deleteBtn) {
const id = deleteBtn.getAttribute('data-id');
if (id) deleteAdmin(id);
} else if (viewBtn) {
const id = viewBtn.getAttribute('data-id');
if (id) loadAdminDetail(id);
}
});
// ESC键关闭弹窗
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (!formModal.classList.contains('invisible')) closeFormModal();
if (!detailModal.classList.contains('invisible')) closeDetailModal();
}
});
// 阻止表单默认提交
adminForm.addEventListener('submit', e => {
e.preventDefault();
submitFormData();
});
}
});
</script>
+64
View File
@@ -0,0 +1,64 @@
<div class="p-6">
<h2 class="text-2xl font-bold mb-4">🤝 代理管理</h2>
<button onclick="showAddAgent()" class="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">+ 添加代理</button>
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50"><tr>
<th class="px-4 py-3 text-left">代理</th><th class="px-4 py-3">级别</th><th class="px-4 py-3">邀请码</th>
<th class="px-4 py-3">佣金%</th><th class="px-4 py-3">反水%</th><th class="px-4 py-3">玩家数</th>
<th class="px-4 py-3">状态</th><th class="px-4 py-3">操作</th>
</tr></thead>
<tbody>
<?php foreach($agents??[] as $a): ?>
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-2"><?=htmlspecialchars($a['user']['username']??'?')?></td>
<td class="px-4 py-2 text-center"><?=$a['level']==1?'<span class="text-yellow-600 font-bold">总代</span>':'代理'?></td>
<td class="px-4 py-2 font-mono text-xs"><?=$a['agent_code']?></td>
<td class="px-4 py-2 text-center"><?=$a['commission_rate']?>%</td>
<td class="px-4 py-2 text-center"><?=$a['rebate_rate']?>%</td>
<td class="px-4 py-2 text-center"><?=$a['player_count']?></td>
<td class="px-4 py-2 text-center"><?=$a['status']?'<span class="text-green-500">启用</span>':'<span class="text-red-500">禁用</span>'?></td>
<td class="px-4 py-2 text-center">
<button onclick="editAgent(<?=htmlspecialchars(json_encode($a))?>)" class="text-blue-500 hover:underline text-xs">编辑</button>
<button onclick="deleteAgent(<?=$a['id']?>)" class="text-red-500 hover:underline text-xs ml-2">删除</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- 弹窗 -->
<div id="agentModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl p-6 w-full max-w-md">
<h3 class="font-bold mb-4" id="agentModalTitle">添加代理</h3>
<input type="hidden" id="agentId" value="0">
<div class="space-y-3">
<div><label class="text-xs text-gray-400">用户ID</label><input type="number" id="agentUserId" class="w-full border rounded px-3 py-2"></div>
<div><label class="text-xs text-gray-400">上级代理ID (留空=总代)</label><input type="number" id="agentParent" class="w-full border rounded px-3 py-2"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="text-xs text-gray-400">佣金 %</label><input type="number" step="0.1" id="agentComm" class="w-full border rounded px-3 py-2" value="1"></div>
<div><label class="text-xs text-gray-400">反水 %</label><input type="number" step="0.1" id="agentRebate" class="w-full border rounded px-3 py-2" value="0.5"></div>
</div>
<div><label class="text-xs text-gray-400">状态</label><select id="agentStatus" class="w-full border rounded px-3 py-2"><option value="1">启用</option><option value="0">禁用</option></select></div>
</div>
<div class="flex gap-2 mt-4">
<button onclick="saveAgent()" class="flex-1 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">保存</button>
<button onclick="document.getElementById('agentModal').classList.add('hidden')" class="flex-1 py-2 bg-gray-200 rounded hover:bg-gray-300">取消</button>
</div>
</div>
</div>
</div>
<script>
function showAddAgent(){document.getElementById('agentId').value=0;document.getElementById('agentModalTitle').textContent='添加代理';document.getElementById('agentModal').classList.remove('hidden');}
function editAgent(a){document.getElementById('agentId').value=a.id;document.getElementById('agentComm').value=a.commission_rate;document.getElementById('agentRebate').value=a.rebate_rate;document.getElementById('agentStatus').value=a.status;document.getElementById('agentModalTitle').textContent='编辑代理';document.getElementById('agentModal').classList.remove('hidden');}
async function saveAgent(){
const body={id:parseInt(document.getElementById('agentId').value),user_id:parseInt(document.getElementById('agentUserId').value),parent_id:document.getElementById('agentParent').value||null,commission_rate:parseFloat(document.getElementById('agentComm').value),rebate_rate:parseFloat(document.getElementById('agentRebate').value),status:parseInt(document.getElementById('agentStatus').value)};
const r=await fetch('/admin/agents/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await r.json();if(d.status==='success')location.reload();else alert(d.message);
}
async function deleteAgent(id){if(!confirm('确定删除该代理?'))return;await fetch('/admin/agents/delete/'+id,{method:'POST'});location.reload();}
</script>
+169
View File
@@ -0,0 +1,169 @@
<div class="p-6">
<h2 class="text-2xl font-bold mb-4">⏱️ 自动开期设置</h2>
<!-- 宝塔配置提示 -->
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
<h3 class="font-bold text-blue-700 mb-2">📋 宝塔面板定时任务配置</h3>
<div class="text-sm text-blue-600 space-y-1">
<p>1. 登录宝塔面板 <b>计划任务</b></p>
<p>2. 任务类型: <b>Shell脚本</b></p>
<p>3. 任务名称: <b>PK10自动开期</b></p>
<p>4. 执行周期: <b>每N分钟 1分钟</b></p>
<p>5. 脚本内容:</p>
<pre class="bg-blue-100 p-2 rounded mt-1 text-xs overflow-x-auto select-all" id="cronCmd">cd <?=ROOT_PATH?> && /usr/bin/php cron/auto_period_task.php >> Storage/log/auto_period.log 2>&1</pre>
<button onclick="copyCmd()" class="mt-2 px-3 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600">📋 复制命令</button>
</div>
</div>
<!-- 全局控制 -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<span class="text-gray-500">全局控制:</span>
<button onclick="toggleAll(1)" class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm">✅ 全部启用</button>
<button onclick="toggleAll(0)" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-sm">🚫 全部关闭</button>
</div>
<button onclick="refreshStatus()" class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 text-sm">🔄 刷新状态</button>
</div>
<!-- 游戏列表 -->
<div class="space-y-4 mb-8">
<?php foreach($games ?? [] as $game): ?>
<div class="bg-white rounded-xl p-5 shadow" id="game-<?=$game['id']?>">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-lg font-bold"><?=$game['name']?></span>
<span class="px-2 py-1 rounded text-xs bg-gray-100 text-gray-500"><?=$game['type']?></span>
<span class="px-2 py-1 rounded text-xs <?=($game['auto_period_enabled']??0)?'bg-green-100 text-green-700':'bg-red-100 text-red-700'?>" id="status-<?=$game['id']?>">
<?=($game['auto_period_enabled']??0)?'✅ 自动开期中':'🚫 已关闭'?>
</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <?=($game['auto_period_enabled']??0)?'checked':''?> onchange="toggleGame(<?=$game['id']?>, this.checked)">
<div class="w-11 h-6 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:bg-green-500 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 每期时长 -->
<div>
<label class="block text-sm text-gray-500 mb-1">每期时长</label>
<div class="flex items-center gap-2">
<input type="number" min="60" max="3600" step="30" value="<?=$game['period_duration']??300?>"
id="duration-<?=$game['id']?>" class="w-24 border rounded px-3 py-2 text-center">
<span class="text-gray-400 text-sm">秒</span>
<span class="text-gray-300 text-xs">( = <span id="durationMin-<?=$game['id']?>"><?=round(($game['period_duration']??300)/60, 1)?></span> 分钟)</span>
</div>
</div>
<!-- 封盘提前时间 -->
<div>
<label class="block text-sm text-gray-500 mb-1">结束前提前封盘</label>
<div class="flex items-center gap-2">
<input type="number" min="5" max="120" step="5" value="<?=$game['lock_before_end']??30?>"
id="lock-<?=$game['id']?>" class="w-24 border rounded px-3 py-2 text-center">
<span class="text-gray-400 text-sm">秒</span>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button onclick="saveGame(<?=$game['id']?>)" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">💾 保存设置</button>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- 运行日志 -->
<div class="bg-white rounded-xl shadow overflow-hidden">
<h3 class="font-bold px-4 py-3 bg-gray-50 border-b">📜 最近自动开期日志</h3>
<div class="max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 sticky top-0"><tr>
<th class="px-4 py-2 text-left">时间</th>
<th class="px-4 py-2 text-left">游戏</th>
<th class="px-4 py-2 text-left">动作</th>
<th class="px-4 py-2 text-left">详情</th>
</tr></thead>
<tbody>
<?php foreach($logs ?? [] as $log): ?>
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-2 text-gray-400 text-xs whitespace-nowrap"><?=$log['created_at']??''?></td>
<td class="px-4 py-2">G<?=$log['game_id']??0?></td>
<td class="px-4 py-2">
<span class="px-2 py-1 rounded text-xs
<?php
$act = $log['action'] ?? '';
echo match($act) {
'start' => 'bg-green-100 text-green-700',
'lock' => 'bg-orange-100 text-orange-700',
'draw' => 'bg-blue-100 text-blue-700',
'settle' => 'bg-purple-100 text-purple-700',
'error' => 'bg-red-100 text-red-700',
default => 'bg-gray-100 text-gray-700',
};
?>">
<?=match($act) { 'start'=>'开期', 'lock'=>'封盘', 'draw'=>'开奖', 'settle'=>'结算', 'error'=>'错误', default=>$act }?>
</span>
</td>
<td class="px-4 py-2 text-gray-500 text-xs"><?=htmlspecialchars($log['message']??'')?></td>
</tr>
<?php endforeach; ?>
<?php if(empty($logs)): ?>
<tr><td colspan="4" class="px-4 py-8 text-center text-gray-400">暂无日志记录。请先执行数据库迁移并配置宝塔定时任务。</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
function copyCmd(){
const t=document.getElementById('cronCmd').textContent;
navigator.clipboard.writeText(t).then(()=>alert('已复制到剪贴板')).catch(()=>{
const ta=document.createElement('textarea');ta.value=t;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();alert('已复制');
});
}
async function toggleGame(gameId, enabled){
const r=await fetch('/admin/auto-period/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({game_id:gameId,auto_period_enabled:enabled?1:0})});
const d=await r.json();
if(d.success){
const el=document.getElementById('status-'+gameId);
el.textContent=enabled?'✅ 自动开期中':'🚫 已关闭';
el.className='px-2 py-1 rounded text-xs '+(enabled?'bg-green-100 text-green-700':'bg-red-100 text-red-700');
}else{alert(d.message||'操作失败');}
}
async function saveGame(gameId){
const duration=parseInt(document.getElementById('duration-'+gameId).value)||300;
const lock=parseInt(document.getElementById('lock-'+gameId).value)||30;
const r=await fetch('/admin/auto-period/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({game_id:gameId,period_duration:duration,lock_before_end:lock})});
const d=await r.json();
if(d.success){
document.getElementById('durationMin-'+gameId).textContent=(duration/60).toFixed(1);
alert('保存成功');
}else{alert(d.message||'保存失败');}
}
async function toggleAll(enabled){
if(!confirm(enabled?'确认启用所有游戏的自动开期?':'确认关闭所有游戏的自动开期?'))return;
const r=await fetch('/admin/auto-period/toggle-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});
const d=await r.json();
if(d.success){location.reload();}else{alert(d.message||'操作失败');}
}
async function refreshStatus(){
const r=await fetch('/admin/auto-period/status');
const d=await r.json();
if(d.success){location.reload();}
}
// 每期时长实时更新分钟显示
document.querySelectorAll('input[id^="duration-"]').forEach(el=>{
el.addEventListener('input',function(){
const gid=this.id.split('-')[1];
document.getElementById('durationMin-'+gid).textContent=(parseInt(this.value||300)/60).toFixed(1);
});
});
</script>
+214
View File
@@ -0,0 +1,214 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fas fa-list-alt text-primary mr-3"></i>
投注记录
</h1>
<!-- 筛选工具栏 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<form class="flex flex-col md:flex-row gap-4" method="GET" action="/admin/bets">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">用户ID</label>
<input type="text" name="user_id" value="<?= htmlspecialchars($_GET['user_id'] ?? '') ?>" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm" placeholder="输入用户ID">
</div>
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">期号</label>
<input type="text" name="period_number" value="<?= htmlspecialchars($_GET['period_number'] ?? '') ?>" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm" placeholder="输入期号">
</div>
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">状态</label>
<select name="status" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm">
<option value="">全部状态</option>
<option value="pending" <?= ($_GET['status'] ?? '') === 'pending' ? 'selected' : '' ?>>待结算</option>
<option value="win" <?= ($_GET['status'] ?? '') === 'win' ? 'selected' : '' ?>>已中奖</option>
<option value="lose" <?= ($_GET['status'] ?? '') === 'lose' ? 'selected' : '' ?>>未中奖</option>
<option value="settled" <?= ($_GET['status'] ?? '') === 'settled' ? 'selected' : '' ?>>已结算</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary hover:bg-primary/90 text-white px-6 py-2 rounded-lg text-sm transition-colors h-[38px]">
<i class="fas fa-search mr-2"></i>查询
</button>
<a href="/admin/bets" class="ml-2 bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm transition-colors h-[38px] flex items-center">
重置
</a>
</div>
</form>
</div>
<!-- 数据列表 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<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">状态</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>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<?php if (!empty($bets) && is_array($bets)): ?>
<?php foreach ($bets as $bet): ?>
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-4 text-sm text-gray-500">
#<?= $bet['id'] ?>
</td>
<td class="px-4 py-4">
<div class="text-sm font-medium text-gray-900"><?= htmlspecialchars($bet['username'] ?? '未知用户') ?></div>
<div class="text-xs text-gray-500">ID: <?= $bet['user_id'] ?></div>
<div class="text-xs text-gray-500">余额: <?= number_format($bet['balance'] ?? 0) ?></div>
</td>
<td class="px-4 py-4 text-sm text-gray-700">
<?= htmlspecialchars($bet['game_name'] ?? '未关联') ?>
</td>
<td class="px-4 py-4 text-sm text-gray-700">
<?= htmlspecialchars($bet['period_number']) ?>
</td>
<td class="px-4 py-4">
<?php
// ========== 投注类型中文映射(完整版)==========
$bt = $bet['bet_type'];
$bv = $bet['bet_value'];
$displayType = '';
$displayValue = '';
// PK10 名次投注: rank + rank1_5
if ($bt === 'rank' && preg_match('/^rank(\d+)_(\d+)$/', $bv, $m)) {
$rn = (int)$m[1];
$displayType = $rn === 1 ? '冠军' : ($rn === 2 ? '亚军' : '第'.$rn.'名');
$displayValue = $m[2] . '号车';
}
// PK10 大小: bs + rank1_big
elseif ($bt === 'bs' && preg_match('/^rank(\d+)_(big|small)$/', $bv, $m)) {
$rn = (int)$m[1];
$displayType = ($rn === 1 ? '冠军' : ($rn === 2 ? '亚军' : '第'.$rn.'名')) . ' 大小';
$displayValue = $m[2] === 'big' ? '大' : '小';
}
// PK10 单双: oe + rank1_odd
elseif ($bt === 'oe' && preg_match('/^rank(\d+)_(odd|even)$/', $bv, $m)) {
$rn = (int)$m[1];
$displayType = ($rn === 1 ? '冠军' : ($rn === 2 ? '亚军' : '第'.$rn.'名')) . ' 单双';
$displayValue = $m[2] === 'odd' ? '单' : '双';
}
// PK10 龙虎: dt + dt1_dragon
elseif ($bt === 'dt' && preg_match('/^dt(\d+)_(dragon|tiger)$/', $bv, $m)) {
$pairs = [1=>[1,10],2=>[2,9],3=>[3,8],4=>[4,7],5=>[5,6]];
$p = $pairs[(int)$m[1]] ?? [(int)$m[1], 11-(int)$m[1]];
$displayType = '龙虎 '.$p[0].'vs'.$p[1];
$displayValue = $m[2] === 'dragon' ? '龙' : '虎';
}
// PK10 冠亚和值: sum + sum_11
elseif ($bt === 'sum' && preg_match('/^sum_(\d+)$/', $bv, $m)) {
$displayType = '冠亚和';
$displayValue = $m[1];
}
// PK10 冠亚和大小: sum_bs + sum_big
elseif ($bt === 'sum_bs') {
$displayType = '冠亚和';
$sbMap = ['sum_big'=>'大','sum_small'=>'小','sum_odd'=>'单','sum_even'=>'双'];
$displayValue = $sbMap[$bv] ?? $bv;
}
// 骰子 大小/单双/点数/单骰/豹子
else {
$typeMap = [
'xiu' => '小', 'tai' => '大', 'chan' => '双', 'le' => '单',
'number' => '点数', 'dice' => '单骰', 'combo' => '豹子',
'big_small' => '大小', 'odd_even' => '单双',
];
$displayType = $typeMap[$bt] ?? $bt;
$valueMap = ['big'=>'大','small'=>'小','odd'=>'单','even'=>'双',
'4red'=>'4红','4white'=>'4白','3red1white'=>'3红1白','1red3white'=>'1红3白'];
$displayValue = $valueMap[$bv] ?? $bv;
}
?>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<?= htmlspecialchars($displayType) ?>
</span>
<span class="text-sm font-bold ml-1 text-gray-700">
<?= htmlspecialchars($displayValue) ?>
</span>
<div class="text-xs text-gray-500 mt-0.5">赔率: <?= $bet['odds'] ?></div>
</td>
<td class="px-4 py-4 text-sm font-bold text-gray-900">
<?= number_format($bet['amount']) ?>
</td>
<td class="px-4 py-4">
<?php
$status = $bet['status'];
$statusClassMap = [
'pending' => 'bg-yellow-100 text-yellow-800',
'win' => 'bg-green-100 text-green-800',
'lose' => 'bg-gray-100 text-gray-800',
'settled' => 'bg-blue-100 text-blue-800'
];
$statusClass = $statusClassMap[$status] ?? 'bg-gray-100 text-gray-800';
$statusTextMap = [
'pending' => '待结算',
'win' => '已中奖',
'lose' => '未中奖',
'settled' => '已结算'
];
$statusText = $statusTextMap[$status] ?? $status;
?>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full <?= $statusClass ?>">
<?= $statusText ?>
</span>
</td>
<td class="px-4 py-4">
<?php if ($status === 'win'): ?>
<div class="text-sm font-bold text-green-600">+<?= number_format($bet['win_amount']) ?></div>
<div class="text-xs text-gray-500">盈利: <?= number_format($bet['win_amount'] - $bet['amount']) ?></div>
<?php elseif ($status === 'lose'): ?>
<div class="text-sm font-bold text-red-500">-<?= number_format($bet['amount']) ?></div>
<?php else: ?>
<span class="text-gray-400">-</span>
<?php endif; ?>
</td>
<td class="px-4 py-4 text-sm text-gray-500 hidden md:table-cell">
<?= $bet['created_at'] ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" class="px-6 py-12 text-center text-gray-500">
暂无投注记录
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 分页 -->
<?php if ($totalPages > 1): ?>
<div class="mt-4 flex justify-between items-center">
<div class="text-sm text-gray-500">
共 <?= $totalPages ?> 页
</div>
<div class="flex gap-2">
<?php if ($currentPage > 1): ?>
<a href="?page=<?= $currentPage - 1 ?><?= http_build_query(array_diff_key($_GET, ['page' => ''])) ? '&' . http_build_query(array_diff_key($_GET, ['page' => ''])) : '' ?>" class="px-3 py-1 border rounded hover:bg-gray-50">上一页</a>
<?php endif; ?>
<?php for ($i = max(1, $currentPage - 2); $i <= min($totalPages, $currentPage + 2); $i++): ?>
<a href="?page=<?= $i ?><?= http_build_query(array_diff_key($_GET, ['page' => ''])) ? '&' . http_build_query(array_diff_key($_GET, ['page' => ''])) : '' ?>" class="px-3 py-1 border rounded <?= $i == $currentPage ? 'bg-primary text-white border-primary' : 'hover:bg-gray-50' ?>">
<?= $i ?>
</a>
<?php endfor; ?>
<?php if ($currentPage < $totalPages): ?>
<a href="?page=<?= $currentPage + 1 ?><?= http_build_query(array_diff_key($_GET, ['page' => ''])) ? '&' . http_build_query(array_diff_key($_GET, ['page' => ''])) : '' ?>" class="px-3 py-1 border rounded hover:bg-gray-50">下一页</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
+268
View File
@@ -0,0 +1,268 @@
<!-- 页面标题 -->
<div class="mb-6">
<h3 class="text-2xl font-bold text-dark">
控制台
</h3>
<p class="text-gray-500 mt-1">
<?=date('Y年m月d日')?> · 今天是星期<?=['日','一','二','三','四','五','六'][date('w')]?>
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-xl p-6 card-shadow hover-lift">
<div class="flex justify-between items-start">
<div>
<p class="text-gray-500 text-sm">今日投注总额</p>
<h3 class="text-2xl font-bold mt-1">
<?= isset($stats['today_bet_amount']) ? number_format($stats['today_bet_amount'], 0) : '0' ?>
</h3>
<p class="text-gray-500 text-xs mt-2">
单位:USDT,含所有有效期号
</p>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-coins text-primary"></i>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 card-shadow hover-lift">
<div class="flex justify-between items-start">
<div>
<p class="text-gray-500 text-sm">今日已派彩金额</p>
<h3 class="text-2xl font-bold mt-1">
<?= isset($stats['today_payout_amount']) ? number_format($stats['today_payout_amount'], 0) : '0' ?>
</h3>
<p class="text-gray-500 text-xs mt-2">
已结算期号产生的实际派彩
</p>
</div>
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
<i class="fas fa-hand-holding-usd text-success"></i>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 card-shadow hover-lift">
<div class="flex justify-between items-start">
<div>
<p class="text-gray-500 text-sm">待处理开奖</p>
<h3 class="text-2xl font-bold mt-1">
<?= isset($stats['pending_draw_count']) ? (int)$stats['pending_draw_count'] : 0 ?>
</h3>
<p class="text-gray-500 text-xs mt-2">
含待录入结果与待审核期号
</p>
</div>
<div class="w-10 h-10 rounded-full bg-warning/10 flex items-center justify-center">
<i class="fas fa-trophy text-warning"></i>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 card-shadow hover-lift">
<div class="flex justify-between items-start">
<div>
<p class="text-gray-500 text-sm">待处理提现</p>
<h3 class="text-2xl font-bold mt-1">
<?= isset($stats['pending_withdraw_count']) ? (int)$stats['pending_withdraw_count'] : 0 ?>
</h3>
<p class="text-gray-500 text-xs mt-2">
仅统计处于待审核状态的提现申请
</p>
</div>
<div class="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center">
<i class="fas fa-file-invoice-dollar text-danger"></i>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-xl p-6 card-shadow lg:col-span-2">
<h2 class="text-lg font-semibold mb-4">业务流程概览</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
<div class="p-4 border border-gray-100 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">用户与资金</span>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-primary/10 text-primary">
<i class="fas fa-user-shield mr-1"></i> 账户安全
</span>
</div>
<p class="leading-relaxed">
管理玩家账号、登录状态与风险标记,掌握充值、余额、提现等资金流向,
为后续投注与开奖提供可靠资金基础。
</p>
</div>
<div class="p-4 border border-gray-100 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">游戏与期号</span>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-warning/10 text-warning">
<i class="fas fa-dice mr-1"></i> 核心玩法
</span>
</div>
<p class="leading-relaxed">
维护骰子游戏配置与赔率,按业务策略生成期号,控制期号生命周期
(下注、封盘、开奖、结算)并处理异常期号。
</p>
</div>
<div class="p-4 border border-gray-100 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">开奖与派彩</span>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-success/10 text-success">
<i class="fas fa-trophy mr-1"></i> 结果可信
</span>
</div>
<p class="leading-relaxed">
根据直播画面录入开奖结果并上传截图,完成结果审核与锁定,
自动触发派彩结算并与资金流水进行对账。
</p>
</div>
<div class="p-4 border border-gray-100 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">风控与监控</span>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-danger/10 text-danger">
<i class="fas fa-exclamation-triangle mr-1"></i> 风险预警
</span>
</div>
<p class="leading-relaxed">
依托后台数据监控高额投注、异常盈利、频繁提现等行为,
为人工复核和规则优化提供依据,保障平台资金安全。
</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 card-shadow">
<h2 class="text-lg font-semibold mb-4">运营快捷入口</h2>
<div class="space-y-3">
<a href="/admin/users" class="flex items-center justify-between px-4 py-2 rounded-lg border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-sm">
<span class="flex items-center">
<i class="fas fa-user-cog w-5 text-center mr-2 text-primary"></i>
<span>用户管理</span>
</span>
<i class="fas fa-chevron-right text-xs text-gray-400"></i>
</a>
<a href="/admin/games" class="flex items-center justify-between px-4 py-2 rounded-lg border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-sm">
<span class="flex items-center">
<i class="fas fa-dice w-5 text-center mr-2 text-primary"></i>
<span>游戏管理</span>
</span>
<i class="fas fa-chevron-right text-xs text-gray-400"></i>
</a>
<a href="/admin/periods" class="flex items-center justify-between px-4 py-2 rounded-lg border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-sm">
<span class="flex items-center">
<i class="fas fa-list-ol w-5 text-center mr-2 text-primary"></i>
<span>期号管理</span>
</span>
<i class="fas fa-chevron-right text-xs text-gray-400"></i>
</a>
<a href="/admin/draws" class="flex items-center justify-between px-4 py-2 rounded-lg border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-sm">
<span class="flex items-center">
<i class="fas fa-trophy w-5 text-center mr-2 text-primary"></i>
<span>开奖管理</span>
</span>
<i class="fas fa-chevron-right text-xs text-gray-400"></i>
</a>
<a href="/admin/finance" class="flex items-center justify-between px-4 py-2 rounded-lg border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-sm">
<span class="flex items-center">
<i class="fas fa-yen-sign w-5 text-center mr-2 text-primary"></i>
<span>财务管理</span>
</span>
<i class="fas fa-chevron-right text-xs text-gray-400"></i>
</a>
</div>
<div class="mt-4 pt-4 border-t border-dashed border-gray-200 text-xs text-gray-600 space-y-2">
<div class="flex items-center justify-between">
<span class="flex items-center">
<span class="w-2 h-2 rounded-full bg-warning mr-2"></span>
<span>待处理开奖</span>
</span>
<span class="font-semibold text-warning">
<?= isset($stats['pending_draw_count']) ? (int)$stats['pending_draw_count'] : 0 ?> 期
</span>
</div>
<div class="flex items-center justify-between">
<span class="flex items-center">
<span class="w-2 h-2 rounded-full bg-danger mr-2"></span>
<span>待处理提现</span>
</span>
<span class="font-semibold text-danger">
<?= isset($stats['pending_withdraw_count']) ? (int)$stats['pending_withdraw_count'] : 0 ?> 笔
</span>
</div>
</div>
</div>
</div>
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white rounded-xl p-6 card-shadow">
<h2 class="text-lg font-semibold mb-4">后台运营模块概览</h2>
<div id="game-management" class="mb-4">
<h3 class="text-sm font-medium mb-1 flex items-center">
<i class="fas fa-dice text-primary mr-2"></i> 游戏管理
</h3>
<p class="text-xs text-gray-500 leading-relaxed">
维护游戏平台中的骰子游戏配置,包括游戏列表、赔率设置、直播间绑定和启用状态,为前端提供可用游戏和玩法数据。
</p>
</div>
<div id="period-management" class="mb-4">
<h3 class="text-sm font-medium mb-1 flex items-center">
<i class="fas fa-list-ol text-primary mr-2"></i> 期号管理
</h3>
<p class="text-xs text-gray-500 leading-relaxed">
管理每天的开奖期号,配置期数、生成规则及时间区间,维护期号状态(未开始、下注中、封盘、已开奖、作废),并支持异常期号处理。
</p>
</div>
<div id="draw-management" class="mb-4">
<h3 class="text-sm font-medium mb-1 flex items-center">
<i class="fas fa-trophy text-primary mr-2"></i> 开奖管理
</h3>
<p class="text-xs text-gray-500 leading-relaxed">
开奖工作人员在后台对照直播流为指定期号录入开奖结果,上传开奖截图,完成审核与结果锁定,必要时进行重开或作废处理并记录日志。
</p>
</div>
<div id="finance-management">
<h3 class="text-sm font-medium mb-1 flex items-center">
<i class="fas fa-yen-sign text-primary mr-2"></i> 财务管理
</h3>
<p class="text-xs text-gray-500 leading-relaxed">
处理充值、提现和资金流水统计,核对用户余额变动情况,配合开奖结算结果进行对账,为风控和运营提供数据支持。
</p>
</div>
</div>
<div class="bg-white rounded-xl p-6 card-shadow">
<h2 class="text-lg font-semibold mb-4">操作指引</h2>
<div class="space-y-3 text-sm text-gray-600">
<p>
登录管理后台后,可通过左侧菜单快速进入用户管理、游戏管理、期号管理、开奖管理、财务管理和系统管理等模块,完成日常运营工作。
</p>
<p>
在正式接入真实数据前,建议优先配置游戏基本信息和直播间绑定,然后按业务流程逐步完善期号生成规则和开奖操作流程。
</p>
<p>
系统管理模块用于维护管理员账号和权限,以及后续扩展的系统配置与操作日志,确保平台运行安全可控。
</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 页面初始化操作
console.log('Project features page loaded successfully');
// 为功能卡片添加悬停动画效果
const featureCards = document.querySelectorAll('.hover\\:border-primary\\/30');
featureCards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.classList.add('transform', 'translate-y-[-5px]', 'shadow-md');
});
card.addEventListener('mouseleave', function() {
this.classList.remove('transform', 'translate-y-[-5px]', 'shadow-md');
});
});
});
</script>
+778
View File
@@ -0,0 +1,778 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fas fa-dice text-primary mr-3"></i>
骰子游戏期号管理
</h1>
<!-- 游戏期号管理区域 -->
<?php if (!empty($gamesList) && is_array($gamesList)): ?>
<?php foreach ($gamesList as $game): ?>
<?php
$gameId = $game['id'];
$gameName = $game['name'];
$currentPeriod = isset($currentPeriods[$gameId]) ? $currentPeriods[$gameId] : null;
?>
<!-- 单个游戏的期号卡片 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6 border-l-4 border-primary">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-800 flex items-center">
<i class="fas fa-gamepad text-primary mr-2"></i>
<?= htmlspecialchars($gameName) ?>
</h2>
<?php if ($currentPeriod): ?>
<!-- 有当前期号 -->
<div class="flex gap-2">
<?php if ($currentPeriod['status'] === 'pending'): ?>
<button
type="button"
class="period-lock-btn inline-block bg-warning hover:bg-warning/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-lock mr-2"></i>封盘
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'locked'): ?>
<button
type="button"
class="period-draw-btn inline-block bg-success hover:bg-success/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-dice mr-2"></i>开奖
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'drawn'): ?>
<button
type="button"
class="period-settle-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-coins mr-2"></i>结算
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'settled'): ?>
<button
type="button"
class="period-start-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-game-id="<?= $gameId ?>"
>
<i class="fas fa-play mr-2"></i>开始新一期
</button>
<?php endif; ?>
</div>
<?php else: ?>
<button
type="button"
class="period-start-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-game-id="<?= $gameId ?>"
>
<i class="fas fa-play mr-2"></i>开始新一期
</button>
<?php endif; ?>
</div>
<?php if ($currentPeriod): ?>
<!-- 当前期号信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<p class="text-sm text-gray-500 mb-1">期号</p>
<p class="text-lg font-bold text-gray-900"><?= htmlspecialchars($currentPeriod['period_number']) ?></p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">状态</p>
<?php
$status = $currentPeriod['status'];
$statusMap = [
'pending' => ['text' => '待开奖', 'class' => 'bg-warning/10 text-warning'],
'locked' => ['text' => '已封盘', 'class' => 'bg-danger/10 text-danger'],
'drawn' => ['text' => '已开奖', 'class' => 'bg-primary/10 text-primary'],
'settled' => ['text' => '已结算', 'class' => 'bg-success/10 text-success']
];
$statusInfo = $statusMap[$status] ?? $statusMap['pending'];
?>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm <?= $statusInfo['class'] ?>">
<?= $statusInfo['text'] ?>
</span>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">开始时间</p>
<p class="text-sm text-gray-900"><?= htmlspecialchars($currentPeriod['start_time'] ?? '-') ?></p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">开奖结果</p>
<?php if (!empty($currentPeriod['dice1']) && !empty($currentPeriod['dice2']) && !empty($currentPeriod['dice3'])): ?>
<p class="text-lg font-bold text-gray-900">
<?= $currentPeriod['dice1'] ?>.<?= $currentPeriod['dice2'] ?>.<?= $currentPeriod['dice3'] ?>
<span class="text-sm text-gray-500">(<?= $currentPeriod['total'] ?>)</span>
</p>
<?php else: ?>
<p class="text-sm text-gray-400">未开奖</p>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<div class="text-center py-4">
<p class="text-sm text-gray-500">暂无进行中的期号,点击"开始新一期"按钮启动</p>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 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-2xl font-bold mt-1">
<?= isset($periods) && is_array($periods) ? count($periods) : 0 ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-list 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 text-warning">
<?php
$pendingCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && $p['status'] === 'pending') {
$pendingCount++;
}
}
}
echo $pendingCount;
?>
</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-2xl font-bold mt-1 text-success">
<?php
$drawnCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && ($p['status'] === 'drawn' || $p['status'] === 'settled')) {
$drawnCount++;
}
}
}
echo $drawnCount;
?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
<i class="fas fa-check-circle 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 text-primary">
<?php
$settledCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && $p['status'] === 'settled') {
$settledCount++;
}
}
}
echo $settledCount;
?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-coins text-primary"></i>
</div>
</div>
</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">期号</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-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200" id="periodList">
<?php if (!empty($periods) && is_array($periods)): ?>
<?php foreach ($periods as $period): ?>
<tr class="hover:bg-gray-50 transition-colors" data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>">
<td class="px-4 py-4">
<div class="text-sm font-semibold text-gray-900">
<?= htmlspecialchars((string)($period['period_number'] ?? '')) ?>
</div>
<?php if (!empty($period['auto_generated'])): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 text-gray-600 mt-1">
自动生成
</span>
<?php endif; ?>
</td>
<td class="px-4 py-4">
<?php
$gameId = $period['game_id'] ?? null;
$gameName = $gameId && isset($games[$gameId]) ? $games[$gameId] : '未关联';
?>
<span class="text-sm text-gray-600"><?= htmlspecialchars($gameName) ?></span>
</td>
<td class="px-4 py-4">
<?php
$status = $period['status'] ?? 'pending';
$statusMap = [
'pending' => ['text' => '待开奖', 'class' => 'bg-warning/10 text-warning'],
'locked' => ['text' => '已封盘', 'class' => 'bg-danger/10 text-danger'],
'drawn' => ['text' => '已开奖', 'class' => 'bg-primary/10 text-primary'],
'settled' => ['text' => '已结算', 'class' => 'bg-success/10 text-success']
];
$statusInfo = $statusMap[$status] ?? $statusMap['pending'];
?>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $statusInfo['class'] ?>">
<span class="w-2 h-2 rounded-full mr-1 <?= str_replace('/10', '', $statusInfo['class']) ?>"></span>
<?= $statusInfo['text'] ?>
</span>
</td>
<td class="px-4 py-4">
<?php if (!empty($period['dice1']) && !empty($period['dice2']) && !empty($period['dice3'])): ?>
<div class="text-sm text-gray-900">
<span class="font-semibold">
<?= htmlspecialchars((string)$period['dice1']) ?>.
<?= htmlspecialchars((string)$period['dice2']) ?>.
<?= htmlspecialchars((string)$period['dice3']) ?>
</span>
<span class="text-gray-500 ml-1">
(<?= htmlspecialchars((string)($period['total'] ?? '')) ?>)
</span>
<?php if (!empty($period['result'])): ?>
<span class="ml-1 px-2 py-0.5 rounded text-[11px] <?= $period['result'] === 'Tài' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700' ?>">
<?= htmlspecialchars((string)$period['result']) ?>
</span>
<?php endif; ?>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">未开奖</span>
<?php endif; ?>
</td>
<td class="px-4 py-4 hidden md:table-cell">
<?php if (!empty($period['created_at'])): ?>
<div class="text-xs text-gray-500">
<?= date('Y-m-d H:i', strtotime((string)$period['created_at'])) ?>
</div>
<?php else: ?>
<span class="text-xs text-gray-400">时间未知</span>
<?php endif; ?>
</td>
<td class="px-4 py-4 text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<?php if (($period['status'] ?? '') === 'pending'): ?>
<button
type="button"
class="period-lock-btn text-gray-500 hover:text-warning"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="封盘"
>
<i class="fas fa-lock"></i>
</button>
<?php endif; ?>
<?php if (($period['status'] ?? '') === 'locked' || ($period['status'] ?? '') === 'pending'): ?>
<button
type="button"
class="period-draw-btn text-gray-500 hover:text-primary"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="录入开奖"
>
<i class="fas fa-dice"></i>
</button>
<?php endif; ?>
<?php if (($period['status'] ?? '') === 'drawn'): ?>
<button
type="button"
class="period-draw-btn text-gray-500 hover:text-warning"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="修改结果"
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="period-settle-btn text-gray-500 hover:text-success"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="确认结算"
>
<i class="fas fa-check-circle"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php 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-dice 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
id="drawPeriodBackdrop"
class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"
></div>
<div
id="drawPeriodModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95"
>
<div class="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fas fa-dice text-primary mr-2"></i>
录入开奖结果
</h3>
<button id="closeDrawPeriodBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fas fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="drawPeriodForm" class="space-y-4">
<input type="hidden" id="drawPeriodId" name="id">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
期号
</label>
<p id="drawPeriodNumber" class="text-lg font-bold text-gray-900"></p>
</div>
<!-- 骰子游戏输入 -->
<div id="diceInputSection" class="grid grid-cols-3 gap-4">
<div>
<label for="drawDice1" class="block text-sm font-medium text-gray-700 mb-1">
骰子1 <span class="text-red-500">*</span>
</label>
<input
type="number"
id="drawDice1"
name="dice1"
min="1"
max="6"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm text-center text-lg font-bold"
placeholder="1-6"
>
</div>
<div>
<label for="drawDice2" class="block text-sm font-medium text-gray-700 mb-1">
骰子2 <span class="text-red-500">*</span>
</label>
<input
type="number"
id="drawDice2"
name="dice2"
min="1"
max="6"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm text-center text-lg font-bold"
placeholder="1-6"
>
</div>
<div>
<label for="drawDice3" class="block text-sm font-medium text-gray-700 mb-1">
骰子3 <span class="text-red-500">*</span>
</label>
<input
type="number"
id="drawDice3"
name="dice3"
min="1"
max="6"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm text-center text-lg font-bold"
placeholder="1-6"
>
</div>
</div>
<div id="drawResultPreview" class="hidden p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600 mb-1">开奖结果预览:</p>
<p class="text-lg font-bold">
<span id="drawResultText"></span>
<span id="drawResultTotal" class="ml-2 text-gray-500"></span>
<span id="drawResultType" class="ml-2"></span>
</p>
</div>
<div class="pt-4 border-t border-gray-100">
<button
type="button"
id="submitDrawPeriodBtn"
class="w-full bg-primary hover:bg-primary/90 text-white px-4 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center justify-center"
>
<i class="fas fa-check mr-2"></i>
确认录入
</button>
</div>
</form>
</div>
</div>
</div>
<script src="/Static/js/admin.js"></script>
<script>
layui.use(['layer'], function() {
var layer = layui.layer;
// 获取元素
const closeDrawPeriodBtn = document.getElementById('closeDrawPeriodBtn');
const drawPeriodBackdrop = document.getElementById('drawPeriodBackdrop');
const drawPeriodModal = document.getElementById('drawPeriodModal');
const drawPeriodForm = document.getElementById('drawPeriodForm');
const submitDrawPeriodBtn = document.getElementById('submitDrawPeriodBtn');
const drawDice1 = document.getElementById('drawDice1');
const drawDice2 = document.getElementById('drawDice2');
const drawDice3 = document.getElementById('drawDice3');
const drawResultPreview = document.getElementById('drawResultPreview');
const drawResultText = document.getElementById('drawResultText');
const drawResultTotal = document.getElementById('drawResultTotal');
const drawResultType = document.getElementById('drawResultType');
// 打开录入开奖模态框
function openDrawPeriodModal(periodId) {
fetch(`/admin/dice-periods/${periodId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
const period = data.data;
document.getElementById('drawPeriodId').value = period.id;
document.getElementById('drawPeriodNumber').textContent = period.period_number;
// 填充已有的骰子数据
drawDice1.value = period.dice1 || '';
drawDice2.value = period.dice2 || '';
drawDice3.value = period.dice3 || '';
updateDrawPreview();
drawPeriodBackdrop.classList.remove('opacity-0', 'pointer-events-none');
drawPeriodModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
drawPeriodModal.classList.add('scale-100');
} else {
layer.msg(data.message || '获取期号信息失败', {icon: 2});
}
})
.catch(e => {
layer.msg('获取期号信息失败:' + e.message, {icon: 2});
});
}
// 关闭录入开奖模态框
function closeDrawPeriodModal() {
drawPeriodBackdrop.classList.add('opacity-0', 'pointer-events-none');
drawPeriodModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
drawPeriodModal.classList.remove('scale-100');
drawPeriodForm.reset();
drawResultPreview.classList.add('hidden');
}
// 更新开奖结果预览
function updateDrawPreview() {
const d1 = parseInt(drawDice1.value) || 0;
const d2 = parseInt(drawDice2.value) || 0;
const d3 = parseInt(drawDice3.value) || 0;
if (d1 >= 1 && d1 <= 6 && d2 >= 1 && d2 <= 6 && d3 >= 1 && d3 <= 6) {
const total = d1 + d2 + d3;
let result = '';
let resultClass = '';
// 检查是否为爆子
if (d1 === d2 && d2 === d3) {
if (d1 <= 3) {
result = 'Xỉu';
resultClass = 'bg-blue-100 text-blue-700 px-2 py-1 rounded text-sm';
} else {
result = 'Tài';
resultClass = 'bg-red-100 text-red-700 px-2 py-1 rounded text-sm';
}
} else if (total >= 4 && total <= 10) {
result = 'Xỉu';
resultClass = 'bg-blue-100 text-blue-700 px-2 py-1 rounded text-sm';
} else {
result = 'Tài';
resultClass = 'bg-red-100 text-red-700 px-2 py-1 rounded text-sm';
}
drawResultText.textContent = `${d1}.${d2}.${d3}`;
drawResultTotal.textContent = `(总和: ${total})`;
drawResultType.innerHTML = `<span class="${resultClass}">${result}</span>`;
drawResultPreview.classList.remove('hidden');
} else {
drawResultPreview.classList.add('hidden');
}
}
// 提交录入开奖
async function submitDrawPeriod() {
const dice1 = parseInt(drawDice1.value);
const dice2 = parseInt(drawDice2.value);
const dice3 = parseInt(drawDice3.value);
if (!dice1 || !dice2 || !dice3 || dice1 < 1 || dice1 > 6 || dice2 < 1 || dice2 > 6 || dice3 < 1 || dice3 > 6) {
layer.msg('请输入有效的骰子点数(1-6', {icon: 2});
return;
}
const data = {
id: document.getElementById('drawPeriodId').value,
auto: false,
dice1: dice1,
dice2: dice2,
dice3: dice3
};
submitDrawPeriodBtn.disabled = true;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>录入中...';
try {
const response = await fetch('/admin/dice-periods/draw', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '开奖结果录入成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '录入失败', {icon: 2});
submitDrawPeriodBtn.disabled = false;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-check mr-2"></i>确认录入';
}
} catch (e) {
layer.msg('录入失败:' + e.message, {icon: 2});
submitDrawPeriodBtn.disabled = false;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-check mr-2"></i>确认录入';
}
}
// 封盘
async function lockPeriod(id) {
layer.confirm('确定要封盘吗?封盘后将无法继续投注。', {icon: 3, title: '确认封盘'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/dice-periods/lock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({id: id})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '封盘成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '封盘失败', {icon: 2});
}
} catch (e) {
layer.msg('封盘失败:' + e.message, {icon: 2});
}
});
}
// 确认结算
async function settlePeriod(id) {
layer.confirm('确定要确认结算吗?此操作不可撤销。', {icon: 3, title: '确认结算'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/dice-periods/settle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({id: id})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '结算成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '结算失败', {icon: 2});
}
} catch (e) {
layer.msg('结算失败:' + e.message, {icon: 2});
}
});
}
// 开始下注
async function startPeriod(event) {
const gameId = event.currentTarget.getAttribute('data-game-id');
if (!gameId) {
layer.msg('游戏ID缺失', {icon: 2});
return;
}
layer.confirm('确定要开始新一期下注吗?', {icon: 3, title: '开始下注'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/dice-periods/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({game_id: parseInt(gameId)})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '启动成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '启动失败', {icon: 2});
}
} catch (e) {
layer.msg('启动失败:' + e.message, {icon: 2});
}
});
}
// 绑定事件
if (closeDrawPeriodBtn) {
closeDrawPeriodBtn.addEventListener('click', closeDrawPeriodModal);
}
if (drawPeriodBackdrop) {
drawPeriodBackdrop.addEventListener('click', closeDrawPeriodModal);
}
if (submitDrawPeriodBtn) {
submitDrawPeriodBtn.addEventListener('click', submitDrawPeriod);
}
// 骰子输入监听
if (drawDice1 && drawDice2 && drawDice3) {
[drawDice1, drawDice2, drawDice3].forEach(input => {
input.addEventListener('input', updateDrawPreview);
});
}
// 事件委托:列表操作按钮
const periodList = document.getElementById('periodList');
if (periodList) {
periodList.addEventListener('click', function(e) {
const lockBtn = e.target.closest('.period-lock-btn');
const drawBtn = e.target.closest('.period-draw-btn');
const settleBtn = e.target.closest('.period-settle-btn');
if (lockBtn) {
const id = lockBtn.getAttribute('data-id');
if (id) lockPeriod(id);
}
if (drawBtn) {
const id = drawBtn.getAttribute('data-id');
if (id) openDrawPeriodModal(id);
}
if (settleBtn) {
const id = settleBtn.getAttribute('data-id');
if (id) settlePeriod(id);
}
});
}
// 当前期号操作按钮
document.querySelectorAll('.period-lock-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) lockPeriod(id);
});
}
});
document.querySelectorAll('.period-draw-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) openDrawPeriodModal(id);
});
}
});
document.querySelectorAll('.period-settle-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) settlePeriod(id);
});
}
});
// Start Button
document.querySelectorAll('.period-start-btn').forEach(btn => {
btn.addEventListener('click', startPeriod);
});
});
</script>
+69
View File
@@ -0,0 +1,69 @@
<?php $empCode=$_SESSION['emp_code']??''; $perms=$_SESSION['emp_permissions']??[]; ?>
<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>员工面板 - <?=$empCode?></title><script src="https://cdn.tailwindcss.com"></script>
<style>body{background:#111827;color:#fff;font-family:system-ui}</style>
</head>
<body class="min-h-screen">
<header class="bg-gray-900 border-b border-gray-700 px-4 py-3 flex justify-between items-center">
<span class="font-bold">👷 <?=$empCode?></span>
<span class="text-xs text-gray-400">权限:<?php $pm=['deposit'=>'充值','withdraw'=>'提现']; echo implode(', ', array_map(function($p) use($pm){return $pm[$p]??$p;}, $perms)); ?></span>
<a href="/logout" class="text-red-400 text-sm">退出</a>
</header>
<div class="max-w-4xl mx-auto p-4 space-y-4">
<!-- 搜索用户 -->
<div class="bg-gray-800 rounded-xl p-4">
<input type="text" id="searchUser" placeholder="搜索用户名..." oninput="filterUsers()" class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:border-blue-400 focus:outline-none">
</div>
<!-- 用户列表 -->
<div class="bg-gray-800 rounded-xl p-4">
<h3 class="text-sm font-bold mb-3">用户列表</h3>
<div class="space-y-2 max-h-96 overflow-y-auto" id="userList">
<?php foreach($users??[] as $u): ?>
<div class="user-row flex items-center justify-between py-2 border-b border-gray-700 text-sm" data-name="<?=strtolower($u['username'])?>">
<div>
<span class="text-white"><?=htmlspecialchars($u['username'])?></span>
<span class="text-gray-400 ml-2">余额: <span class="text-yellow-400" id="bal_<?=$u['id']?>"><?=number_format($u['balance'],2)?></span></span>
</div>
<div class="flex gap-2">
<?php if(in_array('deposit',$perms)): ?>
<button onclick="doAdjust(<?=$u['id']?>,'deposit','<?=htmlspecialchars($u['username'])?>')" class="px-3 py-1 bg-green-600 rounded text-xs hover:bg-green-700">+ 充值</button>
<?php endif; ?>
<?php if(in_array('withdraw',$perms)): ?>
<button onclick="doAdjust(<?=$u['id']?>,'withdraw','<?=htmlspecialchars($u['username'])?>')" class="px-3 py-1 bg-red-600 rounded text-xs hover:bg-red-700">- 提现</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- 操作日志 -->
<div class="bg-gray-800 rounded-xl p-4">
<h3 class="text-sm font-bold mb-3">操作日志</h3>
<div class="space-y-1 max-h-48 overflow-y-auto text-xs">
<?php foreach($logs??[] as $log): ?>
<div class="flex justify-between py-1 border-b border-gray-700">
<span class="<?=$log['action']==='deposit'?'text-green-400':'text-red-400'?>"><?=$log['action']==='deposit'?'充值':'提现'?> → 用户#<?=$log['target_user_id']?></span>
<span class="text-white"><?=number_format($log['amount'],2)?></span>
<span class="text-gray-500"><?=$log['created_at']?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
function filterUsers(){const q=document.getElementById('searchUser').value.toLowerCase();document.querySelectorAll('.user-row').forEach(r=>r.style.display=r.dataset.name.includes(q)?'':'none');}
async function doAdjust(uid,action,name){
const label=action==='deposit'?'充值':'提现';
const amount=prompt(label+'金额(用户:'+name+'):');
if(!amount||isNaN(amount)||parseFloat(amount)<=0)return;
const remark=prompt('备注(可选):')||'';
const r=await fetch('/employee/adjust-balance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user_id:uid,action,amount:parseFloat(amount),remark})});
const d=await r.json();
if(d.success){alert('操作成功!');document.getElementById('bal_'+uid).textContent=parseFloat(d.new_balance).toFixed(2);}else alert(d.message);
}
</script>
</body></html>
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>员工登录</title><script src="https://cdn.tailwindcss.com"></script>
<style>body{background:linear-gradient(135deg,#1a1a2e,#16213e);min-height:100vh;font-family:system-ui}</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-sm bg-white/10 backdrop-blur rounded-2xl p-8 border border-white/10">
<h1 class="text-center text-xl font-bold text-white mb-6">👷 员工登录</h1>
<form onsubmit="return doLogin(event)">
<div class="space-y-4">
<input type="text" id="user" placeholder="用户名" required class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:border-blue-400 focus:outline-none">
<input type="password" id="pass" placeholder="密码" required class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:border-blue-400 focus:outline-none">
<button type="submit" class="w-full py-3 bg-blue-600 text-white font-bold rounded-lg hover:bg-blue-700">登录</button>
</div>
</form>
<div id="msg" class="mt-3 text-center text-sm text-red-400 hidden"></div>
</div>
<script>
async function doLogin(e){e.preventDefault();
const r=await fetch('/employee/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('user').value,password:document.getElementById('pass').value})});
const d=await r.json();if(d.success)window.location.href=d.redirect;else{const m=document.getElementById('msg');m.textContent=d.message;m.classList.remove('hidden');}return false;}
</script></body></html>
+56
View File
@@ -0,0 +1,56 @@
<div class="p-6">
<h2 class="text-2xl font-bold mb-4">👷 员工管理</h2>
<button onclick="showAdd()" class="mb-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">+ 添加员工</button>
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left">编号</th><th class="px-4 py-3">用户名</th><th class="px-4 py-3">姓名</th><th class="px-4 py-3">班次</th><th class="px-4 py-3">权限</th><th class="px-4 py-3">状态</th><th class="px-4 py-3">最后登录</th><th class="px-4 py-3">操作</th></tr></thead>
<tbody>
<?php foreach($employees??[] as $e): $perms=json_decode($e['permissions']??'[]',true); ?>
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-2 font-mono font-bold"><?=$e['emp_code']?></td>
<td class="px-4 py-2"><?=htmlspecialchars($e['username'])?></td>
<td class="px-4 py-2"><?=htmlspecialchars($e['real_name']??'')?></td>
<td class="px-4 py-2 text-center"><span class="px-2 py-1 rounded text-xs <?=$e['shift']==='day'?'bg-yellow-100 text-yellow-700':($e['shift']==='night'?'bg-indigo-100 text-indigo-700':'bg-gray-100')?>"><?=$e['shift']==='day'?'白班':($e['shift']==='night'?'夜班':'全天')?></span></td>
<td class="px-4 py-2 text-xs"><?php $permMap=['deposit'=>'充值','withdraw'=>'提现']; echo implode(', ', array_map(function($p) use($permMap){return $permMap[$p]??$p;}, $perms)); ?></td>
<td class="px-4 py-2 text-center"><?=$e['status']?'<span class="text-green-500">✓ 启用</span>':'<span class="text-red-500">✗ 禁用</span>'?></td>
<td class="px-4 py-2 text-xs text-gray-400"><?=$e['last_login']??'-'?></td>
<td class="px-4 py-2 text-center">
<button onclick='editEmp(<?=json_encode($e)?>)' class="text-blue-500 text-xs">编辑</button>
<button onclick="delEmp(<?=$e['id']?>)" class="text-red-500 text-xs ml-1">删除</button>
</td>
</tr>
<?php endforeach; ?>
</tbody></table></div>
<div id="empModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl p-6 w-full max-w-md">
<h3 class="font-bold mb-4" id="empTitle">添加员工</h3>
<input type="hidden" id="empId" value="0">
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div><label class="text-xs text-gray-400">编号 (如 001)</label><input id="empCode" class="w-full border rounded px-3 py-2"></div>
<div><label class="text-xs text-gray-400">用户名</label><input id="empUser" class="w-full border rounded px-3 py-2"></div>
</div>
<div><label class="text-xs text-gray-400">密码 (留空则不修改)</label><input type="password" id="empPass" class="w-full border rounded px-3 py-2"></div>
<div><label class="text-xs text-gray-400">真实姓名</label><input id="empName" class="w-full border rounded px-3 py-2"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="text-xs text-gray-400">班次</label><select id="empShift" class="w-full border rounded px-3 py-2"><option value="all">全天</option><option value="day">白班 (8-20)</option><option value="night">夜班 (20-8)</option></select></div>
<div><label class="text-xs text-gray-400">状态</label><select id="empStatus" class="w-full border rounded px-3 py-2"><option value="1">启用</option><option value="0">禁用</option></select></div>
</div>
<div><label class="text-xs text-gray-400">权限</label>
<label class="flex items-center gap-2 mt-1"><input type="checkbox" id="permDeposit" checked> 充值</label>
<label class="flex items-center gap-2"><input type="checkbox" id="permWithdraw" checked> 提现</label>
</div>
</div>
<div class="flex gap-2 mt-4">
<button onclick="saveEmp()" class="flex-1 py-2 bg-blue-500 text-white rounded">保存</button>
<button onclick="document.getElementById('empModal').classList.add('hidden')" class="flex-1 py-2 bg-gray-200 rounded">取消</button>
</div>
</div></div>
</div>
<script>
function showAdd(){document.getElementById('empId').value=0;document.getElementById('empTitle').textContent='添加员工';['empCode','empUser','empPass','empName'].forEach(i=>document.getElementById(i).value='');document.getElementById('empModal').classList.remove('hidden');}
function editEmp(e){document.getElementById('empId').value=e.id;document.getElementById('empCode').value=e.emp_code;document.getElementById('empUser').value=e.username;document.getElementById('empName').value=e.real_name||'';document.getElementById('empShift').value=e.shift;document.getElementById('empStatus').value=e.status;const p=JSON.parse(e.permissions||'[]');document.getElementById('permDeposit').checked=p.includes('deposit');document.getElementById('permWithdraw').checked=p.includes('withdraw');document.getElementById('empTitle').textContent='编辑员工';document.getElementById('empModal').classList.remove('hidden');}
async function saveEmp(){const perms=[];if(document.getElementById('permDeposit').checked)perms.push('deposit');if(document.getElementById('permWithdraw').checked)perms.push('withdraw');const b={id:parseInt(document.getElementById('empId').value),emp_code:document.getElementById('empCode').value,username:document.getElementById('empUser').value,password:document.getElementById('empPass').value,real_name:document.getElementById('empName').value,shift:document.getElementById('empShift').value,status:parseInt(document.getElementById('empStatus').value),permissions:perms};const r=await fetch('/admin/employees/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});const d=await r.json();if(d.status==='success')location.reload();else alert(d.message);}
async function delEmp(id){if(!confirm('确定删除该员工?'))return;await fetch('/admin/employees/delete/'+id,{method:'POST'});location.reload();}
</script>
+200
View File
@@ -0,0 +1,200 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fas fa-yen-sign text-primary mr-3"></i>
财务管理
</h1>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 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-success">
<?= number_format((float)($stats['total_deposit'] ?? 0), 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)($stats['total_withdraw'] ?? 0), 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-warning">
<?= number_format((float)($stats['total_bet'] ?? 0), 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)($stats['total_win'] ?? 0), 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 text-success">
<?= number_format((float)($stats['today_deposit'] ?? 0), 2) ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
<i class="fas fa-calendar-day 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)($stats['today_withdraw'] ?? 0), 2) ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center">
<i class="fas fa-calendar-day text-danger"></i>
</div>
</div>
</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 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>
</tr>
</thead>
<tbody class="divide-y divide-gray-200" id="transactionList">
<?php if (!empty($transactions) && is_array($transactions)): ?>
<?php foreach ($transactions as $tx): ?>
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-4">
<span class="text-sm font-medium text-gray-900">#<?= htmlspecialchars((string)($tx['id'] ?? '')) ?></span>
</td>
<td class="px-4 py-4">
<div class="text-sm text-gray-900">
<?= htmlspecialchars((string)($tx['username'] ?? '未知')) ?>
</div>
<div class="text-xs text-gray-500">
ID: <?= htmlspecialchars((string)($tx['user_id'] ?? '')) ?>
</div>
</td>
<td class="px-4 py-4">
<?php
$type = $tx['type'] ?? '';
$typeMap = [
'deposit' => ['text' => '充值', 'class' => 'bg-success/10 text-success'],
'withdraw' => ['text' => '提现', 'class' => 'bg-danger/10 text-danger'],
'bet' => ['text' => '投注', 'class' => 'bg-warning/10 text-warning'],
'win' => ['text' => '中奖', 'class' => 'bg-primary/10 text-primary'],
'refund' => ['text' => '退款', 'class' => 'bg-gray-100 text-gray-700'],
'manual_deposit' => ['text' => '人工加款', 'class' => 'bg-purple-100 text-purple-700'],
'manual_withdraw' => ['text' => '人工扣款', 'class' => 'bg-orange-100 text-orange-700']
];
$typeInfo = $typeMap[$type] ?? ['text' => $type, 'class' => 'bg-gray-100 text-gray-700'];
?>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $typeInfo['class'] ?>">
<?= $typeInfo['text'] ?>
</span>
</td>
<td class="px-4 py-4">
<?php
$amount = floatval($tx['amount'] ?? 0);
$amountClass = $amount >= 0 ? 'text-success' : 'text-danger';
?>
<span class="text-sm font-semibold <?= $amountClass ?>">
<?= $amount >= 0 ? '+' : '' ?><?= number_format($amount, 2) ?>
</span>
</td>
<td class="px-4 py-4">
<div class="text-sm text-gray-900">
<span class="text-gray-500"><?= number_format(floatval($tx['balance_before'] ?? 0), 2) ?></span>
<i class="fas fa-arrow-right mx-1 text-gray-400"></i>
<span class="font-semibold"><?= number_format(floatval($tx['balance_after'] ?? 0), 2) ?></span>
</div>
</td>
<td class="px-4 py-4 hidden md:table-cell">
<span class="text-sm text-gray-600">
<?= htmlspecialchars((string)($tx['description'] ?? '-')) ?>
</span>
</td>
<td class="px-4 py-4 hidden lg:table-cell">
<?php if (!empty($tx['created_at'])): ?>
<div class="text-xs text-gray-500">
<?= date('Y-m-d H:i:s', strtotime((string)$tx['created_at'])) ?>
</div>
<?php else: ?>
<span class="text-xs text-gray-400">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<div class="flex flex-col items-center">
<i class="fas fa-money-bill-wave 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;
// 此处可以添加其他财务相关的JS逻辑
});
</script>
+1120
View File
File diff suppressed because it is too large Load Diff
+690
View File
@@ -0,0 +1,690 @@
<!-- 页面标题 -->
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fas fa-image text-primary mr-3"></i>图片图库管理
</h1>
<!-- 主内容区 -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<!-- 标签页导航 -->
<div class="border-b border-gray-200">
<div class="flex">
<button id="gallery-tab-gallery" class="px-6 py-4 font-medium text-primary border-b-2 border-primary" data-tab="gallery"> <i class="fa fa-th-large mr-2"></i>图库 </button>
<button id="gallery-upload-modal-trigger" class="px-6 py-4 font-medium text-gray-500 hover:text-gray-700"> <i class="fa fa-upload mr-2"></i>上传 </button>
</div>
</div>
<!-- 图库内容 -->
<div id="gallery-content-gallery" class="p-6" data-tab="gallery">
<!-- 批量删除按钮 -->
<button id="gallery-batch-delete" class="mb-6 px-4 py-2 bg-red-500 text-white rounded-md flex items-center opacity-50 cursor-not-allowed" disabled> <i class="fa fa-trash mr-2"></i> 删除选中的图片 </button>
<!-- 图片网格 -->
<div id="gallery-media-grid" class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 mb-6"></div>
<!-- 加载更多按钮 -->
<button id="gallery-load-more" class="w-full py-2 px-4 bg-gray-100 text-gray-800 rounded-md flex items-center justify-center hover:bg-gray-200 transition-colors"> <i class="fa fa-refresh mr-2"></i> 加载更多 </button>
</div>
</div>
<!-- 上传弹出窗口 -->
<div id="gallery-upload-modal" class="fixed inset-0 bg-black/50 z-50 hidden items-center justify-center">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-800">上传图片</h2>
<button id="gallery-close-upload-modal" class="text-gray-500 hover:text-gray-700"> <i class="fa fa-times text-xl"></i> </button>
</div>
<div class="p-6">
<div class="space-y-6">
<!-- 上传参数设置 -->
<div class="space-y-4">
<!-- 质量设置 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">质量设置 (1-100)</label>
<div class="flex items-center space-x-4">
<input type="range" id="gallery-quality" min="35" max="100" value="60" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500">
<span id="gallery-quality-value" class="text-sm font-medium min-w-[3rem] text-center">60</span>
</div>
</div>
<!-- 宽度设置 -->
<div>
<label for="gallery-width" class="block text-sm font-medium text-gray-700 mb-2">转换宽度</label>
<input type="number" id="gallery-width" placeholder="留空为自动" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
</div>
<!-- 高度设置 -->
<div>
<label for="gallery-height" class="block text-sm font-medium text-gray-700 mb-2">转换高度</label>
<input type="number" id="gallery-height" placeholder="留空为自动" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<!-- 上传按钮 -->
<button id="gallery-upload-btn" class="w-full py-3 bg-blue-500 text-white rounded-md flex items-center justify-center hover:bg-blue-600 transition-colors">
<i class="fa fa-cloud-upload mr-2"></i>
<span id="gallery-upload-status">开始上传</span>
</button>
<input type="file" id="gallery-upload-input" multiple accept="image/*" class="hidden">
<!-- 上传结果 -->
<div id="gallery-upload-results" class="mt-6 space-y-4"></div>
</div>
</div>
</div>
</div>
<!-- 图片预览模态框 -->
<div id="gallery-preview-modal" class="fixed inset-0 bg-black/90 z-50 hidden items-center justify-center p-4">
<div class="relative max-w-5xl ">
<!-- 图片容器 - 用于定位关闭按钮 -->
<div class="relative inline-block">
<img src="" alt="预览图片" class="max-w-full max-h-[80vh] bg-white mx-auto object-contain">
<!-- 关闭按钮 - 绝对定位在图片右上角 -->
<button id="gallery-preview-close" class="absolute -top-8 bg-white w-8 h-8 p-0 rounded-full -right-8 text-red text-2xl hover:text-gray-300 transition-colors">
<i class="fa fa-times"></i>
</button>
</div>
</div>
</div>
<script>
// 组件状态
const galleryState = {
targetInputId: null,
currentPage: 1,
totalItems: 0,
totalPages: 0,
mediaItems: [],
isLoading: false,
activeTab: 'gallery',
selectedImageIds: []
};
// DOM元素缓存 - 使用唯一ID避免冲突
const galleryElements = {
mediaGrid: document.getElementById('gallery-media-grid'),
loadMoreBtn: document.getElementById('gallery-load-more'),
batchDeleteBtn: document.getElementById('gallery-batch-delete'),
previewModal: document.getElementById('gallery-preview-modal'),
previewImage: document.querySelector('#gallery-preview-modal img'),
previewCloseBtn: document.getElementById('gallery-preview-close'),
uploadModal: document.getElementById('gallery-upload-modal'),
uploadModalTrigger: document.getElementById('gallery-upload-modal-trigger'),
closeUploadModal: document.getElementById('gallery-close-upload-modal'),
uploadResults: document.getElementById('gallery-upload-results')
};
// 初始化函数
function initGallery() {
setupGalleryEventListeners();
fetchGalleryMediaList();
}
// 格式化文件大小
function galleryFormatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(0)) + ' ' + sizes[i];
}
// 验证图片文件
function galleryIsImageFile(file) {
const extension = file.name.split('.').pop().toLowerCase();
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'avif'];
return imageExtensions.includes(extension) || /image\/.*/.test(file.type);
}
// 本地存储表单状态 - 使用唯一键名
function gallerySaveFormState(width, height) {
localStorage.setItem('galleryFormState', JSON.stringify({ width, height }));
}
function galleryLoadFormState() {
const state = localStorage.getItem('galleryFormState');
return state ? JSON.parse(state) : { width: '', height: '' };
}
// 设置事件监听 - 全部使用唯一ID
function setupGalleryEventListeners() {
// 质量滑块事件
const qualityInput = document.getElementById('gallery-quality');
const qualityValue = document.getElementById('gallery-quality-value');
if (qualityInput && qualityValue) {
qualityInput.addEventListener('input', function() {
qualityValue.textContent = this.value;
});
}
// 上传模态框控制
galleryElements.uploadModalTrigger.addEventListener('click', () => {
galleryElements.uploadModal.classList.remove('hidden');
galleryElements.uploadModal.classList.add('flex');
// 加载保存的表单状态
const { width, height } = galleryLoadFormState();
document.getElementById('gallery-width').value = width;
document.getElementById('gallery-height').value = height;
});
galleryElements.closeUploadModal.addEventListener('click', () => {
galleryElements.uploadModal.classList.add('hidden');
galleryElements.uploadModal.classList.remove('flex');
});
// 点击模态框背景关闭
galleryElements.uploadModal.addEventListener('click', (e) => {
if (e.target === galleryElements.uploadModal) {
galleryElements.uploadModal.classList.add('hidden');
galleryElements.uploadModal.classList.remove('flex');
}
});
// 加载更多按钮
galleryElements.loadMoreBtn.addEventListener('click', galleryLoadMoreImages);
// 批量删除按钮
galleryElements.batchDeleteBtn.addEventListener('click', () => {
if (galleryState.selectedImageIds.length > 0) {
galleryConfirmDeleteImage(galleryState.selectedImageIds);
}
});
// 图片网格事件委托 - 使用数据属性识别元素类型
galleryElements.mediaGrid.addEventListener('click', (e) => {
// 预览图片 - 使用数据属性选择
const previewBtn = e.target.closest('[data-action="preview"]');
if (previewBtn) {
const url = previewBtn.dataset.url;
galleryPreviewFile(url);
return;
}
// 复制链接 - 使用数据属性选择
const copyBtn = e.target.closest('[data-action="copy"]');
if (copyBtn) {
const url = copyBtn.dataset.url;
galleryCopyToClipboard(url);
return;
}
// 图片复选框 - 使用数据属性选择
const checkbox = e.target.closest('[data-type="image-checkbox"]');
if (checkbox) {
const imageId = checkbox.dataset.id;
galleryToggleImageSelection(imageId, checkbox);
return;
}
});
// 上传结果区域事件委托
galleryElements.uploadResults.addEventListener('click', (e) => {
const retryBtn = e.target.closest('[data-action="retry-upload"]');
if (retryBtn) {
document.getElementById('gallery-upload-input').click();
return;
}
});
// 上传按钮
document.getElementById('gallery-upload-btn').addEventListener('click', () => {
document.getElementById('gallery-upload-input').click();
});
// 文件选择事件
document.getElementById('gallery-upload-input').addEventListener('change', galleryHandleFileSelect);
// 预览模态框关闭
galleryElements.previewCloseBtn.addEventListener('click', () => {
galleryElements.previewModal.classList.remove('flex');
galleryElements.previewModal.classList.add('hidden');
});
galleryElements.previewModal.addEventListener('click', (e) => {
if (e.target === galleryElements.previewModal) {
galleryElements.previewModal.classList.remove('flex');
galleryElements.previewModal.classList.add('hidden');
}
});
}
// 切换图片选择状态
function galleryToggleImageSelection(imageId, checkbox) {
const index = galleryState.selectedImageIds.indexOf(imageId);
if (index === -1) {
// 选中
galleryState.selectedImageIds.push(imageId);
checkbox.checked = true;
// 使用DOM导航找到父容器并应用样式
checkbox.closest('[data-type="image-item"]').classList.add('ring-2', 'ring-blue-500', 'ring-offset-2');
} else {
// 取消选中
galleryState.selectedImageIds.splice(index, 1);
checkbox.checked = false;
checkbox.closest('[data-type="image-item"]').classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2');
}
// 更新批量删除按钮状态
if (galleryState.selectedImageIds.length > 0) {
galleryElements.batchDeleteBtn.disabled = false;
galleryElements.batchDeleteBtn.classList.remove('opacity-50', 'cursor-not-allowed');
galleryElements.batchDeleteBtn.classList.add('hover:bg-red-600');
} else {
galleryElements.batchDeleteBtn.disabled = true;
galleryElements.batchDeleteBtn.classList.add('opacity-50', 'cursor-not-allowed');
galleryElements.batchDeleteBtn.classList.remove('hover:bg-red-600');
}
}
// 确认删除
function galleryConfirmDeleteImage(ids) {
const isBatch = ids.length > 1;
if (confirm(`确定要${isBatch ? '批量删除选中的' : '删除这张'}图片吗?此操作不可撤销。`)) {
galleryDeleteImages(ids);
}
}
// 删除图片函数
function galleryDeleteImages(ids) {
galleryState.isLoading = true;
galleryShowLoading();
fetch('/admin/images/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.status === 'success') {
showMessage(`成功删除${ids.length}张图片`, 'success');
galleryState.currentPage = 1;
galleryState.selectedImageIds = [];
galleryState.isLoading = false;
fetchGalleryMediaList();
} else {
showMessage(`删除失败:${data.message}`, 'error');
galleryState.isLoading = false;
galleryRenderMediaGrid();
}
})
.catch(error => {
console.error('删除失败:', error);
showMessage('网络错误,删除失败', 'error');
galleryState.isLoading = false;
galleryRenderMediaGrid();
});
}
// 获取图片列表
function fetchGalleryMediaList() {
if (galleryState.isLoading) return;
galleryState.isLoading = true;
const isInitialLoad = galleryState.currentPage === 1;
if (isInitialLoad) {
galleryShowLoading();
} else {
const loadingIndicator = galleryCreateLoadingIndicator();
galleryElements.mediaGrid.appendChild(loadingIndicator);
}
const itemsPerPage = 20;
const params = new URLSearchParams({
page: galleryState.currentPage,
limit: itemsPerPage
});
fetch(`/admin/images/list?${params}`)
.then(response => {
if (!response.ok) {
throw new Error(`列表请求失败: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.status === 'success') {
galleryState.currentPage = data.page;
galleryState.totalItems = data.total;
galleryState.totalPages = Math.ceil(data.total / itemsPerPage);
galleryState.mediaItems = isInitialLoad ? data.data : [...galleryState.mediaItems, ...data.data];
galleryRenderMediaGrid();
if (galleryState.currentPage >= galleryState.totalPages) {
galleryElements.loadMoreBtn.innerHTML = `<i class="fa fa-check mr-2"></i> 没有更多图片了`;
galleryElements.loadMoreBtn.disabled = true;
galleryElements.loadMoreBtn.classList.add('opacity-50', 'cursor-not-allowed');
} else {
galleryElements.loadMoreBtn.innerHTML = `<i class="fa fa-refresh mr-2"></i> 加载更多`;
galleryElements.loadMoreBtn.disabled = false;
galleryElements.loadMoreBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
} else {
showMessage(data.message, 'error');
galleryRenderMediaGrid();
}
})
.catch(error => {
console.error('获取图片列表失败:', error);
showMessage('网络错误,请重试', 'error');
galleryRenderMediaGrid();
})
.finally(() => {
galleryState.isLoading = false;
});
}
// 显示加载状态
function galleryShowLoading() {
galleryElements.mediaGrid.innerHTML = '';
const loadingIndicator = galleryCreateLoadingIndicator();
galleryElements.mediaGrid.appendChild(loadingIndicator);
}
// 加载更多图片
function galleryLoadMoreImages() {
if (galleryState.isLoading || galleryState.currentPage >= galleryState.totalPages) return;
galleryState.currentPage++;
fetchGalleryMediaList();
}
// 创建加载指示器
function galleryCreateLoadingIndicator() {
const indicator = document.createElement('div');
indicator.className = 'col-span-full flex flex-col items-center justify-center py-12';
indicator.innerHTML = `
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-blue-500 mb-4"></div>
<p class="text-gray-500">加载中...</p>
`;
return indicator;
}
// 渲染图片网格
function galleryRenderMediaGrid() {
galleryElements.mediaGrid.innerHTML = '';
const itemsToRender = galleryState.mediaItems;
if (itemsToRender.length === 0) {
galleryElements.mediaGrid.innerHTML = `
<div class="col-span-full flex flex-col items-center justify-center py-12 text-center px-4">
<i class="fa fa-picture-o text-4xl text-gray-300 mb-4"></i>
<p class="text-gray-500">没有找到图片</p>
<button onclick="document.getElementById('gallery-upload-modal-trigger').click()" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
<i class="fa fa-upload mr-2"></i>上传图片
</button>
</div>
`;
return;
}
// 渲染图片项 - 使用数据属性代替自定义类名
itemsToRender.forEach(item => {
const isSelected = galleryState.selectedImageIds.includes(item.id.toString());
const itemElement = document.createElement('div');
// 使用数据属性标识元素类型,而非自定义类名
itemElement.dataset.type = "image-item";
itemElement.className = `bg-white rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`;
itemElement.innerHTML = `
<div class="relative aspect-[4/3] bg-gray-100 overflow-hidden">
<!-- 复选框 - 使用数据属性标识 -->
<input type="checkbox" data-type="image-checkbox" data-id="${item.id}"
class="absolute top-2 left-2 z-10 w-4 h-4 rounded border-gray-300 text-blue-500 focus:ring-blue-500"
${isSelected ? 'checked' : ''}>
<!-- 缩略图 -->
<img src="${item.url}" alt="${item.name}" class="w-full h-full object-cover" loading="lazy">
<!-- 操作按钮 - 使用数据属性标识操作类型 -->
<div class="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2 p-2">
<button data-action="copy" data-url="${item.url}"
class="bg-white w-8 h-8 p-0 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors"
title="复制链接">
<i class="fa fa-copy text-gray-800"></i>
</button>
<button data-action="preview" data-url="${item.url}"
class="bg-white w-8 h-8 p-0 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors"
title="预览图片">
<i class="fa fa-eye text-gray-800"></i>
</button>
</div>
</div>
<!-- 图片信息 -->
<div class="p-2">
<div class="text-xs font-medium text-gray-800 truncate mb-1" title="${item.name}">${item.name}</div>
<div class="flex justify-between items-center text-xs text-gray-500">
<span>${galleryFormatFileSize(item.size)}</span>
<span>${item.width}*${item.height}</span>
</div>
</div>
`;
galleryElements.mediaGrid.appendChild(itemElement);
});
// 添加统计信息
const statsElement = document.createElement('div');
statsElement.className = 'col-span-full mt-6 pt-4 border-t border-gray-100 text-sm text-gray-500 flex justify-between items-center';
statsElement.innerHTML = `
<div>
共计 <span class="font-semibold text-gray-800">${galleryState.totalItems}</span> 张图片
</div>
<div>
已显示 <span class="font-semibold text-gray-800">${galleryState.mediaItems.length}</span> 张图片
</div>
`;
galleryElements.mediaGrid.appendChild(statsElement);
// 更新批量删除按钮状态
if (galleryState.selectedImageIds.length > 0) {
galleryElements.batchDeleteBtn.disabled = false;
galleryElements.batchDeleteBtn.classList.remove('opacity-50', 'cursor-not-allowed');
galleryElements.batchDeleteBtn.classList.add('hover:bg-red-600');
} else {
galleryElements.batchDeleteBtn.disabled = true;
galleryElements.batchDeleteBtn.classList.add('opacity-50', 'cursor-not-allowed');
galleryElements.batchDeleteBtn.classList.remove('hover:bg-red-600');
}
}
// 处理文件选择
function galleryHandleFileSelect(e) {
const files = e.target.files;
if (!files.length) return;
const validFiles = [];
const invalidFiles = [];
Array.from(files).forEach(file => {
if (!galleryIsImageFile(file)) {
invalidFiles.push({ file, reason: '不支持的文件类型,仅支持图片' });
return;
}
if (file.size > 10 * 1024 * 1024) {
invalidFiles.push({ file, reason: '文件过大,最大支持10MB' });
return;
}
validFiles.push(file);
});
galleryElements.uploadResults.innerHTML = '';
if (invalidFiles.length > 0) {
invalidFiles.forEach(({ file, reason }) => {
const errorItem = document.createElement('div');
errorItem.className = 'p-4 border border-red-200 bg-red-50 rounded-md';
errorItem.innerHTML = `
<div class="flex justify-between items-start mb-1">
<span class="font-medium text-red-800 text-sm">${file.name}</span>
<span class="text-red-600 text-xs">错误</span>
</div>
<p class="text-red-700 text-xs">${reason}</p>
`;
galleryElements.uploadResults.appendChild(errorItem);
});
}
if (validFiles.length > 0) {
galleryUploadFiles(validFiles);
}
}
// 上传文件
function galleryUploadFiles(files) {
const quality = document.getElementById('gallery-quality').value;
const width = document.getElementById('gallery-width').value;
const height = document.getElementById('gallery-height').value;
const uploadStatus = document.getElementById('gallery-upload-status');
gallerySaveFormState(width, height);
// 添加上传中的指示器
files.forEach(file => {
const progressItem = document.createElement('div');
progressItem.className = 'p-4 border border-gray-200 rounded-md overflow-hidden';
progressItem.innerHTML = `
<div class="flex justify-between items-start mb-2">
<span class="font-medium text-gray-800 text-sm">${file.name}</span>
<span class="text-blue-500 text-xs">上传中</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div data-type="upload-progress" class="bg-blue-500 h-1.5 rounded-full w-0 transition-all duration-300"></div>
</div>
`;
galleryElements.uploadResults.appendChild(progressItem);
});
files.forEach((file, index) => {
const formData = new FormData();
formData.append('image', file);
formData.append('quality', quality);
if (width) formData.append('width', width);
if (height) formData.append('height', height);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/admin/images/upload', true);
xhr.timeout = 120000;
xhr.ontimeout = function() {
const progressItems = galleryElements.uploadResults.querySelectorAll('.p-4');
const progressItem = progressItems[index];
galleryHandleUploadError(file, '等待返回超时,已在后台处理,稍后到图片列表中查看', progressItem);
};
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
uploadStatus.textContent = `上传中 ${Math.round(percentComplete)}%`;
const progressItems = galleryElements.uploadResults.querySelectorAll('.p-4');
const progressItem = progressItems[index];
const progressBar = progressItem.querySelector('[data-type="upload-progress"]');
progressBar.style.width = `${percentComplete}%`;
}
});
xhr.onload = function() {
uploadStatus.textContent = `开始上传`;
const progressItems = galleryElements.uploadResults.querySelectorAll('.p-4');
const progressItem = progressItems[index];
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.status === "error") {
galleryHandleUploadError(file, response.message, progressItem);
} else {
galleryHandleUploadSuccess(response, progressItem);
}
} catch (error) {
console.error('解析响应失败:', error);
galleryHandleUploadError(file, '解析服务器响应失败', progressItem);
}
} else {
try {
const responseData = JSON.parse(xhr.responseText);
galleryHandleUploadError(file, responseData.message || '服务器错误', progressItem);
} catch (parseError) {
galleryHandleUploadError(file, `服务器错误 (${xhr.status})`, progressItem);
}
}
};
xhr.onerror = function() {
uploadStatus.textContent = `开始上传`;
const progressItems = galleryElements.uploadResults.querySelectorAll('.p-4');
const progressItem = progressItems[index];
galleryHandleUploadError(file, '网络错误,请重试', progressItem);
};
xhr.send(formData);
});
}
// 处理上传成功
function galleryHandleUploadSuccess(response, progressItem) {
if (!response.data || !response.data.url) {
console.error('上传成功但缺少URL:', response);
return;
}
const newItem = {
id: response.data.id || Date.now(),
name: response.data.name,
url: response.data.url,
size: response.data.size || 0,
width: response.data.width || 0,
height: response.data.height || 0
};
// 添加到图库列表
galleryState.mediaItems.unshift(newItem);
galleryState.totalItems = galleryState.mediaItems.length;
fetchGalleryMediaList();
showMessage('图片上传成功', 'success');
setTimeout(() => {
galleryElements.uploadModal.classList.add('hidden');
galleryElements.uploadModal.classList.remove('flex');
galleryElements.uploadResults.innerHTML = '';
const fileInput = document.getElementById('gallery-upload-input');
if (fileInput) {
fileInput.value = '';
}
}, 2000);
}
// 处理上传失败
function galleryHandleUploadError(file, message, progressItem) {
progressItem.innerHTML = `
<div class="flex justify-between items-start mb-1">
<span class="font-medium text-red-800 text-sm">${file.name}</span>
<span class="text-red-600 text-xs">失败</span>
</div>
<p class="text-red-700 text-sm">${message}</p>
<button data-action="retry-upload"
class="mt-2 text-xs px-3 py-1 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors">
重试
</button>
`;
}
// 预览图片
function galleryPreviewFile(url) {
galleryElements.previewImage.src = url;
galleryElements.previewImage.alt = '图片预览';
galleryElements.previewModal.classList.remove('hidden');
galleryElements.previewModal.classList.add('flex');
}
// 复制到剪贴板
function galleryCopyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showMessage('链接已复制', 'success');
}).catch(err => {
console.error('无法复制文本: ', err);
showMessage('复制失败,请手动复制', 'error');
});
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initGallery);
// 暴露全局函数(如果需要)
window.fetchGalleryMediaList = fetchGalleryMediaList;
</script>
+279
View File
@@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> <?php echo htmlspecialchars($title ?? '后台管理'); ?> </title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.1/build/qrcode.min.js"></script>
<!-- Layui -->
<link rel="stylesheet" href="/Static/layui/css/layui.css">
<script src="/Static/layui/layui.js"></script>
<!-- 配置Tailwind自定义颜色 -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {primary: '#3b82f6', secondary: '#36CFC9', success: '#52C41A', warning: '#FAAD14', danger: '#FF4D4F', dark: '#1D2129', 'gray-light': '#F2F3F5', 'gray-medium': '#C9CDD4' },
fontFamily: {
inter: ['Inter', 'system-ui', 'sans-serif'],
},
}
}
}
</script>
</head>
<body id="app" class="font-inter bg-gray-50 text-dark min-h-screen flex flex-col">
<!-- 顶部导航栏 -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-40">
<div class="mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<a href="/admin/dashboard" class="flex items-center text-primary font-bold text-xl">
<?php
$adminSettings = \App\Core\SettingsHelper::getAll();
?>
<img src="<?= htmlspecialchars($adminSettings['site_logo'] ?? '/Static/tm68/logo_tm68.png.png') ?>" class="w-10 mr-2">
<span>管理后台</span>
</a>
</div>
<!-- 左侧区域:移动端菜单按钮 + 用户区域 -->
<div class="flex items-center space-x-4">
<!-- 用户菜单 -->
<div class="relative order-2 group">
<!-- 触发按钮 -->
<button class="flex items-center space-x-2 focus:outline-none user-menu-button">
<img class="h-8 w-8 rounded-full object-cover" src="https://picsum.photos/200/200?random=1" alt="<?=htmlspecialchars($_SESSION['username'] ?? '用户')?>的头像">
<span class="hidden md:inline-block text-sm font-medium"> <?=htmlspecialchars($_SESSION['username'] ?? '用户')?> </span>
<i class="fas fa-chevron-down text-xs text-gray-500 transition-transform duration-200 group-hover:rotate-180"></i>
</button>
<!-- 下拉菜单 (默认隐藏) -->
<div class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg py-1 z-50 transform opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 origin-top-right scale-95 group-hover:scale-100">
<!-- 个人资料 -->
<div id="profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
<i class="fas fa-user mr-2 text-gray-500"></i>个人资料
</div>
<!-- 分割线 -->
<div class="border-t border-gray-200 my-1"></div>
<!-- 退出登录 -->
<a href="/admin/logout" class="block px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors">
<i class="fas fa-sign-out-alt mr-2"></i>退出登录
</a>
</div>
</div>
<button id="mobile-menu-button" class="md:hidden p-2 rounded-md hover:bg-gray-light order-3">
<i class="fas fa-bars text-gray-600"></i>
</button>
</div>
</div>
</div>
</header>
<!-- 主要内容区 -->
<div class="flex flex-1 overflow-hidden">
<!-- 侧边栏导航 - 固定不动 -->
<aside id="sidebar" class="w-64 bg-white border-r border-gray-200 fixed left-0 top-16 h-[calc(100vh-4rem)] z-30 transform -translate-x-full md:translate-x-0 transition-transform duration-300 ease-in-out overflow-y-auto ">
<div class="p-4 h-full flex flex-col">
<nav class="space-y-1 flex-1">
<a href="/admin/dashboard" 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-home w-5 text-center"></i>
<span>控制台首页</span>
</a>
<a href="/admin/users" 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-users w-5 text-center"></i>
<span>用户管理</span>
</a>
<?php
if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'):
?>
<a href="/admin/admins" 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-user-shield w-5 text-center"></i>
<span>管理员管理</span>
</a>
<?php
endif;
?>
<a href="/admin/pk10-periods" 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-flag-checkered w-5 text-center"></i>
<span>PK10 期号管理</span>
</a>
<a href="/admin/auto-period" 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-clock w-5 text-center"></i>
<span>自动开期设置</span>
</a>
<a href="/admin/games" 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-gamepad w-5 text-center"></i>
<span>游戏与赔率</span>
</a>
<a href="/admin/water" 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-sliders-h w-5 text-center"></i>
<span>放水控制</span>
</a>
<a href="/admin/bets" 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-list-alt w-5 text-center"></i>
<span>投注记录</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>
</a>
<a href="/admin/agents" 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-handshake w-5 text-center"></i>
<span>代理管理</span>
</a>
<a href="/admin/employees" 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-id-badge w-5 text-center"></i>
<span>员工管理</span>
</a>
<a href="/admin/virtual" 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-ghost w-5 text-center"></i>
<span>虚拟账户</span>
</a>
<a href="/admin/reports" 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-chart-bar w-5 text-center"></i>
<span>数据报表</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>
</a>
<?php
if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'):
?>
<a href="/admin/plugins" 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-puzzle-piece w-5 text-center"></i>
<span>插件管理</span>
</a>
<?php
endif;
global $pluginManager;
$menus = $pluginManager->getEnabledPluginMenus();
if (is_array($menus) && !empty($menus)) {
echo "<div class='mt-4 px-4 text-xs text-gray-400 tracking-wide'>插件扩展</div>";
foreach ($menus as $menu) {
$title = htmlspecialchars($menu['title'] ?? '');
$icon = htmlspecialchars($menu['icon'] ?? 'fa fa-plug');
$path = htmlspecialchars($menu['path'] ?? '#');
$hasChildren = !empty($menu['children']) && is_array($menu['children']);
echo "<div class='menu-group'>";
echo "<a href='{$path}' class='flex items-center justify-between gap-3 px-4 py-3 rounded-lg transition-all duration-200 hover:bg-gray-light plugin-menu-link main-menu-item " . ($hasChildren ? 'menu-parent' : '') . "'>";
echo " <div class='flex items-center gap-3'> <i class='{$icon} w-5 text-center'></i> <span>{$title}</span> </div>";
if ($hasChildren) {
echo "<i class='fas fa-chevron-down text-xs text-gray-500 transition-transform duration-200'></i>";
}
echo "</a>";
if ($hasChildren) {
echo "<div class='submenu hidden pl-8'>";
foreach ($menu['children'] as $child) {
$childTitle = htmlspecialchars($child['title'] ?? '');
$childPath = htmlspecialchars($child['path'] ?? '#');
$childIcon = htmlspecialchars($child['icon'] ?? 'fa fa-circle');
echo "<a href='{$childPath}' class='flex items-center gap-3 px-6 py-2 rounded-lg text-sm transition-all mt-1 duration-200 hover:bg-gray-light plugin-submenu-link submenu-item'>";
echo " <i class='{$childIcon} w-4 text-center'></i> <span>{$childTitle}</span>";
echo "</a>";
}
echo "</div>";
}
echo "</div>";
}
}
?>
</nav>
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 text-center hover:bg-danger/10">
<span>当前版本:V 1.0.0</span>
</div>
</div>
</div>
</aside>
<!-- 主内容 - 可滚动 -->
<main id="main-content" class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-gray-50 md:ml-64 transition-all duration-300">
<?php
// 插件主体内容
if (isset($Content)) {
echo $Content;
} else {
echo "<div class='bg-white rounded-lg p-6 shadow-[0_4px_20px_rgba(0,0,0,0.08)] mb-6'>
<h1 class='text-2xl font-semibold mb-4'>控制台首页</h1>
<p class='text-gray-600 mb-6'>欢迎使用系统控制台,请从左侧菜单选择需要操作的功能。</p>
<div class='space-y-6'>";
}
?>
</main>
</div>
<!-- 个人资料弹窗 (默认隐藏) -->
<div id="profileModal" class="fixed inset-0 z-50 flex items-center justify-center invisible opacity-0 transition-all duration-300">
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black bg-opacity-50" id="profileModalBackdrop"></div>
<!-- 弹窗内容 -->
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-md mx-4 transform scale-95 transition-transform duration-300">
<!-- 弹窗头部 -->
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">编辑个人资料</h3>
<button id="closeProfileModal" class="text-gray-500 hover:text-gray-700"> <i class="fas fa-times"></i> </button>
</div>
</div>
<!-- 表单内容 -->
<form id="profileForm" class="p-6">
<!-- 隐藏的用户ID -->
<input type="hidden" id="profileUserId" name="id" value="<?=htmlspecialchars($_SESSION['user_id'] ?? '')?>">
<!-- 用户名 -->
<div class="mb-4">
<label for="profileUsername" class="block text-sm font-medium text-gray-700 mb-1"> 用户名 </label>
<input type="text" id="profileUsername" name="username" required 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" value="<?=htmlspecialchars($_SESSION['username'] ?? '')?>">
</div>
<!-- 邮箱 -->
<div class="mb-4">
<label for="profileEmail" class="block text-sm font-medium text-gray-700 mb-1"> 邮箱地址 </label>
<input type="email" id="profileEmail" name="email" required 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">
</div>
<!-- 密码 (可选修改) -->
<div class="mb-6">
<label for="profilePassword" class="block text-sm font-medium text-gray-700 mb-1"> 密码(不填则不修改) </label>
<input type="password" id="profilePassword" name="password" 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="至少8位,包含字母和数字">
<p class="mt-1 text-xs text-gray-500">不修改密码请留空</p>
</div>
<!-- 角色(空容器,等待JS填充) -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1"> 用户角色 </label>
<div id="userRoleDisplay" class="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg text-gray-700"></div>
<input type="hidden" name="role" id="userRoleInput">
</div>
<!-- 状态(空容器,等待JS填充) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1"> 账号状态 </label>
<div id="userStatusDisplay" class="w-full px-4 py-2 bg-gray-50 border border-gray-200 rounded-lg"></div>
<input type="hidden" name="status" id="userStatusInput">
</div>
<!-- 提交按钮 -->
<div class="flex justify-end space-x-3">
<button type="button" id="cancelProfileBtn" class="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"> 取消 </button>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"> 保存修改 </button>
</div>
</form>
</div>
</div>
<!-- 页脚 -->
<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>
</div>
</div>
</footer>
<!-- 移动端菜单遮罩层 -->
<div id="sidebar-overlay" class="fixed inset-0 bg-black bg-opacity-50 z-20 hidden md:hidden"></div>
<script> const targetUsername = '<?= htmlspecialchars($_SESSION[' username '] ?? ' ') ?>'; </script>
<script src="/Static/js/controller.js"> </script>
<script src="/Static/js/admin.js"> </script>
</body>
</html>
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理登录</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 配置Tailwind自定义颜色 -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
'primary-light': '#93c5fd',
'primary-dark': '#2563eb',
},
}
}
}
</script>
</head>
<body class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-950 to-slate-900 p-4">
<!-- 成功消息容器 -->
<div id="successMessage" class="fixed top-4 right-4 px-6 py-3 rounded-lg shadow shadow-lg flex items-center z-50 transition-all duration-300 transform translate-x-full bg-primary-light border border-primary/20 text-primary-dark">
<i class="fas fa-check-circle mr-2"></i>
<span id="successText"></span>
<button onclick="hideMessages()" class="ml-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 错误消息容器 -->
<div id="errorMessage" class="fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg flex items-center z-50 transition-all duration-300 transform translate-x-full bg-red-50 border border-red-200 text-red-700">
<i class="fas fa-exclamation-circle mr-2"></i>
<span id="errorText"></span>
<button onclick="hideMessages()" class="ml-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<div class="w-full max-w-md">
<div class="auth-card bg-white/95 backdrop-blur rounded-2xl overflow-hidden w-full border border-slate-200 shadow-xl shadow-slate-900/20">
<div class="px-8 pt-8 pb-4">
<div class="flex flex-col items-center space-y-4">
<a href="#" class="flex items-center text-primary font-bold text-2xl">
<img src="/Static/img/logo.png" class="w-11 h-11 mr-3 rounded-xl shadow-sm" alt="Admin Logo">
<span class="text-slate-800">管理后台</span>
</a>
</div>
</div>
<div class="px-8 pb-8">
<?php if (!empty($error)): ?>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6 relative" role="alert">
<i class="fas fa-exclamation-circle mr-2"></i>
<span class="block sm:inline"><?=htmlspecialchars($error)?></span>
</div>
<?php endif; ?>
<form id="loginForm" method="post" action="?s=login" class="space-y-6">
<div class="space-y-1">
<label for="username_or_email" class="block text-sm font-medium text-gray-700 mb-1">用户名或邮箱</label>
<input type="text" id="username_or_email" name="username_or_email" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-300"
placeholder="请输入用户名或邮箱">
</div>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative">
<input type="password" id="password" name="password" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-colors duration-300"
placeholder="请输入您的密码">
<button type="button" id="togglePassword"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-primary transition-colors duration-300">
<i class="far fa-eye"></i>
</button>
</div>
</div>
<div class="flex items-center justify-between text-xs text-slate-400">
<span>为保护数据安全,请勿在公共设备上勾选浏览器保存密码</span>
</div>
<button type="submit"
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition-all duration-300">
登录
</button>
</form>
</div>
</div>
<p class="mt-6 text-center text-[11px] text-slate-400">
© <?= date('Y') ?> 管理系统后台 · Internal Use Only
</p>
</div>
<script>
// 显示消息提示
function showMessage(text, type = 'success') {
// 隐藏所有消息
hideMessages();
// 显示对应类型的消息
const messageElement = document.getElementById(type + 'Message');
const textElement = document.getElementById(type + 'Text');
textElement.textContent = text;
messageElement.classList.remove('translate-x-full');
// 3秒后自动隐藏
setTimeout(hideMessages, 3000);
}
// 隐藏所有消息
function hideMessages() {
document.getElementById('successMessage').classList.add('translate-x-full');
document.getElementById('errorMessage').classList.add('translate-x-full');
}
// 密码可见性切换
document.getElementById('togglePassword').addEventListener('click', function() {
const passwordInput = document.getElementById('password');
const icon = this.querySelector('i');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
icon.classList.remove('far', 'fa-eye');
icon.classList.add('far', 'fa-eye-slash');
} else {
passwordInput.type = 'password';
icon.classList.remove('far', 'fa-eye-slash');
icon.classList.add('far', 'fa-eye');
}
});
// 登录表单处理
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
username_or_email: document.getElementById('username_or_email').value,
password: document.getElementById('password').value,
};
try {
const response = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
credentials: 'include'
});
// 直接解析JSON响应(与后端JSON格式匹配)
const result = await response.json();
if (result.success) {
showMessage(result.message);
// 使用后端返回的跳转地址
if (result.redirect) {
setTimeout(() => {
window.location.href = result.redirect;
}, 1500);
}
} else {
showMessage(result.message || '登录失败,请检查账号密码', 'error');
}
} catch (error) {
console.error('登录请求失败:', error);
showMessage('网络错误,登录失败', 'error');
}
});
</script>
</body>
</html>
+941
View File
@@ -0,0 +1,941 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fas fa-calendar-alt text-primary mr-3"></i>
期号管理
</h1>
<!-- 游戏期号管理区域 -->
<?php if (!empty($gamesList) && is_array($gamesList)): ?>
<?php foreach ($gamesList as $game): ?>
<?php
$gameId = $game['id'];
$gameName = $game['name'];
$currentPeriod = isset($currentPeriods[$gameId]) ? $currentPeriods[$gameId] : null;
?>
<!-- 单个游戏的期号卡片 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6 border-l-4 border-primary">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-800 flex items-center">
<i class="fas fa-gamepad text-primary mr-2"></i>
<?= htmlspecialchars($gameName) ?>
</h2>
<?php if ($currentPeriod): ?>
<!-- 有当前期号 -->
<div class="flex gap-2">
<?php if ($currentPeriod['status'] === 'pending'): ?>
<button
type="button"
class="period-lock-btn inline-block bg-warning hover:bg-warning/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-lock mr-2"></i>封盘
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'locked'): ?>
<button
type="button"
class="period-draw-btn inline-block bg-success hover:bg-success/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-dice mr-2"></i>开奖
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'drawn'): ?>
<button
type="button"
class="period-settle-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-coins mr-2"></i>结算
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'settled'): ?>
<!-- 已结算,显示开始新一期按钮 -->
<button
type="button"
class="period-start-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-game-id="<?= $gameId ?>"
>
<i class="fas fa-play mr-2"></i>开始新一期
</button>
<?php endif; ?>
</div>
<?php else: ?>
<!-- 无当前期号,显示开始按钮 -->
<button
type="button"
class="period-start-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-game-id="<?= $gameId ?>"
>
<i class="fas fa-play mr-2"></i>开始新一期
</button>
<?php endif; ?>
</div>
<?php if ($currentPeriod): ?>
<!-- 当前期号信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<p class="text-sm text-gray-500 mb-1">期号</p>
<p class="text-lg font-bold text-gray-900"><?= htmlspecialchars($currentPeriod['period_number']) ?></p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">状态</p>
<?php
$status = $currentPeriod['status'];
$statusMap = [
'pending' => ['text' => '待开奖', 'class' => 'bg-warning/10 text-warning'],
'locked' => ['text' => '已封盘', 'class' => 'bg-danger/10 text-danger'],
'drawn' => ['text' => '已开奖', 'class' => 'bg-primary/10 text-primary'],
'settled' => ['text' => '已结算', 'class' => 'bg-success/10 text-success']
];
$statusInfo = $statusMap[$status] ?? $statusMap['pending'];
?>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm <?= $statusInfo['class'] ?>">
<?= $statusInfo['text'] ?>
</span>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">开始时间</p>
<p class="text-sm text-gray-900"><?= htmlspecialchars($currentPeriod['start_time'] ?? '-') ?></p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">开奖结果</p>
<?php if (!empty($currentPeriod['dice1'])): ?>
<p class="text-lg font-bold text-gray-900">
<?= $currentPeriod['dice1'] ?>.<?= $currentPeriod['dice2'] ?>.<?= $currentPeriod['dice3'] ?>
<span class="text-sm text-gray-500">(<?= $currentPeriod['total'] ?>)</span>
</p>
<?php else: ?>
<p class="text-sm text-gray-400">未开奖</p>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<!-- 无当前期号提示 -->
<div class="text-center py-4">
<p class="text-sm text-gray-500">暂无进行中的期号,点击"开始新一期"按钮启动</p>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 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-2xl font-bold mt-1">
<?= isset($periods) && is_array($periods) ? count($periods) : 0 ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-list 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 text-warning">
<?php
$pendingCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && $p['status'] === 'pending') {
$pendingCount++;
}
}
}
echo $pendingCount;
?>
</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-2xl font-bold mt-1 text-success">
<?php
$drawnCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && ($p['status'] === 'drawn' || $p['status'] === 'settled')) {
$drawnCount++;
}
}
}
echo $drawnCount;
?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
<i class="fas fa-check-circle 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 text-primary">
<?php
$settledCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && $p['status'] === 'settled') {
$settledCount++;
}
}
}
echo $settledCount;
?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-coins text-primary"></i>
</div>
</div>
</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">期号</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-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200" id="periodList">
<?php if (!empty($periods) && is_array($periods)): ?>
<?php foreach ($periods as $period): ?>
<tr class="hover:bg-gray-50 transition-colors" data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>">
<td class="px-4 py-4">
<div class="text-sm font-semibold text-gray-900">
<?= htmlspecialchars((string)($period['period_number'] ?? '')) ?>
</div>
<?php if (!empty($period['auto_generated'])): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 text-gray-600 mt-1">
自动生成
</span>
<?php endif; ?>
</td>
<td class="px-4 py-4">
<?php
$gameId = $period['game_id'] ?? null;
$gameName = $gameId && isset($games[$gameId]) ? $games[$gameId] : '未关联';
?>
<span class="text-sm text-gray-600"><?= htmlspecialchars($gameName) ?></span>
</td>
<td class="px-4 py-4">
<?php
$status = $period['status'] ?? 'pending';
$statusMap = [
'pending' => ['text' => '待开奖', 'class' => 'bg-warning/10 text-warning'],
'locked' => ['text' => '已封盘', 'class' => 'bg-danger/10 text-danger'],
'drawn' => ['text' => '已开奖', 'class' => 'bg-primary/10 text-primary'],
'settled' => ['text' => '已结算', 'class' => 'bg-success/10 text-success']
];
$statusInfo = $statusMap[$status] ?? $statusMap['pending'];
?>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $statusInfo['class'] ?>">
<span class="w-2 h-2 rounded-full mr-1 <?= str_replace('/10', '', $statusInfo['class']) ?>"></span>
<?= $statusInfo['text'] ?>
</span>
</td>
<td class="px-4 py-4">
<?php
// 获取游戏类型
$gameId = $period['game_id'] ?? 0;
$gameType = 'dice';
if (isset($games[$gameId])) {
// 从游戏列表获取类型(需要查询数据库)
// 简化处理:通过 dice3 是否为 null 判断
if ($period['dice3'] === null && !empty($period['result'])) {
$gameType = 'xocdia';
}
}
// 根据游戏类型显示开奖结果
if ($gameType === 'xocdia'):
// Xóc Đĩa: 显示硬币颜色
if (!empty($period['result'])):
$coins = json_decode($period['result'], true);
if (is_array($coins) && count($coins) === 4):
$redCount = $period['dice1'] ?? 0;
$whiteCount = $period['dice2'] ?? 0;
?>
<div class="text-sm text-gray-900">
<span class="font-semibold">
<?php foreach ($coins as $coin): ?>
<span class="inline-block w-5 h-5 rounded-full <?= $coin === 'red' ? 'bg-red-500' : 'bg-gray-200' ?> border border-gray-300 mr-1"></span>
<?php endforeach; ?>
</span>
<span class="text-gray-600 ml-2">
(<?= $redCount ?>Đ <?= $whiteCount ?>T)
</span>
<span class="ml-1 px-2 py-0.5 rounded text-[11px] <?= ($redCount == 0 || $redCount == 2 || $redCount == 4) ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700' ?>">
<?= ($redCount == 0 || $redCount == 2 || $redCount == 4) ? 'Chẵn' : 'Lẻ' ?>
</span>
</div>
<?php
else:
?>
<span class="text-sm text-gray-400">数据格式错误</span>
<?php
endif;
else:
?>
<span class="text-sm text-gray-400">未开奖</span>
<?php
endif;
else:
// 骰子游戏: 显示骰子点数
if (!empty($period['dice1']) && !empty($period['dice2']) && !empty($period['dice3'])):
?>
<div class="text-sm text-gray-900">
<span class="font-semibold">
<?= htmlspecialchars((string)$period['dice1']) ?>.
<?= htmlspecialchars((string)$period['dice2']) ?>.
<?= htmlspecialchars((string)$period['dice3']) ?>
</span>
<span class="text-gray-500 ml-1">
(<?= htmlspecialchars((string)($period['total'] ?? '')) ?>)
</span>
<?php if (!empty($period['result'])): ?>
<span class="ml-1 px-2 py-0.5 rounded text-[11px] <?= $period['result'] === 'Tài' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700' ?>">
<?= htmlspecialchars((string)$period['result']) ?>
</span>
<?php endif; ?>
</div>
<?php
else:
?>
<span class="text-sm text-gray-400">未开奖</span>
<?php
endif;
endif;
?>
</td>
<td class="px-4 py-4 hidden md:table-cell">
<?php if (!empty($period['created_at'])): ?>
<div class="text-xs text-gray-500">
<?= date('Y-m-d H:i', strtotime((string)$period['created_at'])) ?>
</div>
<?php else: ?>
<span class="text-xs text-gray-400">时间未知</span>
<?php endif; ?>
</td>
<td class="px-4 py-4 text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<?php if (($period['status'] ?? '') === 'pending'): ?>
<button
type="button"
class="period-lock-btn text-gray-500 hover:text-warning"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="封盘"
>
<i class="fas fa-lock"></i>
</button>
<?php endif; ?>
<?php if (($period['status'] ?? '') === 'locked' || ($period['status'] ?? '') === 'pending'): ?>
<button
type="button"
class="period-draw-btn text-gray-500 hover:text-primary"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="录入开奖"
>
<i class="fas fa-dice"></i>
</button>
<?php endif; ?>
<?php if (($period['status'] ?? '') === 'drawn'): ?>
<button
type="button"
class="period-draw-btn text-gray-500 hover:text-warning"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="修改结果"
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="period-settle-btn text-gray-500 hover:text-success"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="确认结算"
>
<i class="fas fa-check-circle"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php 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-calendar-alt 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
id="drawPeriodBackdrop"
class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"
></div>
<div
id="drawPeriodModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95"
>
<div class="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fas fa-dice text-primary mr-2"></i>
录入开奖结果
</h3>
<button id="closeDrawPeriodBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fas fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="drawPeriodForm" class="space-y-4">
<input type="hidden" id="drawPeriodId" name="id">
<input type="hidden" id="drawGameType" name="game_type">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
期号
</label>
<p id="drawPeriodNumber" class="text-lg font-bold text-gray-900"></p>
</div>
<!-- 骰子游戏输入 -->
<div id="diceInputSection" class="grid grid-cols-3 gap-4">
<div>
<label for="drawDice1" class="block text-sm font-medium text-gray-700 mb-1">
骰子1 <span class="text-red-500">*</span>
</label>
<input
type="number"
id="drawDice1"
name="dice1"
min="1"
max="6"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm text-center text-lg font-bold"
placeholder="1-6"
>
</div>
<div>
<label for="drawDice2" class="block text-sm font-medium text-gray-700 mb-1">
骰子2 <span class="text-red-500">*</span>
</label>
<input
type="number"
id="drawDice2"
name="dice2"
min="1"
max="6"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm text-center text-lg font-bold"
placeholder="1-6"
>
</div>
<div>
<label for="drawDice3" class="block text-sm font-medium text-gray-700 mb-1">
骰子3 <span class="text-red-500">*</span>
</label>
<input
type="number"
id="drawDice3"
name="dice3"
min="1"
max="6"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary text-sm text-center text-lg font-bold"
placeholder="1-6"
>
</div>
</div>
<!-- Xóc Đĩa 硬币输入 -->
<div id="xocdiaInputSection" class="hidden space-y-3">
<p class="text-sm text-gray-600">选择4个硬币的颜色:</p>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币1</label>
<select id="coin1" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币2</label>
<select id="coin2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币3</label>
<select id="coin3" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币4</label>
<select id="coin4" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
</div>
</div>
<div id="drawResultPreview" class="hidden p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600 mb-1">开奖结果预览:</p>
<p class="text-lg font-bold">
<span id="drawResultText"></span>
<span id="drawResultTotal" class="ml-2 text-gray-500"></span>
<span id="drawResultType" class="ml-2"></span>
</p>
</div>
<div class="pt-4 border-t border-gray-100">
<button
type="button"
id="submitDrawPeriodBtn"
class="w-full bg-primary hover:bg-primary/90 text-white px-4 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center justify-center"
>
<i class="fas fa-check mr-2"></i>
确认录入
</button>
</div>
</form>
</div>
</div>
</div>
<script src="/Static/js/admin.js"></script>
<script>
layui.use(['layer'], function() {
var layer = layui.layer;
// 获取元素
const closeDrawPeriodBtn = document.getElementById('closeDrawPeriodBtn');
const drawPeriodBackdrop = document.getElementById('drawPeriodBackdrop');
const drawPeriodModal = document.getElementById('drawPeriodModal');
const drawPeriodForm = document.getElementById('drawPeriodForm');
const submitDrawPeriodBtn = document.getElementById('submitDrawPeriodBtn');
const drawDice1 = document.getElementById('drawDice1');
const drawDice2 = document.getElementById('drawDice2');
const drawDice3 = document.getElementById('drawDice3');
const drawResultPreview = document.getElementById('drawResultPreview');
const drawResultText = document.getElementById('drawResultText');
const drawResultTotal = document.getElementById('drawResultTotal');
const drawResultType = document.getElementById('drawResultType');
// 打开录入开奖模态框
function openDrawPeriodModal(periodId) {
fetch(`/admin/periods/${periodId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
const period = data.data;
document.getElementById('drawPeriodId').value = period.id;
document.getElementById('drawPeriodNumber').textContent = period.period_number;
// 获取游戏类型
const gameId = period.game_id;
// 获取游戏信息(包括类型)
fetch(`/admin/games/${gameId}`)
.then(res => res.json())
.then(gameData => {
if (gameData.success && gameData.data) {
const gameType = gameData.data.type || 'dice';
document.getElementById('drawGameType').value = gameType;
// 根据游戏类型显示不同的输入界面
const diceSection = document.getElementById('diceInputSection');
const xocdiaSection = document.getElementById('xocdiaInputSection');
if (gameType === 'xocdia') {
diceSection.classList.add('hidden');
xocdiaSection.classList.remove('hidden');
// 清空骰子输入
drawDice1.value = '';
drawDice2.value = '';
drawDice3.value = '';
// 如果已有开奖结果,填充硬币颜色
if (period.result) {
try {
const coins = JSON.parse(period.result);
if (Array.isArray(coins) && coins.length === 4) {
document.getElementById('coin1').value = coins[0];
document.getElementById('coin2').value = coins[1];
document.getElementById('coin3').value = coins[2];
document.getElementById('coin4').value = coins[3];
}
} catch (e) {
// 忽略解析错误
}
}
} else {
diceSection.classList.remove('hidden');
xocdiaSection.classList.add('hidden');
// 填充已有的骰子数据
drawDice1.value = period.dice1 || '';
drawDice2.value = period.dice2 || '';
drawDice3.value = period.dice3 || '';
}
updateDrawPreview();
// 游戏类型设置完成后再打开模态框
drawPeriodBackdrop.classList.remove('opacity-0', 'pointer-events-none');
drawPeriodModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
drawPeriodModal.classList.add('scale-100');
} else {
layer.msg('获取游戏信息失败', {icon: 2});
}
})
.catch(e => {
layer.msg('获取游戏信息失败:' + e.message, {icon: 2});
});
} else {
layer.msg(data.message || '获取期号信息失败', {icon: 2});
}
})
.catch(e => {
layer.msg('获取期号信息失败:' + e.message, {icon: 2});
});
}
// 关闭录入开奖模态框
function closeDrawPeriodModal() {
drawPeriodBackdrop.classList.add('opacity-0', 'pointer-events-none');
drawPeriodModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
drawPeriodModal.classList.remove('scale-100');
drawPeriodForm.reset();
drawResultPreview.classList.add('hidden');
}
// 更新开奖结果预览
function updateDrawPreview() {
const d1 = parseInt(drawDice1.value) || 0;
const d2 = parseInt(drawDice2.value) || 0;
const d3 = parseInt(drawDice3.value) || 0;
if (d1 >= 1 && d1 <= 6 && d2 >= 1 && d2 <= 6 && d3 >= 1 && d3 <= 6) {
const total = d1 + d2 + d3;
let result = '';
let resultClass = '';
// 检查是否为爆子
if (d1 === d2 && d2 === d3) {
result = 'Bão';
resultClass = 'bg-green-100 text-green-700 px-2 py-1 rounded text-sm';
} else if (total >= 4 && total <= 10) {
result = 'Xỉu';
resultClass = 'bg-blue-100 text-blue-700 px-2 py-1 rounded text-sm';
} else {
result = 'Tài';
resultClass = 'bg-red-100 text-red-700 px-2 py-1 rounded text-sm';
}
drawResultText.textContent = `${d1}.${d2}.${d3}`;
drawResultTotal.textContent = `(总和: ${total})`;
drawResultType.innerHTML = `<span class="${resultClass}">${result}</span>`;
drawResultPreview.classList.remove('hidden');
} else {
drawResultPreview.classList.add('hidden');
}
}
// 提交录入开奖
async function submitDrawPeriod() {
const gameType = document.getElementById('drawGameType').value;
const data = {
id: document.getElementById('drawPeriodId').value,
auto: false
};
console.log('=== 开奖提交调试信息 ===');
console.log('游戏类型:', gameType);
console.log('期号ID:', data.id);
// 根据游戏类型收集数据
if (gameType === 'xocdia') {
// Xóc Đĩa: 收集硬币颜色
const coins = [
document.getElementById('coin1').value,
document.getElementById('coin2').value,
document.getElementById('coin3').value,
document.getElementById('coin4').value
];
data.coins = coins;
console.log('硬币颜色:', coins);
} else {
// 骰子游戏: 收集骰子点数
const dice1 = parseInt(drawDice1.value);
const dice2 = parseInt(drawDice2.value);
const dice3 = parseInt(drawDice3.value);
if (!dice1 || !dice2 || !dice3 || dice1 < 1 || dice1 > 6 || dice2 < 1 || dice2 > 6 || dice3 < 1 || dice3 > 6) {
layer.msg('请输入有效的骰子点数(1-6', {icon: 2});
return;
}
data.dice1 = dice1;
data.dice2 = dice2;
data.dice3 = dice3;
console.log('骰子点数:', dice1, dice2, dice3);
}
console.log('发送的数据:', JSON.stringify(data));
submitDrawPeriodBtn.disabled = true;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>录入中...';
try {
const response = await fetch('/admin/periods/draw', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(data)
});
const result = await response.json();
console.log('服务器响应:', result);
if (result.success) {
layer.msg(result.message || '开奖结果录入成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '录入失败', {icon: 2});
submitDrawPeriodBtn.disabled = false;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-check mr-2"></i>确认录入';
}
} catch (e) {
console.error('提交错误:', e);
layer.msg('录入失败:' + e.message, {icon: 2});
submitDrawPeriodBtn.disabled = false;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-check mr-2"></i>确认录入';
}
}
// 封盘
async function lockPeriod(id) {
layer.confirm('确定要封盘吗?封盘后将无法继续投注。', {icon: 3, title: '确认封盘'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/periods/lock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({id: id})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '封盘成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '封盘失败', {icon: 2});
}
} catch (e) {
layer.msg('封盘失败:' + e.message, {icon: 2});
}
});
}
// 确认结算
async function settlePeriod(id) {
layer.confirm('确定要确认结算吗?结算后将自动创建下一期,此操作不可撤销。', {icon: 3, title: '确认结算'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/periods/settle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({id: id})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '结算成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '结算失败', {icon: 2});
}
} catch (e) {
layer.msg('结算失败:' + e.message, {icon: 2});
}
});
}
// 开始下注
async function startPeriod(event) {
// 从按钮的 data-game-id 属性获取游戏ID
const gameId = event.currentTarget.getAttribute('data-game-id');
if (!gameId) {
layer.msg('游戏ID缺失', {icon: 2});
return;
}
layer.confirm('确定要开始新一期下注吗?', {icon: 3, title: '开始下注'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/periods/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({game_id: parseInt(gameId)})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '启动成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '启动失败', {icon: 2});
}
} catch (e) {
layer.msg('启动失败:' + e.message, {icon: 2});
}
});
}
// 绑定事件
if (closeDrawPeriodBtn) {
closeDrawPeriodBtn.addEventListener('click', closeDrawPeriodModal);
}
if (drawPeriodBackdrop) {
drawPeriodBackdrop.addEventListener('click', closeDrawPeriodModal);
}
if (submitDrawPeriodBtn) {
submitDrawPeriodBtn.addEventListener('click', submitDrawPeriod);
}
// 骰子输入监听
if (drawDice1 && drawDice2 && drawDice3) {
[drawDice1, drawDice2, drawDice3].forEach(input => {
input.addEventListener('input', updateDrawPreview);
});
}
// 事件委托:列表操作按钮
const periodList = document.getElementById('periodList');
if (periodList) {
periodList.addEventListener('click', function(e) {
const lockBtn = e.target.closest('.period-lock-btn');
const drawBtn = e.target.closest('.period-draw-btn');
const settleBtn = e.target.closest('.period-settle-btn');
if (lockBtn) {
const id = lockBtn.getAttribute('data-id');
if (id) lockPeriod(id);
}
if (drawBtn) {
const id = drawBtn.getAttribute('data-id');
if (id) openDrawPeriodModal(id);
}
if (settleBtn) {
const id = settleBtn.getAttribute('data-id');
if (id) settlePeriod(id);
}
});
}
// 当前期号操作按钮(如果在页面中存在)
document.querySelectorAll('.period-lock-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) lockPeriod(id);
});
}
});
document.querySelectorAll('.period-draw-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) openDrawPeriodModal(id);
});
}
});
document.querySelectorAll('.period-settle-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) settlePeriod(id);
});
}
});
// Start Button
document.querySelectorAll('.period-start-btn').forEach(btn => {
btn.addEventListener('click', startPeriod);
});
});
</script>
+107
View File
@@ -0,0 +1,107 @@
<div class="p-6">
<h2 class="text-2xl font-bold mb-4">🏎️ PK10 期号管理</h2>
<!-- 统计 -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-sm">总计</div><div class="text-2xl font-bold"><?=$stats['total']??0?></div></div>
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-sm">待开奖</div><div class="text-2xl font-bold text-yellow-500"><?=$stats['pending']??0?></div></div>
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-sm">已开奖</div><div class="text-2xl font-bold text-blue-500"><?=$stats['drawn']??0?></div></div>
<div class="bg-white rounded-xl p-4 shadow"><div class="text-gray-400 text-sm">已结算</div><div class="text-2xl font-bold text-green-500"><?=$stats['settled']??0?></div></div>
</div>
<!-- 当前期号 -->
<div class="bg-white rounded-xl p-6 shadow mb-6">
<h3 class="font-bold mb-3">当前期号</h3>
<?php if($current): ?>
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<span class="text-gray-400">期号:</span> <span class="font-mono font-bold"><?=$current['period_number']?></span>
<span class="ml-4 px-2 py-1 rounded text-xs <?=$current['status']==='pending'?'bg-yellow-100 text-yellow-700':($current['status']==='drawn'?'bg-blue-100 text-blue-700':'bg-green-100 text-green-700')?>"><?=$current['status']==='pending'?'待开奖':($current['status']==='locked'?'已封盘':($current['status']==='drawn'?'已开奖':'已结算'))?></span>
</div>
<div class="flex gap-2">
<?php if($current['status']==='pending'): ?>
<button onclick="doAction('lock',<?=$current['id']?>)" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm">🔒 封盘</button>
<button onclick="doAction('draw',<?=$current['id']?>)" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">🎲 自动开奖</button>
<button onclick="showManualDraw(<?=$current['id']?>)" class="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm">✏️ 手动开奖</button>
<?php elseif($current['status']==='locked'): ?>
<button onclick="doAction('draw',<?=$current['id']?>)" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">🎲 自动开奖</button>
<button onclick="showManualDraw(<?=$current['id']?>)" class="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 text-sm">✏️ 手动开奖</button>
<?php elseif($current['status']==='drawn'): ?>
<div class="flex gap-1 items-center mr-4">
<?php if($current['pk10']): for($i=1;$i<=10;$i++): $v=$current['pk10']['rank_'.$i]??0; ?>
<span class="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-xs font-bold"><?=$v?></span>
<?php endfor; endif; ?>
</div>
<button onclick="doAction('settle',<?=$current['id']?>)" class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm">💰 结算</button>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<div class="flex items-center justify-between">
<span class="text-gray-400">暂无进行中的期号</span>
<button onclick="doAction('start',0)" class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm">▶ 开始新期</button>
</div>
<?php endif; ?>
</div>
<!-- 手动开奖弹窗 -->
<div id="manualModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl p-6 w-full max-w-md">
<h3 class="font-bold mb-4">手动开奖 - 输入结果 (1-10)</h3>
<div class="grid grid-cols-5 gap-2 mb-4">
<?php for($i=1;$i<=10;$i++): ?>
<div>
<label class="text-xs text-gray-400">第<?=$i?>名</label>
<input type="number" min="1" max="10" id="mr<?=$i?>" class="w-full border rounded px-2 py-1 text-center" placeholder="<?=$i?>">
</div>
<?php endfor; ?>
</div>
<div class="flex gap-2">
<button onclick="submitManualDraw()" class="flex-1 py-2 bg-purple-500 text-white rounded hover:bg-purple-600">确认开奖</button>
<button onclick="document.getElementById('manualModal').classList.add('hidden')" class="flex-1 py-2 bg-gray-200 rounded hover:bg-gray-300">取消</button>
</div>
</div>
</div>
<!-- 历史列表 -->
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50"><tr>
<th class="px-4 py-3 text-left">期号</th><th class="px-4 py-3 text-left">状态</th><th class="px-4 py-3 text-left">结果</th><th class="px-4 py-3 text-left">冠亚和</th><th class="px-4 py-3 text-left">时间</th>
</tr></thead>
<tbody>
<?php foreach($periods??[] as $p): ?>
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-2 font-mono text-xs"><?=$p['period_number']?></td>
<td class="px-4 py-2"><span class="px-2 py-1 rounded text-xs <?=$p['status']==='settled'?'bg-green-100 text-green-700':($p['status']==='drawn'?'bg-blue-100 text-blue-700':'bg-yellow-100 text-yellow-700')?>"><?=$p['status']==='settled'?'已结算':($p['status']==='drawn'?'已开奖':'待开奖')?></span></td>
<td class="px-4 py-2">
<?php if($p['pk10']): ?>
<div class="flex gap-1"><?php for($i=1;$i<=10;$i++): $v=$p['pk10']['rank_'.$i]??0; ?><span class="w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs"><?=$v?></span><?php endfor; ?></div>
<?php else: ?>---<?php endif; ?>
</td>
<td class="px-4 py-2"><?=$p['pk10']['champion_sum']??'-'?></td>
<td class="px-4 py-2 text-gray-400 text-xs"><?=$p['created_at']??''?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script>
let manualPeriodId=0;
function showManualDraw(id){manualPeriodId=id;document.getElementById('manualModal').classList.remove('hidden');}
async function submitManualDraw(){
const result=[];for(let i=1;i<=10;i++){const v=parseInt(document.getElementById('mr'+i).value);if(!v||v<1||v>10){alert('每个名次请输入1-10的数字');return;}result.push(v);}
if(new Set(result).size!==10){alert('10个名次的车号不能重复');return;}
await doAction('draw',manualPeriodId,{manual:true,result});
document.getElementById('manualModal').classList.add('hidden');
}
async function doAction(action,periodId,extra={}){
const body={period_id:periodId,...extra};
const url='/admin/pk10-periods/'+action;
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await r.json();
if(d.status==='success'){location.reload();}else{alert(d.message||'操作失败');}
}
</script>
+291
View File
@@ -0,0 +1,291 @@
<div class="mb-6 flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-dark">插件管理</h1>
<p class="text-gray-500 mt-1">管理、安装和卸载系统插件</p>
</div>
<div class="relative">
<button class="bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg flex items-center gap-2 btn-effect">
<a target="_blank" href="https://plugins.juhe.me" >
<i class="fas fa-th-large"></i>
<span>插件库</span>
</a>
</button>
<div id="uploadOverlay" class="hidden fixed inset-0 bg-black/50 z-40 transition-opacity duration-300"></div>
<div id="uploadForm" class="hidden fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-white rounded-lg shadow-lg p-4 z-50 border border-gray-200 transition-all duration-300 scale-95 opacity-0">
<h3 class="font-medium mb-3">上传插件</h3>
<div class="mb-3">
<label class="block text-sm text-gray-600 mb-1">选择插件包 (.zip)</label>
<input type="file" id="pluginZip" accept=".zip" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary">
<p class="text-xs text-gray-500 mt-1">支持的格式: .zip</p>
</div>
<div class="flex gap-2">
<button type="button" id="submitUpload" class="flex-1 bg-primary text-white px-3 py-2 rounded-md text-sm btn-effect">
确认上传
</button>
<button type="button" id="cancelUpload" class="px-3 py-2 border border-gray-300 rounded-md text-sm btn-effect">
取消
</button>
</div>
</div>
</div>
</div>
<!-- 插件统计卡片 -->
<div class="grid grid-cols-1 sm: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-2xl font-bold mt-1">
<?=count($plugins) ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-puzzle-piece 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">
<?=count(array_filter($plugins, function($p) { return $p['status']; })) ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
<i class="fas fa-check 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">
<?=count(array_filter($plugins, function($p) { return !$p['status']; })) ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-gray-medium/30 flex items-center justify-center">
<i class="fas fa-times text-gray-medium"></i>
</div>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex flex-wrap items-center justify-between gap-4">
<h2 class="text-lg font-semibold">插件列表</h2>
<button id="uploadBtn" class="bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg
flex items-center gap-2 btn-effect text-sm">
<i class="fas fa-upload"></i>
<span>上传插件</span>
</button>
</div>
<?php if (empty($plugins)): ?>
<div class="p-10 text-center border-b border-gray-200">
<i class="fas fa-puzzle-piece text-5xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium mb-2">暂无插件</h3>
<p class="text-gray-500 mb-6">请上传并安装插件来扩展系统功能</p>
<button id="emptyStateUploadBtn" class="bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg flex items-center gap-2 btn-effect mx-auto">
<i class="fas fa-upload"></i>
<span>上传插件</span>
</button>
</div>
<?php else: ?>
<div class="divide-y divide-gray-200">
<?php foreach ($plugins as $plugin): ?>
<div class="p-4 hover:bg-gray-50 transition-colors">
<div class="flex flex-wrap md:flex-nowrap justify-between items-start gap-4">
<!-- 插件信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
<i class="<?= htmlspecialchars($plugin['icon']) ?> text-primary text-xl"></i>
<h3 class="font-semibold text-gray-900 truncate">
<?= htmlspecialchars($plugin['title']) ?>
<span class="text-sm font-normal text-gray-500 ml-2">v<?= htmlspecialchars($plugin['version']) ?></span>
</h3>
<?php if ($plugin['status']): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
已启用
</span>
<?php else: ?>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<?= $plugin['installed'] ? '已禁用' : '未安装' ?>
</span>
<?php endif; ?>
</div>
<p class="text-gray-600 text-sm mb-2">
<span class="font-medium">简介:</span>
<?= htmlspecialchars($plugin['description']) ?>
</p>
<p class="text-gray-600 text-sm">
<span class="font-medium">URL:</span>
<a href="<?= htmlspecialchars($plugin['url']) ?>" class="text-primary hover:underline">
<?= htmlspecialchars($plugin['url']) ?>
</a>
</p>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 shrink-0">
<?php if ($plugin['installed']): ?>
<button class="px-3 py-1.5 rounded border text-sm transition-colors btn-effect
<?= $plugin['status'] ? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100' : 'border-green-200 bg-green-50 text-green-700 hover:bg-green-100' ?>"
data-action="toggle"
data-url="/admin/plugins/toggle/<?= $plugin['name'] ?>">
<?= $plugin['status'] ? '禁用' : '启用' ?>
</button>
<button class="px-3 py-1.5 rounded border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 text-sm transition-colors btn-effect"
data-action="uninstall"
data-url="/admin/plugins/uninstall/<?= $plugin['name'] ?>"
data-confirm="确定要卸载此插件吗?卸载此插件将会删除所有和此插件有关的数据。此操作不可恢复!">
<i class="fas fa-trash-alt"></i>
</button>
<?php else: ?>
<button class="px-3 py-1.5 rounded border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 text-sm transition-colors btn-effect"
data-action="install"
data-url="/admin/plugins/install/<?= $plugin['name'] ?>">
安装
</button>
<button class="px-3 py-1.5 rounded border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 text-sm transition-colors btn-effect"
data-action="delete"
data-url="/admin/plugins/delete/<?= $plugin['name'] ?>"
data-confirm="确定要删除此插件安装包吗?">
<i class="fas fa-trash-alt"></i>
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="mt-8 bg-blue-50 border border-blue-100 rounded-xl p-5">
<div class="flex">
<i class="fas fa-info-circle text-primary mt-0.5 mr-3"></i>
<div>
<h3 class="font-medium text-primary mb-2">插件管理说明</h3>
<ul class="text-sm text-gray-700 space-y-1">
<li><i class="fas fa-angle-right mr-1 text-primary/70"></i> 插件以ZIP格式上传,系统会自动解压并安装</li>
<li><i class="fas fa-angle-right mr-1 text-primary/70"></i> 禁用插件不会删除数据,卸载插件将清除所有相关数据</li>
<li><i class="fas fa-angle-right mr-1 text-primary/70"></i> 未安装的插件可以直接删除安装包,不会影响系统</li>
<li><i class="fas fa-angle-right mr-1 text-primary/70"></i> 请只安装来自可信来源的插件,以确保系统安全</li>
</ul>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const url = btn.dataset.url;
const confirmMsg = btn.dataset.confirm;
if (confirmMsg && !confirm(confirmMsg)) return;
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
if (!res.ok) throw new Error('请求失败');
const json = await res.json();
if (json.success) {
showMessage(json.message, 'success');
setTimeout(() => {
location.reload();
}, 2000);
} else {
showMessage(json.message, 'error');
}
} catch (e) {
showMessage(e.message, 'warning');
}
});
});
// ========= 插件上传弹窗功能 =========
const uploadBtn = document.getElementById('uploadBtn');
const emptyStateUploadBtn = document.getElementById('emptyStateUploadBtn');
const uploadForm = document.getElementById('uploadForm');
const uploadOverlay = document.getElementById('uploadOverlay');
const cancelUpload = document.getElementById('cancelUpload');
const submitUpload = document.getElementById('submitUpload');
const pluginZip = document.getElementById('pluginZip');
const showUploadForm = () => {
uploadOverlay.classList.remove('hidden');
uploadForm.classList.remove('hidden');
setTimeout(() => {
uploadOverlay.classList.add('opacity-100');
uploadForm.classList.remove('scale-95', 'opacity-0');
uploadForm.classList.add('scale-100', 'opacity-100');
}, 10);
document.body.style.overflow = 'hidden';
};
const hideUploadForm = () => {
uploadOverlay.classList.remove('opacity-100');
uploadForm.classList.remove('scale-100', 'opacity-100');
uploadForm.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
uploadOverlay.classList.add('hidden');
uploadForm.classList.add('hidden');
document.body.style.overflow = '';
pluginZip.value = '';
}, 300);
};
const handleUpload = () => {
if (!pluginZip.files.length) return;
const file = pluginZip.files[0];
if (!file.name.endsWith('.zip')) return;
submitUpload.disabled = true;
submitUpload.textContent = '上传中...';
const formData = new FormData();
formData.append('plugin_zip', file);
fetch('/admin/plugins/upload', {
method: 'POST',
body: formData
}).then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
setTimeout(() => {
location.reload();
}, 2000);
} else {
alert(data.message || '安装失败');
submitUpload.disabled = false;
submitUpload.textContent = '安装插件';
}
})
.catch(() => {
alert('网络错误');
submitUpload.disabled = false;
submitUpload.textContent = '安装插件';
});
};
if (uploadBtn) uploadBtn.addEventListener('click', showUploadForm);
if (emptyStateUploadBtn) emptyStateUploadBtn.addEventListener('click', showUploadForm);
if (cancelUpload) cancelUpload.addEventListener('click', hideUploadForm);
if (submitUpload) submitUpload.addEventListener('click', handleUpload);
if (uploadOverlay) uploadOverlay.addEventListener('click', hideUploadForm);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !uploadForm.classList.contains('hidden')) {
hideUploadForm();
}
});
});
</script>
+60
View File
@@ -0,0 +1,60 @@
<div class="p-6">
<h2 class="text-2xl font-bold mb-4">📊 数据报表</h2>
<!-- 日期筛选 -->
<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>
<!-- 总览卡片 -->
<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>
<!-- 每日明细 -->
<div class="bg-white rounded-xl shadow overflow-hidden mb-6">
<h3 class="font-bold p-4 border-b">每日明细</h3>
<table class="w-full text-sm">
<thead class="bg-gray-50"><tr><th class="px-4 py-2 text-left">日期</th><th class="px-4 py-2">投注</th><th class="px-4 py-2">中奖</th><th class="px-4 py-2">盈利</th><th class="px-4 py-2">充值</th><th class="px-4 py-2">提现</th></tr></thead>
<tbody>
<?php foreach($dailyStats??[] as $d): $dp=$d['bets']-$d['wins']; ?>
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-2"><?=$d['date']?></td>
<td class="px-4 py-2 text-center"><?=number_format($d['bets'],2)?></td>
<td class="px-4 py-2 text-center"><?=number_format($d['wins'],2)?></td>
<td class="px-4 py-2 text-center <?=$dp>=0?'text-green-500':'text-red-500'?>"><?=number_format($dp,2)?></td>
<td class="px-4 py-2 text-center"><?=number_format($d['deposits'],2)?></td>
<td class="px-4 py-2 text-center"><?=number_format($d['withdraws'],2)?></td>
</tr>
<?php endforeach; ?>
</tbody></table></div>
<!-- 代理报表 -->
<?php if(!empty($agentStats)): ?>
<div class="bg-white rounded-xl shadow overflow-hidden">
<h3 class="font-bold p-4 border-b">代理报表</h3>
<table class="w-full text-sm">
<thead class="bg-gray-50"><tr><th class="px-4 py-2 text-left">代理</th><th class="px-4 py-2">玩家数</th><th class="px-4 py-2">投注</th><th class="px-4 py-2">中奖</th><th class="px-4 py-2">佣金</th><th class="px-4 py-2">盈利</th></tr></thead>
<tbody>
<?php foreach($agentStats as $as): ?>
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-2"><?=htmlspecialchars($as['user']['username']??'')?></td>
<td class="px-4 py-2 text-center"><?=$as['players']?></td>
<td class="px-4 py-2 text-center"><?=number_format($as['bets'],2)?></td>
<td class="px-4 py-2 text-center"><?=number_format($as['wins'],2)?></td>
<td class="px-4 py-2 text-center text-orange-500"><?=number_format($as['commission'],2)?></td>
<td class="px-4 py-2 text-center <?=$as['profit']>=0?'text-green-500':'text-red-500'?>"><?=number_format($as['profit'],2)?></td>
</tr>
<?php endforeach; ?>
</tbody></table></div>
<?php endif; ?>
</div>
+388
View File
@@ -0,0 +1,388 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fas fa-cog text-primary mr-3"></i>
系统设置
</h1>
<!-- 设置表单 -->
<form id="settingsForm" class="space-y-6">
<!-- Logo设置 -->
<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-image text-primary mr-2"></i>
Logo设置
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Logo -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">网站Logo</label>
<div class="flex items-center gap-4">
<div class="flex-shrink-0">
<img id="site_logo_preview" src="<?= htmlspecialchars($settings['site_logo'] ?? '') ?>"
alt="Logo预览"
class="w-32 h-32 object-contain border border-gray-200 rounded-lg bg-gray-50 p-2"
style="<?= empty($settings['site_logo'] ?? '') ? 'display:none;' : '' ?>">
</div>
<div class="flex-1">
<input type="file" id="site_logo_file" name="site_logo_file" accept="image/jpeg,image/png,image/gif,image/webp"
class="hidden" onchange="handleLogoUpload(this, 'site_logo')">
<button type="button" onclick="document.getElementById('site_logo_file').click()"
class="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors">
<i class="fas fa-upload mr-2"></i>选择图片
</button>
<input type="hidden" id="site_logo" name="site_logo" value="<?= htmlspecialchars($settings['site_logo'] ?? '') ?>">
<p class="text-xs text-gray-500 mt-2">建议尺寸:200x60px,支持JPG/PNG/GIF/WEBP,前端统一使用此Logo</p>
</div>
</div>
</div>
<!-- Favicon -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">网站图标 (Favicon)</label>
<div class="flex items-center gap-4">
<div class="flex-shrink-0">
<img id="site_favicon_preview" src="<?= htmlspecialchars($settings['site_favicon'] ?? '') ?>"
alt="Favicon预览"
class="w-16 h-16 object-contain border border-gray-200 rounded-lg bg-gray-50 p-2"
style="<?= empty($settings['site_favicon'] ?? '') ? 'display:none;' : '' ?>">
</div>
<div class="flex-1">
<input type="file" id="site_favicon_file" name="site_favicon_file" accept="image/x-icon,image/vnd.microsoft.icon,image/png"
class="hidden" onchange="handleLogoUpload(this, 'site_favicon')">
<button type="button" onclick="document.getElementById('site_favicon_file').click()"
class="w-full px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors">
<i class="fas fa-upload mr-2"></i>选择图标
</button>
<input type="hidden" id="site_favicon" name="site_favicon" value="<?= htmlspecialchars($settings['site_favicon'] ?? '') ?>">
<p class="text-xs text-gray-500 mt-2">建议尺寸:32x32px,支持ICO/PNG格式</p>
</div>
</div>
</div>
</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-info-circle text-primary mr-2"></i>
网站基本信息
</h3>
<div class="space-y-4">
<div>
<label for="site_title" class="block text-sm font-medium text-gray-700 mb-1">网站标题</label>
<input type="text" id="site_title" name="site_title"
value="<?= htmlspecialchars($settings['site_title'] ?? '') ?>"
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="请输入网站标题">
</div>
<div>
<label for="site_description" class="block text-sm font-medium text-gray-700 mb-1">网站描述</label>
<textarea id="site_description" name="site_description" rows="3"
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 resize-none"
placeholder="请输入网站描述"><?= htmlspecialchars($settings['site_description'] ?? '') ?></textarea>
<p class="text-xs text-gray-500 mt-1">用于SEO优化,建议控制在150字以内</p>
</div>
<div>
<label for="site_keywords" class="block text-sm font-medium text-gray-700 mb-1">网站关键词</label>
<input type="text" id="site_keywords" name="site_keywords"
value="<?= htmlspecialchars($settings['site_keywords'] ?? '') ?>"
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="请输入关键词,用逗号分隔">
<p class="text-xs text-gray-500 mt-1">多个关键词用逗号分隔</p>
</div>
<div>
<label for="site_copyright" class="block text-sm font-medium text-gray-700 mb-1">版权信息</label>
<input type="text" id="site_copyright" name="site_copyright"
value="<?= htmlspecialchars($settings['site_copyright'] ?? '') ?>"
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="请输入版权信息">
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-3 pt-4">
<button type="button" onclick="resetSettings()"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
<i class="fas fa-undo mr-2"></i>重置
</button>
<button type="button" id="saveSettingsBtn"
class="px-6 py-2 bg-primary hover:bg-primary/90 text-white rounded-lg shadow hover:shadow-md transition-all duration-200">
<i class="fas fa-save mr-2"></i>保存设置
</button>
</div>
<!-- SMTP 邮件配置 -->
<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-envelope text-primary mr-2"></i>
SMTP 邮件配置
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 服务器</label>
<input type="text" id="smtp_host" name="smtp_host" value="<?= htmlspecialchars($settings['smtp_host'] ?? '') ?>"
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="如 smtp.gmail.com">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">端口</label>
<input type="number" id="smtp_port" name="smtp_port" value="<?= htmlspecialchars($settings['smtp_port'] ?? '465') ?>"
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="465">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">加密方式</label>
<select id="smtp_encryption" name="smtp_encryption"
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">
<option value="ssl" <?= ($settings['smtp_encryption'] ?? 'ssl') === 'ssl' ? 'selected' : '' ?>>SSL (端口465)</option>
<option value="tls" <?= ($settings['smtp_encryption'] ?? '') === 'tls' ? 'selected' : '' ?>>TLS (端口587)</option>
<option value="none" <?= ($settings['smtp_encryption'] ?? '') === 'none' ? 'selected' : '' ?>>无加密 (端口25)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 用户名</label>
<input type="text" id="smtp_user" name="smtp_user" value="<?= htmlspecialchars($settings['smtp_user'] ?? '') ?>"
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="your@email.com">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 密码</label>
<input type="password" id="smtp_pass" name="smtp_pass" value="<?= htmlspecialchars($settings['smtp_pass'] ?? '') ?>"
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="密码或应用专用密码">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件人地址</label>
<input type="text" id="smtp_from" name="smtp_from" value="<?= htmlspecialchars($settings['smtp_from'] ?? '') ?>"
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="noreply@yourdomain.com(留空则用SMTP用户名)">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件人名称</label>
<input type="text" id="smtp_from_name" name="smtp_from_name" value="<?= htmlspecialchars($settings['smtp_from_name'] ?? 'PK10') ?>"
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="PK10">
</div>
<div class="flex items-end">
<button type="button" onclick="testSmtp()"
class="w-full px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
<i class="fas fa-paper-plane mr-2"></i>发送测试邮件
</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-3">
<i class="fas fa-info-circle mr-1"></i>
常用配置:Gmail(smtp.gmail.com:465/SSL)、Outlook(smtp-mail.outlook.com:587/TLS)、QQ邮箱(smtp.qq.com:465/SSL)、163邮箱(smtp.163.com:465/SSL)
</p>
</div>
</form>
<script>
function testSmtp(){
const email=prompt('输入接收测试邮件的邮箱地址:');
if(!email)return;
// 先保存当前配置再测试
const data={};
document.querySelectorAll('#settingsForm [name^="smtp_"]').forEach(el=>{data[el.name]=el.value;});
fetch('/admin/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})
.then(()=>fetch('/admin/settings/smtp-test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email})}))
.then(r=>r.json())
.then(d=>{showMessage(d.message,d.success?'success':'error');})
.catch(()=>{showMessage('测试失败','error');});
}
</script>
<script>
// Logo上传处理
function handleLogoUpload(input, logoType) {
const file = input.files[0];
if (!file) return;
// 验证文件大小(5MB
if (file.size > 5 * 1024 * 1024) {
alert('文件大小不能超过5MB');
input.value = '';
return;
}
// 验证文件类型
const allowedTypes = logoType === 'site_favicon'
? ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png']
: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
alert('不支持的文件类型');
input.value = '';
return;
}
// 预览图片
const reader = new FileReader();
reader.onload = function(e) {
const previewId = logoType + '_preview';
const previewImg = document.getElementById(previewId);
if (previewImg) {
previewImg.src = e.target.result;
previewImg.style.display = '';
}
};
reader.readAsDataURL(file);
// 立即上传
uploadLogo(file, logoType);
}
// 上传Logo到服务器
function uploadLogo(file, logoType) {
const formData = new FormData();
formData.append(logoType + '_file', file);
formData.append('logo_type', logoType);
// 显示上传中状态
const saveBtn = document.getElementById('saveSettingsBtn');
const originalText = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>上传中...';
fetch('/admin/settings/save', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// 更新隐藏字段的值
const hiddenInput = document.getElementById(logoType);
const previewImg = document.getElementById(logoType + '_preview');
if (data.data && data.data[logoType]) {
if (hiddenInput) hiddenInput.value = data.data[logoType];
if (previewImg) {
previewImg.src = data.data[logoType];
previewImg.style.display = '';
}
}
// 显示成功提示
showMessage('Logo上传成功', 'success');
} else {
alert('上传失败: ' + (data.message || '未知错误'));
}
})
.catch(error => {
console.error('Upload error:', error);
alert('上传失败,请稍后重试');
})
.finally(() => {
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
});
}
// 保存设置
document.getElementById('saveSettingsBtn').addEventListener('click', function() {
const form = document.getElementById('settingsForm');
const formData = new FormData(form);
// 将FormData转换为JSON对象
const data = {};
for (let [key, value] of formData.entries()) {
// 跳过文件输入
if (key.endsWith('_file')) continue;
data[key] = value;
}
// 发送保存请求
const saveBtn = this;
const originalText = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>保存中...';
fetch('/admin/settings/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showMessage('设置保存成功', 'success');
// 如果有返回的数据,更新页面
if (data.data) {
updateSettingsPreview(data.data);
}
} else {
showMessage('保存失败: ' + (data.message || '未知错误'), 'error');
}
})
.catch(error => {
console.error('Save error:', error);
showMessage('保存失败,请稍后重试', 'error');
})
.finally(() => {
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
});
});
// 重置设置
function resetSettings() {
if (confirm('确定要重置所有设置吗?')) {
location.reload();
}
}
// 更新预览
function updateSettingsPreview(settings) {
const logoPreview = document.getElementById('site_logo_preview');
if (logoPreview) {
if (settings.site_logo) {
logoPreview.src = settings.site_logo;
logoPreview.style.display = '';
} else {
logoPreview.src = '';
logoPreview.style.display = 'none';
}
}
const faviconPreview = document.getElementById('site_favicon_preview');
if (faviconPreview) {
if (settings.site_favicon) {
faviconPreview.src = settings.site_favicon;
faviconPreview.style.display = '';
} else {
faviconPreview.src = '';
faviconPreview.style.display = 'none';
}
}
}
// 显示消息提示
function showMessage(message, type = 'success') {
// 创建提示元素
const messageEl = document.createElement('div');
messageEl.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'
}`;
messageEl.innerHTML = `
<div class="flex items-center">
<i class="fas ${type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle'} mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(messageEl);
// 3秒后自动移除
setTimeout(() => {
messageEl.remove();
}, 3000);
}
// 页面加载时获取设置
document.addEventListener('DOMContentLoaded', function() {
// 设置已经在页面加载时通过PHP变量传递,无需额外请求
});
</script>
+767
View File
@@ -0,0 +1,767 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fa fa-users text-primary mr-3"></i>
用户管理中心
</h1>
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<!-- 搜索和操作区 -->
<div class="flex justify-between items-center mb-6 gap-4">
<h2 class="text-xl font-semibold text-gray-700 whitespace-nowrap">用户列表</h2>
<div class="flex gap-3">
<!-- 新增用户按钮 -->
<button id="openFormBtn" class="bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center whitespace-nowrap">
<i class="fa fa-plus mr-2"></i>
<span>新增用户</span>
</button>
</div>
</div>
<!-- 用户列表表格 -->
<div class="overflow-x-auto">
<table class="w-full bg-white rounded-xl shadow-md overflow-hidden">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户信息</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider ">角色</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">余额</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">注册时间</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th scope="col" 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" id="userList">
<?php if (!empty($users) && is_array($users)): ?>
<?php foreach ($users as $user): ?>
<tr class="hover:bg-gray-50 transition-colors" data-id="<?php echo $user['id']; ?>">
<!-- 用户名和头像单元格 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap">
<div class="flex items-center gap-3">
<img src="<?php
if (!empty($user['avatar'])) {
echo htmlspecialchars($user['avatar']);
} else {
// 管理员与普通用户使用不同CDN头像
echo $user['role'] === 'admin'
? "https://robohash.org/admin" . $user['id'] . "?size=40x40"
: "https://robohash.org/user" . $user['id'] . "?size=40x40";
}
?>"
alt="用户头像" class="w-10 h-10 rounded-full object-cover border border-gray-200 flex-shrink-0">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 truncate"><?php echo htmlspecialchars($user['username']); ?></div>
<div class="text-xs text-gray-500 truncate"><?php echo htmlspecialchars($user['email']); ?></div>
</div>
</div>
</td>
<!-- 角色单元格 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap">
<?php
if ($user['role'] === 'admin') {
$roleClass = 'bg-red-100 text-red-800';
$roleText = '管理员';
} else {
$roleClass = 'bg-blue-100 text-blue-800';
$roleText = '平台用户';
}
?>
<span class="inline-block px-2 py-1 text-xs rounded-full <?php echo $roleClass; ?>">
<?php echo $roleText; ?>
</span>
</td>
<!-- 余额单元格 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-green-600">
<?php echo number_format($user['balance'] ?? 0, 2, ',', '.'); ?>
</span>
<div class="flex gap-1">
<button class="balance-increase-btn text-green-500 hover:text-green-700 text-xs"
data-id="<?php echo $user['id']; ?>"
data-username="<?php echo htmlspecialchars($user['username']); ?>"
title="增加余额">
<i class="fa fa-plus-circle"></i>
</button>
<button class="balance-decrease-btn text-red-500 hover:text-red-700 text-xs"
data-id="<?php echo $user['id']; ?>"
data-username="<?php echo htmlspecialchars($user['username']); ?>"
title="减少余额">
<i class="fa fa-minus-circle"></i>
</button>
</div>
</div>
</td>
<!-- 创建时间 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap hidden sm:table-cell">
<div class="text-sm text-gray-500"><?php echo date('Y-m-d H:i', strtotime($user['created_at'])); ?></div>
</td>
<!-- 状态 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap">
<?php
$statusClass = $user['status'] ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
$statusText = $user['status'] ? '启用' : '停用';
?>
<span class="inline-block px-2 py-1 text-xs rounded-full <?php echo $statusClass; ?>">
<?php echo $statusText; ?>
</span>
</td>
<!-- 操作按钮 - 自适应宽度 -->
<td class="px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<button class="view-btn text-gray-500 hover:text-purple-500"
data-id="<?php echo $user['id']; ?>" title="查看详情">
<i class="fa fa-eye"></i>
</button>
<button class="edit-btn text-gray-500 hover:text-blue-500"
data-id="<?php echo $user['id']; ?>" title="编辑">
<i class="fa fa-pencil"></i>
</button>
<button class="delete-btn text-gray-500 hover:text-red-500"
data-id="<?php echo $user['id']; ?>" title="删除">
<i class="fa fa-trash"></i>
</button>
</div>
</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="fa 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">尝试调整筛选条件或添加新用户</p>
<button class="mt-4 bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center"
onclick="openFormModal()">
<i class="fa fa-plus mr-2"></i>
<span>添加新用户</span>
</button>
</div>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="flex justify-between items-center mt-6">
<p class="text-sm text-gray-500">显示 1 至 <?php echo min(10, count($users ?? [])); ?> 条,共 <?php echo count($users ?? []); ?> 条</p>
</div>
</div>
<!-- 用户表单弹窗背景 -->
<div id="formBackdrop" class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div>
<!-- 用户表单弹窗 -->
<div id="formModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-events-none pointer-none transition transition-all duration-300 scale-95">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 id="formTitle" class="text-xl font-bold text-gray-800 flex items-center">
<i class="fa fa-plus-circle text-primary mr-2"></i>
创建新用户
</h3>
<button id="closeFormBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fa fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="userForm" class="space-y-5">
<input type="hidden" id="userId" name="id">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">用户名 <span class="text-red-500">*</span></label>
<input type="text" id="username" name="username" required
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="请输入用户名">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">邮箱 <span class="text-red-500">*</span></label>
<input type="email" id="email" name="email" required
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="请输入邮箱地址">
</div>
<!-- 用户表单表单弹窗中密码字段部分修改 -->
<div id="passwordField">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
密码 <span class="text-red-500">*</span>
</label>
<input type="password" id="password" name="password" required
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="请输入密码">
<p class="mt-1 text-xs text-gray-500">密码长度至少8位,包含字母和数字</p>
</div>
<!-- 用户角色固定为普通用户 -->
<input type="hidden" id="role" name="role" value="user">
<div>
<label class="flex items-center">
<input type="checkbox" id="status" name="status" value="1" checked
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-700">启用用户</span>
</label>
</div>
</form>
</div>
<div class="border-t border-gray-100 px-6 py-4 flex justify-end gap-3">
<button id="cancelBtn" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
取消
</button>
<button id="submitBtn" type="button"
class="bg-primary hover:bg-primary/90 text-white px-5 py-2 rounded-lg shadow hover:shadow-md transition-all duration-200">
保存用户
</button>
</div>
</div>
</div>
<!-- 用户详情弹窗 -->
<div id="detailModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fa fa-user-circle text-primary mr-2"></i>
用户详情
</h3>
<button id="closeDetailBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fa fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-100px)]">
<div class="flex flex-col items-center mb-6">
<img id="detailAvatar" src="https://picsum.photos/seed/user/100/100" alt="用户头像" class="w-24 h-24 rounded-full mb-4">
<h4 id="detailUsername" class="text-xl font-bold text-gray-800">用户名</h4>
<p id="detailRole" class="mt-1 px-3 py-1 text-sm rounded-full bg-green-100 text-green-800">普通用户</p>
</div>
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">ID</span>
<span id="detailId" class="col-span-2 text-gray-800">--</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">余额</span>
<span id="detailBalance" class="col-span-2 text-green-600 font-semibold">--</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">邮箱</span>
<span id="detailEmail" class="col-span-2 text-gray-800">--</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">状态</span>
<span id="detailStatus" class="col-span-2">
<span class="inline-block px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">启用</span>
</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">创建时间</span>
<span id="detailCreatedAt" class="col-span-2 text-gray-800">--</span>
</div>
<div class="w-full h-px bg-gray-100"></div>
<div class="grid grid-cols-3 gap-4 items-center">
<span class="text-sm text-gray-500">最后登录</span>
<span id="detailLastLogin" class="col-span-2 text-gray-800">--</span>
</div>
</div>
</div>
<div class="border-t border-gray-100 px-6 py-4 flex justify-end">
<button id="closeDetailBtn2" class="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors">
关闭
</button>
</div>
</div>
</div>
<script>
console.log('用户管理JS加载完成');
document.addEventListener('DOMContentLoaded', function() {
// 缓存DOM元素
const formModal = document.getElementById('formModal');
const formBackdrop = document.getElementById('formBackdrop');
const detailModal = document.getElementById('detailModal');
const openFormBtn = document.getElementById('openFormBtn');
const closeFormBtn = document.getElementById('closeFormBtn');
const cancelBtn = document.getElementById('cancelBtn');
const submitBtn = document.getElementById('submitBtn');
const formTitle = document.getElementById('formTitle');
const userForm = document.getElementById('userForm');
const userList = document.getElementById('userList');
const passwordField = document.getElementById('passwordField');
const closeDetailBtn = document.getElementById('closeDetailBtn');
const closeDetailBtn2 = document.getElementById('closeDetailBtn2');
const searchInput = document.getElementById('searchInput');
// 检查元素是否存在
function checkElements() {
const elements = [
formModal, formBackdrop, openFormBtn,
closeFormBtn, cancelBtn, submitBtn
];
const missing = elements.filter(el => !el);
if (missing.length > 0) {
console.error('缺少必要的DOM元素,功能无法正常工作');
return false;
}
return true;
}
// 显示表单弹窗
function openFormModal() {
if (!checkElements()) return;
resetForm();
formModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.add('scale-100');
formBackdrop.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
void formModal.offsetWidth; // 强制重绘
}
// 隐藏表单弹窗
function closeFormModal() {
if (!checkElements()) return;
formModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
formModal.classList.remove('scale-100');
formBackdrop.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
// 显示详情弹窗
function openDetailModal() {
detailModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
detailModal.classList.add('scale-100');
formBackdrop.classList.remove('opacity-0', 'pointer-events-none');
document.body.style.overflow = 'hidden';
void detailModal.offsetWidth;
}
// 隐藏详情弹窗
function closeDetailModal() {
detailModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
detailModal.classList.remove('scale-100');
formBackdrop.classList.add('opacity-0', 'pointer-events-none');
document.body.style.overflow = '';
}
// 重置表单(新增模式)
function resetForm() {
userForm.reset();
document.getElementById('userId').value = '';
formTitle.innerHTML = '<i class="fa fa-plus-circle text-primary mr-2"></i> 创建新用户';
// 新增模式:密码必填设置
const passwordLabel = document.querySelector('#passwordField label');
const passwordInput = document.getElementById('password');
passwordLabel.innerHTML = '密码 <span class="text-red-500">*</span>';
passwordInput.required = true;
passwordInput.placeholder = '请输入密码';
passwordField.style.display = 'block';
submitBtn.innerHTML = '保存用户';
submitBtn.disabled = false;
}
// 加载用户数据(编辑模式)
async function loadUserData(id) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 加载中...';
try {
const response = await fetch(`/admin/users/${id}`);
if (!response.ok) throw new Error('获取数据失败');
const data = await response.json();
if (data.success && data.data) {
const { id, username, email, status } = data.data;
document.getElementById('userId').value = id;
document.getElementById('username').value = username || '';
document.getElementById('email').value = email || '';
// 角色固定为普通用户,无需设置
document.getElementById('status').checked = status == 1;
formTitle.innerHTML = '<i class="fa fa-pencil text-primary mr-2"></i> 编辑用户';
// 编辑模式:密码可选设置
const passwordLabel = document.querySelector('#passwordField label');
const passwordInput = document.getElementById('password');
passwordLabel.innerHTML = '密码(不填则不修改)';
passwordInput.required = false;
passwordInput.placeholder = '不修改密码请留空';
passwordField.style.display = 'block';
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (e) {
showMessage(e.message, 'error');
closeFormModal();
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '保存用户';
}
}
// 加载用户详情
async function loadUserDetail(id) {
try {
const response = await fetch(`/admin/users/${id}`);
if (!response.ok) throw new Error('获取详情失败');
const data = await response.json();
if (data.success && data.data) {
const { id, username, email, role, status, created_at, last_login, avatar, balance } = data.data;
// 填充详情数据
document.getElementById('detailId').textContent = id;
document.getElementById('detailUsername').textContent = username || '未知用户';
document.getElementById('detailEmail').textContent = email || '未设置';
// 格式化余额为越南盾格式(点号分隔千位,逗号分隔小数)
var balanceValue = balance !== undefined ? parseFloat(balance) : 0;
var parts = balanceValue.toFixed(2).split('.');
var integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
var formattedBalance = integerPart + ',' + parts[1];
document.getElementById('detailBalance').textContent = formattedBalance;
document.getElementById('detailCreatedAt').textContent = created_at ? new Date(created_at).toLocaleString() : '未知';
document.getElementById('detailLastLogin').textContent = last_login ? new Date(last_login).toLocaleString() : '从未登录';
document.getElementById('detailAvatar').src = avatar || `https://picsum.photos/seed/user${id}/100/100`;
// 设置角色标签样式
let roleClass = 'bg-green-100 text-green-800';
let roleText = '平台用户';
if (role === 'admin') {
roleClass = 'bg-red-100 text-red-800';
roleText = '管理员';
}
document.getElementById('detailRole').className = `mt-1 px-3 py-1 text-sm rounded-full ${roleClass}`;
document.getElementById('detailRole').textContent = roleText;
// 设置状态标签样式
const statusClass = status ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = status ? '启用' : '禁用';
document.getElementById('detailStatus').innerHTML =
`<span class="inline-block px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>`;
openDetailModal();
} else {
throw new Error(data.message || '获取详情失败');
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 表单验证
function validateForm() {
const username = document.getElementById('username').value.trim();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value.trim();
const isEditMode = !!document.getElementById('userId').value;
if (!username) {
showMessage('请输入用户名', 'error');
return false;
}
if (!email) {
showMessage('请输入邮箱地址', 'error');
return false;
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
showMessage('请输入有效的邮箱地址', 'error');
return false;
}
// 仅在新增或编辑时填写了密码的情况下验证长度
if ((!isEditMode || password) && password.length < 8) {
showMessage('密码长度至少8位', 'error');
return false;
}
return true;
}
// 提交表单(创建/更新)
async function submitFormData() {
if (!validateForm()) return;
const formData = new FormData(userForm);
const isEditMode = !!document.getElementById('userId').value;
const statusCheckbox = document.getElementById('status');
formData.delete('status'); // 先除可能存在的旧值
formData.append('status', statusCheckbox.checked ? '1' : '0');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin mr-2"></i> 保存中...';
try {
const response = await fetch('/admin/users/update', {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await response.json();
if (data.success) {
showMessage(isEditMode ? '用户更新成功' : '用户创建成功');
closeFormModal();
setTimeout(() => location.reload(), 1000);
} else {
throw new Error(data.message || (isEditMode ? '更新失败' : '创建失败'));
}
} catch (e) {
showMessage(e.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '保存用户';
}
}
// 删除用户
async function deleteUser(id) {
if (!confirm('确定要删除该用户吗?此操作不可恢复!')) return;
try {
const response = await fetch(`/admin/users/delete/${id}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showMessage('用户已删除');
// 移除DOM元素
const row = document.querySelector(`tr[data-id="${id}"]`);
if (row) {
row.remove();
// 检查是否还有数据行
const rows = userList.querySelectorAll('tr:not(:last-child)');
if (rows.length === 0) {
userList.innerHTML = `
<tr>
<td colspan="8" class="px-6 py-10 text-center text-gray-500 border border-dashed border-gray-200">
<div>
<i class="fa fa-info-circle text-2xl mb-2 text-gray-300"></i>
<p>暂无用户数据</p>
</div>
</td>
</tr>`;
}
}
} else {
throw new Error(data.message || '删除失败');
}
} catch (e) {
showMessage(e.message, 'error');
}
}
// 绑定事件
if (checkElements()) {
// 打开表单
openFormBtn.addEventListener('click', openFormModal);
// 关闭表单
closeFormBtn.addEventListener('click', closeFormModal);
cancelBtn.addEventListener('click', closeFormModal);
formBackdrop.addEventListener('click', () => {
if (!formModal.classList.contains('invisible')) closeFormModal();
if (!detailModal.classList.contains('invisible')) closeDetailModal();
});
// 关闭详情
closeDetailBtn.addEventListener('click', closeDetailModal);
closeDetailBtn2.addEventListener('click', closeDetailModal);
// 提交表单
submitBtn.addEventListener('click', submitFormData);
// 编辑、删除、查看、余额操作事件委托
userList.addEventListener('click', function(e) {
const editBtn = e.target.closest('.edit-btn');
const deleteBtn = e.target.closest('.delete-btn');
const viewBtn = e.target.closest('.view-btn');
const increaseBtn = e.target.closest('.balance-increase-btn');
const decreaseBtn = e.target.closest('.balance-decrease-btn');
if (editBtn) {
const id = editBtn.getAttribute('data-id');
if (id) {
openFormModal();
setTimeout(() => loadUserData(id), 300);
}
} else if (deleteBtn) {
const id = deleteBtn.getAttribute('data-id');
if (id) deleteUser(id);
} else if (viewBtn) {
const id = viewBtn.getAttribute('data-id');
if (id) loadUserDetail(id);
} else if (increaseBtn) {
const id = increaseBtn.getAttribute('data-id');
const username = increaseBtn.getAttribute('data-username');
if (id) adjustBalance(id, username, 'increase');
} else if (decreaseBtn) {
const id = decreaseBtn.getAttribute('data-id');
const username = decreaseBtn.getAttribute('data-username');
if (id) adjustBalance(id, username, 'decrease');
}
});
// 余额调整功能
function adjustBalance(userId, username, action) {
const actionText = action === 'increase' ? '增加' : '减少';
const actionColor = action === 'increase' ? '#5FB878' : '#FF5722';
// 使用 layui 的 prompt 弹窗
layui.use('layer', function(){
var layer = layui.layer;
layer.prompt({
formType: 0, // 0=文本输入框
value: '',
title: actionText + '余额',
area: ['400px', 'auto'],
btn: ['确定', '取消'],
btnAlign: 'c',
yes: function(index, layero){
var amountInput = layero.find('input');
var amount = amountInput.val();
if (!amount || amount.trim() === '' || isNaN(amount) || parseFloat(amount) <= 0) {
layer.msg('请输入有效的金额', {icon: 2, time: 2000});
return;
}
var amountValue = parseFloat(amount);
// 格式化金额为越南盾格式(点号分隔千位,逗号分隔小数)
var parts = amountValue.toFixed(2).split('.');
var integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
var formattedAmount = integerPart + ',' + parts[1];
// 关闭输入弹窗
layer.close(index);
// 使用 layui 的 confirm 确认弹窗
layer.confirm(
'确定要' + actionText + '用户 <span style="color: #1890ff; font-weight: bold;">"' + username + '"</span> 的余额 <span style="color: ' + actionColor + '; font-weight: bold;">' + formattedAmount + '</span> 吗?',
{
icon: 3,
title: '确认操作',
btn: ['确定', '取消'],
btnAlign: 'c',
area: ['450px', 'auto']
},
function(confirmIndex){
// 执行余额调整
executeBalanceAdjust(userId, username, action, amountValue, actionText, layer, confirmIndex);
}
);
}
});
});
}
// 执行余额调整请求
async function executeBalanceAdjust(userId, username, action, amount, actionText, layer, confirmIndex) {
// 关闭确认弹窗
layer.close(confirmIndex);
// 显示加载提示
var loadIndex = layer.load(1, {
content: '正在处理...',
shade: [0.3, '#000']
});
try {
const response = await fetch(`/admin/users/balance/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
amount: amount,
action: action
})
});
const data = await response.json();
// 关闭加载提示
layer.close(loadIndex);
if (data.success) {
layer.msg('余额' + actionText + '成功!', {
icon: 1,
time: 2000,
shade: 0.3
}, function(){
// 刷新页面以更新余额显示
location.reload();
});
} else {
layer.msg(data.message || actionText + '失败', {
icon: 2,
time: 3000
});
}
} catch (e) {
layer.close(loadIndex);
layer.msg('操作失败:' + e.message, {
icon: 2,
time: 3000
});
}
}
// ESC键关闭弹窗
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (!formModal.classList.contains('invisible')) closeFormModal();
if (!detailModal.classList.contains('invisible')) closeDetailModal();
}
});
// 阻止表单默认提交
userForm.addEventListener('submit', e => {
e.preventDefault();
submitFormData();
});
}
});
</script>
+39
View File
@@ -0,0 +1,39 @@
<div class="p-6">
<h2 class="text-2xl font-bold mb-4">👻 虚拟账户</h2>
<button onclick="document.getElementById('addModal').classList.remove('hidden')" class="mb-4 px-4 py-2 bg-blue-500 text-white rounded text-sm">+ 创建虚拟账户</button>
<div class="bg-white rounded-xl shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left">用户名</th><th class="px-4 py-3">余额</th><th class="px-4 py-3">创建时间</th><th class="px-4 py-3">操作</th></tr></thead>
<tbody>
<?php foreach($virtuals??[] as $v): ?>
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-2"><?=htmlspecialchars($v['username'])?> <span class="text-xs text-purple-500">虚拟</span></td>
<td class="px-4 py-2 text-center font-mono"><?=number_format($v['balance'],2)?></td>
<td class="px-4 py-2 text-xs text-gray-400"><?=$v['created_at']??''?></td>
<td class="px-4 py-2 text-center">
<button onclick="adjustBal(<?=$v['id']?>,'<?=htmlspecialchars($v['username'])?>')" class="text-blue-500 text-xs">±余额</button>
<button onclick="delVirt(<?=$v['id']?>)" class="text-red-500 text-xs ml-1">删除</button>
</td>
</tr>
<?php endforeach; ?>
</tbody></table></div>
<div id="addModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center">
<div class="bg-white rounded-xl p-6 w-full max-w-sm">
<h3 class="font-bold mb-4">创建虚拟账户</h3>
<div class="space-y-3">
<input id="vUser" placeholder="用户名" class="w-full border rounded px-3 py-2">
<input id="vPass" placeholder="密码 (默认: 123456)" class="w-full border rounded px-3 py-2">
<input id="vBal" type="number" placeholder="初始余额" value="100000" class="w-full border rounded px-3 py-2">
</div>
<div class="flex gap-2 mt-4">
<button onclick="createVirt()" class="flex-1 py-2 bg-blue-500 text-white rounded">创建</button>
<button onclick="document.getElementById('addModal').classList.add('hidden')" class="flex-1 py-2 bg-gray-200 rounded">取消</button>
</div>
</div></div>
</div>
<script>
async function createVirt(){const r=await fetch('/admin/virtual/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:document.getElementById('vUser').value,password:document.getElementById('vPass').value||'123456',balance:parseFloat(document.getElementById('vBal').value)})});const d=await r.json();if(d.status==='success')location.reload();else alert(d.message);}
async function adjustBal(id,name){const a=prompt('调整 '+name+' 的余额(正数=增加,负数=减少):');if(!a)return;const r=await fetch('/admin/virtual/balance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,amount:parseFloat(a)})});const d=await r.json();if(d.status==='success')location.reload();else alert(d.message);}
async function delVirt(id){if(!confirm('确定删除?'))return;await fetch('/admin/virtual/delete/'+id,{method:'POST'});location.reload();}
</script>
+110
View File
@@ -0,0 +1,110 @@
<div class="p-6">
<h2 class="text-2xl font-bold mb-2">🌊 放水控制 & 投注限额</h2>
<p class="text-gray-500 text-sm mb-6">放水 = 控制玩家胜率。百分比越低,平台赢得越多。设为50%表示公平对赌。</p>
<?php
// 投注类型中文映射
$betTypeLabels = [
// PK10
'rank' => '🏎️ 名次(猜第N名是几号车)',
'bs' => '🔢 大小(名次车号 ≥6大 ≤5小)',
'oe' => '🎯 单双(名次车号的奇偶)',
'dt' => '🐉 龙虎(前名次 vs 后名次比大小)',
'sum' => '➕ 冠亚和值(冠军+亚军车号之和)',
'sum_bs' => '📊 冠亚和大小(和值≥12大 ≤11小)',
// 骰子
'tai' => '🎲 大(总点数 ≥11',
'xiu' => '🎲 小(总点数 ≤10',
'chan' => '🎲 双(总点数为偶数)',
'le' => '🎲 单(总点数为奇数)',
'number' => '🔢 押点数(猜总和具体数字)',
'dice' => '🎯 单骰(猜某颗骰子的点数)',
'combo' => '💎 豹子(三颗相同)',
// Xóc Đĩa
'even' => '⚪ 偶数红',
'odd' => '🔴 奇数红',
'4red' => '🔴🔴🔴🔴 四红',
'4white' => '⚪⚪⚪⚪ 四白',
'big_small' => '🎲 大小',
'odd_even' => '🎲 单双',
];
function getBetLabel($type, $labels) {
return $labels[$type] ?? $type;
}
?>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 放水配置 -->
<div class="bg-white rounded-xl p-6 shadow">
<h3 class="font-bold mb-2">胜率控制</h3>
<p class="text-gray-400 text-xs mb-4">数值含义:玩家赢的概率百分比。例如 40% = 玩家有40%概率赢,平台抽成约60%。</p>
<div class="space-y-3" id="waterList">
<?php foreach($configs??[] as $c): ?>
<div class="flex items-center gap-3 py-2 border-b">
<span class="flex-1 text-sm" title="<?=htmlspecialchars($c['bet_type'])?>"><?=getBetLabel($c['bet_type'], $betTypeLabels)?></span>
<input type="number" step="0.1" min="0" max="100" value="<?=$c['win_rate_pct']?>" class="w-20 border rounded px-2 py-1 text-sm text-center water-pct" data-type="<?=$c['bet_type']?>" data-game="<?=$c['game_id']?>">
<span class="text-xs text-gray-400">%</span>
<label class="flex items-center gap-1"><input type="checkbox" class="water-enabled" data-type="<?=$c['bet_type']?>" <?=$c['enabled']?'checked':''?>><span class="text-xs">启用</span></label>
</div>
<?php endforeach; ?>
<?php if(empty($configs)): ?>
<div class="text-gray-400 text-sm text-center py-4">暂无放水配置,请先在「游戏与赔率」中配置游戏赔率</div>
<?php endif; ?>
</div>
<button onclick="saveWater()" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm">💾 保存放水配置</button>
</div>
<!-- 限额配置 -->
<div class="bg-white rounded-xl p-6 shadow">
<h3 class="font-bold mb-2">投注限额</h3>
<p class="text-gray-400 text-xs mb-4">控制每种玩法的单笔最小/最大金额,以及每期总投注上限。</p>
<div class="space-y-3" id="limitList">
<?php foreach($limits??[] as $l): ?>
<div class="flex items-center gap-2 py-2 border-b flex-wrap">
<span class="w-full text-sm mb-1 font-medium" title="<?=htmlspecialchars($l['bet_type'])?>"><?=getBetLabel($l['bet_type'], $betTypeLabels)?></span>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">单笔最小:</span>
<input type="number" value="<?=$l['min_amount']?>" class="w-24 border rounded px-2 py-1 text-sm text-center limit-min" data-type="<?=$l['bet_type']?>" data-game="<?=$l['game_id']?>">
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">单笔最大:</span>
<input type="number" value="<?=$l['max_amount']?>" class="w-24 border rounded px-2 py-1 text-sm text-center limit-max" data-type="<?=$l['bet_type']?>">
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">每期上限:</span>
<input type="number" value="<?=$l['max_per_period']?>" class="w-24 border rounded px-2 py-1 text-sm text-center limit-period" data-type="<?=$l['bet_type']?>">
</div>
</div>
<?php endforeach; ?>
<?php if(empty($limits)): ?>
<div class="text-gray-400 text-sm text-center py-4">暂无限额配置</div>
<?php endif; ?>
</div>
<button onclick="saveLimits()" class="mt-4 px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 text-sm">💾 保存限额</button>
</div>
</div>
</div>
<script>
async function saveWater(){
const items=[];
document.querySelectorAll('.water-pct').forEach(el=>{
items.push({game_id:el.dataset.game,bet_type:el.dataset.type,win_rate_pct:parseFloat(el.value),
enabled:document.querySelector('.water-enabled[data-type="'+el.dataset.type+'"]').checked?1:0});
});
const r=await fetch('/admin/water/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items})});
const d=await r.json();alert(d.status==='success'?'保存成功!':d.message);
}
async function saveLimits(){
const items=[];
document.querySelectorAll('.limit-min').forEach(el=>{
const t=el.dataset.type;
items.push({game_id:el.dataset.game,bet_type:t,min_amount:parseFloat(el.value),
max_amount:parseFloat(document.querySelector('.limit-max[data-type="'+t+'"]').value),
max_per_period:parseFloat(document.querySelector('.limit-period[data-type="'+t+'"]').value)});
});
const r=await fetch('/admin/water/limits',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items})});
const d=await r.json();alert(d.status==='success'?'保存成功!':d.message);
}
</script>
+783
View File
@@ -0,0 +1,783 @@
<h1 class="text-2xl font-bold text-gray-800 mb-6 flex items-center">
<i class="fas fa-coins text-primary mr-3"></i>
Xóc Đĩa 游戏期号管理
</h1>
<!-- 游戏期号管理区域 -->
<?php if (!empty($gamesList) && is_array($gamesList)): ?>
<?php foreach ($gamesList as $game): ?>
<?php
$gameId = $game['id'];
$gameName = $game['name'];
$currentPeriod = isset($currentPeriods[$gameId]) ? $currentPeriods[$gameId] : null;
?>
<!-- 单个游戏的期号卡片 -->
<div class="bg-white rounded-xl shadow-md p-6 mb-6 border-l-4 border-primary">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-800 flex items-center">
<i class="fas fa-gamepad text-primary mr-2"></i>
<?= htmlspecialchars($gameName) ?>
</h2>
<?php if ($currentPeriod): ?>
<!-- 有当前期号 -->
<div class="flex gap-2">
<?php if ($currentPeriod['status'] === 'pending'): ?>
<button
type="button"
class="period-lock-btn inline-block bg-warning hover:bg-warning/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-lock mr-2"></i>封盘
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'locked'): ?>
<button
type="button"
class="period-draw-btn inline-block bg-success hover:bg-success/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-coins mr-2"></i>开奖
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'drawn'): ?>
<button
type="button"
class="period-settle-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-id="<?= $currentPeriod['id'] ?>"
>
<i class="fas fa-coins mr-2"></i>结算
</button>
<?php endif; ?>
<?php if ($currentPeriod['status'] === 'settled'): ?>
<button
type="button"
class="period-start-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-game-id="<?= $gameId ?>"
>
<i class="fas fa-play mr-2"></i>开始新一期
</button>
<?php endif; ?>
</div>
<?php else: ?>
<button
type="button"
class="period-start-btn inline-block bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-lg text-sm"
data-game-id="<?= $gameId ?>"
>
<i class="fas fa-play mr-2"></i>开始新一期
</button>
<?php endif; ?>
</div>
<?php if ($currentPeriod): ?>
<!-- 当前期号信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<p class="text-sm text-gray-500 mb-1">期号</p>
<p class="text-lg font-bold text-gray-900"><?= htmlspecialchars($currentPeriod['period_number']) ?></p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">状态</p>
<?php
$status = $currentPeriod['status'];
$statusMap = [
'pending' => ['text' => '待开奖', 'class' => 'bg-warning/10 text-warning'],
'locked' => ['text' => '已封盘', 'class' => 'bg-danger/10 text-danger'],
'drawn' => ['text' => '已开奖', 'class' => 'bg-primary/10 text-primary'],
'settled' => ['text' => '已结算', 'class' => 'bg-success/10 text-success']
];
$statusInfo = $statusMap[$status] ?? $statusMap['pending'];
?>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm <?= $statusInfo['class'] ?>">
<?= $statusInfo['text'] ?>
</span>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">开始时间</p>
<p class="text-sm text-gray-900"><?= htmlspecialchars($currentPeriod['start_time'] ?? '-') ?></p>
</div>
<div>
<p class="text-sm text-gray-500 mb-1">开奖结果</p>
<?php if (!empty($currentPeriod['result'])): ?>
<?php
$coins = json_decode($currentPeriod['result'], true);
if (is_array($coins) && count($coins) === 4):
$redCount = $currentPeriod['dice1'] ?? 0;
$whiteCount = $currentPeriod['dice2'] ?? 0;
?>
<div class="text-sm text-gray-900">
<span class="font-semibold">
<?php foreach ($coins as $coin): ?>
<span class="inline-block w-5 h-5 rounded-full <?= $coin === 'red' ? 'bg-red-500' : 'bg-gray-200' ?> border border-gray-300 mr-1"></span>
<?php endforeach; ?>
</span>
<span class="text-gray-600 ml-2">
(<?= $redCount ?>Đ <?= $whiteCount ?>T)
</span>
</div>
<?php else: ?>
<p class="text-sm text-gray-400">数据格式错误</p>
<?php endif; ?>
<?php else: ?>
<p class="text-sm text-gray-400">未开奖</p>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<div class="text-center py-4">
<p class="text-sm text-gray-500">暂无进行中的期号,点击"开始新一期"按钮启动</p>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 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-2xl font-bold mt-1">
<?= isset($periods) && is_array($periods) ? count($periods) : 0 ?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-list 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 text-warning">
<?php
$pendingCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && $p['status'] === 'pending') {
$pendingCount++;
}
}
}
echo $pendingCount;
?>
</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-2xl font-bold mt-1 text-success">
<?php
$drawnCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && ($p['status'] === 'drawn' || $p['status'] === 'settled')) {
$drawnCount++;
}
}
}
echo $drawnCount;
?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center">
<i class="fas fa-check-circle 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 text-primary">
<?php
$settledCount = 0;
if (isset($periods) && is_array($periods)) {
foreach ($periods as $p) {
if (isset($p['status']) && $p['status'] === 'settled') {
$settledCount++;
}
}
}
echo $settledCount;
?>
</h3>
</div>
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i class="fas fa-coins text-primary"></i>
</div>
</div>
</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">管理Xóc Đĩa 游戏期号和开奖结果</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">期号</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-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200" id="periodList">
<?php if (!empty($periods) && is_array($periods)): ?>
<?php foreach ($periods as $period): ?>
<tr class="hover:bg-gray-50 transition-colors" data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>">
<td class="px-4 py-4">
<div class="text-sm font-semibold text-gray-900">
<?= htmlspecialchars((string)($period['period_number'] ?? '')) ?>
</div>
<?php if (!empty($period['auto_generated'])): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] bg-gray-100 text-gray-600 mt-1">
自动生成
</span>
<?php endif; ?>
</td>
<td class="px-4 py-4">
<?php
$gameId = $period['game_id'] ?? null;
$gameName = $gameId && isset($games[$gameId]) ? $games[$gameId] : '未关联';
?>
<span class="text-sm text-gray-600"><?= htmlspecialchars($gameName) ?></span>
</td>
<td class="px-4 py-4">
<?php
$status = $period['status'] ?? 'pending';
$statusMap = [
'pending' => ['text' => '待开奖', 'class' => 'bg-warning/10 text-warning'],
'locked' => ['text' => '已封盘', 'class' => 'bg-danger/10 text-danger'],
'drawn' => ['text' => '已开奖', 'class' => 'bg-primary/10 text-primary'],
'settled' => ['text' => '已结算', 'class' => 'bg-success/10 text-success']
];
$statusInfo = $statusMap[$status] ?? $statusMap['pending'];
?>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs <?= $statusInfo['class'] ?>">
<span class="w-2 h-2 rounded-full mr-1 <?= str_replace('/10', '', $statusInfo['class']) ?>"></span>
<?= $statusInfo['text'] ?>
</span>
</td>
<td class="px-4 py-4">
<?php if (!empty($period['result'])): ?>
<?php
$coins = json_decode($period['result'], true);
if (is_array($coins) && count($coins) === 4):
$redCount = $period['dice1'] ?? 0;
$whiteCount = $period['dice2'] ?? 0;
?>
<div class="text-sm text-gray-900">
<span class="font-semibold">
<?php foreach ($coins as $coin): ?>
<span class="inline-block w-5 h-5 rounded-full <?= $coin === 'red' ? 'bg-red-500' : 'bg-gray-200' ?> border border-gray-300 mr-1"></span>
<?php endforeach; ?>
</span>
<span class="text-gray-600 ml-2">
(<?= $redCount ?>Đ <?= $whiteCount ?>T)
</span>
<span class="ml-1 px-2 py-0.5 rounded text-[11px] <?= ($redCount == 0 || $redCount == 2 || $redCount == 4) ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700' ?>">
<?= ($redCount == 0 || $redCount == 2 || $redCount == 4) ? 'Chẵn' : 'Lẻ' ?>
</span>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">数据格式错误</span>
<?php endif; ?>
<?php else: ?>
<span class="text-sm text-gray-400">未开奖</span>
<?php endif; ?>
</td>
<td class="px-4 py-4 hidden md:table-cell">
<?php if (!empty($period['created_at'])): ?>
<div class="text-xs text-gray-500">
<?= date('Y-m-d H:i', strtotime((string)$period['created_at'])) ?>
</div>
<?php else: ?>
<span class="text-xs text-gray-400">时间未知</span>
<?php endif; ?>
</td>
<td class="px-4 py-4 text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<?php if (($period['status'] ?? '') === 'pending'): ?>
<button
type="button"
class="period-lock-btn text-gray-500 hover:text-warning"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="封盘"
>
<i class="fas fa-lock"></i>
</button>
<?php endif; ?>
<?php if (($period['status'] ?? '') === 'locked' || ($period['status'] ?? '') === 'pending'): ?>
<button
type="button"
class="period-draw-btn text-gray-500 hover:text-primary"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="录入开奖"
>
<i class="fas fa-coins"></i>
</button>
<?php endif; ?>
<?php if (($period['status'] ?? '') === 'drawn'): ?>
<button
type="button"
class="period-draw-btn text-gray-500 hover:text-warning"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="修改结果"
>
<i class="fas fa-edit"></i>
</button>
<button
type="button"
class="period-settle-btn text-gray-500 hover:text-success"
data-id="<?= htmlspecialchars((string)($period['id'] ?? '')) ?>"
title="确认结算"
>
<i class="fas fa-check-circle"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php 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-coins 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">
当前还没有任何Xóc Đĩa 游戏期号。
</p>
</div>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 录入开奖结果模态框 -->
<div
id="drawPeriodBackdrop"
class="fixed inset-0 bg-black/50 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300 z-40"
></div>
<div
id="drawPeriodModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 invisible pointer-events-none transition-all duration-300 scale-95"
>
<div class="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[90vh] overflow-hidden">
<div class="border-b border-gray-100 px-6 py-4 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-800 flex items-center">
<i class="fas fa-coins text-primary mr-2"></i>
录入开奖结果
</h3>
<button id="closeDrawPeriodBtn" class="text-gray-400 hover:text-gray-600 transition-colors p-1">
<i class="fas fa-times"></i>
</button>
</div>
<div class="px-6 py-5 overflow-y-auto max-h-[calc(90vh-130px)]">
<form id="drawPeriodForm" class="space-y-4">
<input type="hidden" id="drawPeriodId" name="id">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
期号
</label>
<p id="drawPeriodNumber" class="text-lg font-bold text-gray-900"></p>
</div>
<!-- Xóc Đĩa 硬币输入 -->
<div id="coinInputSection" class="space-y-3">
<p class="text-sm text-gray-600">选择4个硬币的颜色:</p>
<div class="grid grid-cols-4 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币1</label>
<select id="coin1" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币2</label>
<select id="coin2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币3</label>
<select id="coin3" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">硬币4</label>
<select id="coin4" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white">
<option value="red">红色</option>
<option value="white">白色</option>
</select>
</div>
</div>
</div>
<div id="drawResultPreview" class="hidden p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600 mb-1">开奖结果预览:</p>
<p class="text-lg font-bold">
<span id="drawResultText"></span>
<span id="drawResultTotal" class="ml-2 text-gray-500"></span>
<span id="drawResultType" class="ml-2"></span>
</p>
</div>
<div class="pt-4 border-t border-gray-100">
<button
type="button"
id="submitDrawPeriodBtn"
class="w-full bg-primary hover:bg-primary/90 text-white px-4 py-2.5 rounded-lg shadow hover:shadow-md transition-all duration-200 flex items-center justify-center"
>
<i class="fas fa-check mr-2"></i>
确认录入
</button>
</div>
</form>
</div>
</div>
</div>
<script src="/Static/js/admin.js"></script>
<script>
layui.use(['layer'], function() {
var layer = layui.layer;
// 获取元素
const closeDrawPeriodBtn = document.getElementById('closeDrawPeriodBtn');
const drawPeriodBackdrop = document.getElementById('drawPeriodBackdrop');
const drawPeriodModal = document.getElementById('drawPeriodModal');
const drawPeriodForm = document.getElementById('drawPeriodForm');
const submitDrawPeriodBtn = document.getElementById('submitDrawPeriodBtn');
const coin1 = document.getElementById('coin1');
const coin2 = document.getElementById('coin2');
const coin3 = document.getElementById('coin3');
const coin4 = document.getElementById('coin4');
const drawResultPreview = document.getElementById('drawResultPreview');
const drawResultText = document.getElementById('drawResultText');
const drawResultTotal = document.getElementById('drawResultTotal');
const drawResultType = document.getElementById('drawResultType');
// 打开录入开奖模态框
function openDrawPeriodModal(periodId) {
fetch(`/admin/xocdia-periods/${periodId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
const period = data.data;
document.getElementById('drawPeriodId').value = period.id;
document.getElementById('drawPeriodNumber').textContent = period.period_number;
// 填充已有的硬币数据
if (period.result) {
try {
const coins = JSON.parse(period.result);
if (Array.isArray(coins) && coins.length === 4) {
coin1.value = coins[0];
coin2.value = coins[1];
coin3.value = coins[2];
coin4.value = coins[3];
}
} catch (e) {
// 忽略解析错误
}
}
updateDrawPreview();
drawPeriodBackdrop.classList.remove('opacity-0', 'pointer-events-none');
drawPeriodModal.classList.remove('invisible', 'pointer-events-none', 'scale-95');
drawPeriodModal.classList.add('scale-100');
} else {
layer.msg(data.message || '获取期号信息失败', {icon: 2});
}
})
.catch(e => {
layer.msg('获取期号信息失败:' + e.message, {icon: 2});
});
}
// 关闭录入开奖模态框
function closeDrawPeriodModal() {
drawPeriodBackdrop.classList.add('opacity-0', 'pointer-events-none');
drawPeriodModal.classList.add('invisible', 'pointer-events-none', 'scale-95');
drawPeriodModal.classList.remove('scale-100');
drawPeriodForm.reset();
drawResultPreview.classList.add('hidden');
}
// 更新开奖结果预览
function updateDrawPreview() {
const coins = [coin1.value, coin2.value, coin3.value, coin4.value];
const redCount = coins.filter(c => c === 'red').length;
const whiteCount = 4 - redCount;
let result = '';
let resultClass = '';
// 判断单双
if (redCount === 0 || redCount === 2 || redCount === 4) {
result = 'Chẵn';
resultClass = 'bg-blue-100 text-blue-700 px-2 py-1 rounded text-sm';
} else {
result = 'Lẻ';
resultClass = 'bg-red-100 text-red-700 px-2 py-1 rounded text-sm';
}
// 显示预览
let coinDisplay = coins.map(c =>
`<span class="inline-block w-5 h-5 rounded-full ${c === 'red' ? 'bg-red-500' : 'bg-gray-200'} border border-gray-300 mr-1"></span>`
).join('');
drawResultText.innerHTML = coinDisplay;
drawResultTotal.textContent = `(${redCount}Đ ${whiteCount}T)`;
drawResultType.innerHTML = `<span class="${resultClass}">${result}</span>`;
drawResultPreview.classList.remove('hidden');
}
// 提交录入开奖
async function submitDrawPeriod() {
const coins = [coin1.value, coin2.value, coin3.value, coin4.value];
const data = {
id: document.getElementById('drawPeriodId').value,
auto: false,
coins: coins
};
submitDrawPeriodBtn.disabled = true;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>录入中...';
try {
const response = await fetch('/admin/xocdia-periods/draw', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '开奖结果录入成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '录入失败', {icon: 2});
submitDrawPeriodBtn.disabled = false;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-check mr-2"></i>确认录入';
}
} catch (e) {
layer.msg('录入失败:' + e.message, {icon: 2});
submitDrawPeriodBtn.disabled = false;
submitDrawPeriodBtn.innerHTML = '<i class="fas fa-check mr-2"></i>确认录入';
}
}
// 封盘
async function lockPeriod(id) {
layer.confirm('确定要封盘吗?封盘后将无法继续投注。', {icon: 3, title: '确认封盘'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/xocdia-periods/lock', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({id: id})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '封盘成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '封盘失败', {icon: 2});
}
} catch (e) {
layer.msg('封盘失败:' + e.message, {icon: 2});
}
});
}
// 确认结算
async function settlePeriod(id) {
layer.confirm('确定要确认结算吗?此操作不可撤销。', {icon: 3, title: '确认结算'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/xocdia-periods/settle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({id: id})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '结算成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '结算失败', {icon: 2});
}
} catch (e) {
layer.msg('结算失败:' + e.message, {icon: 2});
}
});
}
// 开始下注
async function startPeriod(event) {
const gameId = event.currentTarget.getAttribute('data-game-id');
if (!gameId) {
layer.msg('游戏ID缺失', {icon: 2});
return;
}
layer.confirm('确定要开始新一期下注吗?', {icon: 3, title: '开始下注'}, async function(index) {
layer.close(index);
try {
const response = await fetch('/admin/xocdia-periods/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({game_id: parseInt(gameId)})
});
const result = await response.json();
if (result.success) {
layer.msg(result.message || '启动成功', {icon: 1}, function() {
location.reload();
});
} else {
layer.msg(result.message || '启动失败', {icon: 2});
}
} catch (e) {
layer.msg('启动失败:' + e.message, {icon: 2});
}
});
}
// 绑定事件
if (closeDrawPeriodBtn) {
closeDrawPeriodBtn.addEventListener('click', closeDrawPeriodModal);
}
if (drawPeriodBackdrop) {
drawPeriodBackdrop.addEventListener('click', closeDrawPeriodModal);
}
if (submitDrawPeriodBtn) {
submitDrawPeriodBtn.addEventListener('click', submitDrawPeriod);
}
// 硬币选择监听
if (coin1 && coin2 && coin3 && coin4) {
[coin1, coin2, coin3, coin4].forEach(select => {
select.addEventListener('change', updateDrawPreview);
});
}
// 事件委托:列表操作按钮
const periodList = document.getElementById('periodList');
if (periodList) {
periodList.addEventListener('click', function(e) {
const lockBtn = e.target.closest('.period-lock-btn');
const drawBtn = e.target.closest('.period-draw-btn');
const settleBtn = e.target.closest('.period-settle-btn');
if (lockBtn) {
const id = lockBtn.getAttribute('data-id');
if (id) lockPeriod(id);
}
if (drawBtn) {
const id = drawBtn.getAttribute('data-id');
if (id) openDrawPeriodModal(id);
}
if (settleBtn) {
const id = settleBtn.getAttribute('data-id');
if (id) settlePeriod(id);
}
});
}
// 当前期号操作按钮
document.querySelectorAll('.period-lock-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) lockPeriod(id);
});
}
});
document.querySelectorAll('.period-draw-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) openDrawPeriodModal(id);
});
}
});
document.querySelectorAll('.period-settle-btn').forEach(btn => {
if (!btn.closest('#periodList')) {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
if (id) settlePeriod(id);
});
}
});
// Start Button
document.querySelectorAll('.period-start-btn').forEach(btn => {
btn.addEventListener('click', startPeriod);
});
});
</script>
BIN
View File
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
<?php /** 底部5栏导航 */ $navActive = $navActive ?? ''; $t = $t ?? function($k){return $k;}; ?>
<nav class="nav-bottom">
<a href="/" class="nav-item <?=$navActive==='game'?'active':''?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/></svg>
<span><?=$t('nav_game')?></span>
</a>
<a href="/lottery" class="nav-item <?=$navActive==='lottery'?'active':''?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>
<span><?=$t('nav_lottery')?></span>
</a>
<a href="/details" class="nav-item <?=$navActive==='details'?'active':''?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span><?=$t('nav_details')?></span>
</a>
<a href="#" class="nav-item <?=$navActive==='chat'?'active':''?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span><?=$t('nav_chat')?></span>
</a>
<a href="/profile" class="nav-item <?=$navActive==='profile'?'active':''?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span><?=$t('nav_profile')?></span>
</a>
</nav>
+263
View File
@@ -0,0 +1,263 @@
<?php use App\Core\I18n; I18n::init(); $t = function($k,$p=[]){return I18n::t($k,$p);}; $page=$page??'home'; ?>
<!DOCTYPE html><html lang="<?=I18n::getLang()?>">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>代理后台</title><script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>body{background:#0f172a;color:#fff;font-family:system-ui}.sidebar a.active{background:rgba(234,179,8,.15);color:#eab308;border-right:3px solid #eab308}</style>
</head>
<body class="min-h-screen flex">
<!-- 侧边栏 -->
<aside class="sidebar w-56 bg-slate-900 border-r border-white/5 min-h-screen hidden md:block shrink-0">
<div class="p-4 border-b border-white/5">
<div class="text-yellow-400 font-bold text-lg">🏎️ 代理后台</div>
<div class="text-white/40 text-xs mt-1"><?=htmlspecialchars($user['username']??'')?></div>
<div class="text-xs mt-1"><span class="text-white/30">邀请码:</span> <span class="text-yellow-400 font-mono"><?=htmlspecialchars($agent['agent_code']??'')?></span></div>
</div>
<nav class="p-2 space-y-1">
<a href="/agent" class="flex items-center gap-3 px-3 py-2 rounded text-sm hover:bg-white/5 <?=$page==='home'?'active':''?>"><i class="fas fa-home w-5 text-center"></i>数据总览</a>
<a href="/agent/odds" class="flex items-center gap-3 px-3 py-2 rounded text-sm hover:bg-white/5 <?=$page==='odds'?'active':''?>"><i class="fas fa-sliders-h w-5 text-center"></i>赔率设置</a>
<a href="/agent/bets" class="flex items-center gap-3 px-3 py-2 rounded text-sm hover:bg-white/5 <?=$page==='bets'?'active':''?>"><i class="fas fa-list-alt w-5 text-center"></i>投注记录</a>
<a href="/agent/commissions" class="flex items-center gap-3 px-3 py-2 rounded text-sm hover:bg-white/5 <?=$page==='commissions'?'active':''?>"><i class="fas fa-coins w-5 text-center"></i>佣金明细</a>
<div class="border-t border-white/5 my-2"></div>
<a href="/" class="flex items-center gap-3 px-3 py-2 rounded text-sm text-white/40 hover:bg-white/5"><i class="fas fa-gamepad w-5 text-center"></i>返回投注</a>
<a href="/logout" class="flex items-center gap-3 px-3 py-2 rounded text-sm text-red-400/60 hover:bg-white/5"><i class="fas fa-sign-out-alt w-5 text-center"></i>退出</a>
</nav>
</aside>
<!-- 手机顶部导航 -->
<div class="md:hidden fixed top-0 left-0 right-0 z-50 bg-slate-900 border-b border-white/5 px-4 py-2 flex items-center justify-between">
<span class="text-yellow-400 font-bold">🏎️ 代理后台</span>
<button onclick="document.getElementById('mobileMenu').classList.toggle('hidden')" class="text-white/60"><i class="fas fa-bars"></i></button>
</div>
<div id="mobileMenu" class="md:hidden fixed top-10 left-0 right-0 z-40 bg-slate-900 border-b border-white/5 hidden">
<div class="flex flex-wrap gap-1 p-2">
<a href="/agent" class="px-3 py-1 rounded text-xs <?=$page==='home'?'bg-yellow-500/20 text-yellow-400':'text-white/60'?>">总览</a>
<a href="/agent/odds" class="px-3 py-1 rounded text-xs <?=$page==='odds'?'bg-yellow-500/20 text-yellow-400':'text-white/60'?>">赔率</a>
<a href="/agent/bets" class="px-3 py-1 rounded text-xs <?=$page==='bets'?'bg-yellow-500/20 text-yellow-400':'text-white/60'?>">投注</a>
<a href="/agent/commissions" class="px-3 py-1 rounded text-xs <?=$page==='commissions'?'bg-yellow-500/20 text-yellow-400':'text-white/60'?>">佣金</a>
<a href="/" class="px-3 py-1 rounded text-xs text-white/40">返回</a>
</div>
</div>
<!-- 主内容 -->
<main class="flex-1 p-4 md:p-6 mt-12 md:mt-0 overflow-auto">
<?php if($page==='home'): ?>
<!-- ========== 数据总览 ========== -->
<h2 class="text-xl font-bold mb-4">📊 数据总览</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="bg-white/5 rounded-xl p-4 text-center">
<div class="text-white/40 text-xs">玩家数</div>
<div class="text-2xl font-bold mt-1"><?=count($players??[])?></div>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<div class="text-white/40 text-xs">总投注额</div>
<div class="text-yellow-400 font-bold text-lg mt-1"><?=number_format($totalBets??0,2)?></div>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<div class="text-white/40 text-xs">今日佣金</div>
<div class="text-green-400 font-bold text-lg mt-1"><?=number_format($todayComm,2)?></div>
</div>
<div class="bg-white/5 rounded-xl p-4 text-center">
<div class="text-white/40 text-xs">累计佣金</div>
<div class="text-green-400 font-bold text-lg mt-1"><?=number_format($totalComm,2)?></div>
</div>
</div>
<!-- 分享链接 -->
<div class="bg-white/5 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold mb-2">📎 邀请链接</h3>
<div class="flex gap-2">
<input type="text" readonly value="<?=($_SERVER['REQUEST_SCHEME']??'http').'://'.$_SERVER['HTTP_HOST']?>/login?invite=<?=htmlspecialchars($agent['agent_code']??'')?>" class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded text-xs text-white font-mono" id="shareLink">
<button onclick="navigator.clipboard.writeText(document.getElementById('shareLink').value);this.textContent='已复制!';setTimeout(()=>this.textContent='复制',1500)" class="px-4 py-2 bg-yellow-500 text-black rounded text-xs font-bold hover:bg-yellow-400">复制</button>
</div>
</div>
<!-- 下级代理 -->
<?php if(!empty($subAgents)): ?>
<div class="bg-white/5 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold mb-3">👥 下级代理</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="text-white/40 text-xs border-b border-white/5"><th class="text-left py-2 px-2">用户名</th><th class="px-2">邀请码</th><th class="px-2">佣金%</th><th class="px-2">玩家数</th><th class="px-2">状态</th></tr></thead>
<tbody>
<?php foreach($subAgents as $sa): ?>
<tr class="border-b border-white/5 text-xs">
<td class="py-2 px-2"><?=htmlspecialchars($sa['user']['username']??'')?></td>
<td class="px-2 text-center font-mono text-yellow-400"><?=$sa['agent_code']?></td>
<td class="px-2 text-center"><?=$sa['commission_rate']?>%</td>
<td class="px-2 text-center"><?=$sa['player_count']?></td>
<td class="px-2 text-center"><?=$sa['status']?'<span class="text-green-400">启用</span>':'<span class="text-red-400">禁用</span>'?></td>
</tr>
<?php endforeach; ?>
</tbody></table></div>
</div>
<?php endif; ?>
<!-- 玩家列表 -->
<div class="bg-white/5 rounded-xl p-4">
<h3 class="text-sm font-bold mb-3">🎮 我的玩家</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="text-white/40 text-xs border-b border-white/5"><th class="text-left py-2 px-2">ID</th><th class="text-left px-2">用户名</th><th class="px-2">余额</th><th class="px-2">注册时间</th><th class="px-2">状态</th></tr></thead>
<tbody>
<?php foreach($players??[] as $p): ?>
<tr class="border-b border-white/5 text-xs">
<td class="py-2 px-2 text-white/40"><?=$p['id']?></td>
<td class="px-2"><?=htmlspecialchars($p['username'])?></td>
<td class="px-2 text-center text-yellow-400"><?=number_format($p['balance'],2)?></td>
<td class="px-2 text-center text-white/40"><?=date('m-d H:i',strtotime($p['created_at']))?></td>
<td class="px-2 text-center"><?=$p['status']?'<span class="text-green-400">✓</span>':'<span class="text-red-400">✗</span>'?></td>
</tr>
<?php endforeach; ?>
<?php if(empty($players)): ?><tr><td colspan="5" class="text-center py-6 text-white/20">暂无玩家</td></tr><?php endif; ?>
</tbody></table></div>
</div>
<?php elseif($page==='odds'): ?>
<!-- ========== 赔率设置 ========== -->
<h2 class="text-xl font-bold mb-4">⚙️ 赔率设置</h2>
<p class="text-white/40 text-xs mb-4">设置你名下玩家的赔率。赔率不能超过上级设定的上限。</p>
<div class="space-y-4">
<?php
$oddsGroups = [
'大小 (Big/Small)' => ['type'=>'bs','targets'=>[]],
'单双 (Odd/Even)' => ['type'=>'oe','targets'=>[]],
'龙虎 (Dragon/Tiger)' => ['type'=>'dt','targets'=>[]],
'冠亚和大小 (Sum BS)' => ['type'=>'sum_bs','targets'=>[]],
];
// 大小
for($r=1;$r<=10;$r++){$oddsGroups['大小 (Big/Small)']['targets'][]=['target'=>"rank{$r}_big",'label'=>"第{$r}名 大"];$oddsGroups['大小 (Big/Small)']['targets'][]=['target'=>"rank{$r}_small",'label'=>"第{$r}名 小"];}
// 单双
for($r=1;$r<=10;$r++){$oddsGroups['单双 (Odd/Even)']['targets'][]=['target'=>"rank{$r}_odd",'label'=>"第{$r}名 单"];$oddsGroups['单双 (Odd/Even)']['targets'][]=['target'=>"rank{$r}_even",'label'=>"第{$r}名 双"];}
// 龙虎
$dtPairs=[[1,10],[2,9],[3,8],[4,7],[5,6]];
foreach($dtPairs as $i=>$p){$n=$i+1;$oddsGroups['龙虎 (Dragon/Tiger)']['targets'][]=['target'=>"dt{$n}_dragon",'label'=>"第{$p[0]}vs{$p[1]} 龙"];$oddsGroups['龙虎 (Dragon/Tiger)']['targets'][]=['target'=>"dt{$n}_tiger",'label'=>"第{$p[0]}vs{$p[1]} 虎"];}
// 冠亚和大小
$oddsGroups['冠亚和大小 (Sum BS)']['targets']=[['target'=>'sum_big','label'=>'和大'],['target'=>'sum_small','label'=>'和小'],['target'=>'sum_odd','label'=>'和单'],['target'=>'sum_even','label'=>'和双']];
?>
<?php foreach($oddsGroups as $groupName=>$group): ?>
<div class="bg-white/5 rounded-xl p-4">
<h3 class="text-sm font-bold mb-3"><?=$groupName?></h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<?php
// 同类型只显示一个代表(大小/单双每个名次赔率相同)
$type=$group['type'];
$firstTarget=$group['targets'][0]['target']??'';
$parentKey=$type.'_'.$firstTarget;
$parentVal=$parentMap[$parentKey]??1.95;
$myVal=$myMap[$parentKey]??$parentVal;
?>
<div class="col-span-full flex items-center gap-3 bg-white/5 rounded p-3">
<span class="text-white/40 text-xs w-24">统一赔率</span>
<span class="text-white/30 text-xs">上限: <?=$parentVal?></span>
<input type="number" step="0.01" min="1" max="<?=$parentVal?>" value="<?=$myVal?>" class="w-24 px-2 py-1 bg-white/10 border border-white/10 rounded text-sm text-white" data-type="<?=$type?>" data-targets='<?=json_encode(array_column($group['targets'],'target'))?>'>
</div>
</div>
</div>
<?php endforeach; ?>
<!-- 名次赔率 -->
<div class="bg-white/5 rounded-xl p-4">
<h3 class="text-sm font-bold mb-3">名次投注 (Rank) — 猜车号</h3>
<?php $parentRank=$parentMap['rank_rank1_1']??9.80; $myRank=$myMap['rank_rank1_1']??$parentRank; ?>
<div class="flex items-center gap-3 bg-white/5 rounded p-3">
<span class="text-white/40 text-xs w-24">统一赔率</span>
<span class="text-white/30 text-xs">上限: <?=$parentRank?></span>
<input type="number" step="0.01" min="1" max="<?=$parentRank?>" value="<?=$myRank?>" class="w-24 px-2 py-1 bg-white/10 border border-white/10 rounded text-sm text-white" id="rankOddsInput" data-type="rank">
</div>
</div>
<button onclick="saveAllOdds()" class="w-full py-3 bg-yellow-500 text-black font-bold rounded-xl hover:bg-yellow-400 text-sm">保存所有赔率</button>
</div>
<script>
async function saveAllOdds(){
const odds=[];
const gameId=<?=$game['id']??0?>;
// 收集各类型
document.querySelectorAll('input[data-type]').forEach(inp=>{
const type=inp.dataset.type;
const val=parseFloat(inp.value);
if(type==='rank'){
// 名次:100个target
for(let r=1;r<=10;r++)for(let c=1;c<=10;c++) odds.push({bet_type:'rank',bet_target:'rank'+r+'_'+c,odds:val});
} else if(inp.dataset.targets){
JSON.parse(inp.dataset.targets).forEach(t=>odds.push({bet_type:type,bet_target:t,odds:val}));
}
});
const r=await fetch('/api/agent/set-odds',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({game_id:gameId,odds:odds})});
const d=await r.json();
alert(d.message||'完成');if(d.success)location.reload();
}
</script>
<?php elseif($page==='bets'): ?>
<!-- ========== 投注记录 ========== -->
<h2 class="text-xl font-bold mb-4">📋 玩家投注记录</h2>
<div class="bg-white/5 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="text-white/40 text-xs border-b border-white/5 bg-white/5">
<th class="text-left py-3 px-3">玩家</th><th class="px-3">期号</th><th class="px-3">下注内容</th><th class="px-3">金额</th><th class="px-3">赔率</th><th class="px-3">状态</th><th class="px-3">赢额</th><th class="px-3">时间</th>
</tr></thead>
<tbody>
<?php foreach($betRecords??[] as $b):
// 投注类型中文转换
$bt=$b['bet_type']; $bv=$b['bet_value']; $label='';
if($bt==='rank'&&preg_match('/^rank(\d+)_(\d+)$/',$bv,$m)){$rn=(int)$m[1];$label=($rn===1?'冠军':($rn===2?'亚军':'第'.$rn.'名')).' '.$m[2].'号车';}
elseif($bt==='bs'&&preg_match('/^rank(\d+)_(big|small)$/',$bv,$m)){$rn=(int)$m[1];$label=($rn===1?'冠军':($rn===2?'亚军':'第'.$rn.'名')).' '.($m[2]==='big'?'大':'小');}
elseif($bt==='oe'&&preg_match('/^rank(\d+)_(odd|even)$/',$bv,$m)){$rn=(int)$m[1];$label=($rn===1?'冠军':($rn===2?'亚军':'第'.$rn.'名')).' '.($m[2]==='odd'?'单':'双');}
elseif($bt==='dt'&&preg_match('/^dt(\d+)_(dragon|tiger)$/',$bv,$m)){$ps=[1=>[1,10],2=>[2,9],3=>[3,8],4=>[4,7],5=>[5,6]];$p=$ps[(int)$m[1]]??[0,0];$label='龙虎 '.$p[0].'vs'.$p[1].' '.($m[2]==='dragon'?'龙':'虎');}
elseif($bt==='sum'&&preg_match('/^sum_(\d+)$/',$bv,$m)){$label='冠亚和 '.$m[1];}
elseif($bt==='sum_bs'){$sm=['sum_big'=>'和大','sum_small'=>'和小','sum_odd'=>'和单','sum_even'=>'和双'];$label=$sm[$bv]??$bv;}
else{$tm=['xiu'=>'小','tai'=>'大','chan'=>'双','le'=>'单','number'=>'点数','dice'=>'单骰','combo'=>'豹子','big_small'=>'大小','odd_even'=>'单双'];$vm=['big'=>'大','small'=>'小','odd'=>'单','even'=>'双','4red'=>'4红','4white'=>'4白','3red1white'=>'3红1白','1red3white'=>'1红3白'];$label=($tm[$bt]??$bt).' '.($vm[$bv]??$bv);}
?>
<tr class="border-b border-white/5 text-xs hover:bg-white/5">
<td class="py-2 px-3"><?=htmlspecialchars($b['username'])?></td>
<td class="px-3 font-mono text-white/40"><?=$b['period_number']?></td>
<td class="px-3"><?=htmlspecialchars($label)?></td>
<td class="px-3 text-yellow-400"><?=number_format($b['amount'],2)?></td>
<td class="px-3"><?=$b['odds']?></td>
<td class="px-3"><?php
$sc=['pending'=>'text-white/40','win'=>'text-green-400','lose'=>'text-red-400','settled'=>'text-blue-400'];
$sl=['pending'=>'待开','win'=>'赢','lose'=>'输','settled'=>'已结'];
echo '<span class="'.($sc[$b['status']]??'').'">'.($sl[$b['status']]??$b['status']).'</span>';
?></td>
<td class="px-3 <?=($b['win_amount']??0)>0?'text-green-400':'text-white/30'?>"><?=number_format($b['win_amount']??0,2)?></td>
<td class="px-3 text-white/30"><?=date('m-d H:i',strtotime($b['created_at']))?></td>
</tr>
<?php endforeach; ?>
<?php if(empty($betRecords)): ?><tr><td colspan="8" class="text-center py-8 text-white/20">暂无投注记录</td></tr><?php endif; ?>
</tbody></table></div>
</div>
<?php elseif($page==='commissions'): ?>
<!-- ========== 佣金明细 ========== -->
<h2 class="text-xl font-bold mb-4">💰 佣金明细</h2>
<div class="bg-white/5 rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="text-white/40 text-xs border-b border-white/5 bg-white/5">
<th class="text-left py-3 px-3">来源玩家</th><th class="px-3">投注额</th><th class="px-3">佣金</th><th class="px-3">类型</th><th class="px-3">时间</th>
</tr></thead>
<tbody>
<?php foreach($records??[] as $r): ?>
<tr class="border-b border-white/5 text-xs hover:bg-white/5">
<td class="py-2 px-3"><?=htmlspecialchars($r['username'])?></td>
<td class="px-3 text-yellow-400"><?=number_format($r['bet_amount'],2)?></td>
<td class="px-3 text-green-400 font-bold"><?=number_format($r['commission'],2)?></td>
<td class="px-3"><?=$r['type']==='bet'?'投注佣金':'反水'?></td>
<td class="px-3 text-white/30"><?=date('m-d H:i',strtotime($r['created_at']))?></td>
</tr>
<?php endforeach; ?>
<?php if(empty($records)): ?><tr><td colspan="5" class="text-center py-8 text-white/20">暂无佣金记录</td></tr><?php endif; ?>
</tbody></table></div>
</div>
<?php endif; ?>
</main>
</body></html>

Some files were not shown because too many files have changed in this diff Show More