第一版

This commit is contained in:
zoulin 2026-06-03 16:08:08 +08:00
parent bfaa777c82
commit 5fbbbc8804
25 changed files with 3886 additions and 0 deletions

493
admin_settings.php Executable file
View File

@ -0,0 +1,493 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireAdmin();
$pageTitle = '后台设置';
require __DIR__ . '/includes/header.php';
$msg = '';
// 生成邀请码
if (isset($_POST['gen_invite'])) {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); }
$count = max(1, min(100, (int)($_POST['count'] ?? 1)));
$maxUses = max(0, (int)($_POST['max_uses'] ?? 1));
$inserted = 0;
$stmt = $pdo->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 = '<div class="alert alert-danger">CSRF token无效</div>'; }
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() ? '<div class="alert alert-success">邀请码已删除</div>' : '<div class="alert alert-danger">无法删除:已被使用或不存在</div>';
}
}
// 新增用户
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 = '<div class="alert alert-danger">用户名和密码不能为空</div>';
} elseif (strlen($username) < 3 || strlen($username) > 20) {
$msg = '<div class="alert alert-danger">用户名长度3-20个字符</div>';
} elseif (strlen($password) < 6) {
$msg = '<div class="alert alert-danger">密码长度至少6位</div>';
} else {
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?');
$stmt->execute([$username]);
if ($stmt->fetch()) {
$msg = '<div class="alert alert-danger">用户名已存在</div>';
} else {
$productIds = array_map('intval', $_POST['product_ids'] ?? []);
$productIds = array_filter($productIds, fn($v) => $v > 0);
if (empty($productIds)) {
$msg = '<div class="alert alert-danger">至少选择一个产品</div>';
} 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 = '<div class="alert alert-danger">至少选择一个产品</div>';
} 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 = '<div class="alert alert-danger">不能修改自己的角色</div>';
} elseif (!in_array($role, ['admin', 'user'])) {
$msg = '<div class="alert alert-danger">无效的角色</div>';
} 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 = '<div class="alert alert-danger">至少保留一名管理员</div>';
} else {
$stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
$stmt->execute([$role, $id]);
$msg = '<div class="alert alert-success">角色已更新</div>';
}
}
}
// 分页 - 邀请码
$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'];
}
?>
<div class="card">
<h2>邀请码管理</h2>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<?= $msg ?>
<form method="post" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px;">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group" style="margin-bottom:0;">
<label>生成数量</label>
<input type="number" name="count" class="form-control" value="5" min="1" max="100" style="width:100px;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>最大使用次数</label>
<input type="number" name="max_uses" class="form-control" value="1" min="0" style="width:120px;" title="0=不限次数">
</div>
<button type="submit" name="gen_invite" class="btn btn-success">生成邀请码</button>
</form>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>邀请码</th>
<th>使用次数</th>
<th>最大次数</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($invites as $i): ?>
<?php
$expired = $i['max_uses'] > 0 && $i['used_count'] >= $i['max_uses'];
?>
<tr>
<td><?= $i['id'] ?></td>
<td><code><?= h($i['code']) ?></code></td>
<td><?= $i['used_count'] ?></td>
<td><?= $i['max_uses'] === 0 ? '不限' : $i['max_uses'] ?></td>
<td>
<?php if ($expired): ?>
<span class="badge badge-danger">已用完</span>
<?php elseif ($i['used_count'] > 0): ?>
<span class="badge badge-warning">使用中</span>
<?php else: ?>
<span class="badge badge-success">未使用</span>
<?php endif; ?>
</td>
<td><?= formatDateTime($i['created_at']) ?></td>
<td>
<div class="action-group">
<a href="javascript:void(0)" class="btn btn-primary btn-sm" onclick="editMaxUses(<?= $i['id'] ?>, <?= $i['max_uses'] ?>)">修改次数</a>
<?php if ($i['used_count'] === 0): ?>
<a href="?del_invite=<?= $i['id'] ?>&csrf_token=<?= csrfToken() ?>" class="btn btn-danger btn-sm" onclick="return confirm('确定删除?')">删除</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php renderPagination($page, $invitePages); ?>
</div>
<div class="card">
<h2 style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;">
用户管理
<a href="javascript:void(0)" class="btn btn-success btn-sm" onclick="showModal('addUserModal')">+ 新增用户</a>
</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>角色</th>
<th>状态</th>
<th>可管理产品</th>
<th>备注</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr>
<td><?= $u['id'] ?></td>
<td><?= h($u['username']) ?></td>
<td>
<?php if ($u['role'] === 'admin'): ?>
<span class="badge badge-danger">管理员</span>
<?php else: ?>
<span class="badge badge-info">普通用户</span>
<?php endif; ?>
</td>
<td>
<?php if (!empty($u['disabled'])): ?>
<span class="badge badge-secondary">已禁用</span>
<?php else: ?>
<span class="badge badge-success">正常</span>
<?php endif; ?>
</td>
<td style="max-width:180px;">
<?php
$upIds = $userProducts[(int)$u['id']] ?? [];
$upNames = array_map(fn($p) => $p['name'], array_filter($allProducts, fn($p) => in_array((int)$p['id'], $upIds)));
echo $upNames ? h(implode(', ', $upNames)) : '<span style="color:#ccc;">-</span>';
?>
</td>
<td>
<?php if ($u['remark']): ?>
<span title="<?= h($u['remark']) ?>"><?= h(mb_substr($u['remark'], 0, 20)) ?><?= mb_strlen($u['remark']) > 20 ? '...' : '' ?></span>
<?php else: ?>
<span style="color:#ccc;">-</span>
<?php endif; ?>
<a href="javascript:void(0)" class="btn btn-sm" style="padding:2px 6px;font-size:11px;" onclick="editRemark(<?= $u['id'] ?>, '<?= h($u['remark'] ?? '') ?>')">✏️</a>
</td>
<td><?= formatDateTime($u['created_at']) ?></td>
<td>
<div class="action-group">
<a href="javascript:void(0)" class="btn btn-info btn-sm" onclick="editUserProducts(<?= $u['id'] ?>, '<?= h($u['username']) ?>')">分配产品</a>
<?php if ((int)$u['id'] !== getCurrentUserId()): ?>
<?php if ($u['role'] === 'user'): ?>
<a href="?set_role=<?= $u['id'] ?>&role=admin&csrf_token=<?= csrfToken() ?>" class="btn btn-primary btn-sm">设为管理员</a>
<?php else: ?>
<a href="?set_role=<?= $u['id'] ?>&role=user&csrf_token=<?= csrfToken() ?>" class="btn btn-warning btn-sm">设为普通用户</a>
<?php endif; ?>
<?php if (!empty($u['disabled'])): ?>
<a href="?toggle_disable=<?= $u['id'] ?>&csrf_token=<?= csrfToken() ?>" class="btn btn-success btn-sm">启用</a>
<?php else: ?>
<a href="?toggle_disable=<?= $u['id'] ?>&csrf_token=<?= csrfToken() ?>" class="btn btn-danger btn-sm" onclick="return confirm('确定禁用用户 <?= h($u['username']) ?>')">禁用</a>
<?php endif; ?>
<?php else: ?>
<span style="color:#999;">当前用户</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- 修改邀请码次数弹窗 -->
<div class="modal" id="maxUsesModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('maxUsesModal')">&times;</span>
<h3>修改最大使用次数</h3>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<input type="hidden" name="edit_max_uses" value="1">
<input type="hidden" name="id" id="max_uses_id">
<div class="form-group">
<label>最大使用次数0=不限次数)</label>
<input type="number" name="max_uses" class="form-control" id="max_uses_value" min="0" required>
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
<!-- 新增用户弹窗 -->
<div class="modal" id="addUserModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('addUserModal')">&times;</span>
<h3>新增用户</h3>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group">
<label>用户名 <span style="color:red">*</span></label>
<input type="text" name="username" class="form-control" required minlength="3" maxlength="20">
</div>
<div class="form-group">
<label>密码 <span style="color:red">*</span></label>
<input type="password" name="password" class="form-control" required minlength="6">
</div>
<div class="form-group">
<label>角色</label>
<select name="role" class="form-control">
<option value="user">普通用户</option>
<option value="admin">管理员</option>
</select>
</div>
<div class="form-group">
<label>可管理产品 <span style="color:red">*</span></label>
<div style="max-height:150px;overflow-y:auto;border:1px solid #ddd;border-radius:4px;padding:8px;">
<?php foreach ($allProducts as $p): ?>
<label style="display:block;font-weight:normal;margin-bottom:4px;">
<input type="checkbox" name="product_ids[]" value="<?= $p['id'] ?>"> <?= h($p['name']) ?>
</label>
<?php endforeach; ?>
</div>
<div style="font-size:12px;color:#999;margin-top:4px;">至少选择一个产品</div>
</div>
<div class="form-group">
<label>备注</label>
<input type="text" name="remark" class="form-control" placeholder="选填">
</div>
<button type="submit" name="add_user" class="btn btn-success">添加用户</button>
</form>
</div>
</div>
<!-- 编辑备注弹窗 -->
<div class="modal" id="remarkModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('remarkModal')">&times;</span>
<h3>编辑备注</h3>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<input type="hidden" name="edit_remark" value="1">
<input type="hidden" name="user_id" id="remark_user_id">
<div class="form-group">
<label>备注</label>
<input type="text" name="remark" class="form-control" id="remark_content" maxlength="255">
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
<!-- 分配产品弹窗 -->
<div class="modal" id="productModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('productModal')">&times;</span>
<h3>分配产品 - <span id="product_user_name"></span></h3>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<input type="hidden" name="edit_user_products" value="1">
<input type="hidden" name="user_id" id="product_user_id">
<div class="form-group">
<label>可管理产品 <span style="color:red">*</span></label>
<div id="product_checkbox_list" style="max-height:200px;overflow-y:auto;border:1px solid #ddd;border-radius:4px;padding:8px;">
<?php foreach ($allProducts as $p): ?>
<label style="display:block;font-weight:normal;margin-bottom:4px;">
<input type="checkbox" name="product_ids[]" value="<?= $p['id'] ?>" class="product-checkbox"> <?= h($p['name']) ?>
</label>
<?php endforeach; ?>
</div>
<div style="font-size:12px;color:#999;margin-top:4px;">至少选择一个产品</div>
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
<script>
function showModal(id) { document.getElementById(id).classList.add('active'); }
function hideModal(id) { document.getElementById(id).classList.remove('active'); }
function editRemark(id, content) {
document.getElementById('remark_user_id').value = id;
document.getElementById('remark_content').value = content;
showModal('remarkModal');
}
function editMaxUses(id, maxUses) {
document.getElementById('max_uses_id').value = id;
document.getElementById('max_uses_value').value = maxUses;
showModal('maxUsesModal');
}
function editUserProducts(id, name) {
document.getElementById('product_user_id').value = id;
document.getElementById('product_user_name').textContent = name;
// 取消所有选中
document.querySelectorAll('.product-checkbox').forEach(function(cb) { cb.checked = false; });
// 选中当前用户已有的产品
var data = <?= json_encode($userProducts) ?>;
var ids = data[id] || [];
document.querySelectorAll('.product-checkbox').forEach(function(cb) {
if (ids.indexOf(parseInt(cb.value)) !== -1) {
cb.checked = true;
}
});
showModal('productModal');
}
document.querySelectorAll('.modal').forEach(function(m) {
m.addEventListener('click', function(e) { if (e.target === this) this.classList.remove('active'); });
});
</script>
<?php require __DIR__ . '/includes/footer.php'; ?>

