373 lines
16 KiB
PHP
Executable File
373 lines
16 KiB
PHP
Executable File
<?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')">×</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'; ?>
|