From 5fbbbc8804b6c01af7cd6629f2d72382ee7a54c7 Mon Sep 17 00:00:00 2001 From: zoulin Date: Wed, 3 Jun 2026 16:08:08 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_settings.php | 493 ++++++++++++++++++++++++++ assets/css/style.css | 605 ++++++++++++++++++++++++++++++++ bill_records.php | 284 +++++++++++++++ captcha.php | 49 +++ claim_code.php | 119 +++++++ claim_records.php | 367 +++++++++++++++++++ code_manage.php | 372 ++++++++++++++++++++ config/db.php | 23 ++ export_template.php | 16 + includes/auth.php | 89 +++++ includes/footer.php | 17 + includes/functions.php | 105 ++++++ includes/header.php | 60 ++++ index.php | 166 +++++++++ install.sql | 121 +++++++ login.php | 75 ++++ logout.php | 7 + product_manage.php | 230 ++++++++++++ query_code.php | 90 +++++ register.php | 102 ++++++ uploads/6a1eb922067e1.png | Bin 0 -> 4546 bytes work_order_create.php | 147 ++++++++ work_order_create_validation.md | 17 + work_order_manage.php | 228 ++++++++++++ work_order_records.php | 104 ++++++ 25 files changed, 3886 insertions(+) create mode 100755 admin_settings.php create mode 100755 assets/css/style.css create mode 100644 bill_records.php create mode 100755 captcha.php create mode 100755 claim_code.php create mode 100755 claim_records.php create mode 100755 code_manage.php create mode 100755 config/db.php create mode 100755 export_template.php create mode 100755 includes/auth.php create mode 100755 includes/footer.php create mode 100755 includes/functions.php create mode 100755 includes/header.php create mode 100755 index.php create mode 100755 install.sql create mode 100755 login.php create mode 100755 logout.php create mode 100644 product_manage.php create mode 100755 query_code.php create mode 100755 register.php create mode 100644 uploads/6a1eb922067e1.png create mode 100755 work_order_create.php create mode 100644 work_order_create_validation.md create mode 100755 work_order_manage.php create mode 100755 work_order_records.php diff --git a/admin_settings.php b/admin_settings.php new file mode 100755 index 0000000..fee7c6b --- /dev/null +++ b/admin_settings.php @@ -0,0 +1,493 @@ +prepare('INSERT INTO invite_codes (code, max_uses) VALUES (?, ?)'); + for ($i = 0; $i < $count; $i++) { + $code = strtoupper(bin2hex(random_bytes(4))); + try { + $stmt->execute([$code, $maxUses]); + $inserted++; + } catch (PDOException $e) { + } + } + $_SESSION['flash_msg'] = '成功生成 ' . $inserted . ' 个邀请码(最大使用次数: ' . ($maxUses === 0 ? '不限' : $maxUses) . ')'; + $_SESSION['flash_type'] = 'success'; + header('Location: admin_settings.php'); + exit; +} + +// 修改邀请码次数 +if (isset($_POST['edit_max_uses'])) { + if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); } + $id = (int)($_POST['id'] ?? 0); + $maxUses = max(0, (int)($_POST['max_uses'] ?? 1)); + $stmt = $pdo->prepare('UPDATE invite_codes SET max_uses = ? WHERE id = ?'); + $stmt->execute([$maxUses, $id]); + $_SESSION['flash_msg'] = '已更新最大使用次数'; + $_SESSION['flash_type'] = 'success'; + header('Location: admin_settings.php'); + exit; +} + +// 删除邀请码(仅可删未使用的) +if (isset($_GET['del_invite']) && is_numeric($_GET['del_invite'])) { + if (!verifyCsrf($_GET['csrf_token'] ?? '')) { $msg = '
CSRF token无效
'; } + else { + $id = (int)$_GET['del_invite']; + $stmt = $pdo->prepare('DELETE FROM invite_codes WHERE id = ? AND used_count = 0'); + $stmt->execute([$id]); + $msg = $stmt->rowCount() ? '
邀请码已删除
' : '
无法删除:已被使用或不存在
'; + } +} + +// 新增用户 +if (isset($_POST['add_user'])) { + if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); } + $username = trim($_POST['username'] ?? ''); + $password = $_POST['password'] ?? ''; + $role = $_POST['role'] ?? 'user'; + $remark = trim($_POST['remark'] ?? ''); + if (!in_array($role, ['admin', 'user'])) $role = 'user'; + if ($username === '' || $password === '') { + $msg = '
用户名和密码不能为空
'; + } elseif (strlen($username) < 3 || strlen($username) > 20) { + $msg = '
用户名长度3-20个字符
'; + } elseif (strlen($password) < 6) { + $msg = '
密码长度至少6位
'; + } else { + $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?'); + $stmt->execute([$username]); + if ($stmt->fetch()) { + $msg = '
用户名已存在
'; + } else { + $productIds = array_map('intval', $_POST['product_ids'] ?? []); + $productIds = array_filter($productIds, fn($v) => $v > 0); + if (empty($productIds)) { + $msg = '
至少选择一个产品
'; + } else { + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare('INSERT INTO users (username, password, role, remark) VALUES (?, ?, ?, ?)'); + $stmt->execute([$username, $hash, $role, $remark ?: null]); + $userId = (int)$pdo->lastInsertId(); + $stmt = $pdo->prepare('INSERT INTO user_products (user_id, product_id) VALUES (?, ?)'); + foreach ($productIds as $pid) { + $stmt->execute([$userId, $pid]); + } + $_SESSION['flash_msg'] = '用户 ' . h($username) . ' 添加成功'; + $_SESSION['flash_type'] = 'success'; + header('Location: admin_settings.php'); + exit; + } + } + } +} + +// 修改备注 +if (isset($_POST['edit_remark'])) { + if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); } + $id = (int)($_POST['user_id'] ?? 0); + $remark = trim($_POST['remark'] ?? ''); + $stmt = $pdo->prepare('UPDATE users SET remark = ? WHERE id = ?'); + $stmt->execute([$remark ?: null, $id]); + $_SESSION['flash_msg'] = '备注已更新'; + $_SESSION['flash_type'] = 'success'; + header('Location: admin_settings.php'); + exit; +} + +// 分配产品 +if (isset($_POST['edit_user_products'])) { + if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); } + $id = (int)($_POST['user_id'] ?? 0); + $productIds = array_map('intval', $_POST['product_ids'] ?? []); + $productIds = array_filter($productIds, fn($v) => $v > 0); + if (empty($productIds)) { + $msg = '
至少选择一个产品
'; + } else { + $stmt = $pdo->prepare('DELETE FROM user_products WHERE user_id = ?'); + $stmt->execute([$id]); + $stmt = $pdo->prepare('INSERT INTO user_products (user_id, product_id) VALUES (?, ?)'); + foreach ($productIds as $pid) { + $stmt->execute([$id, $pid]); + } + $_SESSION['flash_msg'] = '产品分配已更新'; + $_SESSION['flash_type'] = 'success'; + header('Location: admin_settings.php'); + exit; + } +} + +// 禁用/启用用户 +if (isset($_GET['toggle_disable']) && is_numeric($_GET['toggle_disable'])) { + if (!verifyCsrf($_GET['csrf_token'] ?? '')) { + $_SESSION['flash_msg'] = 'CSRF token无效'; + $_SESSION['flash_type'] = 'danger'; + } else { + $id = (int)$_GET['toggle_disable']; + if ($id === getCurrentUserId()) { + $_SESSION['flash_msg'] = '不能禁用自己'; + $_SESSION['flash_type'] = 'danger'; + } else { + $stmt = $pdo->prepare('SELECT disabled FROM users WHERE id = ?'); + $stmt->execute([$id]); + $current = (int)$stmt->fetchColumn(); + $newDisabled = $current ? 0 : 1; + $stmt = $pdo->prepare('UPDATE users SET disabled = ? WHERE id = ?'); + $stmt->execute([$newDisabled, $id]); + $_SESSION['flash_msg'] = $newDisabled ? '用户已禁用' : '用户已启用'; + $_SESSION['flash_type'] = 'success'; + } + } + header('Location: admin_settings.php'); + exit; +} + +// 修改用户角色 +if (isset($_GET['set_role']) && is_numeric($_GET['set_role'])) { + $id = (int)$_GET['set_role']; + $role = $_GET['role'] ?? ''; + if ($id === getCurrentUserId()) { + $msg = '
不能修改自己的角色
'; + } elseif (!in_array($role, ['admin', 'user'])) { + $msg = '
无效的角色
'; + } else { + $stmt = $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'admin'"); + $adminCount = (int)$stmt->fetchColumn(); + $stmt = $pdo->prepare('SELECT role FROM users WHERE id = ?'); + $stmt->execute([$id]); + $targetRole = $stmt->fetchColumn(); + if ($role === 'user' && $targetRole === 'admin' && $adminCount <= 1) { + $msg = '
至少保留一名管理员
'; + } else { + $stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?'); + $stmt->execute([$role, $id]); + $msg = '
角色已更新
'; + } + } +} + +// 分页 - 邀请码 +$page = max(1, (int)($_GET['p'] ?? 1)); +$perPage = 20; +$offset = ($page - 1) * $perPage; + +$stmt = $pdo->query('SELECT COUNT(*) FROM invite_codes'); +$inviteTotal = (int)$stmt->fetchColumn(); +$invitePages = max(1, ceil($inviteTotal / $perPage)); + +$stmt = $pdo->prepare('SELECT * FROM invite_codes ORDER BY id DESC LIMIT ? OFFSET ?'); +$stmt->execute([$perPage, $offset]); +$invites = $stmt->fetchAll(); + +// 用户列表 +$stmt = $pdo->query('SELECT * FROM users ORDER BY id ASC'); +$users = $stmt->fetchAll(); + +// 所有启用产品 +$stmt = $pdo->query('SELECT id, name, code FROM products WHERE status = 1 ORDER BY id ASC'); +$allProducts = $stmt->fetchAll(); + +// 用户-产品关联 +$userProducts = []; +$stmt = $pdo->query('SELECT user_id, product_id FROM user_products'); +while ($row = $stmt->fetch()) { + $userProducts[$row['user_id']][] = (int)$row['product_id']; +} +?> +
+