605
assets/css/style.css Executable file
View File

@ -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;
}
}

284
bill_records.php Normal file
View File

@ -0,0 +1,284 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireLogin();
$pageTitle = '账单管理';
require __DIR__ . '/includes/header.php';
$isAdmin = isAdmin();
$userId = $isAdmin ? (int)($_GET['user_id'] ?? 0) : getCurrentUserId();
$detailDate = $_GET['date'] ?? '';
$detailUserId = (int)($_GET['duid'] ?? $userId);
$startDate = $_GET['start_date'] ?? '';
$endDate = $_GET['end_date'] ?? '';
// 管理员:修改账单状态
$statusMsg = '';
if ($isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); }
if ($_POST['action'] === 'set_bill_status') {
$sDate = $_POST['bill_date'] ?? '';
$sUserId = (int)($_POST['user_id'] ?? 0);
$sStatus = $_POST['status'] ?? '';
$sStartDate = $_POST['start_date'] ?? '';
$sEndDate = $_POST['end_date'] ?? '';
if ($sDate && $sUserId > 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 = '<div class="alert alert-danger">参数错误</div>';
}
}
}
// 详情模式
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() ?: '未结算';
?>
<div class="card">
<h2>
账单明细 - <?= h($detailDate) ?>
<?php if ($detailUserId > 0 && isset($details[0]['username'])): ?>
<?= h($details[0]['username']) ?>
<?php endif; ?>
<span style="font-size:14px;font-weight:normal;margin-left:12px;">状态:<?= billStatusBadge($billStatus) ?></span>
</h2>
<div style="margin-bottom:16px;">
<a href="bill_records.php<?= $userId > 0 ? '?user_id=' . $userId : '' ?><?= $startDate ? '&start_date=' . $startDate : '' ?><?= $endDate ? '&end_date=' . $endDate : '' ?>" class="btn btn-primary btn-sm"> 返回</a>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>兑换码名称</th>
<th>批次号</th>
<th>兑换码</th>
<th>面值</th>
<th>使用时间</th>
<th>会员ID</th>
<th>用户</th>
<th>出货价一档</th>
<th>出货价二挡</th>
<th>备注</th>
</tr>
</thead>
<tbody>
<?php foreach ($details as $r): ?>
<tr>
<td><?= $r['id'] ?></td>
<td><?= h($r['code_name']) ?></td>
<td><?= h($r['batch_no']) ?></td>
<td><?= h($r['code']) ?></td>
<td><?= h($r['value']) ?></td>
<td><?= formatDateTime($r['used_at']) ?></td>
<td><?= h($r['used_user_id'] ?? '-') ?></td>
<td><?= h($r['username'] ?? '-') ?></td>
<td><?= $r['price_tier1'] !== null ? h($r['price_tier1']) : '-' ?></td>
<td><?= $r['price_tier2'] !== null ? h($r['price_tier2']) : '-' ?></td>
<td><?= h($r['remark'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($details)): ?>
<tr><td colspan="11" style="text-align:center;color:#999;">暂无数据</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php
require __DIR__ . '/includes/footer.php';
exit;
}
// 汇总模式
$page = max(1, (int)($_GET['p'] ?? 1));
$perPage = 30;
$offset = ($page - 1) * $perPage;
$pid = getCurrentProductId();
$where = 'WHERE r.status = 2 AND r.product_id = ?';
$params = [$pid];
if ($userId > 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 '<span class="badge badge-warning">未结算</span>';
case '已结算': return '<span class="badge badge-success">已结算</span>';
case '已作废': return '<span class="badge badge-danger">已作废</span>';
default: return '<span class="badge">未知</span>';
}
}
$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;
?>
<div class="card">
<h2>账单管理</h2>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<?= $statusMsg ?>
<?php if ($isAdmin): ?>
<form method="get" style="margin-bottom:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label>用户:</label>
<select name="user_id" class="form-control" style="max-width:160px;" onchange="this.form.submit()">
<option value="">全部</option>
<?php foreach ($allUsers as $u): ?>
<option value="<?= $u['id'] ?>" <?= $userId === (int)$u['id'] ? 'selected' : '' ?>><?= h($u['username']) ?></option>
<?php endforeach; ?>
</select>
<label>开始日期:</label>
<input type="date" name="start_date" class="form-control" style="max-width:160px;" value="<?= h($startDate) ?>">
<label>结束日期:</label>
<input type="date" name="end_date" class="form-control" style="max-width:160px;" value="<?= h($endDate) ?>">
<button type="submit" class="btn btn-primary btn-sm">查询</button>
<?php if ($startDate || $endDate || $userId > 0): ?>
<a href="bill_records.php" class="btn btn-sm" style="background:#999;color:#fff;">清空</a>
<?php endif; ?>
</form>
<?php else: ?>
<form method="get" style="margin-bottom:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label>开始日期:</label>
<input type="date" name="start_date" class="form-control" style="max-width:160px;" value="<?= h($startDate) ?>">
<label>结束日期:</label>
<input type="date" name="end_date" class="form-control" style="max-width:160px;" value="<?= h($endDate) ?>">
<button type="submit" class="btn btn-primary btn-sm">查询</button>
<?php if ($startDate || $endDate): ?>
<a href="bill_records.php" class="btn btn-sm" style="background:#999;color:#fff;">清空</a>
<?php endif; ?>
</form>
<?php endif; ?>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>日期</th>
<?php if (!$userId): ?><th>用户</th><?php endif; ?>
<th>使用数量</th>
<th>出货价一档合计</th>
<th>出货价二挡合计</th>
<th>状态</th>
<?php if ($isAdmin): ?><th>操作</th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($records as $r): ?>
<?php
$uid = $userId > 0 ? $userId : $r['uid'];
$statusKey = $r['bill_date'] . '_' . $uid;
$billStatus = $statusMap[$statusKey] ?? '未结算';
?>
<tr>
<td><?= h($r['bill_date']) ?></td>
<?php if (!$userId): ?><td><?= h($r['username'] ?? '-') ?></td><?php endif; ?>
<td><a href="bill_records.php?date=<?= h($r['bill_date']) ?>&duid=<?= $uid ?><?= $startDate ? '&start_date=' . $startDate : '' ?><?= $endDate ? '&end_date=' . $endDate : '' ?>" class="btn btn-info btn-sm"><?= (int)$r['total'] ?></a></td>
<td><?= $r['total_price1'] > 0 ? number_format($r['total_price1'], 2) : '-' ?></td>
<td><?= $r['total_price2'] > 0 ? number_format($r['total_price2'], 2) : '-' ?></td>
<td><?= billStatusBadge($billStatus) ?></td>
<?php if ($isAdmin): ?>
<td>
<form method="post" style="display:inline;">
<input type="hidden" name="action" value="set_bill_status">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<input type="hidden" name="bill_date" value="<?= h($r['bill_date']) ?>">
<input type="hidden" name="user_id" value="<?= $uid ?>">
<?php if ($startDate): ?><input type="hidden" name="start_date" value="<?= h($startDate) ?>"><?php endif; ?>
<?php if ($endDate): ?><input type="hidden" name="end_date" value="<?= h($endDate) ?>"><?php endif; ?>
<select name="status" class="form-control" style="display:inline-block;width:auto;max-width:100px;padding:4px 6px;font-size:12px;" onchange="this.form.submit()">
<option value="未结算" <?= $billStatus === '未结算' ? 'selected' : '' ?>>未结算</option>
<option value="已结算" <?= $billStatus === '已结算' ? 'selected' : '' ?>>已结算</option>
<option value="已作废" <?= $billStatus === '已作废' ? 'selected' : '' ?>>已作废</option>
</select>
</form>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php if (empty($records)): ?>
<tr><td colspan="<?= $userId ? 5 : 6 ?><?= $isAdmin ? 1 : 0 ?>" style="text-align:center;color:#999;">暂无数据</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php
$extra = [];
if ($userId > 0) $extra['user_id'] = $userId;
if ($startDate) $extra['start_date'] = $startDate;
if ($endDate) $extra['end_date'] = $endDate;
renderPagination($page, $totalPages, $extra);
?>
</div>
<?php require __DIR__ . '/includes/footer.php'; ?>