邀请码管理

+ +
+ + + +
+ +
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + 0 && $i['used_count'] >= $i['max_uses']; + ?> + + + + + + + + + + + +
ID邀请码使用次数最大次数状态创建时间操作
+ + 已用完 + 0): ?> + 使用中 + + 未使用 + + +
+ 修改次数 + + 删除 + +
+
+
+ +
+ +
+

+ 用户管理 + + 新增用户 +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID用户名角色状态可管理产品备注注册时间操作
+ + 管理员 + + 普通用户 + + + + 已禁用 + + 正常 + + + $p['name'], array_filter($allProducts, fn($p) => in_array((int)$p['id'], $upIds))); + echo $upNames ? h(implode(', ', $upNames)) : '-'; + ?> + + + 20 ? '...' : '' ?> + + - + + ✏️ + +
+ 分配产品 + + + 设为管理员 + + 设为普通用户 + + + 启用 + + 禁用 + + + 当前用户 + +
+
+
+
+ + + + + + + + + + + + + + + diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100755 index 0000000..2def509 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,605 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: #f0f2f5; + color: #333; + min-height: 100vh; +} + +/* Navbar */ +.navbar { + background: #1a1a2e; + color: #fff; + padding: 0 16px; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-container { + max-width: 100%; + margin: 0 auto; + display: flex; + align-items: center; + height: 56px; + flex-wrap: wrap; +} + +.nav-brand { + color: #fff; + font-size: 18px; + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} + +.nav-toggle { + display: none; + background: none; + border: none; + color: #fff; + font-size: 24px; + cursor: pointer; + margin-left: auto; +} + +.nav-menu { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +.nav-link { + color: #ccc; + text-decoration: none; + padding: 8px 12px; + border-radius: 6px; + font-size: 14px; + white-space: nowrap; + transition: background 0.2s, color 0.2s; +} + +.nav-link:hover { + background: rgba(255,255,255,0.12); + color: #fff; +} + +.nav-active, .nav-link.nav-active { + background: rgba(74,108,247,0.35); + color: #fff; + font-weight: 600; +} + +.nav-user { + color: #aaa; + font-size: 14px; + padding: 0 12px; + white-space: nowrap; +} + +.product-switcher { + margin-left: 12px; +} + +.product-switcher select option { + color: #333; + background: #fff; +} + +@media (max-width: 768px) { + .product-switcher { + margin: 6px 0; + width: 100%; + } + .product-switcher select { + width: 100%; + } +} + +.nav-logout { + color: #ff6b6b; +} + +/* Container */ +.container { + max-width: 100%; + margin: 0 auto; + padding: 20px 16px; +} + +/* Card */ +.card { + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + padding: 24px; + margin-bottom: 20px; +} + +.card h2 { + font-size: 20px; + margin-bottom: 16px; + color: #1a1a2e; +} + +/* Tables */ +.table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + position: relative; +} + +table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 14px; + min-width: 700px; +} + +th, td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #eee; + white-space: nowrap; +} + +th { + background: #f8f9fa; + font-weight: 600; + color: #555; +} + +tr:hover td { + background: #f5f5f5; +} + +th:last-child, +td:last-child { + position: sticky; + right: 0; + z-index: 2; +} + +th:last-child { + background: #f8f9fa; + box-shadow: -4px 0 8px rgba(0,0,0,0.06); +} + +td:last-child { + background: #fff; +} + +tr:hover td:last-child { + background: #f5f5f5; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 14px; + color: #555; +} + +.form-control { + width: 100%; + max-width: 500px; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.2s; +} + +.form-control:focus { + outline: none; + border-color: #4a6cf7; + box-shadow: 0 0 0 3px rgba(74,108,247,0.15); +} + +textarea.form-control { + min-height: 100px; + resize: vertical; +} + +select.form-control { + max-width: 300px; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + cursor: pointer; + text-decoration: none; + transition: background 0.2s, opacity 0.2s; +} + +.btn:hover { + opacity: 0.9; +} + +.btn-primary { + background: #4a6cf7; + color: #fff; +} + +.btn-success { + background: #22c55e; + color: #fff; +} + +.btn-danger { + background: #ef4444; + color: #fff; +} + +.btn-warning { + background: #f59e0b; + color: #fff; +} + +.btn-default { + background: #fff; + color: #666; + border: 1px solid #ddd; +} + +.btn-default:hover { + background: #f5f5f5; +} + +.btn-info { + background: #3b82f6; + color: #fff; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.badge-warning { + background: #fef3c7; + color: #92400e; +} + +.badge-success { + background: #dcfce7; + color: #166534; +} + +.badge-danger { + background: #fee2e2; + color: #991b1b; +} + +.badge-info { + background: #dbeafe; + color: #1e40af; +} + +.badge-secondary { + background: #e2e8f0; + color: #475569; +} + +/* Alert */ +.alert { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 14px; +} + +.alert-success { + background: #dcfce7; + color: #166534; + border: 1px solid #bbf7d0; +} + +.alert-danger { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} + +.alert-warning { + background: #fef3c7; + color: #92400e; + border: 1px solid #fde68a; +} + +.alert-info { + background: #dbeafe; + color: #1e40af; + border: 1px solid #bfdbfe; +} + +/* Auth pages */ +.auth-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: #f0f2f5; +} + +.auth-card { + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + padding: 32px; + width: 100%; + max-width: 400px; +} + +.auth-card h1 { + text-align: center; + margin-bottom: 24px; + font-size: 24px; + color: #1a1a2e; +} + +.auth-card .form-control { + max-width: 100%; +} + +.auth-card .btn { + width: 100%; +} + +.auth-card .captcha-row { + display: flex; + gap: 10px; + align-items: center; +} + +.auth-card .captcha-row .form-control { + flex: 1; +} + +.auth-card .captcha-row img { + border-radius: 6px; + cursor: pointer; + height: 42px; +} + +.auth-footer { + text-align: center; + margin-top: 16px; + font-size: 14px; + color: #777; +} + +.auth-footer a { + color: #4a6cf7; + text-decoration: none; +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + margin-top: 16px; + flex-wrap: wrap; +} + +.pagination a, .pagination span:not(.page-jump span) { + display: inline-block; + padding: 6px 12px; + border-radius: 6px; + font-size: 14px; + text-decoration: none; + color: #4a6cf7; + background: #fff; + border: 1px solid #ddd; +} + +.pagination .active { + background: #4a6cf7; + color: #fff; + border-color: #4a6cf7; +} + +.pagination a:hover { + background: #eef2ff; +} + +.pagination span.disabled { + color: #bbb; + background: #f9f9f9; + border-color: #eee; + cursor: not-allowed; +} + +.pagination span.ellipsis { + border: none; + color: #999; + background: transparent; + padding: 6px 4px; +} + +.pagination .page-jump { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + font-size: 13px; + color: #666; +} + +.pagination .page-jump input[type="number"] { + width: 60px; + padding: 5px 6px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + text-align: center; +} + +.pagination .page-jump button { + padding: 5px 10px; + border: 1px solid #4a6cf7; + border-radius: 4px; + background: #4a6cf7; + color: #fff; + cursor: pointer; + font-size: 13px; +} + +.pagination .page-jump button:hover { + background: #3b5de7; +} + +/* Stats grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.stat-card { + background: #fff; + border-radius: 10px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + text-align: center; +} + +.stat-card .stat-value { + font-size: 28px; + font-weight: 700; + color: #1a1a2e; +} + +.stat-card .stat-label { + font-size: 14px; + color: #777; + margin-top: 4px; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 200; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + justify-content: center; + align-items: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: #fff; + border-radius: 12px; + padding: 24px; + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; +} + +.modal-content h3 { + margin-bottom: 16px; +} + +.modal-close { + float: right; + font-size: 24px; + cursor: pointer; + color: #999; +} + +/* Action buttons group */ +.action-group { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* File input */ +.form-control-file { + max-width: 500px; +} + +/* Responsive */ +@media (max-width: 768px) { + .nav-toggle { + display: block; + } + + .nav-menu { + display: none; + width: 100%; + flex-direction: column; + padding: 8px 0 16px; + gap: 2px; + } + + .nav-menu.active { + display: flex; + } + + .nav-link, .nav-user { + width: 100%; + padding: 10px 12px; + } + + .card { + padding: 16px; + } + + th, td { + padding: 8px 10px; + font-size: 13px; + } + + .auth-card { + margin: 16px; + padding: 24px; + } + + .action-group { + flex-direction: column; + } + + .action-group .btn-sm { + width: 100%; + text-align: center; + } +} diff --git a/bill_records.php b/bill_records.php new file mode 100644 index 0000000..d8c5057 --- /dev/null +++ b/bill_records.php @@ -0,0 +1,284 @@ + 0 && in_array($sStatus, ['未结算', '已结算', '已作废'])) { + $stmt = $pdo->prepare('INSERT INTO bill_status (bill_date, user_id, status, updated_at, updated_by) VALUES (?, ?, ?, NOW(), ?) ON DUPLICATE KEY UPDATE status = ?, updated_at = NOW(), updated_by = ?'); + $stmt->execute([$sDate, $sUserId, $sStatus, getCurrentUserId(), $sStatus, getCurrentUserId()]); + $_SESSION['flash_msg'] = '账单状态已更新'; + $_SESSION['flash_type'] = 'success'; + $queryRedirect = []; + $filterUserId = $userId; // 保留原始筛选条件 + if ($filterUserId > 0) $queryRedirect[] = 'user_id=' . $filterUserId; + if ($sStartDate) $queryRedirect[] = 'start_date=' . $sStartDate; + if ($sEndDate) $queryRedirect[] = 'end_date=' . $sEndDate; + header('Location: bill_records.php' . ($queryRedirect ? '?' . implode('&', $queryRedirect) : '')); + exit; + } else { + $statusMsg = '
参数错误
'; + } + } +} + +// 详情模式 +if ($detailDate) { + $pid = getCurrentProductId(); + $where = 'WHERE r.status = 2 AND r.product_id = ? AND DATE(r.used_at) = ?'; + $params = [$pid, $detailDate]; + if ($detailUserId > 0) { + $where .= ' AND r.user_id = ?'; + $params[] = $detailUserId; + } + + $stmt = $pdo->prepare("SELECT r.*, u.username FROM claim_records r LEFT JOIN users u ON r.user_id = u.id $where ORDER BY r.id ASC"); + $stmt->execute($params); + $details = $stmt->fetchAll(); + + // 读取该日该用户的账单状态 + $stmt = $pdo->prepare("SELECT status FROM bill_status WHERE bill_date = ? AND user_id = ?"); + $stmt->execute([$detailDate, $detailUserId]); + $billStatus = $stmt->fetchColumn() ?: '未结算'; +?> +
+

+ 账单明细 - + 0 && isset($details[0]['username'])): ?> + () + + 状态: +

+
+ ← 返回 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID兑换码名称批次号兑换码面值使用时间会员ID用户出货价一档出货价二挡备注
暂无数据
+
+
+ 0) { + $where .= ' AND r.user_id = ?'; + $params[] = $userId; +} +if ($startDate) { + $where .= ' AND DATE(r.used_at) >= ?'; + $params[] = $startDate; +} +if ($endDate) { + $where .= ' AND DATE(r.used_at) <= ?'; + $params[] = $endDate; +} + +$stmt = $pdo->prepare("SELECT COUNT(DISTINCT DATE(r.used_at)" . ($userId > 0 ? "" : ", r.user_id") . ") FROM claim_records r $where"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); +$totalPages = max(1, ceil($total / $perPage)); + +$groupBy = $userId > 0 ? 'DATE(r.used_at)' : 'DATE(r.used_at), r.user_id'; +$selectCols = $userId > 0 + ? "DATE(r.used_at) AS bill_date, r.user_id AS uid, COUNT(*) AS total, SUM(COALESCE(r.price_tier1, 0)) AS total_price1, SUM(COALESCE(r.price_tier2, 0)) AS total_price2" + : "DATE(r.used_at) AS bill_date, r.user_id AS uid, u.username, COUNT(*) AS total, SUM(COALESCE(r.price_tier1, 0)) AS total_price1, SUM(COALESCE(r.price_tier2, 0)) AS total_price2"; + +$stmt = $pdo->prepare("SELECT $selectCols FROM claim_records r LEFT JOIN users u ON r.user_id = u.id $where GROUP BY $groupBy ORDER BY bill_date DESC" . ($userId > 0 ? '' : ', u.username ASC') . " LIMIT ? OFFSET ?"); +$stmt->execute(array_merge($params, [$perPage, $offset])); +$records = $stmt->fetchAll(); + +// 批量查询账单状态 +$statusMap = []; +if (!empty($records)) { + $cases = []; + foreach ($records as $r) { + $uid = $userId > 0 ? $userId : $r['uid']; + $cases[] = "('" . $r['bill_date'] . "', " . (int)$uid . ")"; + } + if (!empty($cases)) { + $stmt = $pdo->query("SELECT CONCAT(bill_date, '_', user_id) AS k, status FROM bill_status WHERE (bill_date, user_id) IN (" . implode(',', $cases) . ")"); + while ($row = $stmt->fetch()) { + $statusMap[$row['k']] = $row['status']; + } + } +} + +function billStatusBadge(string $status): string { + switch ($status) { + case '未结算': return '未结算'; + case '已结算': return '已结算'; + case '已作废': return '已作废'; + default: return '未知'; + } +} + +$allUsers = $isAdmin ? $pdo->query('SELECT id, username FROM users ORDER BY id ASC')->fetchAll() : []; + +// 构建筛选条件 URL 参数 +$queryParams = []; +if ($userId > 0) $queryParams['user_id'] = $userId; +if ($startDate) $queryParams['start_date'] = $startDate; +if ($endDate) $queryParams['end_date'] = $endDate; +?> +
+

账单管理

+ +
+ + + + +
+ + + + + + + + 0): ?> + 清空 + +
+ +
+ + + + + + + 清空 + +
+ +
+ + + + + + + + + + + + + + + 0 ? $userId : $r['uid']; + $statusKey = $r['bill_date'] . '_' . $uid; + $billStatus = $statusMap[$statusKey] ?? '未结算'; + ?> + + + + + + + + + + + + + + + + +
日期用户使用数量出货价一档合计出货价二挡合计状态操作
0 ? number_format($r['total_price1'], 2) : '-' ?> 0 ? number_format($r['total_price2'], 2) : '-' ?> +
+ + + + + + + +
+
暂无数据
+
+ 0) $extra['user_id'] = $userId; + if ($startDate) $extra['start_date'] = $startDate; + if ($endDate) $extra['end_date'] = $endDate; + renderPagination($page, $totalPages, $extra); + ?> +
+ \ No newline at end of file diff --git a/captcha.php b/captcha.php new file mode 100755 index 0000000..c014b08 --- /dev/null +++ b/captcha.php @@ -0,0 +1,49 @@ +prepare("SELECT value, COUNT(*) AS cnt FROM redemption_codes WHERE status = 1 AND (expired_at IS NULL OR expired_at > NOW()) AND product_id = ? GROUP BY value ORDER BY value ASC"); +$stmt->execute([$pid]); +$valueOptions = $stmt->fetchAll(); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); } + $codeValue = trim($_POST['code_value'] ?? ''); + $quantity = max(1, min(100, (int)($_POST['quantity'] ?? 1))); + + // 校验面值是否有效 + $validValues = array_map(fn($v) => $v['value'], $valueOptions); + if (!in_array($codeValue, $validValues, true)) { + $msg = '
无效的面值
'; + } else { + // 检查当前用户未使用兑换码数量 + $stmt = $pdo->prepare("SELECT COUNT(*) FROM claim_records WHERE user_id = ? AND product_id = ? AND status = 1"); + $stmt->execute([getCurrentUserId(), $pid]); + $unusedCount = (int)$stmt->fetchColumn(); + if ($unusedCount >= 200) { + $msg = '
您当前未使用的兑换码已达 ' . $unusedCount . ' 条(上限200条),请先使用后再领取
'; + } else { + // 检查频繁领取(同面值距上一次领取不足3天) + $stmt = $pdo->prepare("SELECT MAX(claimed_at) FROM claim_records WHERE user_id = ? AND product_id = ? AND value = ?"); + $stmt->execute([getCurrentUserId(), $pid, $codeValue]); + $lastClaim = $stmt->fetchColumn(); + if ($lastClaim && strtotime($lastClaim) > strtotime('-3 days')) { + $remaining = ceil((strtotime($lastClaim) + 3 * 86400 - time()) / 3600); + $msg = '
当前面值兑换码距上次领取不足3天,请 ' . $remaining . ' 小时后再领取
'; + } else { + $pdo->beginTransaction(); + try { + // 查找指定面值的可领取兑换码 +$stmt = $pdo->prepare('SELECT * FROM redemption_codes WHERE value = ? AND status = 1 AND (expired_at IS NULL OR expired_at > NOW()) AND product_id = ? ORDER BY id ASC LIMIT ? FOR UPDATE'); +$stmt->execute([$codeValue, $pid, $quantity]); + $available = $stmt->fetchAll(); + + if (count($available) < $quantity) { + $msg = '
该面值的可领取兑换码不足,当前仅有 ' . count($available) . ' 个可用
'; + $pdo->rollBack(); + } else { + $claimed = 0; + foreach ($available as $code) { + $stmt = $pdo->prepare('UPDATE redemption_codes SET status = 2, claim_user_id = ?, claimed_at = NOW() WHERE id = ?'); + $stmt->execute([getCurrentUserId(), $code['id']]); + +$stmt = $pdo->prepare('INSERT INTO claim_records (product_id, created_at, user_id, code_name, batch_no, code_type, code, value, status, expired_at, price_tier1, price_tier2, claim_user, claimed_at) VALUES (?, NOW(), ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, NOW())'); +$stmt->execute([ + $code['product_id'], + getCurrentUserId(), + $code['name'], + $code['batch_no'], + $code['type'], + $code['code'], + $code['value'], + $code['expired_at'], + $code['price_tier1'], + $code['price_tier2'], + getCurrentUsername() +]); + $claimed++; + } + $pdo->commit(); + $_SESSION['flash_msg'] = '成功领取 ' . $claimed . ' 个兑换码!'; + $_SESSION['flash_type'] = 'success'; + redirect('claim_code.php'); + } + } catch (Exception $e) { + $pdo->rollBack(); + $msg = '
领取失败,请重试
'; + } + } + } + } + } + +$totalAvailable = array_sum(array_column($valueOptions, 'cnt')); +?> +
+

兑换码领取