49
captcha.php Executable file
View File

@ -0,0 +1,49 @@
<?php
session_start();
$width = 120;
$height = 42;
$fontSize = 20;
$image = imagecreatetruecolor($width, $height);
$bgColor = imagecolorallocate($image, 255, 255, 255);
imagefill($image, 0, 0, $bgColor);
// 干扰线
for ($i = 0; $i < 5; $i++) {
$lineColor = imagecolorallocatealpha($image, rand(100, 200), rand(100, 200), rand(100, 200), 50);
imageline($image, rand(0, $width), rand(0, $height), rand(0, $width), rand(0, $height), $lineColor);
}
// 干扰点
for ($i = 0; $i < 80; $i++) {
$pixelColor = imagecolorallocate($image, rand(150, 200), rand(150, 200), rand(150, 200));
imagesetpixel($image, rand(0, $width), rand(0, $height), $pixelColor);
}
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $chars[rand(0, strlen($chars) - 1)];
}
$_SESSION['captcha'] = $code;
// 尝试使用TTF字体不存在则用内置字体
$fontFile = __DIR__ . '/assets/arial.ttf';
if (file_exists($fontFile)) {
for ($i = 0; $i < 4; $i++) {
$angle = rand(-15, 15);
$x = 8 + $i * 28;
$textColor = imagecolorallocate($image, rand(30, 100), rand(30, 100), rand(30, 100));
imagettftext($image, $fontSize, $angle, $x, 34, $textColor, $fontFile, $code[$i]);
}
} else {
$textColor = imagecolorallocate($image, 50, 50, 50);
imagestring($image, 5, 10, 12, $code, $textColor);
}
header('Content-Type: image/png');
header('Cache-Control: no-cache, no-store, must-revalidate');
imagepng($image);
imagedestroy($image);

119
claim_code.php Executable file
View File

@ -0,0 +1,119 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireLogin();
$pageTitle = '兑换码领取';
require __DIR__ . '/includes/header.php';
$msg = '';
$pid = getCurrentProductId();
// 获取所有面值及对应可领取数量
$stmt = $pdo->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 = '<div class="alert alert-danger">无效的面值</div>';
} 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 = '<div class="alert alert-danger">您当前未使用的兑换码已达 ' . $unusedCount . ' 条上限200条请先使用后再领取</div>';
} 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 = '<div class="alert alert-danger">当前面值兑换码距上次领取不足3天请 ' . $remaining . ' 小时后再领取</div>';
} 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 = '<div class="alert alert-danger">该面值的可领取兑换码不足,当前仅有 ' . count($available) . ' 个可用</div>';
$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 = '<div class="alert alert-danger">领取失败,请重试</div>';
}
}
}
}
}
$totalAvailable = array_sum(array_column($valueOptions, 'cnt'));
?>
<div class="card">
<h2>兑换码领取</h2>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<?= $msg ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group">
<label>兑换码面值</label>
<select name="code_value" class="form-control" required>
<option value="">-- 请选择面值 --</option>
<?php foreach ($valueOptions as $opt): ?>
<option value="<?= h($opt['value']) ?>" <?= (isset($_POST['code_value']) && $_POST['code_value'] === $opt['value']) ? 'selected' : '' ?>>
<?= h($opt['value']) ?>(剩余 <?= $opt['cnt'] ?> 个)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>领取数量</label>
<input type="number" name="quantity" class="form-control" value="1" min="1" max="100" required>
<div style="font-size:12px;color:#999;margin-top:4px;">当前共计可领取: <?= $totalAvailable ?>单次最多100个</div>
</div>
<button type="submit" class="btn btn-success" onclick="return confirm('确认领取?')">立即领取</button>
<button type="button" class="btn btn-default" onclick="history.back()" style="margin-left:8px;">取消</button>
</form>
</div>
<?php require __DIR__ . '/includes/footer.php'; ?>

367
claim_records.php Executable file
View File