+ +
+ + + +
+ +
+ + +
+
+ + +
当前共计可领取: 个,单次最多100个
+
+ + +
+
+ diff --git a/claim_records.php b/claim_records.php new file mode 100755 index 0000000..13bbd86 --- /dev/null +++ b/claim_records.php @@ -0,0 +1,367 @@ +prepare("SELECT r.id, r.code_name, r.batch_no, r.code, r.value, r.status, r.expired_at FROM claim_records r $where ORDER BY r.id DESC"); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename=claim_records.csv'); + echo "\xEF\xBB\xBF"; + $f = fopen('php://output', 'w'); + fputcsv($f, ['ID', '兑换码名称', '批次号', '兑换码', '面值', '状态', '过期时间']); + foreach ($rows as $r) { + $statusMap = [1 => '未使用', 2 => '已使用', 3 => '已过期']; + fputcsv($f, [ + $r['id'], + $r['code_name'], + $r['batch_no'], + (int)$r['status'] === 3 ? maskCode($r['code']) : $r['code'], + $r['value'], + $statusMap[(int)$r['status']] ?? '未知', + $r['expired_at'] ? date('Y-m-d H:i:s', strtotime($r['expired_at'])) : '', + ]); + } + fclose($f); + exit; +} + +$pageTitle = '兑换码领取记录'; +require __DIR__ . '/includes/header.php'; + +$isAdmin = isAdmin(); +$msg = ''; + +// 管理员:修改状态 +if ($isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); } + $id = (int)($_POST['id'] ?? 0); + if ($_POST['action'] === 'edit_status') { + $newStatus = (int)($_POST['status'] ?? 0); + $usedAt = ($_POST['used_at'] ?? '') !== '' ? str_replace('T', ' ', $_POST['used_at']) . ':00' : ''; + $userId = (int)($_POST['user_id'] ?? 0); + $usedUserId = trim($_POST['used_user_id'] ?? ''); + if ($newStatus < 1 || $newStatus > 3) { + $msg = '
无效的状态值
'; + } elseif ($newStatus === 2 && $usedUserId === '') { + $msg = '
状态设为已使用时,必须填写会员ID
'; + } else { + $update = ['status = ?']; + $params = [$newStatus]; + if ($usedAt !== '') { $update[] = 'used_at = ?'; $params[] = $usedAt; } + if ($userId > 0) { $update[] = 'user_id = ?'; $params[] = $userId; } + if ($usedUserId !== '') { $update[] = 'used_user_id = ?'; $params[] = $usedUserId; } + $params[] = $id; + $stmt = $pdo->prepare('UPDATE claim_records SET ' . implode(', ', $update) . ' WHERE id = ?'); + $stmt->execute($params); + $_SESSION['flash_msg'] = '状态已更新'; + $_SESSION['flash_type'] = 'success'; + header('Location: claim_records.php'); + exit; + } + } elseif ($_POST['action'] === 'manual_add') { + $codeName = trim($_POST['code_name'] ?? ''); + $batchNo = trim($_POST['batch_no'] ?? ''); + $codeType = (int)($_POST['code_type'] ?? 1); + $code = trim($_POST['code'] ?? ''); + $value = trim($_POST['value'] ?? ''); + $expiredAt = $_POST['expired_at'] ?: null; + $userId = (int)($_POST['user_id'] ?? 0); + $claimedAt = ($_POST['claimed_at'] ?? '') !== '' ? str_replace('T', ' ', $_POST['claimed_at']) . ':00' : null; + $price1 = $_POST['price_tier1'] !== '' ? (float)$_POST['price_tier1'] : null; + $price2 = $_POST['price_tier2'] !== '' ? (float)$_POST['price_tier2'] : null; + $recStatus = (int)($_POST['rec_status'] ?? 1); + if (!in_array($recStatus, [1, 2, 3])) $recStatus = 1; + $usedUserId = trim($_POST['used_user_id'] ?? ''); + $usedAt = ($_POST['used_at'] ?? '') !== '' ? str_replace('T', ' ', $_POST['used_at']) . ':00' : null; + if (!$codeName || !$batchNo || !$code) { + $msg = '
兑换码名称、批次号、兑换码为必填项
'; + } else { + $stmt = $pdo->prepare('INSERT INTO claim_records (product_id, created_at, user_id, code_name, batch_no, code_type, code, value, status, expired_at, price_tier1, price_tier2, claimed_at, used_user_id, used_at, remark) VALUES (?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + $stmt->execute([getCurrentProductId(), $userId ?: null, $codeName, $batchNo, $codeType, $code, $value, $recStatus, $expiredAt, $price1, $price2, $claimedAt, $usedUserId ?: null, $usedAt, '手动添加']); + $_SESSION['flash_msg'] = '手动添加成功'; + $_SESSION['flash_type'] = 'success'; + header('Location: claim_records.php'); + exit; + } + } +} + +// 分页 + 条件 +$page = max(1, (int)($_GET['p'] ?? 1)); +$perPage = 20; +$offset = ($page - 1) * $perPage; + +$pid = getCurrentProductId(); +$where = 'WHERE r.product_id = ?'; +$searchParams = [$pid]; + +if (!$isAdmin) { + $where .= ' AND r.user_id = ?'; + $searchParams[] = getCurrentUserId(); +} + +if (!empty($_GET['search'])) { + $search = '%' . $_GET['search'] . '%'; + $where .= ' AND (r.code LIKE ? OR r.batch_no LIKE ? OR r.code_name LIKE ?)'; + $searchParams = array_merge($searchParams, [$search, $search, $search]); +} + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM claim_records r $where"); +$stmt->execute($searchParams); +$total = (int)$stmt->fetchColumn(); +$totalPages = max(1, ceil($total / $perPage)); + +$stmt = $pdo->prepare("SELECT r.*, u.username FROM claim_records r LEFT JOIN users u ON r.user_id = u.id $where ORDER BY r.id DESC LIMIT ? OFFSET ?"); +$stmt->execute(array_merge($searchParams, [$perPage, $offset])); +$records = $stmt->fetchAll(); +?> +
+

兑换码领取记录

+ +
+ + + +
+ 兑换码领取 + + + 手动添加 + + 下载 CSV +
+ + + + 清空 + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID创建时间兑换码名称批次号类型兑换码面值状态过期时间使用时间会员ID用户领取时间出货价一档出货价二挡备注操作
+ 修改状态 +
暂无数据
+
+ $_GET['search']] : []); ?> +
+ +query('SELECT id, username FROM users ORDER BY id ASC')->fetchAll(); +if ($isAdmin): +?> + + + + + + + diff --git a/code_manage.php b/code_manage.php new file mode 100755 index 0000000..aca8405 --- /dev/null +++ b/code_manage.php @@ -0,0 +1,372 @@ +仅支持 CSV、XLS、XLSX 格式'; + } else { + $imported = 0; + $errors = []; + + if ($ext === 'csv') { + $handle = fopen($_FILES['file']['tmp_name'], 'r'); + if ($handle) { + $lineNo = 0; + while (($row = fgetcsv($handle)) !== false) { + $lineNo++; + if ($lineNo === 1) continue; // 跳过表头 + + if (count($row) < 5) { + $errors[] = "第{$lineNo}行: 列数不足(至少需要5列)"; + continue; + } + + $name = trim($row[0]); + $batchNo = trim($row[1]); + $type = (int)trim($row[2]); + $code = trim($row[3]); + $value = trim($row[4]); + $expiredAt = isset($row[5]) ? trim($row[5]) : ''; + $price1 = isset($row[6]) && $row[6] !== '' ? (float)trim($row[6]) : null; + $price2 = isset($row[7]) && $row[7] !== '' ? (float)trim($row[7]) : null; + + if (!$name || !$batchNo || !$code) { + $errors[] = "第{$lineNo}行: 兑换码名称、批次号、兑换码为必填项"; + continue; + } + if ($type !== 1) { + $errors[] = "第{$lineNo}行: 兑换码类型必须为1,当前为{$type}"; + continue; + } + + $stmt = $pdo->prepare('INSERT INTO redemption_codes (product_id, created_at, user_id, name, batch_no, type, code, value, expired_at, price_tier1, price_tier2) VALUES (?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), batch_no = VALUES(batch_no), type = VALUES(type), value = VALUES(value), expired_at = VALUES(expired_at), price_tier1 = VALUES(price_tier1), price_tier2 = VALUES(price_tier2)'); + $stmt->execute([$pid, getCurrentUserId(), $name, $batchNo, $type, $code, $value, $expiredAt ?: null, $price1, $price2]); + $imported++; + } + fclose($handle); + } + } elseif ($ext === 'xlsx') { + $rows = readXlsx($_FILES['file']['tmp_name']); + if ($rows === false) { + $errors[] = '无法解析 XLSX 文件,请确认文件未损坏'; + } else { + foreach ($rows as $lineNo => $row) { + if ($lineNo === 0) continue; // 跳过表头 + $ln = $lineNo + 1; + if (count($row) < 5) { + $errors[] = "第{$ln}行: 列数不足(至少需要5列)"; + continue; + } + $name = trim($row[0] ?? ''); + $batchNo = trim($row[1] ?? ''); + $type = (int)($row[2] ?? 0); + $code = trim($row[3] ?? ''); + $value = trim($row[4] ?? ''); + $expiredAt = isset($row[5]) ? trim($row[5]) : ''; + $price1 = isset($row[6]) && $row[6] !== '' ? (float)$row[6] : null; + $price2 = isset($row[7]) && $row[7] !== '' ? (float)$row[7] : null; + if (!$name || !$batchNo || !$code) { + $errors[] = "第{$ln}行: 兑换码名称、批次号、兑换码为必填项"; + continue; + } + if ($type !== 1) { + $errors[] = "第{$ln}行: 兑换码类型必须为1,当前为{$type}"; + continue; + } + $stmt = $pdo->prepare('INSERT INTO redemption_codes (product_id, created_at, user_id, name, batch_no, type, code, value, expired_at, price_tier1, price_tier2) VALUES (?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), batch_no = VALUES(batch_no), type = VALUES(type), value = VALUES(value), expired_at = VALUES(expired_at), price_tier1 = VALUES(price_tier1), price_tier2 = VALUES(price_tier2)'); + $stmt->execute([$pid, getCurrentUserId(), $name, $batchNo, $type, $code, $value, $expiredAt ?: null, $price1, $price2]); + $imported++; + } + } + } else { + $errors[] = 'XLS(旧版Excel格式)不支持,请另存为 XLSX 或 CSV 格式后导入'; + } + + $flashMsg = ''; + if ($imported > 0) { + $flashMsg = '成功导入 ' . $imported . ' 条记录'; + if (!empty($errors)) $flashMsg .= ',' . count($errors) . ' 条失败'; + } + if (!empty($errors) && $imported > 0) { + $flashMsg .= "\n" . implode("\n", array_slice($errors, 0, 20)); + $_SESSION['flash_msg'] = $flashMsg; + $_SESSION['flash_type'] = 'success'; + header('Location: code_manage.php'); + exit; + } elseif ($imported > 0) { + $_SESSION['flash_msg'] = $flashMsg; + $_SESSION['flash_type'] = 'success'; + header('Location: code_manage.php'); + exit; + } elseif (!empty($errors)) { + $msg = '
' . h(implode("\n", array_slice($errors, 0, 20))) . '
'; + } + } + } else { + $msg = '
请选择文件
'; + } +} + +// 删除单个兑换码 +if (isset($_GET['del']) && is_numeric($_GET['del'])) { + if (!verifyCsrf($_GET['csrf_token'] ?? '')) { $_SESSION['flash_msg'] = 'CSRF token无效'; $_SESSION['flash_type'] = 'danger'; header('Location: code_manage.php'); exit; } + $id = (int)$_GET['del']; + $pdo->prepare('DELETE FROM redemption_codes WHERE id = ?')->execute([$id]); + $_SESSION['flash_msg'] = '兑换码已删除'; + $_SESSION['flash_type'] = 'success'; + header('Location: code_manage.php'); + exit; +} + +// 搜索条件 +$where = 'WHERE c.product_id = ?'; +$searchParams = [$pid]; +$joinUser = false; +if (!empty($_GET['claim_user'])) { + $where .= ' AND u.username LIKE ?'; + $searchParams[] = '%' . $_GET['claim_user'] . '%'; + $joinUser = true; +} +foreach (['code' => 'c.code', 'name' => 'c.name', 'batch_no' => 'c.batch_no'] as $key => $col) { + if (!empty($_GET[$key])) { + $where .= " AND $col LIKE ?"; + $searchParams[] = '%' . $_GET[$key] . '%'; + } +} + +// 分页 +$page = max(1, (int)($_GET['p'] ?? 1)); +$perPage = 20; +$offset = ($page - 1) * $perPage; + +$join = 'LEFT JOIN users u ON c.claim_user_id = u.id'; +$stmt = $pdo->prepare("SELECT COUNT(*) FROM redemption_codes c $join $where"); +$stmt->execute($searchParams); +$total = (int)$stmt->fetchColumn(); +$totalPages = max(1, ceil($total / $perPage)); + +$stmt = $pdo->prepare("SELECT c.*, u.username AS claim_username FROM redemption_codes c $join $where ORDER BY c.id DESC LIMIT ? OFFSET ?"); +$stmt->execute(array_merge($searchParams, [$perPage, $offset])); +$codes = $stmt->fetchAll(); +?> +
+