@ -0,0 +1,367 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireLogin();
// 导出 CSV
if (isset($_GET['export'])) {
$isAdmin = isAdmin();
$pid = getCurrentProductId();
$where = 'WHERE r.product_id = ?';
$params = [$pid];
if (!$isAdmin) {
$where .= ' AND r.user_id = ?';
$params[] = getCurrentUserId();
}
if (!empty($_GET['search'])) {
$s = '%' . $_GET['search'] . '%';
$where .= ' AND (r.code LIKE ? OR r.batch_no LIKE ? OR r.code_name LIKE ?)';
$params = array_merge($params, [$s, $s, $s]);
}
$stmt = $pdo->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 = '<div class="alert alert-danger">无效的状态值</div>';
} elseif ($newStatus === 2 && $usedUserId === '') {
$msg = '<div class="alert alert-danger">状态设为已使用时必须填写会员ID</div>';
} 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 = '<div class="alert alert-danger">兑换码名称、批次号、兑换码为必填项</div>';
} 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();
?>
<div class="card">
<h2>兑换码领取记录</h2>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<?= $msg ?>
<div style="margin-bottom:16px;display:flex;flex-wrap:wrap;gap:8px;">
<a href="claim_code.php" class="btn btn-info btn-sm">兑换码领取</a>
<?php if ($isAdmin): ?>
<a href="javascript:void(0)" class="btn btn-success btn-sm" onclick="showModal('addModal')">+ 手动添加</a>
<?php endif; ?>
<a href="?export=1<?= !empty($_GET['search']) ? '&search=' . urlencode($_GET['search']) : '' ?>" class="btn btn-primary btn-sm">下载 CSV</a>
<form method="get" style="display:flex;gap:8px;margin-left:auto;">
<input type="text" name="search" class="form-control" placeholder="搜索兑换码/批次号/名称" value="<?= h($_GET['search'] ?? '') ?>" style="max-width:300px;">
<button type="submit" class="btn btn-primary btn-sm">搜索</button>
<?php if (!empty($_GET['search'])): ?>
<a href="claim_records.php" class="btn btn-sm" style="background:#999;color:#fff;">清空</a>
<?php endif; ?>
</form>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>创建时间</th>
<th>兑换码名称</th>
<th>批次号</th>
<th>类型</th>
<th>兑换码</th>
<th>面值</th>
<th>状态</th>
<th>过期时间</th>
<th>使用时间</th>
<th>会员ID</th>
<th>用户</th>
<th>领取时间</th>
<?php if ($isAdmin): ?>
<th>出货价一档</th>
<th>出货价二挡</th>
<?php endif; ?>
<th>备注</th>
<?php if ($isAdmin): ?><th>操作</th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($records as $r): ?>
<tr>
<td><?= $r['id'] ?></td>
<td><?= formatDateTime($r['created_at']) ?></td>
<td><?= h($r['code_name']) ?></td>
<td><?= h($r['batch_no']) ?></td>
<td><?= h($r['code_type']) ?></td>
<td><?php if ((int)$r['status'] === 3): ?><code><?= h(maskCode($r['code'])) ?></code><?php else: ?><?= h($r['code']) ?><?php endif; ?></td>
<td><?= h($r['value']) ?></td>
<td><?= statusBadge((int)$r['status']) ?></td>
<td><?= formatDateTime($r['expired_at']) ?></td>
<td><?= formatDateTime($r['used_at']) ?></td>
<td><?= h($r['used_user_id'] ?? '-') ?></td>
<td><?= h($r['username'] ?? $r['user_id'] ?? '-') ?></td>
<td><?= formatDateTime($r['claimed_at']) ?></td>
<?php if ($isAdmin): ?>
<td><?= $r['price_tier1'] !== null ? h($r['price_tier1']) : '-' ?></td>
<td><?= $r['price_tier2'] !== null ? h($r['price_tier2']) : '-' ?></td>
<?php endif; ?>
<td><?= h($r['remark'] ?? '') ?></td>
<?php if ($isAdmin): ?>
<td>
<a href="javascript:void(0)" class="btn btn-warning btn-sm" onclick="editStatus(<?= $r['id'] ?>, <?= $r['status'] ?>, '<?= h($r['used_at'] ?? '') ?>', <?= (int)($r['user_id'] ?? 0) ?>, '<?= h($r['used_user_id'] ?? '') ?>')">修改状态</a>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php if (empty($records)): ?>
<tr><td colspan="<?= $isAdmin ? 17 : 14 ?>" style="text-align:center;color:#999;">暂无数据</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php renderPagination($page, $totalPages, !empty($_GET['search']) ? ['search' => $_GET['search']] : []); ?>
</div>
<?php
$allUsers = $pdo->query('SELECT id, username FROM users ORDER BY id ASC')->fetchAll();
if ($isAdmin):
?>
<!-- 修改状态弹窗 -->
<div class="modal" id="editModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('editModal')">&times;</span>
<h3>修改状态</h3>
<form method="post">
<input type="hidden" name="action" value="edit_status">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<input type="hidden" name="id" id="edit_id">
<div class="form-group">
<label>状态</label>
<select name="status" class="form-control" id="edit_status">
<option value="1">未使用</option>
<option value="2">已使用</option>
<option value="3">已过期</option>
</select>
</div>
<div class="form-group">
<label>使用时间</label>
<input type="datetime-local" name="used_at" class="form-control" id="edit_used_at">
</div>
<div class="form-group">
<label>用户</label>
<select name="user_id" class="form-control" id="edit_user_id">
<option value="">-- 不关联 --</option>
<?php foreach ($allUsers as $u): ?>
<option value="<?= $u['id'] ?>"><?= h($u['username']) ?> (ID: <?= $u['id'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" id="edit_used_user_group">
<label>会员ID <span id="edit_used_user_required" style="color:red;display:none;">*</span></label>
<input type="text" name="used_user_id" class="form-control" id="edit_used_user_id" placeholder="使用该兑换码的用户标识">
</div>
<button type="submit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
<!-- 手动添加弹窗 -->
<div class="modal" id="addModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('addModal')">&times;</span>
<h3>手动添加记录</h3>
<form method="post">
<input type="hidden" name="action" value="manual_add">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group">
<label>兑换码名称 <span style="color:red">*</span></label>
<input type="text" name="code_name" class="form-control" required>
</div>
<div class="form-group">
<label>批次号 <span style="color:red">*</span></label>
<input type="text" name="batch_no" class="form-control" required>
</div>
<div class="form-group">
<label>兑换码类型</label>
<input type="number" name="code_type" class="form-control" value="1" readonly>
</div>
<div class="form-group">
<label>兑换码 <span style="color:red">*</span></label>
<input type="text" name="code" class="form-control" required>
</div>
<div class="form-group">
<label>面值</label>
<input type="text" name="value" class="form-control" placeholder="如: 100元">
</div>
<div class="form-group">
<label>状态</label>
<select name="rec_status" class="form-control">
<option value="1">未使用</option>
<option value="2">已使用</option>
<option value="3">已过期</option>
</select>
</div>
<div class="form-group">
<label>使用时间</label>
<input type="datetime-local" name="used_at" class="form-control">
</div>
<div class="form-group">
<label>过期时间</label>
<input type="text" name="expired_at" class="form-control" placeholder="2027-06-01 12:26:53">
</div>
<div class="form-group">
<label>会员ID</label>
<input type="text" name="used_user_id" class="form-control" placeholder="使用该兑换码的用户标识">
</div>
<div class="form-group">
<label>用户</label>
<select name="user_id" class="form-control">
<option value="">-- 不关联 --</option>
<?php foreach ($allUsers as $u): ?>
<option value="<?= $u['id'] ?>"><?= h($u['username']) ?> (ID: <?= $u['id'] ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>领取时间</label>
<input type="datetime-local" name="claimed_at" class="form-control">
</div>
<div class="form-group">
<label>出货价一档</label>
<input type="number" step="0.01" name="price_tier1" class="form-control">
</div>
<div class="form-group">
<label>出货价二挡</label>
<input type="number" step="0.01" name="price_tier2" class="form-control">
</div>
<button type="submit" class="btn btn-success">添加</button>
</form>
</div>
</div>
<script>
function showModal(id) { document.getElementById(id).classList.add('active'); }
function hideModal(id) { document.getElementById(id).classList.remove('active'); }
function editStatus(id, status, usedAt, userId, usedUserId) {
document.getElementById('edit_id').value = id;
document.getElementById('edit_status').value = status;
document.getElementById('edit_used_at').value = usedAt ? usedAt.replace(' ', 'T').substring(0, 16) : '';
document.getElementById('edit_user_id').value = userId;
document.getElementById('edit_used_user_id').value = usedUserId;
toggleUsedUserRequired();
showModal('editModal');
}
function toggleUsedUserRequired() {
var status = document.getElementById('edit_status').value;
var indicator = document.getElementById('edit_used_user_required');
var input = document.getElementById('edit_used_user_id');
if (status === '2') {
indicator.style.display = 'inline';
input.required = true;
} else {
indicator.style.display = 'none';
input.required = false;
}
}
document.getElementById('edit_status').addEventListener('change', toggleUsedUserRequired);
document.querySelectorAll('.modal').forEach(function(m) {
m.addEventListener('click', function(e) { if (e.target === this) this.classList.remove('active'); });
});
</script>
<?php endif; ?>
<?php require __DIR__ . '/includes/footer.php'; ?>

372
code_manage.php Executable file
View File

@ -0,0 +1,372 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireAdmin();
$pageTitle = '库存管理';
require __DIR__ . '/includes/header.php';
$msg = '';
$pid = getCurrentProductId();
// Excel导入 (CSV格式)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'import') {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); }
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['csv', 'xls', 'xlsx'])) {
$msg = '<div class="alert alert-danger">仅支持 CSV、XLS、XLSX 格式</div>';
} 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 = '<div class="alert alert-warning"><pre>' . h(implode("\n", array_slice($errors, 0, 20))) . '</pre></div>';
}
}
} else {
$msg = '<div class="alert alert-danger">请选择文件</div>';
}
}
// 删除单个兑换码
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();
?>
<div class="card">
<h2>库存管理</h2>
<?= $msg ?>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<form method="get" style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;align-items:flex-end;">
<div class="form-group" style="margin-bottom:0;">
<label style="font-size:12px;">用户</label>
<input type="text" name="claim_user" class="form-control" value="<?= h($_GET['claim_user'] ?? '') ?>" placeholder="用户名" style="width:110px;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label style="font-size:12px;">兑换码</label>
<input type="text" name="code" class="form-control" value="<?= h($_GET['code'] ?? '') ?>" style="width:130px;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label style="font-size:12px;">名称</label>
<input type="text" name="name" class="form-control" value="<?= h($_GET['name'] ?? '') ?>" style="width:100px;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label style="font-size:12px;">批次号</label>
<input type="text" name="batch_no" class="form-control" value="<?= h($_GET['batch_no'] ?? '') ?>" style="width:120px;">
</div>
<button type="submit" class="btn btn-primary btn-sm">搜索</button>
<?php if (!empty($_GET['claim_user']) || !empty($_GET['code']) || !empty($_GET['name']) || !empty($_GET['batch_no'])): ?>
<a href="code_manage.php" class="btn btn-sm" style="background:#999;color:#fff;">清空</a>
<?php endif; ?>
</form>
<div style="margin-bottom:16px;">
<a href="javascript:void(0)" class="btn btn-success" onclick="showModal('importModal')">+ Excel导入</a>
<a href="export_template.php" class="btn btn-info">下载导入模板</a>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>创建时间</th>
<th>名称</th>
<th>批次号</th>
<th>类型</th>
<th>兑换码</th>
<th>面值</th>
<th>状态</th>
<th>过期时间</th>
<th>用户</th>
<th>领取时间</th>
<th>出货价一档</th>
<th>出货价二挡</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($codes as $c): ?>
<tr>
<td><?= $c['id'] ?></td>
<td><?= formatDateTime($c['created_at']) ?></td>
<td><?= h($c['name']) ?></td>
<td><?= h($c['batch_no']) ?></td>
<td><?= h($c['type']) ?></td>
<td><?= h($c['code']) ?></td>
<td><?= h($c['value']) ?></td>
<td><?= claimStatusBadge((int)$c['status']) ?></td>
<td><?= formatDateTime($c['expired_at']) ?></td>
<td><?= h($c['claim_username'] ?? ($c['claim_user_id'] ? 'ID:' . $c['claim_user_id'] : '-')) ?></td>
<td><?= formatDateTime($c['claimed_at']) ?></td>
<td><?= $c['price_tier1'] !== null ? h($c['price_tier1']) : '-' ?></td>
<td><?= $c['price_tier2'] !== null ? h($c['price_tier2']) : '-' ?></td>
<td><a href="?del=<?= $c['id'] ?>&csrf_token=<?= csrfToken() ?>" class="btn btn-danger btn-sm" onclick="return confirm('确定删除此兑换码?')">删除</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($codes)): ?>
<tr><td colspan="14" style="text-align:center;color:#999;">暂无数据</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php
$extra = [];
foreach (['claim_user', 'code', 'name', 'batch_no'] as $k) {
if (!empty($_GET[$k])) $extra[$k] = $_GET[$k];
}
renderPagination($page, $totalPages, $extra);
?>
</div>
<!-- 导入弹窗 -->
<div class="modal" id="importModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('importModal')">&times;</span>
<h3>Excel导入兑换码</h3>
<div class="alert alert-info">
<strong>导入说明:</strong><br>
1. 支持 CSV XLSX 格式<br>
2. 文件必须包含表头,列顺序为:<br>
<code>兑换码名称, 批次号, 类型, 兑换码, 面值, 过期时间, 出货价一档, 出货价二挡</code><br>
3. 类型必须为 1<br>
4. 过期时间格式2027-06-01 12:26:53(可选)<br>
5. 出货价字段可选<br>
6. 推荐先<a href="export_template.php">下载模板</a>
</div>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="action" value="import">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group">
<label>选择文件</label>
<input type="file" name="file" class="form-control-file" accept=".csv,.xls,.xlsx" required>
</div>
<button type="submit" class="btn btn-success">开始导入</button>
</form>
</div>
</div>
<script>
function showModal(id) { document.getElementById(id).classList.add('active'); }
function hideModal(id) { document.getElementById(id).classList.remove('active'); }
document.querySelectorAll('.modal').forEach(m => {
m.addEventListener('click', function(e) { if (e.target === this) this.classList.remove('active'); });
});
</script>
<?php
/**
* 使用内置 ZipArchive + SimpleXML 解析 XLSX 文件,无需第三方库
*/
function readXlsx($file) {
$zip = new ZipArchive;
if ($zip->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'; ?>

23
config/db.php Executable file
View File

@ -0,0 +1,23 @@
<?php
session_set_cookie_params([
'httponly' => 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());
}

16
export_template.php Executable file
View File

@ -0,0 +1,16 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireAdmin();
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename=redemption_code_template.csv');
// BOM for Excel UTF-8
echo "\xEF\xBB\xBF";
$handle = fopen('php://output', 'w');
fputcsv($handle, ['兑换码名称', '批次号', '类型', '兑换码', '面值', '过期时间', '出货价一档', '出货价二挡']);
fputcsv($handle, ['示例券', 'BATCH001', '1', 'ABC123XYZ', '100', '2027-12-31 23:59:59', '80', '70']);
fputcsv($handle, ['示例券2', 'BATCH001', '1', 'DEF456UVW', '50', '', '', '']);
fclose($handle);

89
includes/auth.php Executable file
View File

@ -0,0 +1,89 @@
<?php
require_once __DIR__ . '/../config/db.php';
// 产品切换
if (isset($_GET['set_product'])) {
$pid = (int)$_GET['set_product'];
if ($pid > 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();
}

17
includes/footer.php Executable file
View File

@ -0,0 +1,17 @@
</main>
<script>
(function() {
var alerts = document.querySelectorAll('.alert');
for (var i = 0; i < alerts.length; i++) {
(function(el) {
setTimeout(function() {
el.style.transition = 'opacity 0.5s';
el.style.opacity = '0';
setTimeout(function() { el.style.display = 'none'; }, 500);
}, 5000);
})(alerts[i]);
}
})();
</script>
</body>
</html>

105
includes/functions.php Executable file
View File

@ -0,0 +1,105 @@
<?php
function h(?string $str): string {
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
}
function redirect(string $url): void {
header("Location: $url");
exit;
}
function csrfToken(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verifyCsrf(string $token): bool {
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
function statusBadge(int $status): string {
switch ($status) {
case 1: return '<span class="badge badge-warning">未使用</span>';
case 2: return '<span class="badge badge-success">已使用</span>';
case 3: return '<span class="badge badge-danger">已过期</span>';
default: return '<span class="badge">未知</span>';
}
}
function claimStatusBadge(int $status): string {
switch ($status) {
case 1: return '<span class="badge badge-info">未领取</span>';
case 2: return '<span class="badge badge-success">已领取</span>';
default: return '<span class="badge">未知</span>';
}
}
function workOrderStatusBadge(string $status): string {
switch ($status) {
case '未处理': return '<span class="badge badge-warning">未处理</span>';
case '已处理': return '<span class="badge badge-success">已处理</span>';
case '已驳回': return '<span class="badge badge-danger">已驳回</span>';
default: return '<span class="badge">未知</span>';
}
}
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);
};
?>
<div class="pagination">
<?php if ($current > 1): ?>
<a href="<?= h($buildUrl($current - 1)) ?>">上一页</a>
<?php else: ?>
<span class="disabled">上一页</span>
<?php endif; ?>
<?php
$range = [];
$range[] = 1;
$start = max(2, $current - 2);
$end = min($total - 1, $current + 2);
if ($start > 2) $range[] = '...';
for ($i = $start; $i <= $end; $i++) $range[] = $i;
if ($end < $total - 1) $range[] = '...';
if ($total > 1) $range[] = $total;
foreach ($range as $p):
if ($p === '...'): ?>
<span class="ellipsis"></span>
<?php else: ?>
<a href="<?= h($buildUrl($p)) ?>" class="<?= $p === $current ? 'active' : '' ?>"><?= $p ?></a>
<?php endif;
endforeach; ?>
<?php if ($current < $total): ?>
<a href="<?= h($buildUrl($current + 1)) ?>">下一页</a>
<?php else: ?>
<span class="disabled">下一页</span>
<?php endif; ?>
<form method="get" class="page-jump" onsubmit="return (function(f){var p=f.querySelector('[name=p]');if(p.value<1||p.value><?= $total ?>){alert('页数超出范围');return false;}return true;})(this)">
<span>跳转</span>
<input type="number" name="p" value="<?= $current ?>" min="1" max="<?= $total ?>">
<?php foreach ($extra as $k => $v): ?>
<input type="hidden" name="<?= h($k) ?>" value="<?= h($v) ?>">
<?php endforeach; ?>
<button type="submit">GO</button>
</form>
</div>
<?php
}
function maskCode(string $code): string {
$len = mb_strlen($code);
if ($len <= 6) return mb_substr($code, 0, 1) . str_repeat('*', $len - 1);
$show = max(3, intval($len / 4));
return mb_substr($code, 0, $show) . str_repeat('*', $len - $show * 2) . mb_substr($code, -$show);
}

60
includes/header.php Executable file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($pageTitle ?? '兑换码管理系统') ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<?php $enabledProducts = getEnabledProducts(); ?>
<nav class="navbar">
<div class="nav-container">
<a href="index.php" class="nav-brand">兑换码管理系统</a>
<?php if (count($enabledProducts) > 0): $pid = getCurrentProductId(); ?>
<div class="product-switcher">
<select onchange="switchProduct(this)" style="padding:4px 8px;border:1px solid rgba(255,255,255,0.2);border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;font-size:13px;cursor:pointer;">
<?php foreach ($enabledProducts as $p): ?>
<option value="<?= $p['id'] ?>" <?= $pid == $p['id'] ? 'selected' : '' ?>><?= h($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<button class="nav-toggle" id="navToggle">&#9776;</button>
<div class="nav-menu" id="navMenu">
<a href="query_code.php" class="nav-link">查询兑换码</a>
<a href="claim_records.php" class="nav-link">领取记录</a>
<a href="work_order_records.php" class="nav-link">工单记录</a>
<a href="bill_records.php" class="nav-link">账单管理</a>
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<a href="work_order_manage.php" class="nav-link">工单管理</a>
<a href="code_manage.php" class="nav-link">库存管理</a>
<a href="product_manage.php" class="nav-link">产品管理</a>
<a href="admin_settings.php" class="nav-link">后台设置</a>
<?php endif; ?>
<span class="nav-user"><?= h($_SESSION['username'] ?? '') ?></span>
<a href="logout.php" class="nav-link nav-logout">退出</a>
</div>
</div>
</nav>
<main class="container">
<script>
document.getElementById('navToggle')?.addEventListener('click', function() {
document.getElementById('navMenu').classList.toggle('active');
});
(function() {
var current = window.location.pathname.split('/').pop();
var links = document.querySelectorAll('.nav-menu .nav-link');
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute('href');
if (href === current) {
links[i].classList.add('nav-active');
}
}
})();
function switchProduct(el) {
var url = new URL(window.location.href);
url.searchParams.set('set_product', el.value);
window.location.href = url.toString();
}
</script>

166
index.php Executable file
View File

@ -0,0 +1,166 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireLogin();
// 修改密码
$pwdMsg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'change_password') {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); }
$oldPwd = $_POST['old_password'] ?? '';
$newPwd = $_POST['new_password'] ?? '';
$confirmPwd = $_POST['confirm_password'] ?? '';
if ($oldPwd === '' || $newPwd === '' || $confirmPwd === '') {
$pwdMsg = '请填写所有字段';
} elseif (strlen($newPwd) < 6) {
$pwdMsg = '新密码长度至少6位';
} elseif ($newPwd !== $confirmPwd) {
$pwdMsg = '两次密码输入不一致';
} else {
$stmt = $pdo->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'];
}
?>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value"><?= $stats['total_codes'] ?></div>
<div class="stat-label">总兑换码</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= $stats['claimed_codes'] ?></div>
<div class="stat-label">已领取</div>
</div>
<?php if (isAdmin()): ?>
<div class="stat-card">
<div class="stat-value"><?= $stats['total_records'] ?></div>
<div class="stat-label">领取记录</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= $stats['pending_orders'] ?>/<?= $stats['total_orders'] ?></div>
<div class="stat-label">待处理工单/总数</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= $stats['total_users'] ?></div>
<div class="stat-label">用户数</div>
</div>
<?php else: ?>
<div class="stat-card">
<div class="stat-value"><?= $stats['my_records'] ?></div>
<div class="stat-label">我的领取记录</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= $stats['my_orders'] ?></div>
<div class="stat-label">我的工单</div>
</div>
<?php endif; ?>
</div>
<div class="card">
<h2>快速入口</h2>
<div class="action-group">
<a href="claim_code.php" class="btn btn-success">领取兑换码</a>
<a href="work_order_create.php" class="btn btn-primary">发起工单</a>
<a href="query_code.php" class="btn btn-info">查询兑换码</a>
<a href="claim_records.php" class="btn btn-warning">领取记录</a>
<a href="bill_records.php" class="btn btn-info">账单管理</a>
<a href="work_order_records.php" class="btn btn-primary">工单记录</a>
<?php if (isAdmin()): ?>
<a href="code_manage.php" class="btn btn-success">库存管理</a>
<a href="work_order_manage.php" class="btn btn-info">工单管理</a>
<a href="admin_settings.php" class="btn btn-primary">后台设置</a>
<?php endif; ?>
</div>
<div style="margin-top:16px;">
<a href="javascript:void(0)" class="btn btn-primary btn-sm" onclick="showModal('pwdModal')">修改密码</a>
</div>
</div>
<!-- 修改密码弹窗 -->
<div class="modal<?= $pwdMsg ? ' active' : '' ?>" id="pwdModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('pwdModal')">&times;</span>
<h3>修改密码</h3>
<?php if ($pwdMsg): ?>
<div class="alert alert-danger"><?= h($pwdMsg) ?></div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="action" value="change_password">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group">
<label>当前密码</label>
<input type="password" name="old_password" class="form-control" required>
</div>
<div class="form-group">
<label>新密码</label>
<input type="password" name="new_password" class="form-control" required minlength="6">
</div>
<div class="form-group">
<label>确认新密码</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">确认修改</button>
</form>
</div>
</div>
<script>
function showModal(id) { document.getElementById(id).classList.add('active'); }
function hideModal(id) { document.getElementById(id).classList.remove('active'); }
document.getElementById('pwdModal')?.addEventListener('click', function(e) { if (e.target === this) this.classList.remove('active'); });
</script>
<?php require __DIR__ . '/includes/footer.php'; ?>