库存管理

+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + 清空 + +
+
+ + Excel导入 + 下载导入模板 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID创建时间名称批次号类型兑换码面值状态过期时间用户领取时间出货价一档出货价二挡操作
删除
暂无数据
+
+ +
+ + + + + +open($file) !== true) return false; + + // 读取共享字符串表 + $sharedStrings = []; + $ssXml = $zip->getFromName('xl/sharedStrings.xml'); + if ($ssXml !== false) { + $ss = simplexml_load_string($ssXml); + if ($ss) { + foreach ($ss->si as $si) { + $t = $si->t; + if ($t) { + $sharedStrings[] = (string)$t; + } else { + // 富文本,拼接所有 t 子元素 + $parts = []; + foreach ($si->r->t as $rT) { + $parts[] = (string)$rT; + } + $sharedStrings[] = implode('', $parts); + } + } + } + } + + // 读取第一个工作表 + $sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml'); + $zip->close(); + if ($sheetXml === false) return false; + + $sheet = simplexml_load_string($sheetXml); + if (!$sheet) return false; + + $ns = $sheet->getNamespaces(true); + $sheet->registerXPathNamespace('s', $ns[''] ?? ''); + + $rows = []; + $maxCol = 0; + $cells = $sheet->xpath('//s:sheetData/s:row/s:c') ?: []; + + // 第一遍:收集所有数据 + $data = []; + foreach ($cells as $c) { + $ref = (string)$c['r']; + if (!preg_match('/^([A-Z]+)(\d+)$/', $ref, $m)) continue; + $colStr = $m[1]; + $rowNum = (int)$m[2]; + + // 列字母转数字 + $colNum = 0; + for ($i = 0; $i < strlen($colStr); $i++) { + $colNum = $colNum * 26 + (ord($colStr[$i]) - 64); + } + + $type = (string)$c['t']; + $value = (string)$c->v; + + if ($type === 's') { + $idx = (int)$value; + $cellValue = $sharedStrings[$idx] ?? ''; + } else { + $cellValue = $value; + } + + if (!isset($data[$rowNum])) $data[$rowNum] = []; + $data[$rowNum][$colNum] = $cellValue; + if ($colNum > $maxCol) $maxCol = $colNum; + } + + // 按行排序输出 + ksort($data); + foreach ($data as $rowNum => $cols) { + $row = []; + for ($c = 1; $c <= $maxCol; $c++) { + $row[] = $cols[$c] ?? ''; + } + $rows[] = $row; + } + + return $rows; +} + +require __DIR__ . '/includes/footer.php'; ?> diff --git a/config/db.php b/config/db.php new file mode 100755 index 0000000..564db59 --- /dev/null +++ b/config/db.php @@ -0,0 +1,23 @@ + true, + 'samesite' => 'Lax', + 'secure' => isset($_SERVER['HTTPS']), +]); +session_start(); + +$db_host = '127.0.0.1'; +$db_port = '3306'; +$db_name = 'coupon_wenyitu'; +$db_user = 'coupon_wenyitu'; +$db_pass = 'xhEXhtG5cNEiWRKT'; + +try { + $pdo = new PDO("mysql:host=$db_host;port=$db_port;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); +} catch (PDOException $e) { + die('数据库连接失败: ' . $e->getMessage()); +} \ No newline at end of file diff --git a/export_template.php b/export_template.php new file mode 100755 index 0000000..3db608e --- /dev/null +++ b/export_template.php @@ -0,0 +1,16 @@ + 0) { + if (isAdmin()) { + $stmt = $pdo->prepare('SELECT id FROM products WHERE id = ? AND status = 1'); + $stmt->execute([$pid]); + } else if (isset($_SESSION['user_id'])) { + $stmt = $pdo->prepare('SELECT p.id FROM products p INNER JOIN user_products up ON p.id = up.product_id WHERE p.id = ? AND p.status = 1 AND up.user_id = ?'); + $stmt->execute([$pid, $_SESSION['user_id']]); + } + if ($stmt && $stmt->fetch()) { + $_SESSION['current_product_id'] = $pid; + } + } + $url = strtok($_SERVER['REQUEST_URI'], '?'); + $params = $_GET; + unset($params['set_product']); + if ($params) { + $url .= '?' . http_build_query($params); + } + header("Location: $url"); + exit; +} + +function isLoggedIn(): bool { + return isset($_SESSION['user_id']); +} + +function requireLogin(): void { + if (!isLoggedIn()) { + header('Location: login.php'); + exit; + } +} + +function isAdmin(): bool { + return isset($_SESSION['role']) && $_SESSION['role'] === 'admin'; +} + +function requireAdmin(): void { + requireLogin(); + if (!isAdmin()) { + header('Location: index.php'); + exit; + } +} + +function getCurrentUserId(): ?int { + return $_SESSION['user_id'] ?? null; +} + +function getCurrentUsername(): ?string { + return $_SESSION['username'] ?? null; +} + +function getCurrentProductId(): ?int { + $pid = $_SESSION['current_product_id'] ?? null; + if ($pid) { + $products = getEnabledProducts(); + $ids = array_column($products, 'id'); + if (!in_array($pid, $ids)) { + unset($_SESSION['current_product_id']); + $pid = null; + } + } + if (!$pid) { + $products = getEnabledProducts(); + if (!empty($products)) { + $_SESSION['current_product_id'] = (int)$products[0]['id']; + $pid = (int)$products[0]['id']; + } + } + return $pid; +} + +function getEnabledProducts(): array { + global $pdo; + if (isAdmin()) { + $stmt = $pdo->query('SELECT id, name FROM products WHERE status = 1 ORDER BY id ASC'); + } else { + $stmt = $pdo->prepare('SELECT p.id, p.name FROM products p INNER JOIN user_products up ON p.id = up.product_id WHERE up.user_id = ? AND p.status = 1 ORDER BY p.id ASC'); + $stmt->execute([getCurrentUserId()]); + } + return $stmt->fetchAll(); +} diff --git a/includes/footer.php b/includes/footer.php new file mode 100755 index 0000000..76393d6 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,17 @@ + + + + diff --git a/includes/functions.php b/includes/functions.php new file mode 100755 index 0000000..a777220 --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,105 @@ +未使用'; + case 2: return '已使用'; + case 3: return '已过期'; + default: return '未知'; + } +} + +function claimStatusBadge(int $status): string { + switch ($status) { + case 1: return '未领取'; + case 2: return '已领取'; + default: return '未知'; + } +} + +function workOrderStatusBadge(string $status): string { + switch ($status) { + case '未处理': return '未处理'; + case '已处理': return '已处理'; + case '已驳回': return '已驳回'; + default: return '未知'; + } +} + +function formatDateTime(?string $datetime): string { + if (!$datetime || $datetime === '0000-00-00 00:00:00') return '-'; + return date('Y-m-d H:i:s', strtotime($datetime)); +} + +function renderPagination(int $current, int $total, array $extra = []): void { + if ($total <= 1) return; + $buildUrl = function(int $page) use ($extra): string { + $params = $extra; + $params['p'] = $page; + return '?' . http_build_query($params); + }; + ?> + + + + + + + <?= h($pageTitle ?? '兑换码管理系统') ?> + + + + + +
+ diff --git a/index.php b/index.php new file mode 100755 index 0000000..13f077d --- /dev/null +++ b/index.php @@ -0,0 +1,166 @@ +prepare('SELECT password FROM users WHERE id = ?'); + $stmt->execute([getCurrentUserId()]); + $stored = $stmt->fetchColumn(); + if (!password_verify($oldPwd, $stored)) { + $pwdMsg = '当前密码错误'; + } else { + $hash = password_hash($newPwd, PASSWORD_DEFAULT); + $stmt = $pdo->prepare('UPDATE users SET password = ? WHERE id = ?'); + $stmt->execute([$hash, getCurrentUserId()]); + $_SESSION['flash_msg'] = '密码修改成功'; + $_SESSION['flash_type'] = 'success'; + header('Location: index.php'); + exit; + } + } +} + +$pageTitle = '首页'; +require __DIR__ . '/includes/header.php'; + +// 统计数据 +$stats = []; +$pid = getCurrentProductId(); + +// 总兑换码数量 +$stmt = $pdo->prepare('SELECT COUNT(*) as total FROM redemption_codes WHERE product_id = ?'); +$stmt->execute([$pid]); +$stats['total_codes'] = $stmt->fetch()['total']; + +// 已领取数量 +$stmt = $pdo->prepare('SELECT COUNT(*) as total FROM redemption_codes WHERE status = 2 AND product_id = ?'); +$stmt->execute([$pid]); +$stats['claimed_codes'] = $stmt->fetch()['total']; + +if (isAdmin()) { + $stmt = $pdo->prepare('SELECT COUNT(*) as total FROM claim_records WHERE product_id = ?'); + $stmt->execute([$pid]); + $stats['total_records'] = $stmt->fetch()['total']; + + $stmt = $pdo->prepare('SELECT COUNT(*) as total FROM work_orders WHERE product_id = ?'); + $stmt->execute([$pid]); + $stats['total_orders'] = $stmt->fetch()['total']; + + $stmt = $pdo->prepare("SELECT COUNT(*) as total FROM work_orders WHERE status = '未处理' AND product_id = ?"); + $stmt->execute([$pid]); + $stats['pending_orders'] = $stmt->fetch()['total']; + + $stmt = $pdo->query('SELECT COUNT(*) as total FROM users'); + $stats['total_users'] = $stmt->fetch()['total']; +} else { + $stmt = $pdo->prepare('SELECT COUNT(*) as total FROM claim_records WHERE user_id = ? AND product_id = ?'); + $stmt->execute([getCurrentUserId(), $pid]); + $stats['my_records'] = $stmt->fetch()['total']; + + $stmt = $pdo->prepare('SELECT COUNT(*) as total FROM work_orders WHERE creator_id = ? AND product_id = ?'); + $stmt->execute([getCurrentUserId(), $pid]); + $stats['my_orders'] = $stmt->fetch()['total']; +} +?> +
+
+
+
总兑换码
+
+
+
+
已领取
+
+ +
+
+
领取记录
+
+
+
/
+
待处理工单/总数
+
+
+
+
用户数
+
+ +
+
+
我的领取记录
+
+
+
+
我的工单
+
+ +
+ + + + +
+ +
+ + + diff --git a/install.sql b/install.sql new file mode 100755 index 0000000..3ebdbfb --- /dev/null +++ b/install.sql @@ -0,0 +1,121 @@ +-- 数据库初始化脚本 +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS redemption_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE redemption_system; + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'user') NOT NULL DEFAULT 'user', + disabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0=启用 1=禁用', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 插入默认管理员 (密码: admin123) +INSERT INTO users (username, password, role) VALUES ('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin'); + +-- 邀请码表 +CREATE TABLE IF NOT EXISTS invite_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL UNIQUE, + used_by INT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 插入默认邀请码 +INSERT INTO invite_codes (code) VALUES ('DEFAULT2024'); + +-- 产品表 +CREATE TABLE IF NOT EXISTS products ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL COMMENT '产品ID', + name VARCHAR(100) NOT NULL, + status TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=启用 0=禁用', + api_url VARCHAR(500) DEFAULT NULL COMMENT '接口地址', + token VARCHAR(500) DEFAULT NULL COMMENT '接口认证Token', + remark VARCHAR(255) DEFAULT NULL COMMENT '备注', + updated_at DATETIME DEFAULT NULL, + updated_by INT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO products (code, name) VALUES ('default', '默认产品'); + +-- 用户-产品关联表 +CREATE TABLE IF NOT EXISTS user_products ( + user_id INT NOT NULL, + product_id INT NOT NULL, + PRIMARY KEY (user_id, product_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 管理员关联到默认产品 +INSERT INTO user_products (user_id, product_id) +SELECT u.id, p.id FROM users u, products p +WHERE u.role = 'admin' AND p.code = 'default' +AND NOT EXISTS (SELECT 1 FROM user_products up WHERE up.user_id = u.id AND up.product_id = p.id); + +-- 兑换码表 +CREATE TABLE IF NOT EXISTS redemption_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + product_id INT DEFAULT NULL COMMENT '所属产品', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_id INT DEFAULT NULL COMMENT '创建人(管理员ID)', + name VARCHAR(100) NOT NULL COMMENT '兑换码名称', + batch_no VARCHAR(100) NOT NULL COMMENT '批次号', + type INT NOT NULL DEFAULT 1 COMMENT '兑换码类型', + code VARCHAR(200) NOT NULL COMMENT '兑换码', + value DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '兑换码面值', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1未领取 2已领取', + expired_at DATETIME DEFAULT NULL COMMENT '过期时间', + claim_user_id INT DEFAULT NULL COMMENT '领取人ID', + claimed_at DATETIME DEFAULT NULL COMMENT '领取时间', + price_tier1 DECIMAL(10,2) DEFAULT NULL COMMENT '出货价一档', + price_tier2 DECIMAL(10,2) DEFAULT NULL COMMENT '出货价二挡', + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (claim_user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 领取记录表 +CREATE TABLE IF NOT EXISTS claim_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + product_id INT DEFAULT NULL COMMENT '所属产品', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_id INT DEFAULT NULL COMMENT '用户ID', + code_name VARCHAR(100) NOT NULL COMMENT '兑换码名称', + batch_no VARCHAR(100) NOT NULL COMMENT '批次号', + code_type INT NOT NULL DEFAULT 1 COMMENT '兑换码类型', + code VARCHAR(200) NOT NULL COMMENT '兑换码', + value DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '兑换码面值', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1未使用 2已使用 3过期', + expired_at DATETIME DEFAULT NULL COMMENT '过期时间', + used_at DATETIME DEFAULT NULL COMMENT '使用时间', + price_tier1 DECIMAL(10,2) DEFAULT NULL COMMENT '出货价一档', + price_tier2 DECIMAL(10,2) DEFAULT NULL COMMENT '出货价二挡', + claim_user VARCHAR(50) DEFAULT NULL COMMENT '领取人(用户名)', + claimed_at DATETIME DEFAULT NULL COMMENT '领取时间', + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 工单表 +CREATE TABLE IF NOT EXISTS work_orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + product_id INT DEFAULT NULL COMMENT '所属产品', + content TEXT NOT NULL COMMENT '工单内容', + code VARCHAR(200) DEFAULT NULL COMMENT '关联兑换码', + attachment VARCHAR(500) DEFAULT NULL COMMENT '附件路径', + creator_id INT DEFAULT NULL COMMENT '发起人ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发起时间', + status ENUM('未处理', '已处理', '已驳回') NOT NULL DEFAULT '未处理' COMMENT '状态', + processed_at DATETIME DEFAULT NULL COMMENT '处理时间', + processor_id INT DEFAULT NULL COMMENT '处理人ID', + FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (processor_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE UNIQUE INDEX idx_code ON redemption_codes(code); diff --git a/login.php b/login.php new file mode 100755 index 0000000..edb155e --- /dev/null +++ b/login.php @@ -0,0 +1,75 @@ +prepare('SELECT * FROM users WHERE username = ?'); + $stmt->execute([$username]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password'])) { + if (!empty($user['disabled'])) { + $error = '账户已被禁用'; + } else { + session_regenerate_id(true); + $_SESSION['user_id'] = (int)$user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + redirect('index.php'); + } + } else { + $error = '用户名或密码错误'; + } + } + $_SESSION['captcha'] = ''; +} +?> + + + + + 登录 - 兑换码管理系统 + + + +
+

兑换码管理系统

+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + 验证码 +
+
+ +
+ +
+ + diff --git a/logout.php b/logout.php new file mode 100755 index 0000000..b546c2a --- /dev/null +++ b/logout.php @@ -0,0 +1,7 @@ +产品ID不能为空'; + } elseif ($name === '') { + $msg = '
产品名称不能为空
'; + } else { + $stmt = $pdo->prepare('SELECT id FROM products WHERE code = ?'); + $stmt->execute([$code]); + if ($stmt->fetch()) { + $msg = '
产品ID已存在
'; + } else { + $stmt = $pdo->prepare('INSERT INTO products (code, name, api_url, token, remark) VALUES (?, ?, ?, ?, ?)'); + $stmt->execute([$code, $name, $apiUrl ?: null, $token ?: null, $remark ?: null]); + $_SESSION['flash_msg'] = '产品已添加'; + $_SESSION['flash_type'] = 'success'; + header('Location: product_manage.php'); + exit; + } + } +} + +// 编辑 +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit'])) { + if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); } + $id = (int)($_POST['id'] ?? 0); + $code = trim($_POST['code'] ?? ''); + $name = trim($_POST['name'] ?? ''); + $apiUrl = trim($_POST['api_url'] ?? ''); + $token = trim($_POST['token'] ?? ''); + $remark = trim($_POST['remark'] ?? ''); + if ($code === '') { + $msg = '
产品ID不能为空
'; + } elseif ($name === '') { + $msg = '
产品名称不能为空
'; + } else { + $stmt = $pdo->prepare('SELECT id FROM products WHERE code = ? AND id != ?'); + $stmt->execute([$code, $id]); + if ($stmt->fetch()) { + $msg = '
产品ID已被其他产品使用
'; + } else { + $stmt = $pdo->prepare('UPDATE products SET code = ?, name = ?, api_url = ?, token = ?, remark = ?, updated_at = NOW(), updated_by = ? WHERE id = ?'); + $stmt->execute([$code, $name, $apiUrl ?: null, $token ?: null, $remark ?: null, getCurrentUserId(), $id]); + $_SESSION['flash_msg'] = '产品已更新'; + $_SESSION['flash_type'] = 'success'; + header('Location: product_manage.php'); + exit; + } + } +} + +// 切换状态 +if (isset($_GET['toggle']) && is_numeric($_GET['toggle'])) { + if (!verifyCsrf($_GET['csrf_token'] ?? '')) { + $_SESSION['flash_msg'] = 'CSRF token无效'; + $_SESSION['flash_type'] = 'danger'; + } else { + $id = (int)$_GET['toggle']; + $stmt = $pdo->prepare('SELECT status FROM products WHERE id = ?'); + $stmt->execute([$id]); + $current = (int)$stmt->fetchColumn(); + $newStatus = $current ? 0 : 1; + $stmt = $pdo->prepare('UPDATE products SET status = ?, updated_at = NOW(), updated_by = ? WHERE id = ?'); + $stmt->execute([$newStatus, getCurrentUserId(), $id]); + $_SESSION['flash_msg'] = $newStatus ? '产品已启用' : '产品已禁用'; + $_SESSION['flash_type'] = 'success'; + } + header('Location: product_manage.php'); + exit; +} + +$stmt = $pdo->query('SELECT p.*, u.username FROM products p LEFT JOIN users u ON p.updated_by = u.id ORDER BY p.id ASC'); +$products = $stmt->fetchAll(); +?> +
+

产品管理

+ +
+ + + +
+ + 新增产品 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID产品ID产品名称接口地址Token备注状态修改时间修改用户操作
启用' : '禁用' ?> +
+ 编辑 + + 禁用 + + 启用 + +
+
暂无产品
+
+
+ + + + + + + + + diff --git a/query_code.php b/query_code.php new file mode 100755 index 0000000..829822d --- /dev/null +++ b/query_code.php @@ -0,0 +1,90 @@ +prepare('SELECT COUNT(*) FROM redemption_codes WHERE product_id = ? AND (code = ? OR batch_no = ?)'); + $stmt->execute([$pid, $q, $q]); + $total = (int)$stmt->fetchColumn(); + $totalPages = max(1, ceil($total / $perPage)); + + // 分页查询 + $stmt = $pdo->prepare("SELECT r.*, u.username FROM redemption_codes r LEFT JOIN users u ON r.claim_user_id = u.id WHERE r.product_id = ? AND (r.code = ? OR r.batch_no = ?) ORDER BY r.id DESC LIMIT ? OFFSET ?"); + $stmt->execute([$pid, $q, $q, $perPage, $offset]); + $results = $stmt->fetchAll(); +} +?> +
+

查询兑换码

+
+ + +
+
+ + +
+

查询结果 0): ?>(共 条)

+ +
未找到匹配的兑换码
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
序号兑换码名称批次号类型兑换码面值状态过期时间创建时间用户领取时间
+
+ $q]); ?> + +
+ + diff --git a/register.php b/register.php new file mode 100755 index 0000000..6c8e183 --- /dev/null +++ b/register.php @@ -0,0 +1,102 @@ + 20) { + $error = '用户名长度3-20个字符'; + } elseif (strlen($password) < 6) { + $error = '密码长度至少6位'; + } elseif ($password !== $confirm) { + $error = '两次密码输入不一致'; + } else { + // 检查用户名是否已存在 + $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?'); + $stmt->execute([$username]); + if ($stmt->fetch()) { + $error = '用户名已存在'; + } else { + $pdo->beginTransaction(); + try { + // 验证邀请码(次数校验:未超限 或 不限次数) + $stmt = $pdo->prepare("SELECT id, max_uses, used_count FROM invite_codes WHERE code = ? AND (used_count < max_uses OR max_uses = 0) FOR UPDATE"); + $stmt->execute([$invite]); + $ic = $stmt->fetch(); + + if (!$ic) { + $error = '邀请码无效或已超出使用次数'; + $pdo->rollBack(); + } else { + $hash = password_hash($password, PASSWORD_DEFAULT); + $stmt = $pdo->prepare('INSERT INTO users (username, password) VALUES (?, ?)'); + $stmt->execute([$username, $hash]); + + // 增加邀请码使用次数 + $stmt = $pdo->prepare('UPDATE invite_codes SET used_count = used_count + 1 WHERE id = ?'); + $stmt->execute([$ic['id']]); + + $pdo->commit(); + redirect('login.php'); + } + } catch (Exception $e) { + $pdo->rollBack(); + $error = '注册失败,请重试'; + } + } + } +} +?> + + + + + 注册 - 兑换码管理系统 + + + +
+

用户注册

+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + diff --git a/uploads/6a1eb922067e1.png b/uploads/6a1eb922067e1.png new file mode 100644 index 0000000000000000000000000000000000000000..c86e1109b6f77691a81b575aaca8db2cc74c48a8 GIT binary patch literal 4546 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 literal 0 HcmV?d00001 diff --git a/work_order_create.php b/work_order_create.php new file mode 100755 index 0000000..575e19e --- /dev/null +++ b/work_order_create.php @@ -0,0 +1,147 @@ + trim($v) !== ''); + $codes = array_map('trim', $codes); + $codesStr = implode("\n", $codes); + $attachment = ''; + + if ($content === '') { + $msg = '
工单内容不能为空
'; + } elseif (empty($codes)) { + $msg = '
请至少关联一个兑换码
'; + } elseif (count($codes) > 10) { + $msg = '
最多关联10个兑换码
'; + } elseif (count(array_unique($codes)) !== count($codes)) { + $msg = '
关联的兑换码不能重复
'; + } else { + $userId = getCurrentUserId(); + $pid = getCurrentProductId(); + $placeholders = implode(',', array_fill(0, count($codes), '?')); + $stmt = $pdo->prepare("SELECT code, status FROM claim_records WHERE user_id = ? AND product_id = ? AND code IN ($placeholders)"); + $stmt->execute(array_merge([$userId, $pid], $codes)); + $foundCodes = $stmt->fetchAll(); + $expiredCodes = array_map(fn($r) => $r['code'], array_filter($foundCodes, fn($r) => (int)$r['status'] === 3)); + $invalidCodes = array_diff($codes, array_column($foundCodes, 'code')); + if (!empty($expiredCodes)) { + $msg = '
以下兑换码已过期,无法提交工单:' . h(implode(', ', $expiredCodes)) . '
'; + } elseif (!empty($invalidCodes)) { + $msg = '
以下兑换码不在您的领取记录中:' . h(implode(', ', $invalidCodes)) . '
'; + } else { + // 检查是否已在未处理工单中 + $stmt = $pdo->prepare("SELECT code FROM work_orders WHERE creator_id = ? AND product_id = ? AND status = '未处理'"); + $stmt->execute([$userId, $pid]); + $pendingOrderCodes = []; + while ($row = $stmt->fetch()) { + $pendingOrderCodes = array_merge($pendingOrderCodes, array_filter(explode("\n", $row['code'] ?? ''))); + } + $pendingOrderCodes = array_map('trim', $pendingOrderCodes); + $inPending = array_intersect($codes, $pendingOrderCodes); + if (!empty($inPending)) { + $msg = '
以下兑换码已在未处理的工单中,请处理后再提交:' . h(implode(', ', $inPending)) . '
'; + } else { + if (isset($_FILES['attachment']) && $_FILES['attachment']['error'] === UPLOAD_ERR_OK) { + $ext = strtolower(pathinfo($_FILES['attachment']['name'], PATHINFO_EXTENSION)); + $allowed = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', 'rar', 'txt']; + if (!in_array($ext, $allowed)) { + $msg = '
不支持的文件格式
'; + } elseif ($_FILES['attachment']['size'] > 10 * 1024 * 1024) { + $msg = '
文件大小不能超过10MB
'; + } else { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $_FILES['attachment']['tmp_name']); + finfo_close($finfo); + if (!in_array($mime, ['image/jpeg','image/png','image/gif','application/pdf','application/msword','application/vnd.openxmlformats-officedocument.wordprocessingml.document','application/vnd.ms-excel','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','application/zip','application/x-rar-compressed','text/plain'])) { + $msg = '
不支持的文件格式
'; + } else { + $filename = bin2hex(random_bytes(16)) . '.' . $ext; + $dest = __DIR__ . '/uploads/' . $filename; + if (move_uploaded_file($_FILES['attachment']['tmp_name'], $dest)) { + $attachment = 'uploads/' . $filename; + } + } + } + } + + if (!$msg) { + try { + $stmt = $pdo->prepare('INSERT INTO work_orders (product_id, content, code, attachment, creator_id, created_at, status) VALUES (?, ?, ?, ?, ?, NOW(), ?)'); + $stmt->execute([$pid, $content, $codesStr ?: null, $attachment ?: null, getCurrentUserId(), '未处理']); + $_SESSION['flash_msg'] = '工单提交成功'; + $_SESSION['flash_type'] = 'success'; + header('Location: work_order_records.php'); + exit; + } catch (Exception $e) { + $msg = '
提交失败,请重试
'; + } + } + } + } + } +} + +$pageTitle = '发起工单'; +require __DIR__ . '/includes/header.php'; +?> +
+

发起工单

+ +
+ +
+ + +
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+
+ + +
支持 jpg/png/gif/pdf/doc/xls/zip/rar/txt,最大10MB
+
+ + +
+
+ + diff --git a/work_order_create_validation.md b/work_order_create_validation.md new file mode 100644 index 0000000..0d716e8 --- /dev/null +++ b/work_order_create_validation.md @@ -0,0 +1,17 @@ +# 发起工单页面校验条件 + +文件:`work_order_create.php` + +| # | 校验项 | 校验方式 | 错误提示 | +|---|--------|----------|----------| +| 1 | CSRF Token | `verifyCsrf()` 验证 | 直接 `die('CSRF token无效')` | +| 2 | 工单内容 | `trim($_POST['content'])` 非空 | 工单内容不能为空 | +| 3 | 关联兑换码至少一个 | `array_filter` 去除空白后非空 | 请至少关联一个兑换码 | +| 4 | 兑换码数量 ≤ 10 | `count($codes) > 10` | 最多关联10个兑换码 | +| 5 | 兑换码不重复 | `array_unique` 对比长度 | 关联的兑换码不能重复 | +| 6 | 兑换码属于当前用户 | 查询 `claim_records` 匹配 `user_id` 和 `code` | 以下兑换码不在您的领取记录中 | +| 7 | 兑换码未过期 | `status !== 3` | 以下兑换码已过期,无法提交工单 | +| 8 | 兑换码不在未处理工单中 | 查询 `work_orders`(`creator_id` + `status='未处理'`) | 以下兑换码已在未处理的工单中,请处理后再提交 | +| 9 | 附件扩展名 | `pathinfo` 白名单:jpg/jpeg/png/gif/pdf/doc/docx/xls/xlsx/zip/rar/txt | 不支持的文件格式 | +| 10 | 附件大小 | `$_FILES['size']` ≤ 10MB | 文件大小不能超过10MB | +| 11 | 附件 MIME 类型 | `finfo` 白名单验证 | 不支持的文件格式 | diff --git a/work_order_manage.php b/work_order_manage.php new file mode 100755 index 0000000..de5258a --- /dev/null +++ b/work_order_manage.php @@ -0,0 +1,228 @@ +beginTransaction(); + try { + $stmt = $pdo->prepare("SELECT * FROM work_orders WHERE id = ? AND status = '未处理'"); + $stmt->execute([$id]); + $order = $stmt->fetch(); + if (!$order) { + $msg = '
工单不存在或状态已变更
'; + $pdo->rollBack(); + } else { + $codes = array_filter(explode("\n", $order['code'] ?? '')); + $missingClaim = 0; + $noStock = 0; + + foreach ($codes as $code) { + $code = trim($code); + if ($code === '') continue; + + $stmt = $pdo->prepare("SELECT * FROM claim_records WHERE code = ? AND user_id = ? AND product_id = ? FOR UPDATE"); + $stmt->execute([$code, $order['creator_id'], $order['product_id']]); + $cr = $stmt->fetch(); + if (!$cr) { $missingClaim++; continue; } + + $stmt = $pdo->prepare("SELECT 1 FROM redemption_codes WHERE value = ? AND status = 1 AND (expired_at IS NULL OR expired_at > NOW()) AND product_id = ? LIMIT 1 FOR UPDATE"); + $stmt->execute([$cr['value'], $order['product_id']]); + if (!$stmt->fetch()) { $noStock++; } + } + + if ($missingClaim > 0 || $noStock > 0) { + $pdo->rollBack(); + $errParts = []; + if ($missingClaim > 0) $errParts[] = $missingClaim . ' 个兑换码未找到领取记录'; + if ($noStock > 0) $errParts[] = '库存不足,无法补发'; + $msg = '
' . implode(';', $errParts) . ',工单无法处理
'; + } else { + $replaced = 0; + foreach ($codes as $code) { + $code = trim($code); + if ($code === '') continue; + + $stmt = $pdo->prepare("SELECT * FROM claim_records WHERE code = ? AND user_id = ? AND product_id = ?"); + $stmt->execute([$code, $order['creator_id'], $order['product_id']]); + $cr = $stmt->fetch(); + if (!$cr) continue; + + $stmt = $pdo->prepare("UPDATE claim_records SET status = 3, used_at = COALESCE(used_at, NOW()), remark = '该码已过期,新码已补发' WHERE id = ?"); + $stmt->execute([$cr['id']]); + + $stmt = $pdo->prepare("SELECT * FROM redemption_codes WHERE value = ? AND status = 1 AND (expired_at IS NULL OR expired_at > NOW()) AND product_id = ? LIMIT 1 FOR UPDATE"); + $stmt->execute([$cr['value'], $order['product_id']]); + $newCode = $stmt->fetch(); + + $stmt = $pdo->prepare("UPDATE redemption_codes SET status = 2, claim_user_id = ?, claimed_at = NOW() WHERE id = ?"); + $stmt->execute([$order['creator_id'], $newCode['id']]); + + $stmt = $pdo->prepare("INSERT INTO claim_records (product_id, created_at, user_id, code_name, batch_no, code_type, code, value, status, expired_at, price_tier1, price_tier2, claim_user, claimed_at, remark) VALUES (?, NOW(), ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, NOW(), '系统补发')"); + $stmt->execute([ + $newCode['product_id'], + $order['creator_id'], + $newCode['name'], + $newCode['batch_no'], + $newCode['type'], + $newCode['code'], + $newCode['value'], + $newCode['expired_at'], + $newCode['price_tier1'], + $newCode['price_tier2'], + $cr['claim_user'] + ]); + $replaced++; + } + + $stmt = $pdo->prepare("UPDATE work_orders SET status = '已处理', processed_at = NOW(), processor_id = ? WHERE id = ?"); + $stmt->execute([getCurrentUserId(), $id]); + $pdo->commit(); + + $_SESSION['flash_msg'] = '工单已处理,' . $replaced . ' 个兑换码已过期并补发新码'; + $_SESSION['flash_type'] = 'success'; + header('Location: work_order_manage.php'); + exit; + } + } + } catch (Exception $e) { + $pdo->rollBack(); + $msg = '
处理失败,请重试
'; + } + } elseif ($_POST['action'] === 'reject') { + $stmt = $pdo->prepare("SELECT attachment FROM work_orders WHERE id = ?"); + $stmt->execute([$id]); + $order = $stmt->fetch(); + $stmt = $pdo->prepare("UPDATE work_orders SET status = '已驳回', processed_at = NOW(), processor_id = ? WHERE id = ? AND status = '未处理'"); + $stmt->execute([getCurrentUserId(), $id]); + if ($stmt->rowCount()) { + if ($order && $order['attachment'] && file_exists(__DIR__ . '/' . $order['attachment'])) { + unlink(__DIR__ . '/' . $order['attachment']); + } + $_SESSION['flash_msg'] = '工单已驳回'; + $_SESSION['flash_type'] = 'warning'; + header('Location: work_order_manage.php'); + exit; + } else { + $msg = '
工单状态已变更,无法驳回
'; + } + } elseif ($_POST['action'] === 'delete') { + $stmt = $pdo->prepare("SELECT attachment, status FROM work_orders WHERE id = ?"); + $stmt->execute([$id]); + $order = $stmt->fetch(); + if (!$order) { + $msg = '
工单不存在
'; + } elseif ($order['status'] === '已处理') { + $msg = '
已处理的工单不能删除
'; + } else { + if ($order['attachment'] && file_exists(__DIR__ . '/' . $order['attachment'])) { + unlink(__DIR__ . '/' . $order['attachment']); + } + $pdo->prepare('DELETE FROM work_orders WHERE id = ?')->execute([$id]); + $_SESSION['flash_msg'] = '工单已删除'; + $_SESSION['flash_type'] = 'success'; + header('Location: work_order_manage.php'); + exit; + } + } +} + +// 分页 +$page = max(1, (int)($_GET['p'] ?? 1)); +$perPage = 20; +$offset = ($page - 1) * $perPage; + +$stmt = $pdo->prepare('SELECT COUNT(*) FROM work_orders WHERE product_id = ?'); +$stmt->execute([$pid]); +$total = (int)$stmt->fetchColumn(); +$totalPages = max(1, ceil($total / $perPage)); + +$stmt = $pdo->prepare('SELECT w.*, c.username AS creator_name, p.username AS processor_name FROM work_orders w LEFT JOIN users c ON w.creator_id = c.id LEFT JOIN users p ON w.processor_id = p.id WHERE w.product_id = ? ORDER BY w.created_at DESC LIMIT ? OFFSET ?'); +$stmt->execute([$pid, $perPage, $offset]); +$orders = $stmt->fetchAll(); +?> +
+

工单管理

+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID内容兑换码附件发起人发起时间处理时间处理人状态操作
100 ? '...' : '' ?>- + + 查看 + + - + + +
+ +
+ + + + +
+
+ + + + +
+
+ + + + +
+ + 已驳回,不可操作 + +
+
暂无工单
+
+ +
+ diff --git a/work_order_records.php b/work_order_records.php new file mode 100755 index 0000000..c88934b --- /dev/null +++ b/work_order_records.php @@ -0,0 +1,104 @@ +prepare("SELECT attachment FROM work_orders WHERE id = ? AND creator_id = ? AND product_id = ? AND status = '未处理'"); + $stmt->execute([$id, getCurrentUserId(), $pid]); + $order = $stmt->fetch(); + if ($order) { + if ($order['attachment'] && file_exists(__DIR__ . '/' . $order['attachment'])) { + unlink(__DIR__ . '/' . $order['attachment']); + } + $pdo->prepare('DELETE FROM work_orders WHERE id = ? AND product_id = ?')->execute([$id, $pid]); + $_SESSION['flash_msg'] = '工单已撤销'; + $_SESSION['flash_type'] = 'success'; + } else { + $_SESSION['flash_msg'] = '无法撤销:工单不存在、非本人发起或状态已变更'; + $_SESSION['flash_type'] = 'danger'; + } + header('Location: work_order_records.php'); + exit; +} + +$pageTitle = '工单记录'; +require __DIR__ . '/includes/header.php'; + +// 分页 +$page = max(1, (int)($_GET['p'] ?? 1)); +$perPage = 20; +$offset = ($page - 1) * $perPage; +$pid = getCurrentProductId(); + +$stmt = $pdo->prepare('SELECT COUNT(*) FROM work_orders WHERE creator_id = ? AND product_id = ?'); +$stmt->execute([getCurrentUserId(), $pid]); +$total = (int)$stmt->fetchColumn(); +$totalPages = max(1, ceil($total / $perPage)); + +$stmt = $pdo->prepare('SELECT * FROM work_orders WHERE creator_id = ? AND product_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'); +$stmt->execute([getCurrentUserId(), $pid, $perPage, $offset]); +$orders = $stmt->fetchAll(); +?> +
+ + +
+ + + +
+

我的工单

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID内容兑换码附件发起时间状态操作
100 ? '...' : '' ?>- + + 查看 + + - + + + + 撤销 + + 已驳回 + + - + +
暂无工单
+
+ +
+