121
install.sql Executable file
View File

@ -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);

75
login.php Executable file
View File

@ -0,0 +1,75 @@
<?php
require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/functions.php';
if (isset($_SESSION['user_id'])) {
redirect('index.php');
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$captcha = strtoupper(trim($_POST['captcha'] ?? ''));
if ($captcha !== ($_SESSION['captcha'] ?? '')) {
$error = '验证码错误';
} elseif ($username === '' || $password === '') {
$error = '请输入用户名和密码';
} else {
$stmt = $pdo->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'] = '';
}
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - 兑换码管理系统</title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="auth-page">
<div class="auth-card">
<h1>兑换码管理系统</h1>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<form method="post">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" class="form-control" required autocomplete="username">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" class="form-control" required autocomplete="current-password">
</div>
<div class="form-group">
<label>验证码</label>
<div class="captcha-row">
<input type="text" name="captcha" class="form-control" required maxlength="4" autocomplete="off">
<img src="captcha.php?<?= time() ?>" alt="验证码" onclick="this.src='captcha.php?'+Date.now()" title="点击刷新">
</div>
</div>
<button type="submit" class="btn btn-primary"> </button>
</form>
<div class="auth-footer">没有账号?<a href="register.php">立即注册</a></div>
</div>
</body>
</html>

7
logout.php Executable file
View File

@ -0,0 +1,7 @@
<?php
session_start();
$_SESSION = [];
setcookie(session_name(), '', time() - 42000, '/');
session_destroy();
header('Location: login.php');
exit;

230
product_manage.php Normal file
View File

@ -0,0 +1,230 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireAdmin();
$pageTitle = '产品管理';
require __DIR__ . '/includes/header.php';
$msg = '';
// 新增
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add'])) {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); }
$code = trim($_POST['code'] ?? '');
$name = trim($_POST['name'] ?? '');
$apiUrl = trim($_POST['api_url'] ?? '');
$token = trim($_POST['token'] ?? '');
$remark = trim($_POST['remark'] ?? '');
if ($code === '') {
$msg = '<div class="alert alert-danger">产品ID不能为空</div>';
} elseif ($name === '') {
$msg = '<div class="alert alert-danger">产品名称不能为空</div>';
} else {
$stmt = $pdo->prepare('SELECT id FROM products WHERE code = ?');
$stmt->execute([$code]);
if ($stmt->fetch()) {
$msg = '<div class="alert alert-danger">产品ID已存在</div>';
} 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 = '<div class="alert alert-danger">产品ID不能为空</div>';
} elseif ($name === '') {
$msg = '<div class="alert alert-danger">产品名称不能为空</div>';
} else {
$stmt = $pdo->prepare('SELECT id FROM products WHERE code = ? AND id != ?');
$stmt->execute([$code, $id]);
if ($stmt->fetch()) {
$msg = '<div class="alert alert-danger">产品ID已被其他产品使用</div>';
} 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();
?>
<div class="card">
<h2>产品管理</h2>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<?= $msg ?>
<div style="margin-bottom:16px;">
<a href="javascript:void(0)" class="btn btn-success" onclick="showModal('addModal')">+ 新增产品</a>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>产品ID</th>
<th>产品名称</th>
<th>接口地址</th>
<th>Token</th>
<th>备注</th>
<th>状态</th>
<th>修改时间</th>
<th>修改用户</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($products as $p): ?>
<tr>
<td><?= $p['id'] ?></td>
<td><code><?= h($p['code']) ?></code></td>
<td><?= h($p['name']) ?></td>
<td><?= h($p['api_url'] ?? '-') ?></td>
<td><?= $p['token'] ? h(maskCode($p['token'])) : '-' ?></td>
<td><?= h($p['remark'] ?? '-') ?></td>
<td><?= $p['status'] ? '<span class="badge badge-success">启用</span>' : '<span class="badge badge-danger">禁用</span>' ?></td>
<td><?= formatDateTime($p['updated_at']) ?></td>
<td><?= h($p['username'] ?? '-') ?></td>
<td>
<div class="action-group">
<a href="javascript:void(0)" class="btn btn-primary btn-sm" onclick="editProduct(<?= $p['id'] ?>, '<?= h($p['code']) ?>', '<?= h($p['name']) ?>', '<?= h($p['api_url'] ?? '') ?>', '<?= h($p['token'] ?? '') ?>', '<?= h($p['remark'] ?? '') ?>')">编辑</a>
<?php if ($p['status']): ?>
<a href="?toggle=<?= $p['id'] ?>&csrf_token=<?= csrfToken() ?>" class="btn btn-warning btn-sm" onclick="return confirm('确定禁用此产品?')">禁用</a>
<?php else: ?>
<a href="?toggle=<?= $p['id'] ?>&csrf_token=<?= csrfToken() ?>" class="btn btn-success btn-sm">启用</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($products)): ?>
<tr><td colspan="10" style="text-align:center;color:#999;">暂无产品</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 新增弹窗 -->
<div class="modal" id="addModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('addModal')">&times;</span>
<h3>新增产品</h3>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group">
<label>产品ID <span style="color:red">*</span></label>
<input type="text" name="code" class="form-control" required>
</div>
<div class="form-group">
<label>产品名称 <span style="color:red">*</span></label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="form-group">
<label>接口地址</label>
<input type="text" name="api_url" class="form-control" placeholder="https://example.com/api">
</div>
<div class="form-group">
<label>Token</label>
<input type="text" name="token" class="form-control" placeholder="接口认证Token">
</div>
<div class="form-group">
<label>备注</label>
<input type="text" name="remark" class="form-control" placeholder="选填">
</div>
<button type="submit" name="add" class="btn btn-success">添加</button>
</form>
</div>
</div>
<!-- 编辑弹窗 -->
<div class="modal" id="editModal">
<div class="modal-content">
<span class="modal-close" onclick="hideModal('editModal')">&times;</span>
<h3>编辑产品</h3>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<input type="hidden" name="id" id="edit_id">
<div class="form-group">
<label>产品ID <span style="color:red">*</span></label>
<input type="text" name="code" class="form-control" id="edit_code" required>
</div>
<div class="form-group">
<label>产品名称 <span style="color:red">*</span></label>
<input type="text" name="name" class="form-control" id="edit_name" required>
</div>
<div class="form-group">
<label>接口地址</label>
<input type="text" name="api_url" class="form-control" id="edit_api_url" placeholder="https://example.com/api">
</div>
<div class="form-group">
<label>Token</label>
<input type="text" name="token" class="form-control" id="edit_token" placeholder="接口认证Token">
</div>
<div class="form-group">
<label>备注</label>
<input type="text" name="remark" class="form-control" id="edit_remark" placeholder="选填">
</div>
<button type="submit" name="edit" class="btn btn-primary">保存</button>
</form>
</div>
</div>
<script>
function showModal(id) { document.getElementById(id).classList.add('active'); }
function hideModal(id) { document.getElementById(id).classList.remove('active'); }
function editProduct(id, code, name, apiUrl, token, remark) {
document.getElementById('edit_id').value = id;
document.getElementById('edit_code').value = code;
document.getElementById('edit_name').value = name;
document.getElementById('edit_api_url').value = apiUrl;
document.getElementById('edit_token').value = token;
document.getElementById('edit_remark').value = remark;
showModal('editModal');
}
document.querySelectorAll('.modal').forEach(function(m) {
m.addEventListener('click', function(e) { if (e.target === this) this.classList.remove('active'); });
});
</script>
<?php require __DIR__ . '/includes/footer.php'; ?>

90
query_code.php Executable file
View File

@ -0,0 +1,90 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireLogin();
$pageTitle = '查询兑换码';
require __DIR__ . '/includes/header.php';
$results = [];
$searched = false;
$q = trim($_GET['q'] ?? '');
$page = max(1, (int)($_GET['p'] ?? 1));
$perPage = 100;
$pid = getCurrentProductId();
if ($q !== '') {
$searched = true;
$offset = ($page - 1) * $perPage;
// 统计总数
$stmt = $pdo->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();
}
?>
<div class="card">
<h2>查询兑换码</h2>
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap;">
<input type="text" name="q" class="form-control" placeholder="输入兑换码或批次号" value="<?= h($q) ?>" required style="flex:1;min-width:200px;">
<button type="submit" class="btn btn-primary">查询</button>
</form>
</div>
<?php if ($searched): ?>
<div class="card">
<h2>查询结果 <?php if ($total > 0): ?><span style="font-size:14px;color:#999;font-weight:normal;">(共 <?= $total ?> 条)</span><?php endif; ?></h2>
<?php if (empty($results)): ?>
<div class="alert alert-info">未找到匹配的兑换码</div>
<?php else: ?>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>序号</th>
<th>兑换码名称</th>
<th>批次号</th>
<th>类型</th>
<th>兑换码</th>
<th>面值</th>
<th>状态</th>
<th>过期时间</th>
<?php if (isAdmin()): ?>
<th>创建时间</th>
<th>用户</th>
<th>领取时间</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php $idx = $offset; foreach ($results as $r): $idx++; ?>
<tr>
<td><?= $idx ?></td>
<td><?= h($r['name']) ?></td>
<td><?= h($r['batch_no']) ?></td>
<td><?= h($r['type']) ?></td>
<td><code><?= h(maskCode($r['code'])) ?></code></td>
<td><?= h($r['value']) ?></td>
<td><?= claimStatusBadge((int)$r['status']) ?></td>
<td><?= formatDateTime($r['expired_at']) ?></td>
<?php if (isAdmin()): ?>
<td><?= formatDateTime($r['created_at']) ?></td>
<td><?= h($r['username'] ?? '-') ?></td>
<td><?= formatDateTime($r['claimed_at']) ?></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php renderPagination($page, $totalPages, ['q' => $q]); ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php require __DIR__ . '/includes/footer.php'; ?>

102
register.php Executable file
View File

@ -0,0 +1,102 @@
<?php
require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/functions.php';
if (isset($_SESSION['user_id'])) {
redirect('index.php');
}
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$confirm = $_POST['confirm'] ?? '';
$invite = trim($_POST['invite'] ?? '');
if ($username === '' || $password === '' || $confirm === '' || $invite === '') {
$error = '请填写所有字段';
} elseif (strlen($username) < 3 || strlen($username) > 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 = '注册失败,请重试';
}
}
}
}
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注册 - 兑换码管理系统</title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body class="auth-page">
<div class="auth-card">
<h1>用户注册</h1>
<?php if ($error): ?>
<div class="alert alert-danger"><?= h($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><?= h($success) ?></div>
<div class="auth-footer"><a href="login.php">前往登录</a></div>
<?php else: ?>
<form method="post">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" class="form-control" required minlength="3" maxlength="20">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" class="form-control" required minlength="6">
</div>
<div class="form-group">
<label>确认密码</label>
<input type="password" name="confirm" class="form-control" required>
</div>
<div class="form-group">
<label>邀请码</label>
<input type="text" name="invite" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary"> </button>
</form>
<div class="auth-footer">已有账号?<a href="login.php">立即登录</a></div>
<?php endif; ?>
</div>
</body>
</html>

BIN
uploads/6a1eb922067e1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

147
work_order_create.php Executable file
View File

@ -0,0 +1,147 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireLogin();
$msg = '';
$oldCodes = $_POST['codes'] ?? [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); }
$content = trim($_POST['content'] ?? '');
$codes = array_filter($_POST['codes'] ?? [], fn($v) => trim($v) !== '');
$codes = array_map('trim', $codes);
$codesStr = implode("\n", $codes);
$attachment = '';
if ($content === '') {
$msg = '<div class="alert alert-danger">工单内容不能为空</div>';
} elseif (empty($codes)) {
$msg = '<div class="alert alert-danger">请至少关联一个兑换码</div>';
} elseif (count($codes) > 10) {
$msg = '<div class="alert alert-danger">最多关联10个兑换码</div>';
} elseif (count(array_unique($codes)) !== count($codes)) {
$msg = '<div class="alert alert-danger">关联的兑换码不能重复</div>';
} 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 = '<div class="alert alert-danger">以下兑换码已过期,无法提交工单:' . h(implode(', ', $expiredCodes)) . '</div>';
} elseif (!empty($invalidCodes)) {
$msg = '<div class="alert alert-danger">以下兑换码不在您的领取记录中:' . h(implode(', ', $invalidCodes)) . '</div>';
} 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 = '<div class="alert alert-danger">以下兑换码已在未处理的工单中,请处理后再提交:' . h(implode(', ', $inPending)) . '</div>';
} 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 = '<div class="alert alert-danger">不支持的文件格式</div>';
} elseif ($_FILES['attachment']['size'] > 10 * 1024 * 1024) {
$msg = '<div class="alert alert-danger">文件大小不能超过10MB</div>';
} 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 = '<div class="alert alert-danger">不支持的文件格式</div>';
} 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 = '<div class="alert alert-danger">提交失败,请重试</div>';
}
}
}
}
}
}
$pageTitle = '发起工单';
require __DIR__ . '/includes/header.php';
?>
<div class="card">
<h2>发起工单</h2>
<?= $msg ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<div class="form-group">
<label>工单内容 <span style="color:red">*</span></label>
<textarea name="content" class="form-control" required><?= h($_POST['content'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label>关联兑换码 <span style="color:red">*</span></label>
<div id="codesContainer">
<?php if (empty($oldCodes)): ?>
<div class="code-row" style="display:flex;gap:6px;margin-bottom:6px;">
<input type="text" name="codes[]" class="form-control" placeholder="输入兑换码" style="flex:1;">
<button type="button" class="btn btn-danger btn-sm" onclick="removeCode(this)" style="display:none;">删除</button>
</div>
<?php else: ?>
<?php foreach ($oldCodes as $v): ?>
<div class="code-row" style="display:flex;gap:6px;margin-bottom:6px;">
<input type="text" name="codes[]" class="form-control" value="<?= h($v) ?>" placeholder="输入兑换码" style="flex:1;">
<button type="button" class="btn btn-danger btn-sm" onclick="removeCode(this)">删除</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<button type="button" class="btn btn-info btn-sm" onclick="addCode()">+ 添加兑换码</button>
</div>
<div class="form-group">
<label>附件</label>
<input type="file" name="attachment" class="form-control-file">
<div style="font-size:12px;color:#999;margin-top:4px;">支持 jpg/png/gif/pdf/doc/xls/zip/rar/txt最大10MB</div>
</div>
<button type="submit" class="btn btn-primary">提交工单</button>
<button type="button" class="btn btn-default" onclick="history.back()" style="margin-left:8px;">取消</button>
</form>
</div>
<script>
function addCode() {
var c = document.getElementById('codesContainer');
if (c.children.length >= 10) { alert('最多添加10个兑换码'); return; }
var row = document.createElement('div');
row.className = 'code-row';
row.style.cssText = 'display:flex;gap:6px;margin-bottom:6px;';
row.innerHTML = '<input type="text" name="codes[]" class="form-control" placeholder="输入兑换码" style="flex:1;">' +
'<button type="button" class="btn btn-danger btn-sm" onclick="removeCode(this)">删除</button>';
c.appendChild(row);
}
function removeCode(btn) {
var row = btn.parentNode;
row.parentNode.removeChild(row);
}
</script>
<?php require __DIR__ . '/includes/footer.php'; ?>

View File

@ -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` 白名单验证 | 不支持的文件格式 |

228
work_order_manage.php Executable file
View File

@ -0,0 +1,228 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireAdmin();
$pageTitle = '工单管理';
require __DIR__ . '/includes/header.php';
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if (!verifyCsrf($_POST['csrf_token'] ?? '')) { die('CSRF token无效'); }
$id = (int)($_POST['id'] ?? 0);
if ($_POST['action'] === 'set_done') {
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("SELECT * FROM work_orders WHERE id = ? AND status = '未处理'");
$stmt->execute([$id]);
$order = $stmt->fetch();
if (!$order) {
$msg = '<div class="alert alert-danger">工单不存在或状态已变更</div>';
$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 = '<div class="alert alert-danger">' . implode('', $errParts) . ',工单无法处理</div>';
} 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 = '<div class="alert alert-danger">处理失败,请重试</div>';
}
} 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 = '<div class="alert alert-danger">工单状态已变更,无法驳回</div>';
}
} elseif ($_POST['action'] === 'delete') {
$stmt = $pdo->prepare("SELECT attachment, status FROM work_orders WHERE id = ?");
$stmt->execute([$id]);
$order = $stmt->fetch();
if (!$order) {
$msg = '<div class="alert alert-danger">工单不存在</div>';
} elseif ($order['status'] === '已处理') {
$msg = '<div class="alert alert-danger">已处理的工单不能删除</div>';
} 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();
?>
<div class="card">
<h2>工单管理</h2>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<?= $msg ?>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>内容</th>
<th>兑换码</th>
<th>附件</th>
<th>发起人</th>
<th>发起时间</th>
<th>处理时间</th>
<th>处理人</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $o): ?>
<tr>
<td><?= $o['id'] ?></td>
<td style="max-width:200px;white-space:pre-wrap;word-break:break-all;"><?= h(mb_substr($o['content'], 0, 100)) ?><?= mb_strlen($o['content']) > 100 ? '...' : '' ?></td>
<td style="white-space:pre-wrap;"><?php if ($o['code']): $lines = explode("\n", $o['code']); echo h(implode("\n", $lines)); else: ?>-<?php endif; ?></td>
<td>
<?php if ($o['attachment']): ?>
<a href="<?= h($o['attachment']) ?>" target="_blank" class="btn btn-info btn-sm">查看</a>
<?php else: ?>
-
<?php endif; ?>
</td>
<td><?= h($o['creator_name'] ?? '-') ?></td>
<td><?= formatDateTime($o['created_at']) ?></td>
<td><?= formatDateTime($o['processed_at']) ?></td>
<td><?= h($o['processor_name'] ?? '-') ?></td>
<td><?= workOrderStatusBadge($o['status']) ?></td>
<td>
<div class="action-group">
<?php if ($o['status'] === '未处理'): ?>
<form method="post" style="display:inline;">
<input type="hidden" name="action" value="set_done">
<input type="hidden" name="id" value="<?= $o['id'] ?>">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<button type="submit" class="btn btn-success btn-sm">设为已处理</button>
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="action" value="reject">
<input type="hidden" name="id" value="<?= $o['id'] ?>">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<button type="submit" class="btn btn-warning btn-sm" onclick="return confirm('确定驳回此工单?驳回后数据保留但不可操作')">驳回</button>
</form>
<form method="post" style="display:inline;" onsubmit="return confirm('确定删除此工单?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $o['id'] ?>">
<input type="hidden" name="csrf_token" value="<?= csrfToken() ?>">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
</form>
<?php elseif ($o['status'] === '已驳回'): ?>
<span style="color:#999;font-size:12px;">已驳回,不可操作</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($orders)): ?>
<tr><td colspan="10" style="text-align:center;color:#999;">暂无工单</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php renderPagination($page, $totalPages); ?>
</div>
<?php require __DIR__ . '/includes/footer.php'; ?>

104
work_order_records.php Executable file
View File

@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
requireLogin();
// 撤销工单(在输出 HTML 前处理)
if (isset($_GET['cancel']) && is_numeric($_GET['cancel'])) {
$id = (int)$_GET['cancel'];
$pid = getCurrentProductId();
$stmt = $pdo->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();
?>
<div style="margin-bottom:16px;">
<a href="work_order_create.php" class="btn btn-primary">+ 发起工单</a>
</div>
<?php if (isset($_SESSION['flash_msg'])): ?>
<div class="alert alert-<?= h($_SESSION['flash_type'] ?? 'success') ?>"><?= h($_SESSION['flash_msg']) ?></div>
<?php unset($_SESSION['flash_msg'], $_SESSION['flash_type']); ?>
<?php endif; ?>
<div class="card">
<h2>我的工单</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>内容</th>
<th>兑换码</th>
<th>附件</th>
<th>发起时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $o): ?>
<tr>
<td><?= $o['id'] ?></td>
<td style="max-width:200px;white-space:pre-wrap;word-break:break-all;"><?= h(mb_substr($o['content'], 0, 100)) ?><?= mb_strlen($o['content']) > 100 ? '...' : '' ?></td>
<td style="white-space:pre-wrap;"><?php if ($o['code']): $lines = explode("\n", $o['code']); echo h(implode("\n", $lines)); else: ?>-<?php endif; ?></td>
<td>
<?php if ($o['attachment']): ?>
<a href="<?= h($o['attachment']) ?>" target="_blank" class="btn btn-info btn-sm">查看</a>
<?php else: ?>
-
<?php endif; ?>
</td>
<td><?= formatDateTime($o['created_at']) ?></td>
<td><?= workOrderStatusBadge($o['status']) ?></td>
<td>
<?php if ($o['status'] === '未处理'): ?>
<a href="?cancel=<?= $o['id'] ?>&csrf_token=<?= csrfToken() ?>" class="btn btn-danger btn-sm" onclick="return confirm('确定撤销此工单?')">撤销</a>
<?php elseif ($o['status'] === '已驳回'): ?>
<span style="color:#999;font-size:12px;">已驳回</span>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($orders)): ?>
<tr><td colspan="7" style="text-align:center;color:#999;">暂无工单</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php renderPagination($page, $totalPages); ?>
</div>
<?php require __DIR__ . '/includes/footer.php'; ?>