2.1 版本文件

This commit is contained in:
2026-08-14 10:55:06 +08:00
commit a3434ec620
67 changed files with 12559 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
function adminHeader($title = '', $active = '') {
$staff = currentStaff();
if (!$staff) {
// 未登录(理论上 requireStaff 已拦截,这里兜底)
redirect('login.php');
}
$isAdmin = (int) $staff['is_admin'] === 1;
$roleLabel = $isAdmin ? '管理员' : '客服';
if ($title) {
$pageTitle = $title . ' - ' . getSiteName();
} else {
$pageTitle = getSiteName() . ' - 后台';
}
$cssVer = file_exists(__DIR__ . '/../assets/style.css') ? filemtime(__DIR__ . '/../assets/style.css') : time();
// 后台导航定义
$navAll = [
'index' => ['url' => './', 'label' => '<i class="fas fa-dashboard"></i> 仪表盘'],
'products' => ['url' => 'products', 'label' => '<i class="fas fa-box"></i> 商品管理'],
'groups' => ['url' => 'groups', 'label' => '<i class="fas fa-tags"></i> 商品分组'],
'orders' => ['url' => 'orders', 'label' => '<i class="fas fa-receipt"></i> 订单管理'],
'tickets' => ['url' => 'tickets', 'label' => '<i class="fas fa-ticket-alt"></i> 工单管理'],
'departments' => ['url' => 'departments','label' => '<i class="fas fa-sitemap"></i> 工单部门'],
'announcements' => ['url' => 'announcements', 'label' => '<i class="fas fa-bullhorn"></i> 公告中心'],
'users' => ['url' => 'users', 'label' => '<i class="fas fa-users"></i> 用户管理'],
'admins' => ['url' => 'admins', 'label' => '<i class="fas fa-user-shield"></i> 管理员'],
'settings' => ['url' => 'settings', 'label' => '<i class="fas fa-cog"></i> 设置'],
'logs' => ['url' => 'logs', 'label' => '<i class="fas fa-scroll"></i> 系统日志'],
];
// 管理员与客服均可见全部导航(客服为只读/受限操作,在各页面内单独控制)
$nav = $navAll;
$theme = currentTheme() === 'dark' ? 'dark' : 'light';
?>
<!DOCTYPE html>
<html lang="zh-CN" data-theme="<?= $theme ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($pageTitle) ?></title>
<?php
$favicon = getSetting('favicon', '');
if (!empty($favicon) && file_exists(__DIR__ . '/../' . $favicon)) {
echo '<link rel="icon" href="../' . h($favicon) . '?v=' . filemtime(__DIR__ . '/../' . $favicon) . '">' . "\n ";
}
?>
<link rel="stylesheet" href="../assets/vendor/fontawesome/all.min.css">
<link rel="stylesheet" href="../assets/style.css?v=<?= $cssVer ?>">
</head>
<body>
<header class="admin-bar">
<div class="container nav">
<a href="/" class="btn btn-ghost"><i class="fas fa-arrow-left"></i></a>
<a href="./" class="brand"><i class="fas fa-store"></i></a>
<button class="nav-toggle" onclick="toggleAdminNav()">
<i class="fas fa-bars"></i>
</button>
<nav class="nav-links" id="adminNavLinks">
<?php foreach ($nav as $k => $n): ?>
<a href="<?= $n['url'] ?>" class="<?= $k === $active ? 'active' : '' ?>"><?= $n['label'] ?></a>
<?php endforeach; ?>
</nav>
<div class="nav-actions">
<span class="admin-user" title="当前登录身份"><i class="fas fa-<?= $isAdmin ? 'user-shield' : 'headset' ?>"></i> <?= h($staff['username']) ?> · <?= $roleLabel ?></span>
<a href="logout" class="btn btn-ghost"><i class="fas fa-sign-out-alt"></i></a>
</div>
</div>
</header>
<main class="container admin-main">
<?php
}
function adminFooter()
{
?>
</main>
<footer class="admin-footer">
<div class="container">
<p>&copy; <?= date('Y') ?> <?= h(getSiteName()) ?> · 后台管理</p>
</div>
</footer>
<script>
function toggleAdminNav() {
var nav = document.getElementById('adminNavLinks');
nav.classList.toggle('open');
}
document.addEventListener('click', function(e) {
var nav = document.getElementById('adminNavLinks');
var toggle = document.querySelector('.admin-bar .nav-toggle');
if (nav && toggle) {
if (!nav.contains(e.target) && !toggle.contains(e.target)) {
nav.classList.remove('open');
}
}
});
</script>
</body>
</html>
<?php
}
+202
View File
@@ -0,0 +1,202 @@
<?php
// 后台:管理员 / 客服账户管理(增删改角色 + 终极管理员保护)
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$me = currentStaff();
$db = db();
$msg = '';
$err = '';
// 客服只读
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
}
// ─── 编辑角色(admin ↔ cs)────
if (isset($_POST['edit_role']) && $isAdmin) {
verifyCsrf();
$id = (int) ($_POST['edit_role'] ?? 0);
$role = trim($_POST['new_role'] ?? '');
$role = in_array($role, ['admin', 'cs'], true) ? $role : '';
if ($id <= 0 || $role === '') {
$err = '参数不合法。';
} elseif ($id === (int) $me['id']) {
$err = '不能修改当前登录账号的角色。';
} elseif ($id === 1) {
$err = '不能修改终极管理员(ID=1)的角色。';
} else {
$row = $db->prepare('SELECT id, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND role IN (\'admin\',\'cs\')')->execute([$id])->fetch();
if (!$row) {
$err = '账号不存在。';
} else {
$newIsAdmin = ($role === 'admin') ? 1 : 0;
$db->prepare('UPDATE ' . tn('users') . ' SET role = ?, is_admin = ? WHERE id = ?')
->execute([$role, $newIsAdmin, $id]);
$msg = '已将账号 #' . $id . ' 角色改为「' . ($role === 'admin' ? '管理员' : '客服') . '」。';
}
}
}
// ─── 删除 ──
if (isset($_POST['delete']) && $isAdmin) {
verifyCsrf();
$id = (int) ($_POST['delete'] ?? 0);
if ($id === (int) $me['id']) {
$err = '不能删除当前登录的账号。';
} elseif ($id === 1) {
$err = '不能删除终极管理员(ID=1),该账号受系统保护。';
} else {
$row = $db->prepare('SELECT id, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND role IN (\'admin\',\'cs\')')->execute([$id])->fetch();
if (!$row) {
$err = '账号不存在。';
} elseif ((int) $row['is_admin'] === 1) {
// 管理员:确保至少保留一名管理员(含终极管理员)
$cnt = $db->query('SELECT COUNT(*) FROM ' . tn('users') . ' WHERE role = \'admin\' AND status = 1')->fetchColumn();
if ((int) $cnt <= 1) {
$err = '至少要保留一名管理员,不能继续删除。';
} else {
$db->prepare('DELETE FROM ' . tn('users') . ' WHERE id = ?')->execute([$id]);
$msg = '管理员已删除。';
}
} else {
$db->prepare('DELETE FROM ' . tn('users') . ' WHERE id = ?')->execute([$id]);
$msg = '客服已删除。';
}
}
}
// ─── 添加 ──
if (isset($_POST['add']) && $isAdmin) {
verifyCsrf();
$u = trim($_POST['username'] ?? '');
$em = trim($_POST['email'] ?? '');
$pw = $_POST['password'] ?? '';
$role = trim($_POST['role'] ?? 'admin');
$role = in_array($role, ['admin', 'cs'], true) ? $role : 'admin';
if (strlen($u) < 3) $err = '账号至少 3 个字符。';
elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $err = '请填写有效的邮箱。';
elseif (strlen($pw) < 6) $err = '密码至少 6 位。';
else {
$chk = $db->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ?');
$chk->execute([$u]);
if ($chk->fetch()) {
$err = '该用户名已存在。';
} else {
$hash = password_hash($pw, PASSWORD_DEFAULT);
$code = genInviteCode();
$isAdminFlag = $role === 'admin' ? 1 : 0;
$db->prepare('INSERT INTO ' . tn('users') . ' (username,password,email,invite_code,is_admin,role,created_at) VALUES (?,?,?,?,?,?,NOW())')
->execute([$u, $hash, $em, $code, $isAdminFlag, $role]);
$msg = ($role === 'admin' ? '管理员' : '客服') . '已添加。';
}
}
}
$list = $db->query('SELECT id, username, email, status, role, created_at FROM ' . tn('users') . ' WHERE role IN (\'admin\',\'cs\') ORDER BY role DESC, id ASC')->fetchAll();
adminHeader('管理员', 'admins');
?>
<h1 class="page-title"><i class="fas fa-user-shield"></i> 管理员与客服</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<div class="panel">
<h3>添加账号(管理员 / 客服)</h3>
<?php if ($isAdmin): ?>
<p class="muted">管理员拥有全部后台权限;客服可查看所有页面但仅能处理工单相关操作。</p>
<form method="post" action="" class="grid-form" style="max-width:680px;">
<?= csrfField() ?>
<label>账号 *<input type="text" name="username" placeholder="至少 3 个字符" required></label>
<label>邮箱 *<input type="email" name="email" required></label>
<label>角色
<select name="role">
<option value="admin">管理员(全部权限)</option>
<option value="cs">客服(查看 + 工单操作)</option>
</select>
</label>
<label class="span2">密码 *<input type="password" name="password" placeholder="至少 6 位密码" required></label>
<div class="form-actions span2"><button type="submit" name="add" class="btn btn-primary"><i class="fas fa-user-plus"></i> 添加账号</button></div>
</form>
<?php else: ?>
<p class="muted">客服账号无法添加新管理员,如需操作请联系管理员。</p>
<?php endif; ?>
</div>
<div class="panel">
<h3>账号列表(共 <?= count($list) ?> 名)</h3>
<?php if (empty($list)): ?>
<p class="empty">暂无账号。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>ID</th><th>账号</th><th>邮箱</th><th>角色</th><th>状态</th><th>注册时间</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($list as $a): ?>
<?php
$isMe = (int)$a['id'] === (int)$me['id'];
$isSuper = (int)$a['id'] === 1;
$isAdm = ($a['role'] === 'admin');
?>
<tr <?= $isSuper ? 'style="background:color-mix(in srgb,var(--primary) 6%,transparent)"' : '' ?>>
<td><?= (int) $a['id'] ?><?= $isSuper ? ' <sup style="color:var(--primary);font-size:.72rem">超级</sup>' : '' ?></td>
<td><?= h($a['username']) ?><?= $isMe ? ' <span class="badge-on">我</span>' : '' ?></td>
<td><?= h($a['email']) ?></td>
<td>
<?php if ($isAdmin && !$isMe && !$isSuper): ?>
<form method="post" action="" style="display:inline" onsubmit="return confirmRoleChange(<?= (int) $a['id'] ?>, '<?= h($a['username']) ?>');">
<?= csrfField() ?>
<input type="hidden" name="edit_role" value="<?= (int) $a['id'] ?>">
<select name="new_role" onchange="this.form.submit()" style="padding:2px 6px;border:1px solid var(--outline);border-radius:6px;font-size:.82rem;cursor:pointer;background:transparent;color:inherit">
<option value="admin" <?php if ($isAdm): ?>selected<?php endif; ?>>管理员</option>
<option value="cs" <?php if (!$isAdm): ?>selected<?php endif; ?>>客服</option>
</select>
</form>
<?php else: ?>
<?php if ($isAdm): ?>
<span class="badge-on">管理员</span>
<?php else: ?>
<span class="badge-on" style="background:var(--primary-container);color:var(--on-primary-container)">客服</span>
<?php endif; ?>
<?php if ($isSuper): ?><span class="muted" style="font-size:.75rem;margin-left:4px">(受保护)</span><?php endif; ?>
<?php endif; ?>
</td>
<td><?= (int) $a['status'] === 1 ? '<span class="badge-on">正常</span>' : '<span class="badge-off">封禁</span>' ?></td>
<td><?= date('Y-m-d', strtotime($a['created_at'])) ?></td>
<td class="row-actions">
<?php if ($isMe): ?>
<span class="muted">当前账号</span>
<?php elseif ($isAdmin): ?>
<?php if ($isSuper): ?>
<span class="muted" title="终极管理员受系统保护,不可删除"><i class="fas fa-shield-halved" style="color:var(--primary)"></i> 受保护</span>
<?php else: ?>
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定删除该账号?此操作不可恢复。');">
<?= csrfField() ?>
<button type="submit" name="delete" value="<?= (int) $a['id'] ?>" class="mini-btn danger"><i class="fas fa-trash"></i></button>
</form>
<?php endif; ?>
<?php else: ?>
<span class="muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($isAdmin): ?>
<p class="muted" style="margin-top:10px;font-size:.82rem">
<i class="fas fa-info-circle"></i> 终极管理员(ID=1)受系统保护,不可被删除或降级。其他管理员可由同级管理员删除或改为客服。
</p>
<?php endif; ?>
<?php endif; ?>
</div>
<script>
function confirmRoleChange(id, name) {
var sel = event.target.form.elements.new_role;
var roleLabel = sel.options[sel.selectedIndex].text;
return confirm('确定将 ' + name + ' 的角色改为「' + roleLabel + '」?');
}
</script>
<?php adminFooter(); ?>
+145
View File
@@ -0,0 +1,145 @@
<?php
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$db = db();
$msg = '';
$err = '';
// 客服账号仅可查看,禁止写操作
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
}
if (isset($_POST['delete'])) {
verifyCsrf();
$id = (int) ($_POST['delete'] ?? 0);
$db->prepare('DELETE FROM ' . tn('announcements') . ' WHERE id = ?')->execute([$id]);
$msg = '已删除这条公告。';
}
if (isset($_POST['save'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$pinned = isset($_POST['pinned']) ? 1 : 0;
$status = isset($_POST['status']) ? 1 : 0;
if ($title === '') $err = '请填写公告标题。';
elseif ($content === '') $err = '请填写公告内容。';
if ($err === '') {
if ($id > 0) {
$db->prepare('UPDATE ' . tn('announcements') . ' SET title=?, content=?, pinned=?, status=? WHERE id=?')
->execute([$title, $content, $pinned, $status, $id]);
$msg = '公告已更新!';
} else {
$db->prepare('INSERT INTO ' . tn('announcements') . ' (title, content, pinned, status, created_at) VALUES (?,?,?,?,NOW())')
->execute([$title, $content, $pinned, $status]);
$msg = '公告已发布!';
}
}
}
$edit = null;
if (isset($_GET['edit'])) {
$stmt = $db->prepare('SELECT * FROM ' . tn('announcements') . ' WHERE id = ?');
$stmt->execute([(int) $_GET['edit']]);
$edit = $stmt->fetch();
}
$showForm = ($edit !== null) || isset($_GET['act']) && $_GET['act'] === 'add';
$list = $db->query('SELECT * FROM ' . tn('announcements') . ' ORDER BY pinned DESC, created_at DESC')->fetchAll();
adminHeader('公告中心', 'announcements');
?>
<h1 class="page-title"><i class="fas fa-bullhorn"></i> 公告中心</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<?php if ($showForm): ?>
<div class="panel">
<h3><?= $edit ? '编辑公告 #' . (int)$edit['id'] : '发布新公告' ?></h3>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= $edit ? (int)$edit['id'] : 0 ?>">
<label class="span2">
<span>公告标题 <b style="color: red;">*</b></span>
<input type="text" name="title" value="<?= $edit ? h($edit['title']) : '' ?>" required>
</label>
<label class="span2">
<span>公告内容 <b style="color: red;">*</b>(支持 <a href="https://markdown.com.cn/cheat-sheet.html">Markdown 语法</a></span>
<textarea id="annContent" name="content" rows="8" required><?= $edit ? h($edit['content']) : '' ?></textarea>
</label>
<div class="span2 ann-preview-wrap">
<div class="ann-preview-bar">
<strong>实时预览</strong>
<button type="button" class="btn btn-ghost btn-sm" id="annPreviewToggle">显示 / 隐藏预览</button>
</div>
<div class="ann-detail-body ann-preview" id="annPreview"></div>
</div>
<label class="checkbox"><input type="checkbox" name="pinned" <?= $edit && !empty($edit['pinned']) ? 'checked' : '' ?>> 置顶</label>
<label class="checkbox"><input type="checkbox" name="status" <?= !$edit || !empty($edit['status']) ? 'checked' : '' ?>> 前台显示</label>
<div class="form-actions span2">
<button type="submit" name="save" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
<a href="announcements.php" class="btn btn-ghost">取消</a>
</div>
</form>
</div>
<?php endif; ?>
<div class="panel">
<div class="panel-head">
<h3>公告列表(共 <?= count($list) ?> 条)</h3>
<?php if (!$showForm): ?><a href="announcements.php?act=add" class="btn btn-primary"><i class="fas fa-plus"></i> 发布公告</a><?php endif; ?>
</div>
<?php if (empty($list)): ?>
<p class="empty">还没有公告,点上方「发布公告」。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>ID</th><th>标题</th><th>置顶</th><th>显示</th><th>时间</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($list as $a): ?>
<tr>
<td><?= (int)$a['id'] ?></td>
<td><?= h($a['title']) ?></td>
<td><?= !empty($a['pinned']) ? '<span class="badge-on">置顶</span>' : '<span class="badge-off">否</span>' ?></td>
<td><?= !empty($a['status']) ? '<span class="badge-on">显示</span>' : '<span class="badge-off">隐藏</span>' ?></td>
<td><?= date('m-d H:i', strtotime($a['created_at'])) ?></td>
<td class="row-actions">
<a href="../announcement.php?id=<?= (int)$a['id'] ?>" class="mini-btn" target="_blank" title="前台预览"><i class="fas fa-eye"></i></a>
<a href="announcements.php?edit=<?= (int)$a['id'] ?>" class="mini-btn"><i class="fas fa-edit"></i></a>
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定删除该公告?');">
<?= csrfField() ?>
<button type="submit" name="delete" value="<?= (int)$a['id'] ?>" class="mini-btn danger"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
$mv = file_exists(__DIR__ . '/../assets/marked.js') ? filemtime(__DIR__ . '/../assets/marked.js') : time();
?>
<script src="../assets/marked.js?v=<?= $mv ?>"></script>
<script>
(function () {
var ta = document.getElementById('annContent');
var pv = document.getElementById('annPreview');
if (!ta || !pv || !window.marked) return;
function render() {
try { pv.innerHTML = marked.parse(ta.value || ''); }
catch (e) { pv.textContent = ta.value; }
}
ta.addEventListener('input', render);
render();
var btn = document.getElementById('annPreviewToggle');
if (btn) btn.addEventListener('click', function () { pv.hidden = !pv.hidden; });
})();
</script>
<?php adminFooter(); ?>
+87
View File
@@ -0,0 +1,87 @@
<?php
// 后台:工单部门管理(创建 / 删除)
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$db = db();
$msg = '';
$err = '';
// 客服账号仅可查看,禁止写操作
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
}
// 删除
if (isset($_POST['delete'])) {
verifyCsrf();
$id = (int) ($_POST['delete'] ?? 0);
// 该部门下的工单改挂「未指定」(dept_id=0)
$db->prepare('UPDATE ' . tn('tickets') . ' SET dept_id = 0 WHERE dept_id = ?')->execute([$id]);
$db->prepare('DELETE FROM ' . tn('ticket_departments') . ' WHERE id = ?')->execute([$id]);
$msg = '部门已删除,其工单已移至「未指定」。';
}
// 新增
if (isset($_POST['add'])) {
verifyCsrf();
$name = trim($_POST['name'] ?? '');
$desc = trim($_POST['description'] ?? '');
$sort = (int) ($_POST['sort'] ?? 0);
if ($name === '') {
$err = '请填写部门名称';
} else {
$db->prepare('INSERT INTO ' . tn('ticket_departments') . ' (name, description, sort, created_at) VALUES (?,?,?,NOW())')
->execute([$name, $desc, $sort]);
$msg = '部门已添加';
}
}
$list = $db->query('SELECT * FROM ' . tn('ticket_departments') . ' ORDER BY sort, id')->fetchAll();
$counts = $db->query('SELECT dept_id, COUNT(*) c FROM ' . tn('tickets') . ' GROUP BY dept_id')->fetchAll(PDO::FETCH_KEY_PAIR);
adminHeader('工单部门', 'departments');
?>
<h1 class="page-title"><i class="fas fa-sitemap"></i> 工单部门</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<div class="panel">
<h3>新增部门</h3>
<form method="post" action="" class="grid-form" style="max-width:640px;">
<?= csrfField() ?>
<label>部门名称 *<input type="text" name="name" required></label>
<label>排序<input type="number" name="sort" value="0"></label>
<label class="span2">说明<input type="text" name="description" placeholder="选填,如负责范围"></label>
<div class="form-actions span2"><button type="submit" name="add" class="btn btn-primary"><i class="fas fa-plus"></i> 添加部门</button></div>
</form>
</div>
<div class="panel">
<h3>部门列表(共 <?= count($list) ?> 个)</h3>
<?php if (empty($list)): ?>
<p class="empty">还没有部门。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>ID</th><th>名称</th><th>说明</th><th>工单数</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($list as $d): ?>
<tr>
<td><?= (int) $d['id'] ?></td>
<td><?= h($d['name']) ?></td>
<td><?= h($d['description']) ?></td>
<td><?= (int) ($counts[$d['id']] ?? 0) ?></td>
<td class="row-actions">
<form method="post" action="" style="display:inline" onsubmit="return confirm('删除该部门?其下工单将移至「未指定」。');">
<?= csrfField() ?>
<button type="submit" name="delete" value="<?= (int) $d['id'] ?>" class="mini-btn danger"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php adminFooter(); ?>
+137
View File
@@ -0,0 +1,137 @@
<?php
// 后台商品分组管理:列表 + 新增 / 删除(删除前将相关商品归为「其他」避免孤儿)
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$db = db();
$msg = '';
$err = '';
// 客服账号仅可查看,禁止写操作
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
}
// ---------- 新增 / 编辑保存 ----------
if (isset($_POST['save'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$icon = trim($_POST['icon'] ?? 'fa-tags');
$sort = max(0, (int) ($_POST['sort_order'] ?? 0));
if ($name === '') {
$err = '请填写分组名称';
} elseif (mb_strlen($name) > 40) {
$err = '分组名称过长(最多 40 字)';
} else {
// 重名检查(排除自身)
$dup = $db->prepare('SELECT COUNT(*) FROM ' . tn('product_groups') . ' WHERE name = ? AND id <> ?');
$dup->execute([$name, $id]);
if ((int) $dup->fetchColumn() > 0) {
$err = '已存在同名分组,请换一个名称';
}
}
if ($err === '') {
if ($id > 0) {
$db->prepare('UPDATE ' . tn('product_groups') . ' SET name=?, icon=?, sort_order=? WHERE id=?')
->execute([$name, $icon, $sort, $id]);
$msg = '分组已更新';
} else {
$db->prepare('INSERT INTO ' . tn('product_groups') . ' (name, icon, sort_order, created_at) VALUES (?,?,?,NOW())')
->execute([$name, $icon, $sort]);
$msg = '分组已添加';
}
}
}
// ---------- 删除 ----------
if (isset($_POST['delete'])) {
verifyCsrf();
$id = (int) $_POST['delete'];
$row = $db->prepare('SELECT name FROM ' . tn('product_groups') . ' WHERE id = ?');
$row->execute([$id]);
$g = $row->fetch();
if ($g) {
// 将该分组下的商品归为「其他」,避免孤儿数据
$db->prepare('UPDATE ' . tn('products') . ' SET category = ? WHERE category = ?')
->execute(['其他', $g['name']]);
$db->prepare('DELETE FROM ' . tn('product_groups') . ' WHERE id = ?')->execute([$id]);
$msg = '分组「' . $g['name'] . '」已删除,相关商品已归为「其他」';
}
}
// ---------- 编辑回填 ----------
$edit = null;
if (isset($_GET['edit'])) {
$stmt = $db->prepare('SELECT * FROM ' . tn('product_groups') . ' WHERE id = ?');
$stmt->execute([(int) $_GET['edit']]);
$edit = $stmt->fetch();
}
$showForm = ($edit !== null) || (isset($_GET['act']) && $_GET['act'] === 'add');
// ---------- 列表(含商品计数) ----------
$list = $db->query(
'SELECT g.*, (SELECT COUNT(*) FROM ' . tn('products') . ' p WHERE p.category = g.name) AS cnt
FROM ' . tn('product_groups') . ' g ORDER BY g.sort_order ASC, g.id ASC'
)->fetchAll();
adminHeader('商品分组', 'groups');
?>
<h1 class="page-title"><i class="fas fa-tags"></i> 商品分组</h1>
<p class="muted" style="margin-bottom:16px;">分组用于首页分类筛选与商品归类。删除分组会将其下商品自动归为「其他」,不会丢失商品数据。</p>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<?php if ($showForm): ?>
<div class="panel">
<h3><?= $edit ? '编辑分组 #' . (int) $edit['id'] : '新增分组' ?></h3>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= $edit ? (int) $edit['id'] : 0 ?>">
<label>分组名称 *<input type="text" name="name" value="<?= $edit ? h($edit['name']) : '' ?>" required maxlength="40" placeholder="如 周边 / 数码"></label>
<label>排序(数字小靠前)<input type="number" name="sort_order" value="<?= $edit ? (int) $edit['sort_order'] : 0 ?>"></label>
<label class="span2">图标类名<input type="text" name="icon" value="<?= $edit ? h($edit['icon']) : 'fa-tags' ?>" placeholder="如 fa-tagsFontAwesome 类名)"></label>
<div class="form-actions span2">
<button type="submit" name="save" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
<a href="groups.php" class="btn btn-ghost">取消</a>
</div>
</form>
</div>
<?php endif; ?>
<div class="panel">
<div class="panel-head">
<h3>分组列表(共 <?= count($list) ?> 个)</h3>
<?php if (!$showForm): ?><a href="groups.php?act=add" class="btn btn-primary"><i class="fas fa-plus"></i> 新增分组</a><?php endif; ?>
</div>
<?php if (empty($list)): ?>
<p class="empty">还没有分组,点上方「新增分组」添加吧。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>ID</th><th>图标</th><th>名称</th><th>排序</th><th>商品数</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($list as $g): ?>
<tr>
<td><?= (int) $g['id'] ?></td>
<td><i class="fas <?= h($g['icon']) ?>"></i></td>
<td><?= h($g['name']) ?></td>
<td><?= (int) $g['sort_order'] ?></td>
<td><?= (int) $g['cnt'] ?></td>
<td class="row-actions">
<a href="groups.php?edit=<?= (int) $g['id'] ?>" class="mini-btn"><i class="fas fa-edit"></i></a>
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定删除该分组?其下商品将归为「其他」。');">
<?= csrfField() ?>
<button type="submit" name="delete" value="<?= (int) $g['id'] ?>" class="mini-btn danger"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php adminFooter(); ?>
+132
View File
@@ -0,0 +1,132 @@
<?php
// 后台仪表盘
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$db = db();
$isAdmin = isAdminStaff();
$me = currentStaff();
$openTickets = $db->query("SELECT COUNT(*) FROM " . tn('tickets') . " WHERE status IN ('open','pending')")->fetchColumn();
$myOpenTickets = 0;
if (!$isAdmin) {
// 客服:仅统计分配给自己的待处理工单
$stmt = $db->prepare("SELECT COUNT(*) FROM " . tn('tickets') . " WHERE status IN ('open','pending') AND assignee_id = ?");
$stmt->execute([$me['id']]);
$myOpenTickets = (int) $stmt->fetchColumn();
}
$recentTickets = $db->query(
'SELECT t.*, u.username FROM ' . tn('tickets') . ' t
LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id
' . ($isAdmin ? '' : ' WHERE t.assignee_id = ' . (int)$me['id']) . '
ORDER BY t.updated_at DESC LIMIT 5'
)->fetchAll();
$statCards = [];
if ($isAdmin) {
$onSale = $db->query('SELECT COUNT(*) FROM ' . tn('products') . ' WHERE status = 1')->fetchColumn();
$orderCnt = $db->query('SELECT COUNT(*) FROM ' . tn('orders'))->fetchColumn();
$sales = $db->query("SELECT COALESCE(SUM(total),0) FROM " . tn('orders') . " WHERE status IN ('paid','shipped','completed')")->fetchColumn();
$pending = $db->query("SELECT COUNT(*) FROM " . tn('orders') . " WHERE status = 'paid'")->fetchColumn();
$annCnt = $db->query('SELECT COUNT(*) FROM ' . tn('announcements') . ' WHERE status = 1')->fetchColumn();
$groupCnt = $db->query('SELECT COUNT(*) FROM ' . tn('product_groups'))->fetchColumn();
$statCards[] = ['num' => (int)$onSale, 'label' => '在售商品'];
$statCards[] = ['num' => (int)$orderCnt, 'label' => '累计订单'];
$statCards[] = ['num' => money($sales), 'label' => '销售总额'];
$statCards[] = ['num' => (int)$pending, 'label' => '等待发货', 'warn' => true];
$statCards[] = ['num' => (int)$annCnt, 'label' => '发布公告'];
$statCards[] = ['num' => (int)$groupCnt, 'label' => '商品分组'];
}
$statCards[] = ['num' => ($isAdmin ? (int)$openTickets : $myOpenTickets), 'label' => ($isAdmin ? '待处理工单' : '我待处理工单'), 'warn' => true];
adminHeader('仪表盘', 'index');
?>
<h1 class="page-title"><i class="fas fa-tachometer-alt"></i> 仪表盘</h1>
<div class="stat-grid">
<?php foreach ($statCards as $c): ?>
<div class="stat-card<?= !empty($c['warn']) ? ' warn' : '' ?>"><div class="stat-num"><?= $c['num'] ?></div><div class="stat-label"><?= h($c['label']) ?></div></div>
<?php endforeach; ?>
</div>
<?php if ($isAdmin): ?>
<h2 class="sec-title">最新订单</h2>
<?php
$recent = $db->query(
'SELECT o.*, u.username FROM ' . tn('orders') . ' o
LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id
ORDER BY o.created_at DESC LIMIT 5'
)->fetchAll();
?>
<?php if (empty($recent)): ?>
<p class="empty">暂无订单。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>订单号</th><th>用户</th><th>金额</th><th>状态</th><th>时间</th></tr></thead>
<tbody>
<?php foreach ($recent as $o): ?>
<tr>
<td><?= h($o['order_no']) ?></td>
<td><?= h($o['username'] ?? '游客') ?></td>
<td><?= money($o['total']) ?></td>
<td><span class="badge-status status-<?= h($o['status']) ?>"><?= orderStatusLabel($o['status']) ?></span></td>
<td><?= date('m-d H:i', strtotime($o['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<a href="orders.php" class="btn btn-ghost">查看全部订单 →</a>
<?php endif; ?>
<?php endif; ?>
<h2 class="sec-title"><?= $isAdmin ? '最新工单' : '我负责的工单' ?></h2>
<?php if (empty($recentTickets)): ?>
<p class="empty">暂无工单。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>编号</th><th>标题</th><th>提交人</th><th>状态</th><th>更新</th><th></th></tr></thead>
<tbody>
<?php foreach ($recentTickets as $t): ?>
<tr>
<td>#<?= (int)$t['id'] ?></td>
<td><?= h($t['subject']) ?></td>
<td><?= h($t['username'] ?? '游客') ?></td>
<td><span class="badge-status status-<?= h($t['status']) ?>"><?= ticketStatusLabel($t['status']) ?></span></td>
<td><?= date('m-d H:i', strtotime($t['updated_at'])) ?></td>
<td><a href="tickets.php?view=<?= (int)$t['id'] ?>" class="mini-btn">处理</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<a href="tickets.php" class="btn btn-ghost">查看全部工单 →</a>
<?php endif; ?>
<?php if ($isAdmin): ?>
<h2 class="sec-title">最新公告</h2>
<?php
$recentAnn = $db->query('SELECT * FROM ' . tn('announcements') . ' ORDER BY pinned DESC, created_at DESC LIMIT 5')->fetchAll();
?>
<?php if (empty($recentAnn)): ?>
<p class="empty">暂无公告。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>标题</th><th>置顶</th><th>时间</th></tr></thead>
<tbody>
<?php foreach ($recentAnn as $a): ?>
<tr>
<td><?= h($a['title']) ?><?= !empty($a['pinned']) ? ' <i class="fas fa-thumbtack" style="color:var(--primary)"></i>' : '' ?></td>
<td><?= !empty($a['pinned']) ? '是' : '否' ?></td>
<td><?= date('m-d H:i', strtotime($a['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<a href="announcements.php" class="btn btn-ghost">管理公告 →</a>
<?php endif; ?>
<?php endif; ?>
<?php adminFooter(); ?>
+60
View File
@@ -0,0 +1,60 @@
<?php
// 后台登录(仅管理员 is_admin=1 可进入)
session_start();
require_once __DIR__ . '/../includes/_install_redirect.php';
define('IN_APP', true);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/functions.php';
if (!empty($_SESSION['admin_id'])) {
header("HTTP/2 404 Not Found");
exit;
}
$err = '';
$cssVer = file_exists(__DIR__ . '/../assets/style.css') ? filemtime(__DIR__ . '/../assets/style.css') : time();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf();
$u = trim($_POST['username'] ?? '');
$pw = $_POST['password'] ?? '';
$stmt = db()->prepare('SELECT * FROM ' . tn('users') . ' WHERE username = ? AND role IN (\'admin\', \'cs\')');
$stmt->execute([$u]);
$row = $stmt->fetch();
if ($row && password_verify($pw, $row['password'])) {
$_SESSION['admin_id'] = $row['id'];
$_SESSION['admin_user'] = $row['username'];
unset($_SESSION['csrf']);
header('Location: index.php');
exit;
}
$err = '管理员账号或密码错误';
}
?>
<!DOCTYPE html>
<html lang="zh-CN" data-theme="<?= currentTheme() === 'dark' ? 'dark' : 'light' ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台登录 - <?= h(SITE_NAME) ?></title>
<link rel="stylesheet" href="../assets/vendor/fontawesome/all.min.css">
<link rel="stylesheet" href="../assets/style.css?v=<?= $cssVer ?>">
</head>
<body>
<div class="admin-login">
<div class="auth-card">
<h2 class="al-title"><i class="fas fa-user-shield"></i> 后台登录</h2>
<p class="al-sub"><?= h(SITE_NAME) ?> 管理后台</p>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<form method="post" action="" class="auth-form">
<?= csrfField() ?>
<label>管理员账号<input type="text" name="username" required></label>
<label>密码<input type="password" name="password" required></label>
<button type="submit" class="btn btn-primary" style="width:100%">登 录</button>
</form>
<a href="../index.php" class="al-back"><i class="fas fa-arrow-left"></i> 返回商城前台</a>
</div>
</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<?php
// 后台退出
session_start();
unset($_SESSION['admin_id'], $_SESSION['admin_user']);
session_destroy();
header('Location: login.php');
exit;
+173
View File
@@ -0,0 +1,173 @@
<?php
// 后台:系统日志查看器
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$logDir = realpath(__DIR__ . '/../storage/logs') ?: (__DIR__ . '/../storage/logs');
$msg = '';
$err = '';
// 确保日志目录存在
if (!is_dir($logDir)) {
@mkdir($logDir, 0755, true);
}
// 获取请求的日期,默认今天
$date = trim($_GET['date'] ?? '');
if ($date === '') {
$date = date('Y-m-d');
}
// 安全校验:只允许 YYYY-MM-DD 格式
if (!preg_match('#^\d{4}-\d{2}-\d{2}$#', $date)) {
$date = date('Y-m-d');
}
$logFile = $logDir . DIRECTORY_SEPARATOR . $date . '.log';
$lines = [];
$totalLines = 0;
$pageSize = 100;
$page = max(1, (int) ($_GET['page'] ?? 1));
// 读取日志文件
if (file_exists($logFile) && is_readable($logFile)) {
$content = file_get_contents($logFile);
$allLines = explode("\n", rtrim($content));
$totalLines = count($allLines);
// 分页(最新的在前面,所以倒序)
$allLines = array_reverse($allLines);
$offset = ($page - 1) * $pageSize;
$lines = array_slice($allLines, $offset, $pageSize);
} elseif (file_exists($logFile)) {
$err = '日志文件不可读,请检查目录权限。';
}
// 获取所有可用的日志日期
$logFiles = glob($logDir . DIRECTORY_SEPARATOR . '*.log');
$dates = [];
foreach ($logFiles as $f) {
$n = basename($f);
// 排除轮转文件 (.log.1)
if (preg_match('#^(\d{4}-\d{2}-\d{2})\.log$#', $n, $m)) {
$dates[] = $m[1];
}
}
rsort($dates);
$totalPages = max(1, ceil($totalLines / $pageSize));
adminHeader('系统日志', 'logs');
?>
<h1 class="page-title"><i class="fas fa-scroll"></i> 系统日志</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<!-- 日期选择 -->
<div class="panel" style="margin-bottom:18px;">
<form method="get" action="" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<label style="font-weight:600;margin:0">选择日期:
<input type="date" name="date" value="<?= h($date) ?>" style="padding:6px 10px;border:1px solid var(--outline);border-radius:8px;font-size:.9rem">
</label>
<button type="submit" class="btn btn-ghost btn-sm"><i class="fas fa-search"></i> 查看</button>
<?php if (!empty($dates)): ?>
<span class="muted" style="font-size:.82rem">可用日期:
<?php foreach (array_slice($dates, 0, 14) as $d): ?>
<a href="?date=<?= h($d) ?>" class="<?= $d === $date ? 'btn btn-sm btn-primary' : 'btn btn-ghost btn-sm' ?>" style="padding:2px 8px;font-size:.78rem"><?= h($d) ?></a>
<?php endforeach; ?>
<?php if (count($dates) > 14): ?><span class="muted">... 等 <?= count($dates) ?> 天</span><?php endif; ?>
</span>
<?php endif; ?>
</form>
</div>
<div class="panel">
<h3>
<i class="fas fa-file-lines"></i> <?= h($date) ?>.log
<?php if ($totalLines > 0): ?>
<span class="muted" style="font-weight:400;font-size:.85rem">(共 <?= number_format($totalLines) ?> 条)</span>
<?php endif; ?>
</h3>
<?php if (empty($lines) && empty($err)): ?>
<p class="empty">当天暂无日志记录。</p>
<p class="muted" style="font-size:.85rem">
日志级别:DEBUG / INFO / WARN / ERROR。当前模式:<strong><?= defined('DEBUG') && DEBUG ? 'DEBUG(记录全部)' : 'INFO(及以上)' ?></strong><br>
可在 config.php 中修改 <code>define('DEBUG', true)</code> 以开启 DEBUG 级别记录。
</p>
<?php else: ?>
<!-- 分页 -->
<?php if ($totalPages > 1): ?>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;font-size:.88rem">
<?php if ($page > 1): ?>
<a href="?date=<?= h($date) ?>&page=<?= $page - 1 ?>" class="btn btn-ghost btn-sm">&laquo; 上一页</a>
<?php endif; ?>
<span class="muted">第 <strong><?= $page ?></strong> / <?= $totalPages ?> 页</span>
<?php if ($page < $totalPages): ?>
<a href="?date=<?= h($date) ?>&page=<?= $page + 1 ?>" class="btn btn-ghost btn-sm">下一页 &raquo;</a>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="log-viewer">
<table class="data-table">
<thead><tr><th style="width:160px">时间</th><th style="width:70px">级别</th><th style="width:140px">来源</th><th>内容</th></tr></thead>
<tbody>
<?php foreach ($lines as $line):
$line = trim($line);
if ($line === '') continue;
// 解析格式: [2026-08-13 10:30:00] [WARN] [file.php:42] message [ctx]
preg_match('#^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s*\[(\w+)\](?:\s*\[([^\]]+)\])?\s*(.+)$#sU', $line, $m);
$time = $m[1] ?? '';
$level = $m[2] ?? '';
$source = $m[3] ?? '';
$body = $m[4] ?? $line;
$levelClass = match($level) {
'DEBUG' => 'log-debug',
'INFO' => 'log-info',
'WARN' => 'log-warn',
'ERROR' => 'log-error',
default => ''
};
?>
<tr class="<?= $levelClass ?>">
<td><code style="font-size:.8rem;white-space:nowrap"><?= h($time) ?: '&mdash;' ?></code></td>
<td><span class="badge-status status-<?= strtolower($level) ?>"><?= h($level) ?></span></td>
<td><code style="font-size:.78rem;color:var(--muted)"><?= h($source) ?: '' ?></code></td>
<td style="word-break:break-all;font-family:monospace;font-size:.84rem"><?= h($body) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- 底部分页 -->
<?php if ($totalPages > 1): ?>
<div style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:.88rem">
<?php if ($page > 1): ?>
<a href="?date=<?= h($date) ?>&page=<?= $page - 1 ?>" class="btn btn-ghost btn-sm">&laquo; 上一页</a>
<?php endif; ?>
<span class="muted">第 <strong><?= $page ?></strong> / <?= $totalPages ?> 页</span>
<?php if ($page < $totalPages): ?>
<a href="?date=<?= h($date) ?>&page=<?= $page + 1 ?>" class="btn btn-ghost btn-sm">下一页 &raquo;</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<style>
.log-viewer { overflow-x: auto; }
.log-viewer table { font-size:.85rem; }
.log-debug { opacity: 0.55; }
.log-info { /* default */ }
.log-warn { background: color-mix(in srgb, var(--warn) 8%, transparent); }
.log-error { background: color-mix(in srgb, var(--danger) 8%, transparent); }
.status-debug { background:#909399;color:#fff }
.status-info { background:var(--primary);color:#fff }
.status-warn { background:var(--warn);color:#000 }
.status-error { background:var(--danger);color:#fff }
</style>
<?php adminFooter(); ?>
+306
View File
@@ -0,0 +1,306 @@
<?php
// 后台订单管理:列表 + 状态流转 + 查看详情 + 发货信息填写
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$db = db();
$msg = '';
$err = '';
// 客服账号仅可查看,禁止写操作
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
}
$allowed = ['pending', 'paid', 'shipped', 'completed', 'cancelled'];
// 行内状态变更
if (isset($_POST['update_status'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$status = $_POST['status'] ?? '';
if (!in_array($status, $allowed, true)) {
$err = '非法的订单状态';
} else {
$db->prepare('UPDATE ' . tn('orders') . ' SET status = ? WHERE id = ?')->execute([$status, $id]);
$msg = '订单 #' . $id . ' 状态已更新为「' . orderStatusLabel($status) . '」';
}
}
// 发货:填写连接/登录信息
if (isset($_POST['ship'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$serverIp = trim($_POST['server_ip'] ?? '');
$connAddr = trim($_POST['conn_addr'] ?? '');
$loginUser = trim($_POST['login_user'] ?? '');
$loginPass = trim($_POST['login_pass'] ?? '');
$remark = trim($_POST['remark'] ?? '');
$activated = isset($_POST['activated']) ? 1 : 0;
$productInfo = trim($_POST['product_info'] ?? '');
$chk = $db->prepare('SELECT id, status FROM ' . tn('orders') . ' WHERE id = ?');
$chk->execute([$id]);
$ord = $chk->fetch();
if (!$ord) {
$err = '订单不存在';
} else {
// 已存在发货记录则更新,否则插入
$ex = $db->prepare('SELECT id FROM ' . tn('order_deliveries') . ' WHERE order_id = ? ORDER BY id DESC LIMIT 1');
$ex->execute([$id]);
$exId = $ex->fetchColumn();
if ($exId) {
$db->prepare('UPDATE ' . tn('order_deliveries') . ' SET server_ip=?, conn_addr=?, login_user=?, login_pass=?, remark=?, activated=?, product_info=? WHERE id=?')
->execute([$serverIp, $connAddr, $loginUser, $loginPass, $remark, $activated, $productInfo, $exId]);
} else {
$db->prepare('INSERT INTO ' . tn('order_deliveries') . ' (order_id, server_ip, conn_addr, login_user, login_pass, remark, activated, product_info, created_at) VALUES (?,?,?,?,?,?,?,?,NOW())')
->execute([$id, $serverIp, $connAddr, $loginUser, $loginPass, $remark, $activated, $productInfo]);
}
// 由「已付款」发货后自动转为「已发货」
if ($ord['status'] === 'paid') {
$db->prepare('UPDATE ' . tn('orders') . ' SET status = ? WHERE id = ?')->execute(['shipped', $id]);
}
$msg = '发货信息已保存(订单 #' . $id . ')。';
// 通过 SMTP 通知客户(失败不影响保存)
$u = $db->prepare('SELECT u.email, u.username FROM ' . tn('orders') . ' o LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id WHERE o.id = ?');
$u->execute([$id]);
$urow = $u->fetch();
if ($urow && !empty($urow['email'])) {
$subject = '[' . SITE_NAME . '] 你的订单 #' . $id . ' 已发货';
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
. '<h2 style="color:#1a73e8">订单已发货</h2>'
. '<p>你好 ' . h($urow['username']) . ',你购买的以下服务/商品已开通:</p>'
. '<div style="background:#f5f7fb;border-left:4px solid #1a73e8;padding:12px 16px;margin:10px 0;">'
. '<p style="margin:4px 0;">连接地址:' . h($connAddr) . '</p>'
. '<p style="margin:4px 0;">登录账户:' . h($loginUser) . '</p>'
. '<p style="margin:4px 0;">可在「我的订单 → 详情」中查看完整登录凭据。</p>'
. '</div>'
. '<p class="muted">如有问题请到「我的工单」联系客服。</p></div>';
fnwSendMail($urow['email'], $subject, $body);
}
}
}
// 修改到期时间(后台)
if (isset($_POST['save_expire'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$clear = isset($_POST['clear_expire']);
$expRaw = trim($_POST['expires_at'] ?? '');
$newExp = null;
if (!$clear) {
if ($expRaw === '') {
$err = '请填写新的到期时间,或勾选「清除到期时间」';
} else {
$t = strtotime(str_replace('T', ' ', $expRaw));
if ($t === false) {
$err = '到期时间格式不正确';
} else {
$newExp = date('Y-m-d H:i:s', $t);
}
}
}
if ($err === '') {
$db->prepare('UPDATE ' . tn('orders') . ' SET expires_at = ?, expire_notify_sent = 0 WHERE id = ?')
->execute([$newExp, $id]);
$msg = '订单 #' . $id . ' 的到期时间已更新' . ($newExp ? '为 ' . $newExp : '(已清除)') . ',到期提醒将重新计算。';
}
}
// 删除订单(含明细与发货信息)
if (isset($_POST['delete'])) {
verifyCsrf();
$id = (int) ($_POST['delete'] ?? 0);
$db->prepare('DELETE FROM ' . tn('order_deliveries') . ' WHERE order_id = ?')->execute([$id]);
$db->prepare('DELETE FROM ' . tn('order_items') . ' WHERE order_id = ?')->execute([$id]);
$db->prepare('DELETE FROM ' . tn('orders') . ' WHERE id = ?')->execute([$id]);
$msg = '订单 #' . $id . ' 已删除';
}
$filter = trim($_GET['status'] ?? '');
$where = $filter !== '' ? 'WHERE o.status = ?' : '';
$params = $filter !== '' ? [$filter] : [];
$stmt = $db->prepare(
'SELECT o.*, u.username, u.email AS u_email FROM ' . tn('orders') . ' o
LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id
' . $where . ' ORDER BY o.created_at DESC'
);
$stmt->execute($params);
$list = $stmt->fetchAll();
// 预取商品明细
$itemsOf = [];
foreach ($list as $o) {
$it = $db->prepare('SELECT name, qty, period_days FROM ' . tn('order_items') . ' WHERE order_id = ?');
$it->execute([$o['id']]);
$itemsOf[$o['id']] = $it->fetchAll();
}
// 查看单条 + 发货
$view = isset($_GET['view']) ? (int) $_GET['view'] : 0;
$order = $delivery = null;
if ($view > 0) {
$ostmt = $db->prepare('SELECT o.*, u.username, u.email AS u_email FROM ' . tn('orders') . ' o LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id WHERE o.id = ?');
$ostmt->execute([$view]);
$order = $ostmt->fetch();
if ($order) {
$istmt = $db->prepare('SELECT * FROM ' . tn('order_items') . ' WHERE order_id = ?');
$istmt->execute([$order['id']]);
$order['items'] = $istmt->fetchAll();
$dstmt = $db->prepare('SELECT * FROM ' . tn('order_deliveries') . ' WHERE order_id = ? ORDER BY id DESC LIMIT 1');
$dstmt->execute([$order['id']]);
$delivery = $dstmt->fetch();
}
}
adminHeader('订单管理', 'orders');
?>
<h1 class="page-title"><i class="fas fa-receipt"></i> 订单管理</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<div class="cat-chips">
<a href="orders.php" class="chip <?= $filter===''?'active':'' ?>">全部</a>
<?php foreach ($allowed as $s): ?>
<a href="orders.php?status=<?= $s ?>" class="chip <?= $filter===$s?'active':'' ?>"><?= orderStatusLabel($s) ?></a>
<?php endforeach; ?>
</div>
<?php if ($order): ?>
<div class="panel">
<div class="od-head">
<div>
<div class="od-no">订单号:<?= h($order['order_no']) ?></div>
<div class="od-time"><?= date('Y-m-d H:i', strtotime($order['created_at'])) ?></div>
</div>
<span class="badge-status status-<?= h($order['status']) ?>"><?= orderStatusLabel($order['status']) ?></span>
</div>
<div class="od-info">
<div class="od-block">
<div class="od-label">订单发起人</div>
<div class="od-line"><?= h($order['username'] ?? '未知') ?></div>
<div class="od-line">账号邮箱:<?= h($order['u_email'] ?? '未填写') ?></div>
</div>
<div class="od-block">
<div class="od-label">联系信息</div>
<div class="od-line">联系人:<?= $order['contact'] !== '' ? h($order['contact']) : '—' ?></div>
<div class="od-line">联系邮箱:<?= $order['contact_email'] !== '' ? h($order['contact_email']) : '—' ?></div>
<div class="od-line">收货地址:<?= $order['address'] !== '' ? h($order['address']) : '(未填写)' ?></div>
</div>
<div class="od-block">
<div class="od-label">周期 / 到期</div>
<div class="od-line">周期:<?= periodLabel($order['period_days']) ?></div>
<div class="od-line">到期时间:<?= expiryBadge($order['expires_at']) ?></div>
<?php if (!empty($order['expire_notify_sent'])): ?><div class="od-line muted">已发送「到期前提醒」邮件</div><?php endif; ?>
<form method="post" action="" class="expire-edit">
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= (int) $order['id'] ?>">
<label class="expire-field">修改到期时间
<input type="datetime-local" name="expires_at" value="<?= $order['expires_at'] ? date('Y-m-d\TH:i', strtotime($order['expires_at'])) : '' ?>">
</label>
<label class="expire-clear"><input type="checkbox" name="clear_expire" value="1"> 清除到期时间</label>
<button type="submit" name="save_expire" class="btn btn-primary btn-sm"><i class="fas fa-save"></i> 保存到期时间</button>
</form>
</div>
<div class="od-block">
<div class="od-label">备注</div>
<div class="od-line"><?= $order['note'] !== '' ? nl2br(h($order['note'])) : '(无)' ?></div>
</div>
<div class="od-block">
<div class="od-label">支付方式</div>
<div class="od-line"><?= payTypeLabel($order['pay_type']) ?><?= $order['pay_type']==='points' ? '(消耗 ' . (int)$order['points_used'] . ' 积分)' : '' ?></div>
</div>
</div>
<h3>商品明细</h3>
<table class="data-table">
<thead><tr><th>商品</th><th>单价</th><th>数量</th><th>小计</th><th>周期</th></tr></thead>
<tbody>
<?php foreach (($order['items'] ?? []) as $it): ?>
<tr>
<td><?= h($it['name']) ?></td>
<td><?= money($it['price']) ?></td>
<td><?= (int) $it['qty'] ?></td>
<td><?= money($it['subtotal']) ?></td>
<td><?= periodLabel($it['period_days']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<h3><i class="fas fa-truck"></i> 发货信息(连接地址 / 登录凭据 / 商品信息)</h3>
<?php if ($delivery): ?>
<div class="ship-existing">
<p class="muted">已填写,当前内容如下(可重新提交覆盖):</p>
<div class="od-line">激活状态:<span class="badge-<?= !empty($delivery['activated']) ? 'on' : 'off' ?>"><?= !empty($delivery['activated']) ? '已激活' : '未激活' ?></span></div>
<div class="od-line">服务器 IP<?= h($delivery['server_ip']) ?: '—' ?></div>
<div class="od-line">连接地址:<?= h($delivery['conn_addr']) ?: '—' ?></div>
<div class="od-line">登录账户:<?= h($delivery['login_user']) ?: '—' ?></div>
<div class="od-line">登录密码:<?= $delivery['login_pass'] !== '' ? h($delivery['login_pass']) : '—' ?></div>
<?php if ($delivery['product_info'] !== ''): ?><div class="od-line">商品信息:<?= nl2br(h($delivery['product_info'])) ?></div><?php endif; ?>
<?php if ($delivery['remark'] !== ''): ?><div class="od-line">备注:<?= nl2br(h($delivery['remark'])) ?></div><?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="" class="grid-form ship-form">
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= (int) $order['id'] ?>">
<label>服务器 IP<input type="text" name="server_ip" value="<?= $delivery ? h($delivery['server_ip']) : '' ?>" placeholder="如 1.2.3.4"></label>
<label>连接地址 / 域名<input type="text" name="conn_addr" value="<?= $delivery ? h($delivery['conn_addr']) : '' ?>" placeholder="如 mc.example.com:25565"></label>
<label>登录账户<input type="text" name="login_user" value="<?= $delivery ? h($delivery['login_user']) : '' ?>" placeholder="如 player01"></label>
<label>登录密码<input type="text" name="login_pass" value="<?= $delivery ? h($delivery['login_pass']) : '' ?>" placeholder="如 ********"></label>
<label>激活状态
<select name="activated">
<option value="0" <?= $delivery && empty($delivery['activated']) ? 'selected' : '' ?>>未激活</option>
<option value="1" <?= $delivery && !empty($delivery['activated']) ? 'selected' : '' ?>>已激活</option>
</select>
</label>
<label class="span2">商品信息(展示给用户)<textarea name="product_info" rows="3" placeholder="选填,将展示给该用户,如开通说明、使用须知"><?= $delivery ? h($delivery['product_info']) : '' ?></textarea></label>
<label class="span2">备注<textarea name="remark" rows="2" placeholder="选填,仅内部可见"><?= $delivery ? h($delivery['remark']) : '' ?></textarea></label>
<div class="form-actions span2">
<button type="submit" name="ship" class="btn btn-primary"><i class="fas fa-paper-plane"></i> 保存发货信息<?= $delivery ? '(覆盖)' : '' ?></button>
<a href="orders.php" class="btn btn-ghost">返回列表</a>
</div>
</form>
</div>
<?php else: ?>
<div class="panel">
<?php if (empty($list)): ?>
<p class="empty">没有符合条件的订单。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>订单号</th><th>用户</th><th>商品</th><th>金额</th><th>周期</th><th>支付</th><th>状态</th><th>时间</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($list as $o): ?>
<tr>
<td><?= h($o['order_no']) ?></td>
<td><?= h($o['username'] ?? '游客') ?></td>
<td class="cell-items">
<?php foreach (($itemsOf[$o['id']] ?? []) as $it): ?>
<div><?= h($it['name']) ?> ×<?= (int)$it['qty'] ?></div>
<?php endforeach; ?>
</td>
<td><?= money($o['total']) ?></td>
<td><?= periodLabel($o['period_days']) ?></td>
<td><span class="pay-<?= h($o['pay_type']) ?>"><?= payTypeLabel($o['pay_type']) ?><?= $o['pay_type']==='points' ? ' · ' . (int)$o['points_used'] . '分' : '' ?></span></td>
<td><span class="badge-status status-<?= h($o['status']) ?>"><?= orderStatusLabel($o['status']) ?></span></td>
<td><?= date('m-d H:i', strtotime($o['created_at'])) ?></td>
<td class="row-actions">
<a href="orders.php?view=<?= (int)$o['id'] ?>" class="mini-btn" title="查看 / 发货"><i class="fas fa-eye"></i></a>
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定删除该订单?');">
<?= csrfField() ?>
<button type="submit" name="delete" value="<?= (int)$o['id'] ?>" class="mini-btn danger" title="删除订单"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
<?php adminFooter(); ?>
+150
View File
@@ -0,0 +1,150 @@
<?php
// 后台商品管理:列表 + 新增 / 编辑 / 删除
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$db = db();
$msg = '';
$err = '';
// 客服账号仅可查看,禁止写操作
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
}
// 分类选项来自「商品分组」表,并兜底保留「其他」
$groups = $db->query('SELECT name FROM ' . tn('product_groups') . ' ORDER BY sort_order ASC, name ASC')->fetchAll(PDO::FETCH_COLUMN);
$catOptions = $groups;
if (!in_array('其他', $catOptions, true)) $catOptions[] = '其他';
// 删除
if (isset($_POST['delete'])) {
verifyCsrf();
$id = (int) $_POST['delete'];
$cnt = $db->prepare('SELECT COUNT(*) FROM ' . tn('order_items') . ' WHERE product_id = ?');
$cnt->execute([$id]);
if ((int) $cnt->fetchColumn() > 0) {
$err = '该商品已有订单记录,无法删除,可将其下架。';
} else {
$db->prepare('DELETE FROM ' . tn('products') . ' WHERE id = ?')->execute([$id]);
$msg = '商品已删除';
}
}
// 保存(新增 / 编辑)
if (isset($_POST['save'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$stock = max(0, (int) ($_POST['stock'] ?? 0));
$periodDays = max(0, (int) ($_POST['period_days'] ?? 0));
$pointsPrice= max(0, (int) ($_POST['points_price'] ?? 0));
$category = trim($_POST['category'] ?? '其他');
$icon = trim($_POST['icon'] ?? 'fa-box');
$image = trim($_POST['image'] ?? '');
$description= trim($_POST['description'] ?? '');
$status = isset($_POST['status']) ? 1 : 0;
if ($name === '') $err = '请填写商品名称';
elseif (!in_array($category, $catOptions, true)) $err = '分类不合法';
if ($err === '') {
if ($id > 0) {
$db->prepare('UPDATE ' . tn('products') . ' SET name=?,stock=?,period_days=?,points_price=?,category=?,icon=?,image=?,description=?,status=? WHERE id=?')
->execute([$name, $stock, $periodDays, $pointsPrice, $category, $icon, $image, $description, $status, $id]);
$msg = '商品已更新';
} else {
$db->prepare('INSERT INTO ' . tn('products') . ' (name,stock,period_days,points_price,category,icon,image,description,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,NOW())')
->execute([$name, $stock, $periodDays, $pointsPrice, $category, $icon, $image, $description, $status]);
$msg = '商品已添加';
}
}
}
// 编辑回填
$edit = null;
if (isset($_GET['edit'])) {
$stmt = $db->prepare('SELECT * FROM ' . tn('products') . ' WHERE id = ?');
$stmt->execute([(int) $_GET['edit']]);
$edit = $stmt->fetch();
}
$showForm = ($edit !== null) || isset($_GET['act']) && $_GET['act'] === 'add';
// 列表
$list = $db->query('SELECT * FROM ' . tn('products') . ' ORDER BY created_at DESC')->fetchAll();
adminHeader('商品管理', 'products');
?>
<h1 class="page-title"><i class="fas fa-box"></i> 商品管理</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<?php if ($showForm): ?>
<div class="panel">
<h3><?= $edit ? '编辑商品 #' . (int)$edit['id'] : '新增商品' ?></h3>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= $edit ? (int)$edit['id'] : 0 ?>">
<label>商品名称 *<input type="text" name="name" value="<?= $edit ? h($edit['name']) : '' ?>" required></label>
<label>库存 *<input type="number" min="0" name="stock" value="<?= $edit ? (int)$edit['stock'] : 0 ?>" required></label>
<label>周期(天)<input type="number" min="0" name="period_days" value="<?= $edit ? (int)$edit['period_days'] : 0 ?>" placeholder="0=一次性/实物"></label>
<label>积分价(分)<input type="number" min="0" name="points_price" value="<?= $edit ? (int)$edit['points_price'] : 0 ?>" placeholder="0=直接下单,不扣积分"></label>
<label>分类
<select name="category">
<?php if ($edit && !in_array($edit['category'], $catOptions, true)): ?>
<option value="<?= h($edit['category']) ?>" selected><?= h($edit['category']) ?>(已停用分组)</option>
<?php endif; ?>
<?php foreach ($catOptions as $c): ?>
<option value="<?= h($c) ?>" <?= $edit && $edit['category']===$c ? 'selected' : '' ?>><?= h($c) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>图标类名<input type="text" name="icon" value="<?= $edit ? h($edit['icon']) : 'fa-box' ?>" placeholder="如 fa-box"></label>
<label>图片链接<input type="text" name="image" value="<?= $edit ? h($edit['image']) : '' ?>" placeholder="留空则显示图标"></label>
<label class="span2">商品描述<textarea name="description" rows="3"><?= $edit ? h($edit['description']) : '' ?></textarea></label>
<label class="checkbox"><input type="checkbox" name="status" <?= !$edit || $edit['status']==1 ? 'checked' : '' ?>> 上架销售</label>
<div class="form-actions span2">
<button type="submit" name="save" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
<a href="products.php" class="btn btn-ghost">取消</a>
</div>
</form>
</div>
<?php endif; ?>
<div class="panel">
<div class="panel-head">
<h3>商品列表(共 <?= count($list) ?> 件)</h3>
<?php if (!$showForm): ?><a href="products.php?act=add" class="btn btn-primary"><i class="fas fa-plus"></i> 新增商品</a><?php endif; ?>
</div>
<?php if (empty($list)): ?>
<p class="empty">还没有商品,点上方「新增商品」添加吧。</p>
<?php else: ?>
<table class="data-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 ($list as $p): ?>
<tr>
<td><?= (int)$p['id'] ?></td>
<td><?= h($p['name']) ?></td>
<td><?= h($p['category']) ?></td>
<td><?= (int)$p['points_price'] > 0 ? (int)$p['points_price'] . ' 分' : '直接下单' ?></td>
<td><?= (int)$p['stock'] ?></td>
<td><?= periodLabel($p['period_days']) ?></td>
<td><?= $p['status']==1 ? '<span class="badge-on">上架</span>' : '<span class="badge-off">下架</span>' ?></td>
<td class="row-actions">
<a href="products.php?edit=<?= (int)$p['id'] ?>" class="mini-btn"><i class="fas fa-edit"></i></a>
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定删除该商品?');">
<?= csrfField() ?>
<button type="submit" name="delete" value="<?= (int)$p['id'] ?>" class="mini-btn danger"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php adminFooter(); ?>
+299
View File
@@ -0,0 +1,299 @@
<?php
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$admin = currentAdmin();
$msg = '';
$err = '';
// 客服账号仅可查看,禁止任何写操作(直接丢弃写请求键)
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
foreach (['savesite','changepw','savesmtp','saveabout','savepoints','savepay_ali','savepay_wx','savefavicon','delfavicon'] as $k) {
unset($_POST[$k]);
}
}
if (isset($_POST['savesite'])) {
verifyCsrf();
$siteName = trim($_POST['site_name'] ?? '');
if ($siteName === '') {
$err = '站点名称不能为空。';
} else {
saveSetting('site_name', $siteName);
$GLOBALS['_site_name'] = null; // 清空全局缓存
$msg = '站点名称已修改。';
}
}
if (isset($_POST['changepw'])) {
verifyCsrf();
$cur = $_POST['cur'] ?? '';
$new = $_POST['new'] ?? '';
$new2 = $_POST['new2'] ?? '';
$stmt = db()->prepare('SELECT password FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$admin['id']]);
$hash = $stmt->fetchColumn();
if (!password_verify($cur, $hash)) {
$err = '当前密码错误。';
} elseif (strlen($new) < 6) {
$err = '新密码至少 6 位。';
} elseif ($new !== $new2) {
$err = '两次输入的新密码不一致。';
} else {
$newHash = password_hash($new, PASSWORD_DEFAULT);
db()->prepare('UPDATE ' . tn('users') . ' SET password = ? WHERE id = ?')
->execute([$newHash, $admin['id']]);
$msg = '管理员密码已修改。';
}
}
if (isset($_POST['savesmtp'])) {
verifyCsrf();
foreach (['smtp_host', 'smtp_port', 'smtp_enc', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_fromname', 'notify_emails'] as $k) {
saveSetting($k, trim((string) ($_POST[$k] ?? '')));
}
$msg = 'SMTP 配置已保存!';
}
if (isset($_POST['saveabout'])) {
verifyCsrf();
saveSetting('mall_about', trim((string) ($_POST['mall_about'] ?? '')));
$msg = '商城介绍已保存!';
}
if (isset($_POST['savepoints'])) {
verifyCsrf();
saveSetting('points_enabled', isset($_POST['points_enabled']) ? '1' : '0');
saveSetting('points_sign', max(0, (int) ($_POST['points_sign'] ?? 0)));
saveSetting('points_invite', max(0, (int) ($_POST['points_invite'] ?? 0)));
$msg = '积分设置已保存';
}
if (isset($_POST['savepay_ali'])) {
verifyCsrf();
saveSetting('pay_alipay_enabled', isset($_POST['pay_alipay_enabled']) ? '1' : '0');
saveSetting('pay_alipay_appid', trim((string) ($_POST['pay_alipay_appid'] ?? '')));
saveSetting('pay_alipay_private_key', trim((string) ($_POST['pay_alipay_private_key'] ?? '')));
saveSetting('pay_alipay_public_key', trim((string) ($_POST['pay_alipay_public_key'] ?? '')));
saveSetting('pay_alipay_sandbox', isset($_POST['pay_alipay_sandbox']) ? '1' : '0');
$msg = '支付宝配置已保存!';
}
if (isset($_POST['savepay_wx'])) {
verifyCsrf();
saveSetting('pay_wechat_enabled', isset($_POST['pay_wechat_enabled']) ? '1' : '0');
saveSetting('pay_wechat_mch_id', trim((string) ($_POST['pay_wechat_mch_id'] ?? '')));
saveSetting('pay_wechat_api_key', trim((string) ($_POST['pay_wechat_api_key'] ?? '')));
saveSetting('pay_wechat_appid', trim((string) ($_POST['pay_wechat_appid'] ?? '')));
$msg = '微信支付配置已保存!';
}
if (isset($_POST['savefavicon']) || isset($_POST['delfavicon'])) {
verifyCsrf();
if (!$isAdmin) {
$err = '客服账号仅可查看,如需操作请联系管理员。';
} elseif (isset($_POST['delfavicon'])) {
$old = getSetting('favicon', '');
if ($old && file_exists(__DIR__ . '/../' . $old)) { @unlink(__DIR__ . '/../' . $old); }
saveSetting('favicon', '');
$msg = '已恢复默认图标。';
} else {
if (empty($_FILES['favicon_file']) || $_FILES['favicon_file']['error'] !== UPLOAD_ERR_OK) {
$err = '请选择要上传的 .ico 文件。';
} else {
$f = $_FILES['favicon_file'];
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
if ($ext !== 'ico') {
$err = '仅支持 .ico 格式的图标文件。';
} elseif ($f['size'] > 256 * 1024) {
$err = '文件过大,请控制在 256KB 以内。';
} else {
// 校验 ICO 文件头:00 00 01 00
$head = file_get_contents($f['tmp_name'], false, null, 0, 4);
if ($head !== "\x00\x00\x01\x00") {
$err = '文件内容不是有效的 ICO 图标(文件头校验失败)。';
} else {
$dest = __DIR__ . '/../assets/favicon.ico';
if (!move_uploaded_file($f['tmp_name'], $dest) && !copy($f['tmp_name'], $dest)) {
$err = '保存失败,请检查 assets/ 目录写入权限。';
} else {
saveSetting('favicon', 'assets/favicon.ico');
$msg = '网站图标已更新,刷新浏览器即可看到(若未变请强制刷新清缓存)。';
}
}
}
}
}
}
$testResult = null;
if (isset($_POST['testmail'])) {
verifyCsrf();
$to = trim($_POST['test_to'] ?? '');
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
$err = '请填写有效的测试收件邮箱。';
} else {
$cfg = smtpConfig();
$body = '<div style="font-family:sans-serif;max-width:520px;margin:auto">'
. '<h2 style="color:#2563eb">[' . h(SITE_NAME) . '] SMTP 测试邮件</h2>'
. '<p>这是一封由商城后台发送的测试邮件,若你收到了它,说明 SMTP 配置正确。</p>'
. '<p class="muted">发送时间:' . date('Y-m-d H:i:s') . '</p></div>';
$testResult = fnwSendMail($to, '[' . SITE_NAME . '] SMTP 测试邮件', $body);
if ($testResult['ok']) {
$msg = '测试邮件已发送,请查收(含 SMTP 会话日志见下方)。';
} else {
$err = '发送失败:' . ($testResult['error'] ?? '未知错误');
}
}
}
$cfg = smtpConfig();
adminHeader('设置', 'settings');
?>
<h1 class="page-title"><i class="fas fa-cog"></i> 设置</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<div class="panel" style="max-width:560px;">
<h3>修改管理员密码</h3>
<p class="muted">当前管理员:<strong><?= h($admin['username']) ?></strong></p>
<form method="post" action="" class="auth-form">
<?= csrfField() ?>
<label>当前密码<input type="password" name="cur" required></label>
<label>新密码<input type="password" name="new" placeholder="至少 6 位。" required></label>
<label>确认新密码<input type="password" name="new2" required></label>
<button type="submit" name="changepw" class="btn btn-primary">保存修改</button>
</form>
</div>
<div class="panel" style="max-width:680px;">
<h3>商城介绍(前台首页展示)</h3>
<p class="muted">这段文字会显示在商城首页「关于商城」区块,向访客介绍你的商城。留空则不显示该区块。</p>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<label class="span2">商城介绍<textarea name="mall_about" rows="4" placeholder="如:本商城是 XX 团队旗下的……"><?= h(getSetting('mall_about', '')) ?></textarea></label>
<div class="form-actions span2">
<button type="submit" name="saveabout" class="btn btn-primary"><i class="fas fa-save"></i> 保存商城介绍</button>
</div>
</form>
</div>
<div class="panel" style="max-width:560px;">
<h3><i class="fas fa-store"></i> 站点名称</h3>
<p class="muted">该名称会显示在浏览器标题、页面顶部、邮件通知等位置。</p>
<form method="post" action="" class="auth-form">
<?= csrfField() ?>
<label>站点名称
<input type="text" name="site_name" value="<?= h(getSetting('site_name', SITE_NAME)) ?>" required>
</label>
<button type="submit" name="savesite" class="btn btn-primary"><i class="fas fa-save"></i> 保存站点名称</button>
</form>
</div>
<div class="panel" style="max-width:560px;">
<h3><i class="fas fa-image"></i> 网站图标(Favicon</h3>
<p class="muted">上传 .ico 格式图标(建议 16×16 或 32×32,最大 256KB),将显示在浏览器标签页。留空则使用默认图标。</p>
<p style="margin:12px 0;">
<?php
$favCur = getSetting('favicon', '');
if ($favCur && file_exists(__DIR__ . '/../' . $favCur)):
?>
<img src="../<?= h($favCur) ?>?v=<?= filemtime(__DIR__ . '/../' . $favCur) ?>" alt="当前图标" style="width:32px;height:32px;border:1px solid var(--border);border-radius:6px;vertical-align:middle;">
<span class="muted"> 当前图标(<?= h($favCur) ?></span>
<?php else: ?>
<span class="muted">当前:默认图标</span>
<?php endif; ?>
</p>
<form method="post" action="" enctype="multipart/form-data" class="auth-form">
<?= csrfField() ?>
<label>选择 .ico 文件
<input type="file" name="favicon_file" accept=".ico,image/vnd.microsoft.icon">
</label>
<div class="form-actions">
<button type="submit" name="savefavicon" class="btn btn-primary"><i class="fas fa-upload"></i> 上传并启用</button>
</div>
</form>
<?php if ($favCur): ?>
<form method="post" action="" class="auth-form" style="margin-top:8px;">
<?= csrfField() ?>
<button type="submit" name="delfavicon" class="btn btn-ghost" onclick="return confirm('确定恢复为默认图标?');"><i class="fas fa-undo"></i> 恢复默认</button>
</form>
<?php endif; ?>
</div>
<div class="panel" style="max-width:680px;">
<h3>积分系统设置</h3>
<p class="muted">开启后,用户可在前台「每日签到」获取积分、通过邀请链接邀请好友获得积分;商品可设置「积分价」,结算时选择「积分支付」。</p>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<label class="checkbox"><input type="checkbox" name="points_enabled" <?= getSetting('points_enabled', '1')==='1' ? 'checked' : '' ?>> 启用积分系统</label>
<label>每日签到可得积分<input type="number" min="0" name="points_sign" value="<?= h(getSetting('points_sign', '5')) ?>" placeholder="如 5"></label>
<label>邀请好友成功可得积分<input type="number" min="0" name="points_invite" value="<?= h(getSetting('points_invite', '20')) ?>" placeholder="如 20"></label>
<div class="form-actions span2">
<button type="submit" name="savepoints" class="btn btn-primary"><i class="fas fa-save"></i> 保存积分设置</button>
</div>
</form>
</div>
<div class="panel" style="max-width:680px;">
<h3>SMTP 邮件配置</h3>
<p class="muted">用于:用户提交工单后通知处理人员、处理回复通知用户。常见配置:QQ 邮箱 <code>smtp.qq.com</code> / 端口 <code>465</code> / 加密 <code>SSL</code> / 密码填「授权码」。</p>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<label>SMTP 主机<input type="text" name="smtp_host" value="<?= h($cfg['smtp_host']) ?>" placeholder="如 smtp.qq.com"></label>
<label>端口<input type="text" name="smtp_port" value="<?= h($cfg['smtp_port']) ?>" placeholder="如 465"></label>
<label>加密方式
<select name="smtp_enc">
<option value="" <?= $cfg['smtp_enc']==='' ?'selected':'' ?>>无</option>
<option value="ssl" <?= $cfg['smtp_enc']==='ssl' ?'selected':'' ?>>SSL465</option>
<option value="tls" <?= $cfg['smtp_enc']==='tls' ?'selected':'' ?>>STARTTLS587</option>
</select>
</label>
<label>发件人名称<input type="text" name="smtp_fromname" value="<?= h($cfg['smtp_fromname']) ?>" placeholder="如 自由云商城"></label>
<label class="span2">登录账号(邮箱)<input type="text" name="smtp_user" value="<?= h($cfg['smtp_user']) ?>" placeholder="SMTP 登录邮箱"></label>
<label class="span2">登录密码 / 授权码<input type="password" name="smtp_pass" value="<?= h($cfg['smtp_pass']) ?>" placeholder="QQ 邮箱填授权码"></label>
<label class="span2">发件人邮箱<input type="text" name="smtp_from" value="<?= h($cfg['smtp_from']) ?>" placeholder="留空则同登录账号"></label>
<label class="span2">通知收件人(处理人员,逗号分隔)<input type="text" name="notify_emails" value="<?= h($cfg['notify_emails']) ?>" placeholder="如 admin@example.com,ops@example.com"></label>
<div class="form-actions span2">
<button type="submit" name="savesmtp" class="btn btn-primary"><i class="fas fa-save"></i> 保存 SMTP 配置</button>
</div>
</form>
<hr style="margin:22px 0;border:none;border-top:1px solid var(--border);">
<h3>发送测试邮件</h3>
<form method="post" action="" class="grid-form" style="max-width:480px;">
<?= csrfField() ?>
<label class="span2">测试收件邮箱<input type="email" name="test_to" placeholder="输入一个能收信的邮箱" required></label>
<div class="form-actions span2">
<button type="submit" name="testmail" class="btn btn-ghost"><i class="fas fa-paper-plane"></i> 发送测试邮件</button>
</div>
</form>
<?php if ($testResult): ?>
<div class="smtp-log">
<div class="muted" style="margin-bottom:6px;">SMTP 会话日志:</div>
<pre><?= h(implode("\n", $testResult['log'])) ?></pre>
</div>
<?php endif; ?>
</div>
<div class="panel" style="max-width:780px;">
<h3><i class="fab fa-alipay"></i> 支付接口配置</h3>
<p class="muted">配置后用户下单可选择对应支付方式。未启用(或未填密钥)的渠道不会在前台显示。沙箱模式仅支付宝支持,仅用于联调,正式上线请关闭。</p>
<h4 style="margin:18px 0 10px;">支付宝(电脑网站支付 · RSA2</h4>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<label class="checkbox"><input type="checkbox" name="pay_alipay_enabled" <?= getSetting('pay_alipay_enabled','0')==='1'?'checked':'' ?>> 启用支付宝支付</label>
<label class="checkbox"><input type="checkbox" name="pay_alipay_sandbox" <?= getSetting('pay_alipay_sandbox','0')==='1'?'checked':'' ?>> 沙箱模式(调试用)</label>
<label class="span2">应用 APPID<input type="text" name="pay_alipay_appid" value="<?= h(getSetting('pay_alipay_appid','')) ?>" placeholder="如 2021000000000000"></label>
<label class="span2">应用私钥(RSA2<textarea name="pay_alipay_private_key" rows="3" placeholder="-----BEGIN PRIVATE KEY----- ..."><?= h(getSetting('pay_alipay_private_key','')) ?></textarea></label>
<label class="span2">支付宝公钥<textarea name="pay_alipay_public_key" rows="3" placeholder="-----BEGIN PUBLIC KEY----- ..."><?= h(getSetting('pay_alipay_public_key','')) ?></textarea></label>
<div class="form-actions span2">
<button type="submit" name="savepay_ali" class="btn btn-primary"><i class="fas fa-save"></i> 保存支付宝配置</button>
</div>
</form>
<hr style="margin:24px 0;border:none;border-top:1px solid var(--border);">
<h4 style="margin:18px 0 10px;">微信支付(Native 扫码支付 · V3</h4>
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<label class="checkbox"><input type="checkbox" name="pay_wechat_enabled" <?= getSetting('pay_wechat_enabled','0')==='1'?'checked':'' ?>> 启用微信支付</label>
<label class="span2">商户号 MCH ID<input type="text" name="pay_wechat_mch_id" value="<?= h(getSetting('pay_wechat_mch_id','')) ?>" placeholder="如 1900000000"></label>
<label class="span2">API 密钥(Key / APIv3 Key<input type="text" name="pay_wechat_api_key" value="<?= h(getSetting('pay_wechat_api_key','')) ?>" placeholder="32 位密钥"></label>
<label class="span2">APPID(公众号 / 小程序,可选)<input type="text" name="pay_wechat_appid" value="<?= h(getSetting('pay_wechat_appid','')) ?>" placeholder="留空则用商户默认"></label>
<div class="form-actions span2">
<button type="submit" name="savepay_wx" class="btn btn-primary"><i class="fas fa-save"></i> 保存微信支付配置</button>
</div>
</form>
</div>
<?php adminFooter(); ?>
+370
View File
@@ -0,0 +1,370 @@
<?php
// 后台工单管理:列表(按部门/状态筛选)+ 详情处理(多轮回复、状态流转、邮件通知用户)
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$me = currentStaff();
$isAdmin = isAdminStaff();
$db = db();
$msg = '';
$err = '';
$depts = $db->query('SELECT * FROM ' . tn('ticket_departments') . ' ORDER BY sort, id')->fetchAll();
$deptMap = [];
foreach ($depts as $d) { $deptMap[$d['id']] = $d['name']; }
$validStatus = ['open', 'pending', 'resolved', 'closed'];
// 重新开启已关闭工单(快捷按钮)
if (isset($_POST['reopen'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$stmt = $db->prepare('SELECT id FROM ' . tn('tickets') . ' WHERE id = ?');
$stmt->execute([$id]);
if ($stmt->fetch()) {
$db->prepare('UPDATE ' . tn('tickets') . ' SET status=?, updated_at=NOW(), closed_at=NULL WHERE id=?')
->execute(['open', $id]);
$msg = '工单已重新开启。';
} else {
$err = '工单不存在';
}
}
// 分配负责人(仅管理员可操作;客服仅能看自己负责的工单)
if (isset($_POST['assign']) && $isAdmin) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$toUid = (int) ($_POST['assignee_id'] ?? 0);
if ($id <= 0) {
$err = '工单不存在';
} else {
$staffCheck = $db->prepare('SELECT id FROM ' . tn('users') . ' WHERE id = ? AND role IN (\'admin\',\'cs\') AND status = 1');
$staffCheck->execute([$toUid]);
$realUid = $toUid > 0 && $staffCheck->fetch() ? $toUid : null;
$db->prepare('UPDATE ' . tn('tickets') . ' SET assignee_id = ? WHERE id = ?')
->execute([$realUid, $id]);
$msg = $realUid ? '已分配负责人。' : '已取消分配(工单转为未分配,仅管理员可见)。';
}
}
// 处理提交(追加回复 / 状态变更)
if (isset($_POST['reply'])) {
verifyCsrf();
$id = (int) ($_POST['id'] ?? 0);
$status = trim($_POST['status'] ?? 'open');
$reply = trim($_POST['admin_reply'] ?? '');
$notify = !empty($_POST['notify']);
// 客服仅能处理分配给自己的工单
if (!$isAdmin) {
$chk = $db->prepare('SELECT assignee_id FROM ' . tn('tickets') . ' WHERE id = ?');
$chk->execute([$id]);
$ar = $chk->fetch();
if (!$ar || (int) $ar['assignee_id'] !== (int) $me['id']) {
$err = '该工单未分配给你,无权操作。';
$id = 0;
}
}
if (!in_array($status, $validStatus, true)) {
$err = '状态不合法';
} else {
$stmt = $db->prepare('SELECT t.*, u.email AS user_email, u.username AS user_name FROM ' . tn('tickets') . ' t LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id WHERE t.id = ?');
$stmt->execute([$id]);
$t = $stmt->fetch();
if (!$t) {
$err = '工单不存在';
} else {
$adminId = $me['id'];
$closedAt = $status === 'closed' ? date('Y-m-d H:i:s') : null;
if ($reply !== '') {
$db->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())')
->execute([$id, $adminId, 1, $reply]);
}
$db->prepare('UPDATE ' . tn('tickets') . ' SET status=?, admin_id=?, updated_at=NOW(), closed_at=? WHERE id=?')
->execute([$status, $adminId, $closedAt, $id]);
if ($notify && $t['user_email'] !== '') {
$base = siteBaseUrl();
$url = rtrim($base, '/') . '/mytickets.php?view=' . $id;
$subject = '[' . SITE_NAME . '] 您的工单 #' . $id . ' 有新回复';
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
. '<h2 style="color:#2563eb">工单处理通知</h2>'
. '<p>您好 ' . h($t['user_name']) . ',您提交的工单已更新:</p>'
. '<p><strong>工单标题:</strong>' . h($t['subject']) . '</p>'
. '<p><strong>当前状态:</strong>' . ticketStatusLabel($status) . '</p>'
. ($reply !== '' ? '<div style="background:#f5f7fb;border-left:4px solid #2563eb;padding:12px 16px;margin:10px 0;white-space:pre-wrap">' . nl2br(h($reply)) . '</div>' : '')
. '<p><a href="' . $url . '" style="background:#2563eb;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;display:inline-block">查看工单详情</a></p>'
. '</div>';
$r = fnwSendMail($t['user_email'], $subject, $body);
if (!$r['ok']) {
$msg = '工单已保存,但邮件通知发送失败:' . $r['error'];
} else {
$msg = '工单已保存,并已邮件通知用户。';
}
} else {
$msg = '工单已保存。';
}
}
}
}
// 批准续期(续期工单专用)
if (isset($_POST['approve_renew'])) {
verifyCsrf();
if (!$isAdmin) {
$err = '仅管理员可批准续期。';
} else {
$id = (int) ($_POST['id'] ?? 0);
$reply = trim($_POST['admin_reply'] ?? '');
$stmt = $db->prepare('SELECT t.*, o.expires_at, o.period_days, u.email AS user_email, u.username AS user_name FROM ' . tn('tickets') . ' t LEFT JOIN ' . tn('orders') . ' o ON o.id = t.order_id LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id WHERE t.id = ?');
$stmt->execute([$id]);
$t = $stmt->fetch();
if (!$t || $t['type'] !== 'renewal' || !$t['order_id']) {
$err = '该工单不是有效的续期申请';
} elseif (empty($t['period_days']) || $t['period_days'] <= 0) {
$err = '关联订单无有效周期,无法续期';
} else {
// 从当前到期(未过期则顺延)或现在起,延长一个周期
$base = ($t['expires_at'] && !isExpired($t['expires_at'])) ? $t['expires_at'] : date('Y-m-d H:i:s');
$newExpiry = date('Y-m-d H:i:s', strtotime($base . ' +' . (int)$t['period_days'] . ' days'));
$db->prepare('UPDATE ' . tn('orders') . ' SET expires_at = ? WHERE id = ?')->execute([$newExpiry, $t['order_id']]);
$reply = $reply !== '' ? $reply : ('已为你续期,新到期时间:' . $newExpiry);
$adminId = $me['id'];
$db->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())')
->execute([$id, $adminId, 1, $reply]);
$db->prepare('UPDATE ' . tn('tickets') . ' SET admin_reply=?, status=?, admin_id=?, updated_at=NOW(), closed_at=NULL WHERE id=?')
->execute([$reply, 'resolved', $adminId, $id]);
$msg = '已批准续期,关联订单 #' . $t['order_id'] . ' 到期时间已延长至 ' . $newExpiry;
if (!empty($t['user_email'])) {
$baseUrl = siteBaseUrl();
$url = rtrim($baseUrl, '/') . '/mytickets.php?view=' . $id;
$subject = '[' . SITE_NAME . '] 你的续期申请已通过(订单 #' . $t['order_id'] . '';
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
. '<h2 style="color:#1a73e8">续期成功</h2>'
. '<p>你好 ' . h($t['user_name']) . ',你的续期申请已通过:</p>'
. '<p><strong>关联订单:</strong>#' . (int)$t['order_id'] . '</p>'
. '<p><strong>新到期时间:</strong>' . h($newExpiry) . '</p>'
. '<div style="background:#f5f7fb;border-left:4px solid #1a73e8;padding:12px 16px;margin:10px 0;white-space:pre-wrap">' . nl2br(h($reply)) . '</div>'
. '<p><a href="' . $url . '" style="background:#1a73e8;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;display:inline-block">查看工单</a></p>'
. '<p class="muted">可在「我的订单 → 详情」中查看完整凭据。</p></div>';
fnwSendMail($t['user_email'], $subject, $body);
}
}
}
}
// 列表筛选
$filterStatus = trim($_GET['status'] ?? '');
$filterDept = isset($_GET['dept']) ? (int) $_GET['dept'] : 0;
$where = [];
$params = [];
if ($filterStatus !== '') { $where[] = 't.status = ?'; $params[] = $filterStatus; }
if ($filterDept > 0) { $where[] = 't.dept_id = ?'; $params[] = $filterDept; }
// 客服仅能看到分配给自己的工单
if (!$isAdmin) {
$where[] = 't.assignee_id = ?';
$params[] = $me['id'];
}
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$totalStmt = $db->prepare('SELECT COUNT(*) FROM ' . tn('tickets') . ' t ' . $whereSql);
$totalStmt->execute($params);
$total = $totalStmt->fetchColumn();
$stmt = $db->prepare(
'SELECT t.*, u.username AS user_name, u.email AS user_email, d.name AS dept_name,
a.username AS assignee_name
FROM ' . tn('tickets') . ' t
LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id
LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id
LEFT JOIN ' . tn('users') . ' a ON a.id = t.assignee_id
' . $whereSql . '
ORDER BY t.updated_at DESC'
);
$stmt->execute($params);
$list = $stmt->fetchAll();
// 查看单条
$view = isset($_GET['view']) ? (int) $_GET['view'] : 0;
$ticket = null;
$replies = [];
if ($view > 0) {
$stmt = $db->prepare(
'SELECT t.*, u.username AS user_name, u.email AS user_email, d.name AS dept_name,
a.username AS assignee_name
FROM ' . tn('tickets') . ' t
LEFT JOIN ' . tn('users') . ' u ON u.id = t.user_id
LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id
LEFT JOIN ' . tn('users') . ' a ON a.id = t.assignee_id
WHERE t.id = ?'
);
$stmt->execute([$view]);
$ticket = $stmt->fetch();
// 客服仅能查看分配给自己的工单
if ($ticket && !$isAdmin && (int) $ticket['assignee_id'] !== (int) $me['id']) {
$ticket = null;
$err = '该工单未分配给你,无权查看。';
}
if ($ticket) {
$rstmt = $db->prepare(
'SELECT r.*, u.username FROM ' . tn('ticket_replies') . ' r
LEFT JOIN ' . tn('users') . ' u ON u.id = r.user_id
WHERE r.ticket_id = ? ORDER BY r.created_at ASC'
);
$rstmt->execute([$view]);
$replies = $rstmt->fetchAll();
}
}
// 负责人候选(管理员/客服),仅管理员可在表单中分配
$assignees = [];
if ($isAdmin) {
$assignees = $db->query("SELECT id, username, role FROM " . tn('users') . " WHERE role IN ('admin','cs') AND status = 1 ORDER BY role DESC, username")->fetchAll();
}
adminHeader('工单管理', 'tickets');
?>
<h1 class="page-title"><i class="fas fa-ticket-alt"></i> 工单管理(共 <?= (int) $total ?></h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<?php if ($ticket): ?>
<div class="panel">
<div class="ticket-meta">
<span class="badge-status status-<?= h($ticket['status']) ?>"><?= ticketStatusLabel($ticket['status']) ?></span>
<span class="muted">类型:<?= ticketTypeLabel($ticket['type']) ?></span>
<span class="muted">提交人:<?= h($ticket['user_name'] ?? '游客') ?><?= h($ticket['user_email'] ?? '未填邮箱') ?></span>
<span class="muted">部门:<?= h($ticket['dept_name'] ?? '未指定') ?></span>
<span class="muted">优先级:<?= ticketPriorityLabel($ticket['priority']) ?></span>
<span class="muted">负责人:<?= !empty($ticket['assignee_name']) ? h($ticket['assignee_name']) : '<span style="color:var(--muted)">未分配</span>' ?></span>
<span class="muted">提交:<?= date('Y-m-d H:i', strtotime($ticket['created_at'])) ?></span>
</div>
<?php if ($ticket['type'] === 'renewal' && $ticket['order_id']): ?>
<?php $ord = $db->prepare('SELECT order_no, period_days, expires_at, status FROM ' . tn('orders') . ' WHERE id = ?'); $ord->execute([$ticket['order_id']]); $ord = $ord->fetch(); ?>
<div class="renew-box">
<i class="fas fa-rotate"></i> 续期申请 · 关联订单
#<?= (int)$ticket['order_id'] ?><?= $ord ? h($ord['order_no']) : '已删除' ?>
<?php if ($ord): ?>
· 周期 <?= periodLabel($ord['period_days']) ?> · 当前到期 <?= expiryText($ord['expires_at']) ?>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="ticket-timeline">
<div class="ticket-bubble user-bubble">
<div class="bubble-head"><i class="fas fa-user"></i> <?= h($ticket['user_name'] ?? '用户') ?> · <?= date('Y-m-d H:i', strtotime($ticket['created_at'])) ?></div>
<h3 style="margin:8px 0 6px;font-size:1.05rem;"><?= h($ticket['subject']) ?></h3>
<div class="ticket-msg"><?= nl2br(h($ticket['message'])) ?></div>
</div>
<?php foreach ($replies as $r): ?>
<?php if ((int)$r['is_admin'] === 1): ?>
<div class="ticket-bubble admin-bubble">
<div class="bubble-head"><i class="fas fa-user-shield"></i> 处理人员(<?= h($r['username'] ?? '管理员') ?> · <?= date('Y-m-d H:i', strtotime($r['created_at'])) ?></div>
<div class="ticket-msg"><?= nl2br(h($r['message'])) ?></div>
</div>
<?php else: ?>
<div class="ticket-bubble user-bubble">
<div class="bubble-head"><i class="fas fa-user"></i> <?= h($ticket['user_name'] ?? '用户') ?> · <?= date('Y-m-d H:i', strtotime($r['created_at'])) ?></div>
<div class="ticket-msg"><?= nl2br(h($r['message'])) ?></div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<form method="post" action="" class="grid-form" style="margin-top:18px;">
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= (int) $ticket['id'] ?>">
<label class="span2">追加回复
<textarea name="admin_reply" rows="5" autocomplete="off" placeholder="填写新的处理说明,每次保存都会新增一条回复记录,不会覆盖历史回复(留空可直接修改状态)"></textarea>
</label>
<p class="muted span2" style="font-size:.85rem;margin:-6px 0 4px;"><i class="fas fa-info-circle"></i> 提示:历史回复以上方时间线为准,此处内容仅用于新增回复。</p>
<label>状态
<select name="status">
<option value="open" <?= $ticket['status']==='open'?'selected':'' ?>>待处理</option>
<option value="pending" <?= $ticket['status']==='pending'?'selected':'' ?>>处理中</option>
<option value="resolved" <?= $ticket['status']==='resolved'?'selected':'' ?>>已解决</option>
<option value="closed" <?= $ticket['status']==='closed'?'selected':'' ?>>已关闭</option>
</select>
</label>
<?php if ($isAdmin): ?>
<label>负责人(分配)
<select name="assignee_id">
<option value="0" <?= empty($ticket['assignee_id']) ? 'selected' : '' ?>>未分配(仅管理员可见)</option>
<?php foreach ($assignees as $a): ?>
<option value="<?= (int)$a['id'] ?>" <?= (int)$ticket['assignee_id']===(int)$a['id'] ? 'selected' : '' ?>><?= h($a['username']) ?><?= $a['role']==='admin'?'管理员':'客服' ?></option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<label class="checkbox">
<input type="checkbox" name="notify" <?= $ticket['user_email'] ? 'checked' : 'disabled' ?>> 邮件通知用户
</label>
<div class="form-actions span2">
<button type="submit" name="reply" class="btn btn-primary"><i class="fas fa-save"></i> 保存回复</button>
<?php if ($isAdmin): ?>
<button type="submit" name="assign" class="btn btn-ghost"><i class="fas fa-user-check"></i> 保存分配</button>
<?php endif; ?>
<?php if ($ticket['status'] === 'closed'): ?>
<button type="submit" name="reopen" class="btn btn-ok"><i class="fas fa-undo"></i> 重新开启</button>
<?php endif; ?>
<?php if ($ticket['type'] === 'renewal' && $ticket['order_id']): ?>
<button type="submit" name="approve_renew" class="btn btn-ok"><i class="fas fa-check"></i> 批准续期</button>
<?php endif; ?>
<a href="tickets.php" class="btn btn-ghost">返回列表</a>
</div>
</form>
</div>
<?php else: ?>
<div class="panel">
<form method="get" action="" class="filter-bar">
<select name="status" onchange="this.form.submit()">
<option value="">全部状态</option>
<option value="open" <?= $filterStatus==='open'?'selected':'' ?>>待处理</option>
<option value="pending" <?= $filterStatus==='pending'?'selected':'' ?>>处理中</option>
<option value="resolved" <?= $filterStatus==='resolved'?'selected':'' ?>>已解决</option>
<option value="closed" <?= $filterStatus==='closed'?'selected':'' ?>>已关闭</option>
</select>
<select name="dept" onchange="this.form.submit()">
<option value="0">全部部门</option>
<?php foreach ($depts as $d): ?>
<option value="<?= (int) $d['id'] ?>" <?= $filterDept===$d['id']?'selected':'' ?>><?= h($d['name']) ?></option>
<?php endforeach; ?>
</select>
<a href="tickets.php" class="btn btn-ghost">重置</a>
</form>
<?php if (empty($list)): ?>
<p class="empty">暂无工单。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><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 ($list as $t): ?>
<tr>
<td>#<?= (int) $t['id'] ?></td>
<td><?= h($t['subject']) ?></td>
<td><?= h($t['user_name'] ?? '游客') ?></td>
<td><?= h($t['dept_name'] ?? '未指定') ?></td>
<td><?= ticketTypeLabel($t['type']) ?></td>
<td><?= ticketPriorityLabel($t['priority']) ?></td>
<td><span class="badge-status status-<?= h($t['status']) ?>"><?= ticketStatusLabel($t['status']) ?></span></td>
<td><?= !empty($t['assignee_name']) ? h($t['assignee_name']) : '<span class="muted">未分配</span>' ?></td>
<td><?= date('m-d H:i', strtotime($t['updated_at'])) ?></td>
<td><a href="tickets.php?view=<?= (int) $t['id'] ?>" class="mini-btn">处理</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
<?php adminFooter(); ?>
+199
View File
@@ -0,0 +1,199 @@
<?php
// 后台:普通用户管理(添加 / 封禁 / 解封 / 删除)
require __DIR__ . '/../includes/init.php';
requireStaff();
require '_common.php';
$isAdmin = isAdminStaff();
$db = db();
$msg = '';
$err = '';
// 客服账号仅可查看,禁止写操作
if (!$isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST') {
$err = '客服账号仅可查看,如需操作请联系管理员。';
}
// 封禁 / 解封
if (isset($_POST['toggle'])) {
verifyCsrf();
$id = (int) ($_POST['toggle'] ?? 0);
$stmt = $db->prepare('SELECT id, is_admin, status FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
$err = '用户不存在';
} elseif ((int) $row['is_admin'] === 1) {
$err = '不能对管理员执行该操作';
} else {
$next = ((int) $row['status'] === 1) ? 0 : 1;
$db->prepare('UPDATE ' . tn('users') . ' SET status = ? WHERE id = ?')->execute([$next, $id]);
$msg = $next === 0 ? '该用户已被封禁,无法再登录。' : '已解除封禁。';
}
}
// 删除
if (isset($_POST['delete'])) {
verifyCsrf();
$id = (int) ($_POST['delete'] ?? 0);
$stmt = $db->prepare('SELECT id, is_admin FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
$err = '用户不存在';
} elseif ((int) $row['is_admin'] === 1) {
$err = '不能删除管理员账户';
} else {
$db->prepare('DELETE FROM ' . tn('users') . ' WHERE id = ? AND is_admin = 0')->execute([$id]);
$msg = '用户已删除';
}
}
// 添加
if (isset($_POST['add'])) {
verifyCsrf();
$u = trim($_POST['username'] ?? '');
$em = trim($_POST['email'] ?? '');
$pw = $_POST['password'] ?? '';
if (strlen($u) < 3) $err = '用户名至少 3 个字符';
elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $err = '请填写有效的邮箱';
elseif (strlen($pw) < 6) $err = '密码至少 6 位';
else {
$chk = $db->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ?');
$chk->execute([$u]);
if ($chk->fetch()) {
$err = '该用户名已存在';
} else {
$hash = password_hash($pw, PASSWORD_DEFAULT);
$code = genInviteCode();
$db->prepare('INSERT INTO ' . tn('users') . ' (username,password,email,invite_code,created_at) VALUES (?,?,?,?,NOW())')
->execute([$u, $hash, $em, $code]);
$msg = '用户已添加';
}
}
}
// 手动调整积分
if (isset($_POST['adjust'])) {
verifyCsrf();
$target = trim($_POST['target'] ?? '');
$amount = (int) ($_POST['amount'] ?? 0);
$remark = trim($_POST['remark'] ?? '');
if ($target === '') $err = '请填写用户 ID 或用户名';
elseif ($amount === 0) $err = '积分变动不能为 0';
elseif ($remark === '') $err = '请填写调整备注(便于留痕)';
else {
if (is_numeric($target)) {
$stmt = $db->prepare('SELECT id, is_admin FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([(int) $target]);
} else {
$stmt = $db->prepare('SELECT id, is_admin FROM ' . tn('users') . ' WHERE username = ?');
$stmt->execute([$target]);
}
$row = $stmt->fetch();
if (!$row) {
$err = '未找到该用户';
} else {
$res = addPoints((int) $row['id'], 'manual', $amount, '后台手动调整:' . $remark);
if ($res['ok']) {
$msg = '已为用户 #' . (int) $row['id'] . ' ' . ($amount > 0 ? '增加' : '扣减') . ' ' . abs($amount) . ' 积分,当前余额 ' . $res['balance'] . ' 分。';
} else {
$err = '调整失败:' . $res['error'];
}
}
}
}
// 切换邮箱验证状态
if (isset($_POST['toggle_verify'])) {
verifyCsrf();
$id = (int) ($_POST['toggle_verify'] ?? 0);
$stmt = $db->prepare('SELECT id, verified FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
$err = '用户不存在';
} else {
$next = (int) $row['verified'] === 1 ? 0 : 1;
$db->prepare('UPDATE ' . tn('users') . ' SET verified = ? WHERE id = ?')->execute([$next, $id]);
$msg = $next === 1 ? '已标记该用户邮箱为已验证。' : '已将该用户邮箱设为未验证。';
}
}
$list = $db->query('SELECT id, username, email, status, points, verified, created_at FROM ' . tn('users') . ' WHERE is_admin = 0 ORDER BY created_at DESC')->fetchAll();
$orderCnt = $db->query('SELECT user_id, COUNT(*) c FROM ' . tn('orders') . ' GROUP BY user_id')->fetchAll(PDO::FETCH_KEY_PAIR);
adminHeader('用户管理', 'users');
?>
<h1 class="page-title"><i class="fas fa-users"></i> 用户管理(共 <?= count($list) ?> 人)</h1>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<div class="panel">
<h3>添加用户</h3>
<form method="post" action="" class="grid-form" style="max-width:680px;">
<?= csrfField() ?>
<label>用户名 *<input type="text" name="username" placeholder="至少3位" required></label>
<label>邮箱 *<input type="email" name="email" required></label>
<label class="span2">密码 *<input type="password" name="password" placeholder="至少6位" required></label>
<div class="form-actions span2"><button type="submit" name="add" class="btn btn-primary"><i class="fas fa-user-plus"></i> 添加用户</button></div>
</form>
</div>
<div class="panel">
<h3>手动调整积分</h3>
<form method="post" action="" class="grid-form" style="max-width:720px;">
<?= csrfField() ?>
<label>用户 ID 或用户名 *<input type="text" name="target" placeholder="如 12 或 xiaoming" required></label>
<label>积分变动 *<input type="number" name="amount" placeholder="正数=增加,负数=扣减" required></label>
<label class="span2">调整备注 *<input type="text" name="remark" placeholder="如「活动奖励」「误扣退回」等" required></label>
<div class="form-actions span2">
<button type="submit" name="adjust" class="btn btn-primary"><i class="fas fa-coins"></i> 提交调整</button>
</div>
</form>
</div>
<div class="panel">
<h3>用户列表</h3>
<?php if (empty($list)): ?>
<p class="empty">还没有普通用户。</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>积分</th><th>订单数</th><th>验证</th><th>状态</th><th>注册时间</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($list as $u): ?>
<tr>
<td><?= (int) $u['id'] ?></td>
<td><?= h($u['username']) ?></td>
<td><?= h($u['email']) ?></td>
<td><?= (int) ($u['points'] ?? 0) ?></td>
<td><?= (int) ($orderCnt[$u['id']] ?? 0) ?></td>
<td>
<?php if ((int) $u['verified'] === 1): ?>
<span class="badge-on">已验证</span>
<?php else: ?>
<span class="badge-off" style="background:var(--warn);color:#000">未验证</span>
<?php endif; ?>
</td>
<td><?= (int) $u['status'] === 1 ? '<span class="badge-on">正常</span>' : '<span class="badge-off">已封禁</span>' ?></td>
<td><?= date('Y-m-d', strtotime($u['created_at'])) ?></td>
<td class="row-actions">
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定<?= (int)$u['status']===1?'封禁':'解封' ?>该用户?');">
<?= csrfField() ?>
<button type="submit" name="toggle" value="<?= (int) $u['id'] ?>" class="mini-btn <?= (int)$u['status']===1?'warn':'ok' ?>"><i class="fas fa-<?= (int)$u['status']===1?'ban':'check' ?>"></i></button>
</form>
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定<?= (int)$u['verified']===1?'取消验证':'标记已验证' ?>');">
<?= csrfField() ?>
<button type="submit" name="toggle_verify" value="<?= (int) $u['id'] ?>" class="mini-btn <?= (int)$u['verified']===1?'warn':'ok' ?>" title="切换邮箱验证状态"><i class="fas fa-<?= (int)$u['verified']===1?'envelope':'envelope-circle-check' ?>"></i></button>
</form>
<form method="post" action="" style="display:inline" onsubmit="return confirm('确定删除该用户?此操作不可恢复。');">
<?= csrfField() ?>
<button type="submit" name="delete" value="<?= (int) $u['id'] ?>" class="mini-btn danger"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php adminFooter(); ?>
+64
View File
@@ -0,0 +1,64 @@
<?php
// 前台:公告详情
require __DIR__ . '/includes/init.php';
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id <= 0) {
header('Location: announcement');
exit;
}
$stmt = db()->prepare('SELECT * FROM ' . tn('announcements') . ' WHERE id = ? AND status = 1');
$stmt->execute([$id]);
$a = $stmt->fetch();
if (!$a) {
http_response_code(404);
$pageTitle = '公告不存在';
require 'includes/header.php';
?>
<section class="section">
<div class="auth-card">
<h2><i class="fas fa-exclamation-circle"></i> 公告不存在</h2>
<p>该公告不存在或已被隐藏。</p>
<a href="announcements" class="btn btn-primary"><i class="fas fa-arrow-left"></i> 返回公告中心</a>
</div>
</section>
<?php
require 'includes/footer.php';
exit;
}
$pageTitle = $a['title'];
require 'includes/header.php';
?>
<section class="section">
<article class="ann-detail">
<header class="ann-detail-head">
<h1 class="ann-detail-title">
<?php if (!empty($a['pinned'])): ?><span class="ann-pin"><i class="fas fa-thumbtack"></i> 置顶</span><?php endif; ?>
<?= h($a['title']) ?>
</h1>
<div class="ann-detail-time"><i class="far fa-clock"></i> 发布时间:<?= date('Y-m-d H:i', strtotime($a['created_at'])) ?></div>
</header>
<div class="ann-detail-body" id="ann-body" data-md="<?= h($a['content']) ?>"><?= nl2br(h($a['content'])) ?></div>
<div class="ann-detail-foot">
<a href="announcements" class="btn btn-ghost"><i class="fas fa-arrow-left"></i> 返回公告中心</a>
</div>
</article>
</section>
<?php
$mv = file_exists(__DIR__ . '/assets/marked.js') ? filemtime(__DIR__ . '/assets/marked.js') : time();
?>
<script src="assets/marked.js?v=<?= $mv ?>"></script>
<script>
(function () {
var el = document.getElementById('ann-body');
if (el && window.marked) {
try {
el.innerHTML = marked.parse(el.getAttribute('data-md') || '');
} catch (e) {}
}
})();
</script>
<?php require 'includes/footer.php'; ?>
+38
View File
@@ -0,0 +1,38 @@
<?php
// 前台公告中心
require __DIR__ . '/includes/init.php';
$pageTitle = '公告中心';
$list = db()->query(
'SELECT * FROM ' . tn('announcements') . ' WHERE status = 1 ORDER BY pinned DESC, created_at DESC'
)->fetchAll();
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-bullhorn"></i> 公告中心</h2>
<?php if (empty($list)): ?>
<p class="empty">暂无公告。</p>
<?php else: ?>
<div class="ann-list">
<?php foreach ($list as $a):
$excerpt = mb_substr(preg_replace('/\s+/', ' ', $a['content']), 0, 90);
if (mb_strlen($a['content']) > 90) $excerpt .= '…';
?>
<article class="ann-card">
<a class="ann-card-link" href="announcement?id=<?= (int)$a['id'] ?>">
<div class="ann-head">
<h3 class="ann-title"><?= h($a['title']) ?></h3>
<?php if (!empty($a['pinned'])): ?><span class="ann-pin"><i class="fas fa-thumbtack"></i> 置顶</span><?php endif; ?>
</div>
<div class="ann-time"><i class="far fa-clock"></i> <?= date('Y-m-d H:i', strtotime($a['created_at'])) ?></div>
<p class="ann-excerpt"><?= h($excerpt) ?></p>
<span class="ann-read">查看详情 <i class="fas fa-chevron-right"></i></span>
</a>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php require 'includes/footer.php'; ?>
+316
View File
@@ -0,0 +1,316 @@
<?php
/**
* 商城统一 API 端点(AJAX/XHR
*
* 所有请求通过 action 参数路由,返回 JSON。
* 需登录的操作会校验 session。
* 用法: api.php?action=xxx [&参数]
*/
require __DIR__ . '/includes/init.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// ─── 通用响应函数 ───
function json_ok($data = null, $msg = '') {
echo json_encode(['ok' => true, 'msg' => $msg, 'data' => $data], JSON_UNESCAPED_UNICODE);
exit;
}
function json_err($code = 400, $msg = '操作失败') {
http_response_code($code);
echo json_encode(['ok' => false, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
// ─── CSRF 校验(写操作必须带 token)─────────────────────────────
function requireCsrf() {
$token = trim($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['csrf_token'] ?? '');
if (!$token || !verifyCsrfToken($token)) {
json_err(403, 'CSRF 验证失败,请刷新页面重试');
}
}
$action = trim($_GET['action'] ?? $_POST['action'] ?? '');
// ─── 路由表 ───
$ROUTES = [
// 购物车(需登录)
'cart_get' => true, // GET - 获取购物车
'cart_add' => true, // POST - 加入购物车
'cart_update' => true, // POST - 修改数量
'cart_remove' => true, // POST - 删除一项
'cart_clear' => true, // POST - 清空购物车
// 用户操作(需登录)
'sign_in' => true, // POST - 每日签到
'ticket_submit'=> true, // POST - 提交工单
// 公开读取
'search' => false, // GET - 搜索商品
'products' => false, // GET - 分类筛选商品
'product_info' => false, // GET - 商品详情
'announcements'=> false, // GET - 公告列表
];
if (!isset($ROUTES[$action])) {
json_err(404, '未知操作: ' . h($action));
}
$needAuth = $ROUTES[$action];
if ($needAuth && empty($_SESSION['user_id'])) {
json_err(401, '请先登录');
}
$user = null;
if ($needAuth) {
$user = currentUser();
if (!$user) { json_err(401, '登录已过期'); }
}
// ─── 写操作统一 CSRF ───
$WRITE_ACTIONS = ['cart_add', 'cart_update', 'cart_remove', 'cart_clear', 'sign_in', 'ticket_submit'];
if (in_array($action, $WRITE_ACTIONS)) {
requireCsrf();
}
// ─── 分发处理 ───
switch ($action) {
/* ================================================================
* 购物车
* ================================================================ */
case 'cart_get':
$items = [];
$total = 0;
if (!empty($_SESSION['cart']) && is_array($_SESSION['cart'])) {
$ids = array_keys($_SESSION['cart']);
if (!empty($ids)) {
$inPlaceholders = str_repeat('?', count($ids));
$stmt = db()->prepare(
'SELECT id,name,image,icon,category,points_price,stock,status FROM ' . tn('products') .
' WHERE id IN (' . $inPlaceholders . ') AND status=1'
);
$stmt->execute($ids);
while ($p = $stmt->fetch()) {
$qty = (int)($_SESSION['cart'][$p['id']] ?? 0);
if ($qty <= 0) continue;
$price = !empty($p['points_price']) ? (int)$p['points_price'] : 0;
$items[] = [
'id' => (int)$p['id'],
'name' => $p['name'],
'image' => $p['image'] ?: '',
'icon' => $p['icon'] ?: '',
'category' => $p['category'],
'qty' => $qty,
'points_price' => $price,
'stock' => (int)$p['stock'],
'subtotal' => $price * $qty,
];
$total += $price * $qty;
}
}
}
json_ok(['items' => $items, 'count' => count($items), 'total_points' => $total]);
case 'cart_add':
$pid = (int)($_POST['product_id'] ?? 0);
$qty = max(1, min(99, (int)($_POST['qty'] ?? 1)));
if ($pid <= 0) { json_err(400, '商品 ID 无效'); }
$stmt = db()->prepare('SELECT id,status,stock FROM ' . tn('products') . ' WHERE id=?');
$stmt->execute([$pid]);
$p = $stmt->fetch();
if (!$p || (int)$p['status'] !== 1) { json_err(400, '商品不存在或已下架'); }
if (!isset($_SESSION['cart'])) { $_SESSION['cart'] = []; }
$current = (int)($_SESSION['cart'][$pid] ?? 0);
$newQty = $current + $qty;
if ((int)$p['stock'] >= 0 && $newQty > (int)$p['stock']) {
json_err(400, '库存不足(剩余 ' . (int)$p['stock'] . ' 件)');
}
$_SESSION['cart'][$pid] = $newQty;
$count = array_sum(array_map('intval', $_SESSION['cart']));
json_ok(['cart_count' => $count, 'item_qty' => $newQty], '已加入购物车');
case 'cart_update':
$pid = (int)($_POST['product_id'] ?? 0);
$qty = max(1, min(99, (int)($_POST['qty'] ?? 1)));
if ($pid <= 0) { json_err(400, '商品 ID 无效'); }
if (!isset($_SESSION['cart'][$pid])) { json_err(400, '购物车中没有该商品'); }
$stmt = db()->prepare('SELECT stock FROM ' . tn('products') . ' WHERE id=? AND status=1');
$stmt->execute([$pid]);
$p = $stmt->fetch();
if (!$p) { json_err(400, '商品不存在或已下架'); }
if ((int)$p['stock'] >= 0 && $qty > (int)$p['stock']) {
json_err(400, '库存不足');
}
$_SESSION['cart'][$pid] = $qty;
$count = array_sum(array_map('intval', $_SESSION['cart']));
json_ok(['cart_count' => $count], '数量已更新');
case 'cart_remove':
$pid = (int)($_POST['product_id'] ?? 0);
if ($pid <= 0) { json_err(400, '商品 ID 无效'); }
unset($_SESSION['cart'][$pid]);
$count = isset($_SESSION['cart']) ? array_sum(array_map('intval', $_SESSION['cart'])) : 0;
json_ok(['cart_count' => $count], '已移除');
case 'cart_clear':
$_SESSION['cart'] = [];
json_ok([], '购物车已清空');
/* ================================================================
* 每日签到
* ================================================================ */
case 'sign_in':
$enabled = getSetting('points_enabled', '1') === '1';
if (!$enabled) { json_err(400, '积分系统未启用'); }
$today = date('Y-m-d');
$stmt = db()->prepare('SELECT id FROM ' . tn('sign_logs') . ' WHERE user_id=? AND sign_date=?');
$stmt->execute([$user['id'], $today]);
if ($stmt->fetch()) { json_err(400, '今天已经签到了'); }
$points = max(0, (int)getSetting('points_sign', 5));
db()->beginTransaction();
try {
db()->prepare('INSERT INTO ' . tn('sign_logs') . ' (user_id,sign_date,points) VALUES (?,?,?)')
->execute([$user['id'], $today, $points]);
db()->prepare('UPDATE ' . tn('users') . ' SET points=points+? WHERE id=?')
->execute([$points, $user['id']]);
db()->commit();
} catch (Exception $e) {
db()->rollBack();
json_err(500, '签到失败: 数据库错误');
}
$newTotal = getUserPoints($user['id']);
json_ok(['points_gained' => $points, 'points_total' => $newTotal], "签到成功!+{$points} 积分");
/* ================================================================
* 提交工单
* ================================================================ */
case 'ticket_submit':
$deptId = (int)($_POST['dept_id'] ?? 0);
$title = trim($_POST['title'] ?? '');
$body = trim($_POST['body'] ?? '');
$priority = in_array($_POST['priority'] ?? '', ['low','normal','high']) ? $_POST['priority'] : 'normal';
if ($title === '' || strlen($title) < 3) { json_err(400, '标题至少 3 个字'); }
if ($body === '' || strlen($body) < 5) { json_err(400, '描述至少 5 个字'); }
// 验证部门是否存在
if ($deptId > 0) {
$ds = db()->prepare('SELECT id FROM ' . tn('departments') . ' WHERE id=?');
$ds->execute([$deptId]);
if (!$ds->fetch()) { $deptId = 0; }
}
$orderId = !empty($_POST['order_id']) ? (int)$_POST['order_id'] : null;
db()->prepare(
'INSERT INTO ' . tn('tickets') . ' (user_id,dept_id,title,body,priority,order_id,status,created_at) VALUES (?,?,?,?,?,?,\'open\',NOW())'
)->execute([$user['id'], $deptId, $title, $body, $priority, $orderId]);
$ticketId = (int)db()->lastInsertId();
// 通知处理人员
notifyAdmins('新工单 #' . $ticketId . ': ' . $title, "用户 {$user['username']} 提交了新工单,请及时处理。");
json_ok(['ticket_id' => $ticketId], '工单提交成功!我们会尽快处理。');
/* ================================================================
* 商品搜索 / 分类筛选(公开)
* ================================================================ */
case 'search':
$q = trim($_GET['q'] ?? '');
if ($q === '') { json_err(400, '请输入搜索关键词'); }
$where = ['status = 1', 'name LIKE ?'];
$params = ['%' . $q . '%'];
$sql = 'SELECT id,name,category,image,icon,points_price,stock,description FROM ' . tn('products') .
' WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT 50';
$stmt = db()->prepare($sql);
$stmt->execute($params);
$products = [];
while ($r = $stmt->fetch()) {
$products[] = [
'id' => (int)$r['id'],
'name' => $r['name'],
'category' => $r['category'],
'image' => $r['image'] ?: '',
'icon' => $r['icon'] ?: '',
'points_price' => !empty($r['points_price']) ? (int)$r['points_price'] : null,
'stock' => (int)$r['stock'],
'description' => mb_substr($r['description'] ?? '', 0, 100),
];
}
json_ok(['products' => $products, 'query' => $q, 'count' => count($products)]);
case 'products':
$cat = trim($_GET['cat'] ?? '');
$where = ['status = 1'];
$params = [];
if ($cat !== '') {
$where[] = 'category = ?';
$params[] = $cat;
}
$sql = 'SELECT id,name,category,image,icon,points_price,stock FROM ' . tn('products') .
' WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT 100';
$stmt = db()->prepare($sql);
$stmt->execute($params);
$products = [];
while ($r = $stmt->fetch()) {
$products[] = [
'id' => (int)$r['id'],
'name' => $r['name'],
'category' => $r['category'],
'image' => $r['image'] ?: '',
'icon' => $r['icon'] ?: '',
'points_price' => !empty($r['points_price']) ? (int)$r['points_price'] : null,
'stock' => (int)$r['stock'],
];
}
json_ok(['products' => $products, 'category' => $cat, 'count' => count($products)]);
case 'product_info':
$pid = (int)($_GET['id'] ?? 0);
if ($pid <= 0) { json_err(400, '商品 ID 无效'); }
$stmt = db()->prepare(
'SELECT id,name,category,image,icon,points_price,cash_price,stock,period,description,created_at FROM ' . tn('products') . ' WHERE id=? AND status=1'
);
$stmt->execute([$pid]);
$p = $stmt->fetch();
if (!$p) { json_err(404, '商品不存在'); }
json_ok([
'id' => (int)$p['id'],
'name' => $p['name'],
'category' => $p['category'],
'image' => $p['image'] ?: '',
'icon' => $p['icon'] ?: '',
'points_price' => !empty($p['points_price']) ? (int)$p['points_price'] : null,
'cash_price' => !empty($p['cash_price']) ? floatval($p['cash_price']) : null,
'stock' => (int)$p['stock'],
'period' => $p['period'] ?: '',
'description' => $p['description'] ?: '',
]);
case 'announcements':
$limit = min(20, max(1, (int)($_GET['limit'] ?? 10)));
$stmt = db()->query(
'SELECT id,title,pinned,content,created_at FROM ' . tn('announcements') .
' WHERE status=1 ORDER BY pinned DESC, created_at DESC LIMIT ' . $limit
);
$list = [];
while ($a = $stmt->fetch()) {
$list[] = [
'id' => (int)$a['id'],
'title' => $a['title'],
'pinned' => (int)$a['pinned'],
'excerpt' => mb_substr(strip_tags($a['content'] ?? ''), 0, 120),
'created_at' => $a['created_at'],
];
}
json_ok(['announcements' => $list]);
default:
json_err(404, '未知操作: ' . h($action));
}
+305
View File
@@ -0,0 +1,305 @@
/**
* 商城前台 AJAX 封装
*
* 提供统一的 XHR 请求方法、购物车操作、签到、工单提交等。
* 依赖:页面中需有 <meta name="csrf" content="..."> 或全局变量 FNW_CSRF
*/
var Fnw = (function () {
'use strict';
// ─── CSRF Token ───
function getCsrfToken() {
var m = document.querySelector('meta[name="csrf"]');
return m ? m.getAttribute('content') : (window.FNW_CSRF || '');
}
// ─── 通用 XHR ───
/**
* 发送请求
* @param {Object} opts
* @param {string} opts.action - API action 名称
* @param {string} [opts.method='GET'] - HTTP 方法
* @param {Object} [opts.data] - POST 数据
* @param {function} [opts.success] - 成功回调 (res)
* @param {function} [opts.error] - 失败回调 (res)
* @param {function} [opts.complete] - 完成回调
*/
function request(opts) {
var method = (opts.method || 'GET').toUpperCase();
var url = 'api.php?action=' + encodeURIComponent(opts.action);
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
if (method === 'POST') {
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
}
// CSRF header for POST
if (method === 'POST') {
xhr.setRequestHeader('X-CSRF-Token', getCsrfToken());
}
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (opts.complete) opts.complete();
if (xhr.status === 0) { if (opts.error) opts.error({ ok: false, msg: '网络错误,请检查连接' }); return; }
var res;
try { res = JSON.parse(xhr.responseText); } catch (e) { res = { ok: false, msg: '服务器响应异常' }; }
if (res.ok) {
if (opts.success) opts.success(res);
} else {
if (opts.error) opts.error(res);
}
};
if (method === 'POST' && opts.data) {
var params = [];
// Always include csrf_token in POST body as fallback
opts.data.csrf_token = getCsrfToken();
for (var k in opts.data) {
if (opts.data.hasOwnProperty(k)) {
params.push(encodeURIComponent(k) + '=' + encodeURIComponent(opts.data[k]));
}
}
xhr.send(params.join('&'));
} else {
// Append GET params
if (opts.data && method === 'GET') {
var qs = [];
for (var k in opts.data) {
if (opts.data.hasOwnProperty(k)) {
qs.push(encodeURIComponent(k) + '=' + encodeURIComponent(opts.data[k]));
}
}
if (qs.length) url += '&' + qs.join('&');
}
xhr.send();
}
}
// ─── Toast 提示 ───
var toastTimer = null;
function showToast(msg, type) {
type = type || 'ok'; // 'ok' | 'err' | 'info'
var existing = document.getElementById('fnwToast');
if (existing) existing.remove();
var el = document.createElement('div');
el.id = 'fnwToast';
el.className = 'fnw-toast fnw-toast-' + type;
el.innerHTML = '<span class="fnw-toast-icon">' +
(type === 'ok' ? '&#10003;' : type === 'err' ? '&#10007;' : '&#9432;') +
'</span><span class="fnw-toast-msg">' + msg + '</span>';
Object.assign(el.style, {
position: 'fixed',
top: '-60px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '9999',
padding: '12px 24px',
borderRadius: '10px',
fontSize: '.92rem',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px',
boxShadow: '0 8px 24px rgba(0,0,0,.15)',
transition: 'top .3s ease',
maxWidth: '90vw'
});
var colors = {
ok: { bg: '#f0fdf4', border: '#86efac', color: '#166534', icon: '#22c55e' },
err: { bg: '#fef2f2', border: '#fca5a5', color: '#991b1b', icon: '#ef4444' },
info: { bg: '#eff6ff', border: '#93c5fd', color: '#1e40af', icon: '#3b82f6' }
};
var c = colors[type] || colors.info;
el.style.background = c.bg;
el.style.border = '1px solid ' + c.border;
el.style.color = c.color;
el.querySelector('.fnw-toast-icon').style.color = c.icon;
document.body.appendChild(el);
// Animate in
requestAnimationFrame(function () {
el.style.top = '20px';
});
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
el.style.top = '-60px';
setTimeout(function () { el.remove(); }, 320);
}, 2800);
return el;
}
// ─── 购物车 ───
var cart = {
add: function (productId, qty, callback) {
request({
action: 'cart_add',
method: 'POST',
data: { product_id: productId, qty: qty || 1 },
success: function (res) {
showToast(res.msg || '已加入购物车', 'ok');
cart.updateBadge(res.data.cart_count);
if (callback) callback(null, res.data);
},
error: function (res) {
showToast(res.msg || '加入购物车失败', 'err');
if (callback) callback(new Error(res.msg));
}
});
},
update: function (productId, qty, callback) {
request({
action: 'cart_update',
method: 'POST',
data: { product_id: productId, qty: qty },
success: function (res) {
cart.updateBadge(res.data.cart_count);
if (callback) callback(null, res.data);
},
error: function (res) {
showToast(res.msg || '更新失败', 'err');
if (callback) callback(new Error(res.msg));
}
});
},
remove: function (productId, callback) {
request({
action: 'cart_remove',
method: 'POST',
data: { product_id: productId },
success: function (res) {
showToast('已移除', 'ok');
cart.updateBadge(res.data.cart_count);
if (callback) callback(null, res.data);
},
error: function (res) {
showToast(res.msg || '移除失败', 'err');
if (callback) callback(new Error(res.msg));
}
});
},
clear: function (callback) {
request({
action: 'cart_clear',
method: 'POST',
success: function (res) {
showToast('购物车已清空', 'ok');
cart.updateBadge(0);
if (callback) callback(null);
},
error: function (res) {
showToast(res.msg || '清空失败', 'err');
if (callback) callback(new Error(res.msg));
}
});
},
updateBadge: function (count) {
var badges = document.querySelectorAll('.badge.cart-badge');
badges.forEach(function (b) {
b.textContent = count;
b.style.display = count > 0 ? 'inline' : 'none';
});
// Also try nav-link badge
var navBadge = document.querySelector('a[href="cart"] .badge');
if (navBadge) {
navBadge.textContent = count;
navBadge.style.display = count > 0 ? 'inline' : 'none';
}
}
};
// ─── 签到 ───
var sign = {
do: function (callback) {
request({
action: 'sign_in',
method: 'POST',
success: function (res) {
showToast(res.msg || '签到成功!', 'ok');
if (callback) callback(null, res.data);
},
error: function (res) {
showToast(res.msg || '签到失败', 'err');
if (callback) callback(new Error(res.msg));
}
});
}
};
// ─── 工单 ───
var ticket = {
submit: function (data, callback) {
request({
action: 'ticket_submit',
method: 'POST',
data: data,
success: function (res) {
showToast(res.msg || '工单提交成功!', 'ok');
if (callback) callback(null, res.data);
},
error: function (res) {
showToast(res.msg || '提交失败', 'err');
if (callback) callback(new Error(res.msg));
}
});
}
};
// ─── 搜索(防抖)─────────────────────────────────────────────
var searchTimer = null;
var search = {
/**
* 带防抖的搜索
* @param {string} q 关键词
* @param {number} [delay=400] 防抖毫秒数
* @param {function} callback 回调 (err, results)
*/
query: function (q, delay, callback) {
if (typeof delay === 'function') { callback = delay; delay = 400; }
clearTimeout(searchTimer);
if (!q || q.length < 1) { if (callback) callback(null, []); return; }
searchTimer = setTimeout(function () {
request({
action: 'search',
data: { q: q },
success: function (res) { if (callback) callback(null, res.data); },
error: function (res) { if (callback) callback(new Error(res.msg)); }
});
}, delay);
}
};
// ─── 公开 API ───
return {
request: request,
toast: showToast,
cart: cart,
sign: sign,
ticket: ticket,
search: search,
csrf: getCsrfToken
};
})();
// ─── 页面加载后初始化:注入 CSRF meta ───
(function () {
// 如果页面还没有 csrf meta tag,尝试从表单中获取并注入
if (!document.querySelector('meta[name="csrf"]')) {
var csrfInput = document.querySelector('input[name="csrf"]');
if (csrfInput) {
var meta = document.createElement('meta');
meta.name = 'csrf';
meta.content = csrfInput.value;
document.head.appendChild(meta);
}
}
})();
+441
View File
@@ -0,0 +1,441 @@
{
"_meta": {
"name": "English",
"code": "en_US",
"direction": "ltr"
},
"common": {
"site_name": "Free Cloud Mall",
"home": "Home",
"all_products": "All Products",
"search": "Search",
"search_placeholder": "Search products…",
"cart": "Cart",
"my_orders": "My Orders",
"my_tickets": "My Tickets",
"submit_ticket": "Submit Ticket",
"announcements": "Announcements",
"settings": "Settings",
"sign_in": "Daily Sign-in",
"invite_friends": "Invite Friends",
"logout": "Logout",
"login": "Login",
"register": "Register",
"loading": "Loading…",
"no_data": "No data available",
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"more": "More",
"back": "Back",
"next": "Next",
"prev": "Previous",
"submit": "Submit",
"reset": "Reset",
"total": "Total",
"price": "Price",
"stock": "Stock",
"category": "Category",
"status": "Status",
"action": "Action",
"name": "Name",
"description": "Description",
"date": "Date",
"time": "Time",
"quantity": "Quantity",
"amount": "Amount",
"points": "Points",
"remaining": "Remaining",
"items": "items",
"unit_yuan": "CNY",
"email_verified": "Email Verified",
"email_unverified": "Email Not Verified"
},
"hero": {
"title": "Free Cloud Mall",
"subtitle": "Public Welfare · Affordable · Carefully Selected for You"
},
"product": {
"buy_now": "Buy Now",
"add_to_cart": "Add to Cart",
"out_of_stock": "Out of Stock",
"in_stock": "In Stock",
"points_price": "{points} pts",
"cash_price": "Order Directly",
"period": "Period",
"detail": "Product Details",
"purchased": "Purchase History",
"about_mall": "About Mall"
},
"cart": {
"title": "Shopping Cart",
"empty": "Your cart is empty, go shopping!",
"clear": "Clear Cart",
"checkout": "Checkout",
"continue_shop": "Continue Shopping",
"total": "Total",
"pay_method": "Payment Method",
"points_pay": "Points Payment",
"cash_pay": "Cash Payment (Requires Admin Approval)",
"confirm_order": "Confirm Order",
"remove": "Remove",
"update_qty": "Update Quantity"
},
"order": {
"order_list": "My Orders",
"order_no": "Order No.",
"order_time": "Order Time",
"order_detail": "View Details",
"pending": "Pending",
"paid": "Paid",
"shipped": "Shipped",
"completed": "Completed",
"cancelled": "Cancelled",
"address": "Shipping Address",
"phone": "Phone Number",
"remark": "Remark",
"expire_at": "Expires At",
"renew": "Renew",
"delivery_info": "Delivery Info"
},
"auth": {
"login_title": "Welcome Back",
"register_title": "Create Account",
"username": "Username",
"password": "Password",
"confirm_password": "Confirm Password",
"email": "Email",
"captcha": "CAPTCHA",
"refresh_captcha": "Refresh",
"forgot_password": "Forgot password?",
"no_account": "No account?",
"has_account": "Already have an account?",
"go_register": "Register",
"go_login": "Login",
"login_btn": "Sign In",
"register_btn": "Sign Up"
},
"ticket": {
"title": "Submit Ticket",
"dept": "Department",
"select_dept": "Select Department",
"subject": "Subject",
"content": "Description",
"priority": "Priority",
"low": "Low",
"normal": "Normal",
"high": "High",
"related_order": "Related Order (Optional)",
"submit": "Submit Ticket",
"my_tickets": "My Tickets",
"ticket_no": "Ticket No.",
"open": "Open",
"processing": "Processing",
"resolved": "Resolved",
"closed": "Closed",
"reply": "Reply",
"admin_reply": "Admin Reply",
"assignee": "Assignee"
},
"sign": {
"title": "Daily Sign-in",
"signed_today": "Already signed in today",
"not_signed": "Not signed in today",
"sign_btn": "Sign In",
"points_gained": "Earned {points} points",
"consecutive": "{days} consecutive days",
"rule": "Rule: Earn points daily by signing in. Points can be used to redeem products."
},
"user": {
"profile": "Profile",
"nickname": "Nickname",
"avatar": "Avatar",
"email_verified": "Email Verified",
"email_unverified": "Email Not Verified",
"verify_email": "Verify Email",
"resend": "Resend",
"change_pwd": "Change Password",
"current_pwd": "Current Password",
"new_pwd": "New Password",
"confirm_new_pwd": "Confirm New Password",
"my_points": "My Points",
"invite_code": "My Invite Code",
"invite_link": "Invite Link",
"invite_reward": "Invite Reward",
"invite_earned": "Earned from invites",
"copy_link": "Copy Link",
"copied": "Copied"
},
"announcement": {
"title": "Announcements",
"pinned": "Pinned",
"read_more": "Read More",
"published": "Published on",
"no_announcements": "No announcements"
},
"theme": {
"light": "Light Mode",
"dark": "Dark Mode",
"switch_theme": "Switch Theme"
},
"footer": {
"brand_slogan": "Carefully Selected · Public Welfare · Affordable",
"copyright": "All Rights Reserved",
"nav_home": "Mall Home",
"nav_all": "All Products",
"nav_cart": "Cart",
"nav_ticket": "Submit Ticket",
"nav_announce": "Announcements",
"nav_mytickets": "My Tickets",
"nav_myorders": "My Orders"
},
"admin": {
"dashboard": "Dashboard",
"products": "Products",
"groups": "Groups",
"orders": "Orders",
"tickets": "Tickets",
"departments": "Departments",
"users": "Users",
"admins": "Admins / CS",
"settings": "Settings",
"total_users": "Total Users",
"total_orders": "Total Orders",
"total_products": "Products",
"open_tickets": "Open Tickets",
"today_signins": "Today Sign-ins",
"recent_orders": "Recent Orders",
"my_pending": "My Pending",
"my_assigned": "Assigned to Me",
"add_product": "Add Product",
"edit_product": "Edit Product",
"delete_confirm": "Are you sure? This cannot be undone.",
"site_name_setting": "Site Name",
"change_password": "Change Password",
"mall_intro": "Mall Introduction",
"points_settings": "Points Settings",
"smtp_config": "SMTP Configuration",
"test_mail": "Send Test Email",
"save_success": "Saved Successfully",
"role_admin": "Admin",
"role_cs": "Customer Service"
},
"index": {
"announcement": "Announcement",
"more": "More",
"all": "All",
"no_result": "No matching products found, try another keyword.",
"points_unit": "pts"
},
"login": {
"title": "Login / Register",
"err_login_failed": "Incorrect username or password",
"err_captcha_wrong": "CAPTCHA is incorrect, please try again",
"err_username_short": "Username must be at least 3 characters",
"err_password_short": "Password must be at least 6 characters",
"err_password_mismatch": "Passwords do not match",
"err_email_invalid": "Please enter a valid email address",
"err_invite_invalid": "Invalid invite code, please check or leave blank",
"err_ip_limit": "Daily registration limit reached (2/day) from this network, please try again tomorrow",
"err_username_exists": "This username is already taken",
"tab_login": "Login",
"tab_register": "Register",
"label_username": "Username",
"label_password": "Password",
"label_email": "Email (for order & ticket notifications and email verification)",
"label_email_required": "Email *",
"label_captcha": "CAPTCHA",
"captcha_placeholder": "Enter the 4 digits above",
"captcha_hint": "Can't see clearly? Click to refresh",
"label_confirm_pwd": "Confirm Password",
"label_invite": "Invite Code (optional)",
"invite_placeholder": "Enter invite code if you have one for rewards",
"username_placeholder": "At least 3 characters",
"email_placeholder": "Required, e.g. you@example.com",
"pwd_placeholder": "At least 6 characters",
"btn_login": "Login",
"btn_register": "Register & Login",
"register_tip": "By registering you agree to our terms of service; max 2 registrations per network per day."
},
"cart_page": {
"title": "My Shopping Cart",
"empty": "Your cart is empty, go {link}~",
"empty_link": "find something good",
"points_each": "pts/item",
"update_qty": "Update Quantity",
"clear_confirm": "Clear the cart?",
"clear_btn": "Clear",
"checkout_btn": "Checkout",
"continue_btn": "Continue Shopping",
"remove_title": "Remove",
"subtotal_free": "—"
},
"ticket_page": {
"title": "Submit Ticket",
"desc_hint": "Having issues? Submit a ticket and staff will respond in the admin panel. You'll also receive email notifications.",
"type_general": "General Ticket",
"type_renewal": "Renewal Request",
"select_dept": "Select Department",
"related_order_label": "Related Order (required for renewal)",
"order_no_link": "No association",
"subject_placeholder": "Briefly describe your issue",
"subject_renewal_prefix": "Renewal: Order #",
"message_placeholder": "Please describe your issue in detail, including reproduction steps",
"contact_email": "Contact Email",
"submit_btn": "Submit Ticket",
"view_my_tickets": "View My Tickets",
"err_subject_empty": "Please enter a subject",
"err_subject_long": "Subject too long (max 160 chars)",
"err_message_empty": "Please enter a description",
"err_priority_bad": "Invalid priority level",
"err_order_invalid": "Please select a valid renewal order",
"renewal_period": "Period",
"renewal_expire": "Expires"
},
"mytickets": {
"title": "My Tickets",
"ok_msg": "Operation successful. Staff will review and respond as soon as possible (you'll receive email notifications if SMTP is configured).",
"ticket_detail": "Ticket #{id}",
"meta_type": "Type: ",
"meta_dept": "Department: ",
"meta_priority": "Priority: ",
"meta_time": "Submitted: ",
"meta_unspecified": "Unspecified",
"renewal_note": "Related Order: {link} (staff will renew this order after approval)",
"bubble_me": "Me · ",
"bubble_staff": "Staff · ",
"reply_label": "Follow-up Reply",
"reply_placeholder": "Add more details or feedback about your issue",
"send_reply": "Send Reply",
"back_list": "Back to List",
"closed_lock": "This ticket is closed. You can reopen it to continue, or submit a new ticket.",
"reopen_btn": "Reopen Ticket",
"list_title": "My Tickets",
"new_ticket": "New Ticket",
"no_tickets": "You haven't submitted any tickets yet.",
"view_btn": "View",
"col_id": "#",
"col_subject": "Subject",
"col_type": "Type",
"col_dept": "Department",
"col_priority": "Priority",
"col_status": "Status",
"col_updated": "Updated",
"err_no_perm": "Ticket not found or no permission",
"err_not_closed": "This ticket is not closed, no need to reopen",
"err_closed_reply": "This ticket is closed. Cannot reply; please submit a new ticket for further feedback",
"err_reply_empty": "Please enter reply content",
"err_reply_long": "Reply too long (max 5000 chars)"
},
"myorders": {
"title": "My Orders",
"paid_success": "Order {no} submitted successfully, marked as \"Paid\".",
"no_orders": "You don't have any orders yet, go {link}~",
"shop_link": "browse the mall",
"order_no_label": "Order No.: ",
"detail_btn": "View Details",
"total_label": "Total: ",
"pay_points": "· {pts} pts",
"shipping_label": "Shipping: ",
"period_label": "Period: ",
"expire_label": "Expires: ",
"renew_btn": "Request Renewal",
"items_fmt": "{name} × {qty}"
},
"settings_page": {
"title": "User Settings",
"saved": "Profile saved.",
"saved_email_change": "Profile saved. Email verification required due to email change.",
"err_username_short": "Username must be at least 3 characters",
"err_email_invalid": "Please enter a valid email address",
"err_nickname_long": "Nickname must be at most 30 characters",
"err_username_taken": "This username is already taken",
"avatar_section": "Avatar",
"select_image": "Select Image",
"avatar_hint": "JPG / PNG / WebP / GIF accepted, max 2MB, min 64×64",
"uploading": "Uploading…",
"upload_ok": "Avatar updated successfully",
"upload_fail": "Upload failed",
"img_too_big": "Image must not exceed 2MB",
"network_err": "Network error ({code})",
"network_retry": "Network error, please retry",
"response_err": "Unexpected response",
"save_btn": "Save Changes",
"back_orders": "Back to My Orders",
"verify_section": "Email Verification",
"verify_done": "Your email has been verified.",
"verify_not_done": "Email not verified yet. {link}.",
"verify_send_link": "Click to send verification email",
"nickname_placeholder": "Optional, max 30 chars"
},
"product_page": {
"back_list": "Back to Products",
"category_label": "",
"points_price_label": "Points Price: ",
"points_suffix": " pts",
"cash_price_label": "Order Directly",
"stock_label": "Stock: ",
"stock_unit": " items",
"period_label": "Period: ",
"qty_label": "Quantity",
"add_cart_btn": "Add to Cart",
"sold_out": "Out of Stock",
"err_out_of_stock": "This product is temporarily out of stock",
"err_low_stock": "Insufficient stock, maximum {max} can be purchased",
"purchased_title": "You Have Purchased This Product",
"purchased_toggle": "View Product Details",
"purchased_toggle_close": "Collapse",
"purchased_activated": "Activated",
"purchased_not_activated": "Not Activated",
"purchased_product_info": "Product Info: ",
"server_ip": "Server IP: ",
"conn_addr": "Connection Address: ",
"login_user": "Login Account: ",
"login_pass": "Login Password: ",
"remark_label": "Remark: ",
"pending_delivery": "Order placed, waiting for admin activation and product info.",
"reveal_btn": "Reveal",
"none_value": "—"
},
"admin_extra": {
"logs": "System Logs",
"log_viewer_title": "System Logs",
"log_date_select": "Select Date: ",
"log_view_btn": "View",
"log_available_dates": "Available dates: ",
"log_total_entries": "({n} entries)",
"log_no_records": "No log records for this day.",
"log_mode_hint": "Log levels: DEBUG / INFO / WARN / ERROR. Current mode: <strong>{mode}</strong>",
"log_debug_config": "You can enable DEBUG level by setting define('DEBUG', true) in config.php.",
"log_col_time": "Time",
"log_col_level": "Level",
"log_col_source": "Source",
"log_col_content": "Content",
"log_prev": "« Previous",
"log_next": "Next »",
"log_page_info": "Page {p} of {t}",
"cs_readonly": "Customer Service accounts are read-only. Please contact an administrator for changes.",
"admins_title": "Administrators & CS",
"admins_add_title": "Add Account (Admin / CS)",
"admins_add_desc": "Administrators have full access; CS staff can view all pages but only handle tickets.",
"admins_cs_cannot_add": "CS accounts cannot add administrators. Please contact an admin.",
"admins_list_title": "Account List",
"admins_super_tag": "Super",
"admins_protected": "(Protected)",
"admins_current": "Current Account",
"admins_super_protected": "Protected",
"admins_role_confirm": "Change {user}'s role to \"{role}\"?",
"admins_super_warn": "The Super Admin (ID=1) is protected from deletion or demotion. Other admins can be deleted or demoted by fellow admins.",
"admins_err_self_role": "Cannot change the role of the currently logged-in account.",
"admins_err_super_role": "Cannot change the role of the Super Admin (ID=1).",
"admins_err_self_del": "Cannot delete the currently logged-in account.",
"admins_err_super_del": "Cannot delete the Super Admin (ID=1). This account is system-protected.",
"admins_err_last_admin": "At least one admin must remain. Cannot delete.",
"admins_role_changed": "Account #{id} role changed to \"{role}\".",
"users_title": "User Management ({n} total)",
"cs_readonly_users": "CS accounts can only view the user list."
}
}
+441
View File
@@ -0,0 +1,441 @@
{
"_meta": {
"name": "简体中文",
"code": "zh_CN",
"direction": "ltr"
},
"common": {
"site_name": "自由云商城",
"home": "首页",
"all_products": "全部商品",
"search": "搜索",
"search_placeholder": "搜索商品名称…",
"cart": "购物车",
"my_orders": "我的订单",
"my_tickets": "我的工单",
"submit_ticket": "提交工单",
"announcements": "公告中心",
"settings": "用户设置",
"sign_in": "每日签到",
"invite_friends": "邀请好友",
"logout": "退出登录",
"login": "登录",
"register": "注册",
"loading": "加载中…",
"no_data": "暂无数据",
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"close": "关闭",
"more": "更多",
"back": "返回",
"next": "下一步",
"prev": "上一步",
"submit": "提交",
"reset": "重置",
"total": "合计",
"price": "价格",
"stock": "库存",
"category": "分类",
"status": "状态",
"action": "操作",
"name": "名称",
"description": "描述",
"date": "日期",
"time": "时间",
"quantity": "数量",
"amount": "金额",
"points": "积分",
"remaining": "剩余",
"items": "件",
"unit_yuan": "元",
"email_verified": "邮箱已验证",
"email_unverified": "邮箱未验证"
},
"hero": {
"title": "自由云商城",
"subtitle": "公益 · 实惠 · 用心挑选的每一件好物"
},
"product": {
"buy_now": "立即购买",
"add_to_cart": "加入购物车",
"out_of_stock": "缺货",
"in_stock": "有货",
"points_price": "{points} 分",
"cash_price": "直接下单",
"period": "周期",
"detail": "商品详情",
"purchased": "已购买记录",
"about_mall": "关于商城"
},
"cart": {
"title": "购物车",
"empty": "购物车是空的,去逛逛吧",
"clear": "清空购物车",
"checkout": "去结算",
"continue_shop": "继续购物",
"total": "总计",
"pay_method": "支付方式",
"points_pay": "积分支付",
"cash_pay": "现金支付(需管理员审核)",
"confirm_order": "确认下单",
"remove": "移除",
"update_qty": "更新数量"
},
"order": {
"order_list": "我的订单",
"order_no": "订单号",
"order_time": "下单时间",
"order_detail": "查看详情",
"pending": "待处理",
"paid": "已付款",
"shipped": "已发货",
"completed": "已完成",
"cancelled": "已取消",
"address": "收货地址",
"phone": "联系电话",
"remark": "备注",
"expire_at": "到期时间",
"renew": "续期",
"delivery_info": "发货信息"
},
"auth": {
"login_title": "欢迎回来",
"register_title": "创建账户",
"username": "用户名",
"password": "密码",
"confirm_password": "确认密码",
"email": "邮箱",
"captcha": "验证码",
"refresh_captcha": "换一张",
"forgot_password": "忘记密码?",
"no_account": "没有账号?",
"has_account": "已有账号?",
"go_register": "去注册",
"go_login": "去登录",
"login_btn": "登录",
"register_btn": "注册"
},
"ticket": {
"title": "提交工单",
"dept": "部门",
"select_dept": "选择部门",
"subject": "标题",
"content": "描述内容",
"priority": "优先级",
"low": "低",
"normal": "普通",
"high": "高",
"related_order": "关联订单(可选)",
"submit": "提交工单",
"my_tickets": "我的工单",
"ticket_no": "工单号",
"open": "待处理",
"processing": "处理中",
"resolved": "已解决",
"closed": "已关闭",
"reply": "回复",
"admin_reply": "处理回复",
"assignee": "负责人"
},
"sign": {
"title": "每日签到",
"signed_today": "今天已签到",
"not_signed": "今日尚未签到",
"sign_btn": "签到",
"points_gained": "获得 {points} 积分",
"consecutive": "连续签到 {days} 天",
"rule": "签到规则:每天签到可获得积分,积分可用于兑换商品。"
},
"user": {
"profile": "个人资料",
"nickname": "昵称",
"avatar": "头像",
"email_verified": "邮箱已验证",
"email_unverified": "邮箱未验证",
"verify_email": "验证邮箱",
"resend": "重新发送",
"change_pwd": "修改密码",
"current_pwd": "当前密码",
"new_pwd": "新密码",
"confirm_new_pwd": "确认新密码",
"my_points": "我的积分",
"invite_code": "我的邀请码",
"invite_link": "邀请链接",
"invite_reward": "邀请奖励",
"invite_earned": "已获得邀请积分",
"copy_link": "复制链接",
"copied": "已复制"
},
"announcement": {
"title": "公告中心",
"pinned": "置顶",
"read_more": "阅读全文",
"published": "发布于",
"no_announcements": "暂无公告"
},
"theme": {
"light": "浅色模式",
"dark": "深色模式",
"switch_theme": "切换主题"
},
"footer": {
"brand_slogan": "用心挑选的每一件好物 · 公益 · 实惠",
"copyright": "版权所有",
"nav_home": "商城首页",
"nav_all": "全部商品",
"nav_cart": "购物车",
"nav_ticket": "提交工单",
"nav_announce": "公告中心",
"nav_mytickets": "我的工单",
"nav_myorders": "我的订单"
},
"admin": {
"dashboard": "仪表盘",
"products": "商品管理",
"groups": "商品分组",
"orders": "订单管理",
"tickets": "工单管理",
"departments": "工单部门",
"users": "用户管理",
"admins": "管理员/客服",
"settings": "设置",
"total_users": "总用户数",
"total_orders": "总订单数",
"total_products": "商品数",
"open_tickets": "待处理工单",
"today_signins": "今日签到",
"recent_orders": "最近订单",
"my_pending": "我待处理",
"my_assigned": "我负责的工单",
"add_product": "新增商品",
"edit_product": "编辑商品",
"delete_confirm": "确定删除吗?此操作不可撤销。",
"site_name_setting": "站点名称",
"change_password": "修改密码",
"mall_intro": "商城介绍",
"points_settings": "积分系统设置",
"smtp_config": "SMTP 邮件配置",
"test_mail": "发送测试邮件",
"save_success": "保存成功",
"role_admin": "管理员",
"role_cs": "客服"
},
"index": {
"announcement": "公告",
"more": "更多",
"all": "全部",
"no_result": "没有找到匹配的商品,换个关键词试试吧。",
"points_unit": "分"
},
"login": {
"title": "登录 / 注册",
"err_login_failed": "用户名或密码错误",
"err_captcha_wrong": "图形验证码不正确,请重新输入",
"err_username_short": "用户名至少 3 个字符",
"err_password_short": "密码至少 6 位",
"err_password_mismatch": "两次密码不一致",
"err_email_invalid": "请填写有效的邮箱地址",
"err_invite_invalid": "邀请码无效,请检查或留空",
"err_ip_limit": "当前网络今日注册次数已达上限(2 个/天),请明日再试",
"err_username_exists": "该用户名已被注册",
"tab_login": "登录",
"tab_register": "注册",
"label_username": "用户名",
"label_password": "密码",
"label_email": "邮箱(用于接收订单与工单通知,并完成邮箱验证)",
"label_email_required": "邮箱 *",
"label_captcha": "图形验证码",
"captcha_placeholder": "输入上图 4 位",
"captcha_hint": "看不清?点击刷新",
"label_confirm_pwd": "确认密码",
"label_invite": "邀请码(选填)",
"invite_placeholder": "如有邀请码可填写,享受邀请奖励",
"username_placeholder": "至少3位",
"email_placeholder": "必填,如 you@example.com",
"pwd_placeholder": "至少6位",
"btn_login": "登录",
"btn_register": "注册并登录",
"register_tip": "注册即表示同意公益服务条款;同一网络每日最多注册 2 个账号。"
},
"cart_page": {
"title": "我的购物车",
"empty": "购物车还是空的,去 {link} 吧~",
"empty_link": "挑点好物",
"points_each": "积分/件",
"update_qty": "更新数量",
"clear_confirm": "确定清空购物车?",
"clear_btn": "清空",
"checkout_btn": "去结算",
"continue_btn": "继续购物",
"remove_title": "移除",
"subtotal_free": "—"
},
"ticket_page": {
"title": "提交工单",
"desc_hint": "遇到问题?提交工单后,处理人员会在后台看到并回复,回复也会通过邮件通知你。",
"type_general": "普通工单",
"type_renewal": "续期申请",
"select_dept": "选择部门",
"related_order_label": "关联订单(续期申请时必选)",
"order_no_link": "不关联",
"subject_placeholder": "一句话概括你的问题",
"subject_renewal_prefix": "续期申请:订单 #",
"message_placeholder": "请详细描述你遇到的问题、复现步骤等",
"contact_email": "联系邮箱",
"submit_btn": "提交工单",
"view_my_tickets": "查看我的工单",
"err_subject_empty": "请填写工单标题",
"err_subject_long": "标题过长(最多 160 字)",
"err_message_empty": "请填写问题描述",
"err_priority_bad": "优先级不合法",
"err_order_invalid": "请选择有效的续期订单",
"renewal_period": "周期",
"renewal_expire": "到期"
},
"mytickets": {
"title": "我的工单",
"ok_msg": "操作成功,处理人员会尽快查看并回复(若已配置 SMTP 将邮件通知)。",
"ticket_detail": "工单 #{id}",
"meta_type": "类型:",
"meta_dept": "部门:",
"meta_priority": "优先级:",
"meta_time": "提交时间:",
"meta_unspecified": "未指定",
"renewal_note": "关联订单:{link}(处理人员批准后将为该订单续期)",
"bubble_me": "我 · ",
"bubble_staff": "处理人员 · ",
"reply_label": "追加回复",
"reply_placeholder": "补充说明你的问题或反馈处理结果",
"send_reply": "发送回复",
"back_list": "返回列表",
"closed_lock": "该工单已关闭,你可以重新开启后继续反馈,也可以提交新工单。",
"reopen_btn": "重新开启工单",
"list_title": "我的工单",
"new_ticket": "提交新工单",
"no_tickets": "你还没有提交过工单。",
"view_btn": "查看",
"col_id": "编号",
"col_subject": "标题",
"col_type": "类型",
"col_dept": "部门",
"col_priority": "优先级",
"col_status": "状态",
"col_updated": "更新时间",
"err_no_perm": "工单不存在或无权限",
"err_not_closed": "该工单未关闭,无需重新开启",
"err_closed_reply": "该工单已关闭,无法继续回复;如需反馈请提交新工单",
"err_reply_empty": "请填写回复内容",
"err_reply_long": "回复内容过长(最多 5000 字)"
},
"myorders": {
"title": "我的订单",
"paid_success": "订单 {no} 提交成功,已视为「已付款」。",
"no_orders": "你还没有订单,去 {link} 吧~",
"shop_link": "逛逛商城",
"order_no_label": "订单号:",
"detail_btn": "查看详情",
"total_label": "合计:",
"pay_points": "· {pts} 分",
"shipping_label": "收货:",
"period_label": "周期:",
"expire_label": "到期:",
"renew_btn": "申请续期",
"items_fmt": "{name} × {qty}"
},
"settings_page": {
"title": "用户设置",
"saved": "资料已保存。",
"saved_email_change": "资料已保存,因更换邮箱需重新完成邮箱验证。",
"err_username_short": "用户名至少 3 个字符",
"err_email_invalid": "请填写有效的邮箱地址",
"err_nickname_long": "昵称最多 30 个字符",
"err_username_taken": "该用户名已被占用",
"avatar_section": "头像",
"select_image": "选择图片",
"avatar_hint": "支持 JPG / PNG / WebP / GIF,最大 2MB,最小 64×64",
"uploading": "上传中…",
"upload_ok": "头像更新成功",
"upload_fail": "上传失败",
"img_too_big": "图片不能超过 2MB",
"network_err": "网络错误 ({code})",
"network_retry": "网络错误,请重试",
"response_err": "响应异常",
"save_btn": "保存修改",
"back_orders": "返回我的订单",
"verify_section": "邮箱验证",
"verify_done": "你的邮箱已完成验证。",
"verify_not_done": "邮箱尚未验证,{link}。",
"verify_send_link": "点击发送验证邮件",
"nickname_placeholder": "选填,最多 30 字"
},
"product_page": {
"back_list": "返回商品列表",
"category_label": "",
"points_price_label": "积分价:",
"points_suffix": " 积分",
"cash_price_label": "直接下单",
"stock_label": "库存:",
"stock_unit": " 件",
"period_label": "周期:",
"qty_label": "数量",
"add_cart_btn": "加入购物车",
"sold_out": "暂时缺货",
"err_out_of_stock": "该商品暂时缺货",
"err_low_stock": "库存不足,最多可购买 {max} 件",
"purchased_title": "你已购买该商品",
"purchased_toggle": "查看产品详情",
"purchased_toggle_close": "收起",
"purchased_activated": "已激活",
"purchased_not_activated": "未激活",
"purchased_product_info": "商品信息:",
"server_ip": "服务器 IP",
"conn_addr": "连接地址:",
"login_user": "登录账户:",
"login_pass": "登录密码:",
"remark_label": "备注:",
"pending_delivery": "已下单,等待管理员开通并填写商品信息。",
"reveal_btn": "显示",
"none_value": "—"
},
"admin_extra": {
"logs": "系统日志",
"log_viewer_title": "系统日志",
"log_date_select": "选择日期:",
"log_view_btn": "查看",
"log_available_dates": "可用日期:",
"log_total_entries": "(共 {n} 条)",
"log_no_records": "当天暂无日志记录。",
"log_mode_hint": "日志级别:DEBUG / INFO / WARN / ERROR。当前模式:<strong>{mode}</strong>",
"log_debug_config": "可在 config.php 中修改 define('DEBUG', true) 以开启 DEBUG 级别记录。",
"log_col_time": "时间",
"log_col_level": "级别",
"log_col_source": "来源",
"log_col_content": "内容",
"log_prev": "« 上一页",
"log_next": "下一页 »",
"log_page_info": "第 {p} / {t} 页",
"cs_readonly": "客服账号仅可查看,如需操作请联系管理员。",
"admins_title": "管理员与客服",
"admins_add_title": "添加账号(管理员 / 客服)",
"admins_add_desc": "管理员拥有全部后台权限;客服可查看所有页面但仅能处理工单相关操作。",
"admins_cs_cannot_add": "客服账号无法添加新管理员,如需操作请联系管理员。",
"admins_list_title": "账号列表",
"admins_super_tag": "超级",
"admins_protected": "(受保护)",
"admins_current": "当前账号",
"admins_super_protected": "受保护",
"admins_role_confirm": "确定将 {user} 的角色改为「{role}」?",
"admins_super_warn": "终极管理员受系统保护,不可被删除或降级。其他管理员可由同级管理员删除或改为客服。",
"admins_err_self_role": "不能修改当前登录账号的角色。",
"admins_err_super_role": "不能修改终极管理员(ID=1)的角色。",
"admins_err_self_del": "不能删除当前登录的账号。",
"admins_err_super_del": "不能删除终极管理员(ID=1),该账号受系统保护。",
"admins_err_last_admin": "至少要保留一名管理员,不能继续删除。",
"admins_role_changed": "已将账号 #{id} 角色改为「{role}」。",
"users_title": "用户管理(共 {n} 人)",
"cs_readonly_users": "客服账号仅可查看用户列表,无法执行操作。"
}
}
+263
View File
@@ -0,0 +1,263 @@
/*
* marked.js —— 轻量 Markdown 解析器(本地自带,不依赖任何 CDN)
* 用法:marked.parse(markdownString) -> htmlString
* 支持:标题、粗体/斜体/删除线、行内代码、代码块、引用、有序/无序列表(含嵌套)、
* 链接、图片、分隔线、GFM 表格、段落与软换行。
* 安全:先转义原文 HTML,链接 URL 做白名单过滤,避免 XSS。
*/
(function (global) {
'use strict';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 仅转义属性里的引号(文本已在 escapeHtml 中转义过 < > &
function attrSafe(s) {
return String(s).replace(/"/g, '&quot;');
}
// URL 白名单:仅允许 http/https/mailto/tel/锚点/相对路径;拦截 javascript:/vbscript:/危险 data:
function safeUrl(url) {
var u = String(url || '').trim();
if (/^\s*(javascript|vbscript):/i.test(u)) return '#';
if (/^\s*data:/i.test(u) && !/^data:image\//i.test(u)) return '#';
return u;
}
// 行内解析:输入为「未转义」的 markdown 文本
function parseInline(text) {
var codes = [];
// 1. 先抽出行内代码,避免内部内容被后续规则破坏
text = String(text).replace(/`([^`]+)`/g, function (_m, c) {
codes.push(c);
return 'CODE' + (codes.length - 1) + '';
});
// 2. 转义 HTML(代码占位符只含字母数字与空字符,不受影响)
text = escapeHtml(text);
// 3. 图片 ![alt](url "title")
text = text.replace(/!\[([^\]]*)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+&quot;([^)]*?)&quot;)?\)/g, function (_m, alt, url, title) {
var t = title ? ' title="' + attrSafe(title) + '"' : '';
return '<img src="' + attrSafe(safeUrl(url)) + '" alt="' + attrSafe(alt) + '"' + t + '>';
});
// 4. 链接 [text](url "title")
text = text.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+&quot;([^)]*?)&quot;)?\)/g, function (_m, txt, url, title) {
var t = title ? ' title="' + attrSafe(title) + '"' : '';
var ext = /^(https?:)?\/\//i.test(url);
var extAttr = ext ? ' target="_blank" rel="noopener noreferrer"' : '';
return '<a href="' + attrSafe(safeUrl(url)) + '"' + t + extAttr + '>' + txt + '</a>';
});
// 5. 粗体 **x** / __x__
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// 6. 斜体 *x* / _x_
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
text = text.replace(/(^|[^\w])_([^_]+)_(?=[^\w]|$)/g, '$1<em>$2</em>');
// 7. 删除线 ~~x~~
text = text.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// 8. 还原行内代码
text = text.replace(/CODE(\d+)/g, function (_m, n) {
return '<code>' + escapeHtml(codes[+n]) + '</code>';
});
return text;
}
// 列表解析:返回 { html, next }
function parseList(lines, start) {
var first = lines[start];
var m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(first);
var baseIndent = m[1].length;
var ordered = /\d+\./.test(m[2]);
var items = [];
var i = start;
while (i < lines.length) {
var line = lines[i];
if (/^\s*$/.test(line)) break; // 空行结束列表
var mm = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line);
if (!mm) {
// 同级的后续非标记行视为当前条目的延续(懒继续)
if (items.length && /^\s*\S/.test(line) && !/^\s*[-*+]\s/.test(line) && !/^\s*\d+\.\s/.test(line)) {
items[items.length - 1].text.push(line.trim());
i++;
continue;
}
break;
}
var ind = mm[1].length;
if (ind < baseIndent) break; // 减缩进,结束
if (ind > baseIndent) { // 嵌套列表
var sub = parseList(lines, i);
items[items.length - 1].nested += sub.html;
i = sub.next;
continue;
}
// 同级新条目
items.push({ text: [mm[3]], nested: '' });
i++;
}
var tag = ordered ? 'ol' : 'ul';
var out = '<' + tag + '>';
for (var k = 0; k < items.length; k++) {
out += '<li>' + parseInline(items[k].text.join(' '));
if (items[k].nested) out += items[k].nested;
out += '</li>';
}
out += '</' + tag + '>';
return { html: out, next: i };
}
function splitRow(line) {
var t = line.trim();
if (t.charAt(0) === '|') t = t.slice(1);
if (t.charAt(t.length - 1) === '|') t = t.slice(0, -1);
return t.split('|').map(function (c) { return c.trim(); });
}
// 表格解析:返回 { html, next }
function parseTable(lines, start) {
var header = splitRow(lines[start]);
var sep = lines[start + 1];
var aligns = splitRow(sep).map(function (c) {
var left = c.charAt(0) === ':';
var right = c.charAt(c.length - 1) === ':';
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return '';
});
var rows = [];
var j = start + 2;
while (j < lines.length && /\|/.test(lines[j]) && lines[j].trim() !== '') {
rows.push(splitRow(lines[j]));
j++;
}
function cell(tag, c, idx) {
var a = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : '';
return '<' + tag + a + '>' + parseInline(c) + '</' + tag + '>';
}
var html = '<table><thead><tr>';
for (var h = 0; h < header.length; h++) html += cell('th', header[h], h);
html += '</tr></thead><tbody>';
for (var r = 0; r < rows.length; r++) {
html += '<tr>';
for (var c = 0; c < rows[r].length; c++) html += cell('td', rows[r][c], c);
html += '</tr>';
}
html += '</tbody></table>';
return { html: html, next: j };
}
// 块级解析
function parse(src) {
src = (src || '').replace(/\r\n?/g, '\n');
var lines = src.split('\n');
var html = [];
var para = [];
var i = 0;
function flushPara() {
if (para.length) {
var text = para.join('\n');
// 段内软换行 -> <br>
var inline = parseInline(text).replace(/\n/g, '<br>\n');
html.push('<p>' + inline + '</p>');
para = [];
}
}
while (i < lines.length) {
var line = lines[i];
// 代码围栏 ```
if (/^\s*```/.test(line)) {
flushPara();
var lang = line.replace(/^\s*```/, '').trim();
var buf = [];
i++;
while (i < lines.length && !/^\s*```/.test(lines[i])) {
buf.push(lines[i]);
i++;
}
i++; // 跳过结束围栏
html.push('<pre><code' +
(lang ? ' class="language-' + attrSafe(lang) + '"' : '') +
'>' + escapeHtml(buf.join('\n')) + '</code></pre>');
continue;
}
// 分隔线
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
flushPara();
html.push('<hr>');
i++;
continue;
}
// 标题 # ~ ######
var hm = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
if (hm) {
flushPara();
var lvl = hm[1].length;
html.push('<h' + lvl + '>' + parseInline(hm[2].trim()) + '</h' + lvl + '>');
i++;
continue;
}
// 引用 >
if (/^\s*>\s?/.test(line)) {
flushPara();
var qbuf = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
qbuf.push(lines[i].replace(/^\s*>\s?/, ''));
i++;
}
html.push('<blockquote>' + parse(qbuf.join('\n')) + '</blockquote>');
continue;
}
// 表格(当前行含 |,且下一行是分隔行)
if (/\|/.test(line) && i + 1 < lines.length &&
/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && /-/.test(lines[i + 1])) {
flushPara();
var tbl = parseTable(lines, i);
html.push(tbl.html);
i = tbl.next;
continue;
}
// 列表
if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
flushPara();
var lst = parseList(lines, i);
html.push(lst.html);
i = lst.next;
continue;
}
// 空行
if (/^\s*$/.test(line)) {
flushPara();
i++;
continue;
}
// 段落文本
para.push(line);
i++;
}
flushPara();
return html.join('\n');
}
var api = { parse: parse, escapeHtml: escapeHtml };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
global.marked = api;
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));
+3355
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+163
View File
@@ -0,0 +1,163 @@
<?php
/**
* 用户头像上传接口
*
* POST /avatar_upload
* 需登录,支持 JPG/PNG/WebP/GIF,最大 2MB。
* 上传成功后返回新头像 URL,前端可即时更新显示。
*
* 兼容性说明:
* - MIME 校验优先用 getimagesize()(几乎所有环境都可用),
* 再回退到 finfo / mime_content_type,避免某些主机未启用 fileinfo 扩展导致「永远上传失败」。
* - 目录创建与移动文件均做了失败兜底提示,便于定位环境权限问题。
*/
require __DIR__ . '/includes/init.php';
header('Content-Type: application/json; charset=utf-8');
// 必须登录
if (empty($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['ok' => false, 'msg' => '请先登录']);
exit;
}
$user = currentUser();
if (!$user) {
http_response_code(401);
echo json_encode(['ok' => false, 'msg' => '登录已过期']);
exit;
}
// 检查文件上传
if (!isset($_FILES['avatar']) || !is_array($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
$errMap = [
UPLOAD_ERR_INI_SIZE => '文件超过服务器限制(php.ini 中 upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => '文件超过表单限制',
UPLOAD_ERR_PARTIAL => '文件上传不完整',
UPLOAD_ERR_NO_FILE => '未选择文件',
UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录不存在',
UPLOAD_ERR_CANT_WRITE => '服务器写入失败',
UPLOAD_ERR_EXTENSION => '文件类型被禁止',
];
$code = $_FILES['avatar']['error'] ?? UPLOAD_ERR_NO_FILE;
echo json_encode(['ok' => false, 'msg' => $errMap[$code] ?? '上传错误,请重试']);
exit;
}
$file = $_FILES['avatar'];
// ─── 类型与尺寸校验(多重兜底)──────────────────────────────────────
$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
// 1) getimagesize:同时拿到尺寸与 MIME,几乎在所有环境可用
$imgInfo = @getimagesize($file['tmp_name']);
$mime = ($imgInfo && !empty($imgInfo['mime'])) ? $imgInfo['mime'] : '';
// 2) 若 getimagesize 没拿到 MIME,再尝试 finfo / mime_content_type
if (!in_array($mime, $allowed, true)) {
if (function_exists('finfo_open')) {
$fi = @finfo_open(FILEINFO_MIME_TYPE);
if ($fi) {
$m = @finfo_file($fi, $file['tmp_name']);
finfo_close($fi);
if ($m) {
$mime = $m;
}
}
}
if (!in_array($mime, $allowed, true) && function_exists('mime_content_type')) {
$mime = @mime_content_type($file['tmp_name']);
}
}
if (!in_array($mime, $allowed, true)) {
echo json_encode(['ok' => false, 'msg' => '仅支持 JPG / PNG / WebP / GIF 图片']);
exit;
}
// 尺寸检查(至少 64x64
if (!$imgInfo || $imgInfo[0] < 64 || $imgInfo[1] < 64) {
echo json_encode(['ok' => false, 'msg' => '图片尺寸不能小于 64×64 像素']);
exit;
}
// 大小检查(2MB
if ($file['size'] > 2 * 1024 * 1024) {
echo json_encode(['ok' => false, 'msg' => '图片大小不能超过 2MB']);
exit;
}
// ─── 确保上传目录存在 ─────────────────────────────────────────────
$uploadDir = __DIR__ . '/storage/uploads/avatars';
if (!is_dir($uploadDir)) {
if (!@mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
echo json_encode(['ok' => false, 'msg' => '服务器无法创建上传目录(请检查 storage/uploads 目录权限)']);
exit;
}
}
if (!is_writable($uploadDir)) {
echo json_encode(['ok' => false, 'msg' => '上传目录不可写(请给 storage/uploads/avatars 目录写入权限)']);
exit;
}
// ─── 生成唯一文件名 ───────────────────────────────────────────────
$extMap = [IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_GIF => 'gif', IMAGETYPE_WEBP => 'webp'];
$ext = $extMap[$imgInfo[2]] ?? 'png';
$fileName = $user['id'] . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$filePath = $uploadDir . '/' . $fileName;
// ─── 移动上传文件(move 失败则回退 copy)─────────────────────────
$moved = false;
if (@move_uploaded_file($file['tmp_name'], $filePath)) {
$moved = true;
} elseif (@copy($file['tmp_name'], $filePath)) {
$moved = true;
}
if (!$moved || !is_file($filePath)) {
echo json_encode(['ok' => false, 'msg' => '文件保存失败(请检查 storage/uploads/avatars 目录权限)']);
exit;
}
// ─── 确保 users 表存在 avatar 列(未跑升级脚本时兜底)─────────────
try {
$chk = db()->query("SHOW COLUMNS FROM " . tn('users') . " LIKE 'avatar'");
if ($chk && $chk->rowCount() === 0) {
db()->exec("ALTER TABLE " . tn('users') . " ADD COLUMN avatar VARCHAR(255) NULL DEFAULT NULL AFTER invited_by");
}
} catch (Throwable $e) {
// 忽略:以实际 UPDATE 结果为准
}
// ─── 删除旧头像 ───────────────────────────────────────────────────
$oldAvatar = $user['avatar'] ?? '';
if ($oldAvatar && strpos($oldAvatar, 'storage/uploads/avatars') !== false) {
$oldPath = __DIR__ . '/' . $oldAvatar;
if (file_exists($oldPath) && is_file($oldPath)) {
@unlink($oldPath);
}
}
// ─── 更新数据库 ───────────────────────────────────────────────────
$dbAvatar = 'storage/uploads/avatars/' . $fileName;
try {
db()->prepare('UPDATE ' . tn('users') . ' SET avatar=? WHERE id=?')
->execute([$dbAvatar, $user['id']]);
} catch (Throwable $e) {
// 列确实不存在且兜底 ALTER 也失败时才走到这里
echo json_encode(['ok' => false, 'msg' => '数据库更新失败:' . $e->getMessage()]);
exit;
}
// 清除会话缓存,使其它页面重新读取最新头像
unset($_SESSION['user_id'], $_SESSION['user']);
Logger::info('用户上传头像', ['user_id' => $user['id'], 'file' => $fileName]);
echo json_encode([
'ok' => true,
'msg' => '头像更新成功',
// 返回相对站点根的路径,前端与导航栏展示保持一致
'data' => ['url' => $dbAvatar]
]);
exit;
+50
View File
@@ -0,0 +1,50 @@
<?php
// 自研图形验证码:输出 4 位字符图片,代码存入 session。
// 不依赖任何第三方库,纯 GD 绘制;无 GD 时降级为纯文本兜底。
session_start();
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去除 0/O/1/I 等易混字符
$len = 4;
$code = '';
for ($i = 0; $i < $len; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
$_SESSION['captcha'] = $code;
if (!extension_loaded('gd')) {
// 无 GD 扩展时降级:直接输出字符(仅兜底,建议开启 GD)
header('Content-Type: text/plain; charset=UTF-8');
echo $code;
exit;
}
$w = 120; $h = 44;
$img = imagecreatetruecolor($w, $h);
$bg = imagecolorallocate($img, 248, 250, 252);
imagefill($img, 0, 0, $bg);
// 干扰线
for ($i = 0; $i < 6; $i++) {
$c = imagecolorallocate($img, rand(180, 230), rand(190, 235), rand(200, 245));
imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $c);
}
// 干扰点
for ($i = 0; $i < 40; $i++) {
$c = imagecolorallocate($img, rand(180, 230), rand(190, 235), rand(200, 245));
imagesetpixel($img, rand(0, $w), rand(0, $h), $c);
}
// 逐个绘制字符(随机大小/角度/颜色,轻微错位形成扭曲)
for ($i = 0; $i < $len; $i++) {
$c = imagecolorallocate($img, rand(20, 90), rand(70, 140), rand(170, 230));
$size = rand(20, 26);
$angle = rand(-20, 20);
$x = 14 + $i * 26;
$y = rand(30, 38);
imagestring($img, 5, $x, $y - 16, $code[$i], $c);
}
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
imagepng($img);
imagedestroy($img);
+88
View File
@@ -0,0 +1,88 @@
<?php
// 购物车:查看 / 改数量 / 删除 / 清空
require __DIR__ . '/includes/init.php';
$pageTitle = __('cart_page.title');
$cart = $_SESSION['cart'] ?? [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf();
if (isset($_POST['clear'])) {
$_SESSION['cart'] = [];
} elseif (isset($_POST['remove'])) {
$rid = (int) $_POST['remove'];
unset($cart[$rid]);
$_SESSION['cart'] = $cart;
} elseif (isset($_POST['update'])) {
foreach (($_POST['qty'] ?? []) as $pid => $qv) {
$pid = (int) $pid;
$qv = max(0, (int) $qv);
if ($qv <= 0) {
unset($cart[$pid]);
} else {
$cart[$pid] = $qv;
}
}
$_SESSION['cart'] = $cart;
}
redirect('cart.php');
}
$items = [];
if (!empty($cart)) {
$ids = array_keys($cart);
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = db()->prepare('SELECT * FROM ' . tn('products') . ' WHERE id IN (' . $ph . ') AND status = 1');
$stmt->execute($ids);
foreach ($stmt->fetchAll() as $p) {
$qty = (int) $cart[$p['id']];
if ($qty > $p['stock']) $qty = $p['stock']; // 超出库存则裁剪
$pointsSub = (!empty($p['points_price']) ? (int)$p['points_price'] * $qty : 0);
$items[] = ['p' => $p, 'qty' => $qty, 'points_sub' => $pointsSub];
}
}
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-shopping-cart"></i> <?= __('cart_page.title') ?></h2>
<?php if (empty($items)): ?>
<p class="empty"><?= str_replace('{link}', '<a href="index.php">' . __('cart_page.empty_link') . '</a>', __('cart_page.empty')) ?></p>
<?php else: ?>
<form method="post" action="" class="cart-form">
<?= csrfField() ?>
<div class="cart-list">
<?php foreach ($items as $it): $p = $it['p']; ?>
<div class="cart-item">
<a href="product.php?id=<?= (int)$p['id'] ?>" class="ci-media">
<?php if (!empty($p['image'])): ?><img src="<?= h($p['image']) ?>" alt=""><?php else: ?><i class="fas <?= h($p['icon']) ?>"></i><?php endif; ?>
</a>
<div class="ci-info">
<a href="product.php?id=<?= (int)$p['id'] ?>" class="ci-name"><?= h($p['name']) ?></a>
<?php if (!empty($p['points_price'])): ?>
<div class="ci-price"><?= (int)$p['points_price'] ?> <?= __('cart_page.points_each') ?></div>
<?php endif; ?>
</div>
<div class="ci-qty">
<input type="number" name="qty[<?= (int)$p['id'] ?>]" value="<?= $it['qty'] ?>" min="1" max="<?= (int)$p['stock'] ?>">
</div>
<div class="ci-sub"><?= $it['points_sub'] > 0 ? (int)$it['points_sub'] . ' ' . __('common.points') : __('cart_page.subtotal_free') ?></div>
<button type="submit" name="remove" value="<?= (int)$p['id'] ?>" class="ci-del" title="<?= __('cart_page.remove_title') ?>"><i class="fas fa-trash"></i></button>
</div>
<?php endforeach; ?>
</div>
<div class="cart-foot">
<div class="cart-actions">
<button type="submit" name="update" class="btn btn-ghost"><i class="fas fa-sync"></i> <?= __('cart_page.update_qty') ?></button>
<button type="submit" name="clear" class="btn btn-ghost" onclick="return confirm('<?= __('cart_page.clear_confirm') ?>')"><?= __('cart_page.clear_btn') ?></button>
<a href="checkout.php" class="btn btn-primary"><i class="fas fa-credit-card"></i> <?= __('cart_page.checkout_btn') ?></a>
</div>
</div>
</form>
<?php endif; ?>
</section>
<?php require 'includes/footer.php'; ?>
+153
View File
@@ -0,0 +1,153 @@
<?php
// 结算下单:登录后填写收货信息,生成订单并扣减库存
require __DIR__ . '/includes/init.php';
requireLogin('checkout.php');
$pageTitle = '结算';
$user = currentUser();
$cart = $_SESSION['cart'] ?? [];
$items = [];
$total = 0;
if (!empty($cart)) {
$ids = array_keys($cart);
$ph = implode(',', array_fill(0, count($ids), '?'));
$stmt = db()->prepare('SELECT * FROM ' . tn('products') . ' WHERE id IN (' . $ph . ') AND status = 1');
$stmt->execute($ids);
foreach ($stmt->fetchAll() as $p) {
$qty = (int) $cart[$p['id']];
if ($qty > $p['stock']) $qty = $p['stock'];
$pointsSub = (!empty($p['points_price']) ? (int)$p['points_price'] * $qty : 0);
$items[] = ['p' => $p, 'qty' => $qty, 'points_sub' => $pointsSub];
}
}
// 积分支付可用性:仅当订单内所有商品都设置了积分价
$pointsElg = !empty($items);
$pointsTotal = 0;
foreach ($items as $it) {
$pp = (int) ($it['p']['points_price'] ?? 0);
if ($pp <= 0) { $pointsElg = false; break; }
$pointsTotal += $pp * $it['qty'];
}
if ($pointsTotal <= 0) { $pointsElg = false; }
$userPoints = 0;
if (isLoggedIn()) {
$userPoints = getUserPoints($user['id']);
}
$err = '';
if (empty($items)) {
redirect('cart.php');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf();
$contact = trim($_POST['contact'] ?? '');
$contactEmail = trim($_POST['contact_email'] ?? '');
$address = trim($_POST['address'] ?? '');
$note = trim($_POST['note'] ?? '');
if ($contact === '') $err = '请填写联系人';
elseif ($address === '') $err = '请填写收货地址';
elseif ($contactEmail === '') $contactEmail = $user['email']; // 留空则使用账号邮箱
elseif (!filter_var($contactEmail, FILTER_VALIDATE_EMAIL)) $err = '联系邮箱格式不正确';
if ($err === '') {
$payType = ($pointsElg && ($_POST['pay_type'] ?? '') === 'points') ? 'points' : 'direct';
if ($payType === 'points') {
if ($userPoints < $pointsTotal) {
$err = '积分不足,当前 ' . $userPoints . ' 分,本单需 ' . $pointsTotal . ' 分';
}
}
}
if ($err === '') {
$pdo = db();
try {
$pdo->beginTransaction();
// 二次校验库存
foreach ($items as $it) {
$chk = $pdo->prepare('SELECT stock FROM ' . tn('products') . ' WHERE id = ? FOR UPDATE');
$chk->execute([$it['p']['id']]);
$row = $chk->fetch();
if (!$row || $row['stock'] < $it['qty']) {
throw new Exception('「' . $it['p']['name'] . '」库存不足,请返回购物车调整');
}
}
// 计算本单周期(取商品中最大的周期天数)与到期时间
$maxPeriod = 0;
foreach ($items as $it) {
$maxPeriod = max($maxPeriod, (int) ($it['p']['period_days'] ?? 0));
}
$expiresAt = $maxPeriod > 0 ? date('Y-m-d H:i:s', strtotime("+$maxPeriod days")) : null;
// 本商城不收取现金,现金合计固定记 0;积分支付时抵扣并记流水
$orderTotal = 0;
$pointsUsed = 0;
if ($payType === 'points') {
$pointsUsed = $pointsTotal;
$pdo->prepare('UPDATE ' . tn('users') . ' SET points = points - ? WHERE id = ? AND points >= ?')
->execute([$pointsUsed, $user['id'], $pointsUsed]);
$bal = getUserPoints($user['id']);
$pdo->prepare('INSERT INTO ' . tn('points_log') . ' (user_id,type,amount,balance,remark,created_at) VALUES (?,?,?,?,?,NOW())')
->execute([$user['id'], 'purchase', -$pointsUsed, $bal, '积分兑换:' . $orderNo]);
}
// 生成订单号
$orderNo = 'FNW' . date('Ymd') . strtoupper(substr(md5(uniqid($user['id'], true)), 0, 10));
$pdo->prepare('INSERT INTO ' . tn('orders') . ' (order_no,user_id,total,status,contact,contact_email,address,note,period_days,expires_at,pay_type,points_used,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NOW())')
->execute([$orderNo, $user['id'], $orderTotal, 'paid', $contact, $contactEmail, $address, $note, $maxPeriod, $expiresAt, $payType, $pointsUsed]);
$orderId = $pdo->lastInsertId();
foreach ($items as $it) {
// 商城已取消现金支付,价格字段保留为 0 供历史兼容
$pdo->prepare('INSERT INTO ' . tn('order_items') . ' (order_id,product_id,name,price,qty,subtotal,period_days) VALUES (?,?,?,?,?,?,?)')
->execute([$orderId, $it['p']['id'], $it['p']['name'], 0, $it['qty'], 0, (int) ($it['p']['period_days'] ?? 0)]);
$pdo->prepare('UPDATE ' . tn('products') . ' SET stock = stock - ? WHERE id = ?')
->execute([$it['qty'], $it['p']['id']]);
}
$pdo->commit();
$_SESSION['cart'] = [];
redirect('myorders.php?paid=' . urlencode($orderNo));
} catch (Exception $e) {
$pdo->rollBack();
$err = $e->getMessage();
}
}
}
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-credit-card"></i> 订单结算</h2>
<div class="checkout">
<div class="co-items">
<h3>商品清单</h3>
<?php foreach ($items as $it): ?>
<div class="co-row">
<span><?= h($it['p']['name']) ?> × <?= $it['qty'] ?></span>
<span><?= $it['points_sub'] > 0 ? (int)$it['points_sub'] . ' 积分' : '直接下单' ?></span>
</div>
<?php endforeach; ?>
</div>
<form method="post" action="" class="co-form">
<?= csrfField() ?>
<h3>收货信息</h3>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<label>联系人<input type="text" name="contact" value="<?= h($_POST['contact'] ?? $user['username']) ?>" required></label>
<label>联系邮箱<input type="email" name="contact_email" value="<?= h($_POST['contact_email'] ?? $user['email']) ?>" placeholder="用于接收订单/发货通知" required></label>
<label>收货地址<input type="text" name="address" value="<?= h($_POST['address'] ?? '') ?>" required></label>
<label>备注<input type="text" name="note" value="<?= h($_POST['note'] ?? '') ?>" placeholder="选填"></label>
<?php if ($pointsElg): ?>
<div class="co-pay">
<h3>支付方式</h3>
<input type="hidden" name="pay_type" value="points">
<p class="pay-opt"><i class="fas fa-coins"></i> 积分支付(<?= $pointsTotal ?> 积分)<span class="muted" style="margin-left:10px;font-size:.85em;">当前积分 <?= (int)$userPoints ?></span></p>
</div>
<?php else: ?>
<input type="hidden" name="pay_type" value="direct">
<?php endif; ?>
<p class="co-tip">下单后如有积分价格要求,用积分支付;无积分价格,直接支付即可(发工单即代表您已同意资源领取/续期规定)。</p>
<button type="submit" class="btn btn-primary" style="width:100%;">提交订单</button>
</form>
</div>
</section>
<?php require 'includes/footer.php'; ?>
+118
View File
@@ -0,0 +1,118 @@
<?php
if (!defined('IN_APP')) {
define('IN_APP', true);
}
require __DIR__ . '/config.php';
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/functions.php';
$isCli = (PHP_SAPI === 'cli');
$dryRun = false;
$args = $isCli ? array_slice($argv, 1) : [];
if (in_array('--dry-run', $args, true)) {
$dryRun = true;
}
// Web 触发需携带正确的 key,否则拒绝
if (!$isCli) {
header('Content-Type: text/plain; charset=utf-8');
$validKey = getSetting('cron_notify_key', '');
if ($validKey === '') {
$validKey = defined('SITE_KEY') ? SITE_KEY : '';
}
$got = (string) ($_GET['key'] ?? '');
if ($validKey === '' || !hash_equals($validKey, $got)) {
http_response_code(403);
echo "Access denied\n";
exit;
}
if (isset($_GET['dry'])) {
$dryRun = true;
}
}
$notifyDays = 5;
$lo = date('Y-m-d H:i:s', strtotime('+' . ($notifyDays - 1) . ' days'));
$hi = date('Y-m-d H:i:s', strtotime('+' . ($notifyDays + 1) . ' days'));
$sql = 'SELECT o.id, o.order_no, o.expires_at, o.contact_email, u.email AS u_email, u.username, u.nickname
FROM ' . tn('orders') . ' o
LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id
WHERE o.expires_at IS NOT NULL
AND o.expires_at > NOW()
AND o.expires_at >= ? AND o.expires_at <= ?
AND o.expire_notify_sent = 0
AND o.status IN (\'paid\', \'shipped\', \'completed\')';
$stmt = db()->prepare($sql);
$stmt->execute([$lo, $hi]);
$rows = $stmt->fetchAll();
$sent = 0;
$skipped = 0;
$errors = [];
$preview = [];
foreach ($rows as $row) {
$to = !empty($row['contact_email']) ? $row['contact_email'] : $row['u_email'];
if (empty($to)) {
$skipped++;
$errors[] = '订单 #' . $row['id'] . ' 无收件邮箱,已跳过';
continue;
}
$daysLeft = (int) ceil((strtotime($row['expires_at']) - time()) / 86400);
$name = $row['nickname'] ?: $row['username'];
$subject = '[' . SITE_NAME . '] 您的服务即将到期(剩余 ' . $daysLeft . ' 天),请及时续期';
$body = buildExpireMail($name, $row['order_no'], $row['expires_at'], $daysLeft);
if ($dryRun) {
$preview[] = '预览 #' . $row['id'] . ' → ' . $to . ' | 剩余 ' . $daysLeft . ' 天';
$sent++;
continue;
}
$res = fnwSendMail($to, $subject, $body);
if (!empty($res['ok'])) {
db()->prepare('UPDATE ' . tn('orders') . ' SET expire_notify_sent = 1 WHERE id = ?')->execute([$row['id']]);
$sent++;
} else {
$errors[] = '订单 #' . $row['id'] . ' 发送失败:' . ($res['error'] ?? '未知错误');
}
}
$out = [];
$out[] = '[' . date('Y-m-d H:i:s') . '] 到期前提醒任务' . ($dryRun ? '(预览模式)' : '');
$out[] = '扫描窗口:' . $lo . ' ~ ' . $hi . '(距到期约 ' . ($notifyDays - 1) . '~' . ($notifyDays + 1) . ' 天)';
$out[] = '命中订单:' . count($rows) . ' 笔;已发送:' . $sent . ';跳过:' . $skipped;
if ($dryRun && $preview) {
$out[] = '--- 预览列表 ---';
$out = array_merge($out, $preview);
}
if ($errors) {
$out[] = '--- 注意事项 ---';
$out = array_merge($out, $errors);
}
$out[] = '';
$text = implode("\n", $out);
if ($isCli) {
fwrite(STDOUT, $text);
} else {
echo $text;
}
/**
* 生成到期提醒邮件正文(HTML)。
*/
function buildExpireMail($name, $orderNo, $expiresAt, $daysLeft)
{
$exp = date('Y-m-d H:i', strtotime($expiresAt));
$site = SITE_NAME;
return '<div style="font-family:Segoe UI,Helvetica,Arial,sans-serif;max-width:560px;margin:0 auto;padding:24px;'
. 'border:1px solid #e3e8ef;border-radius:16px;">'
. '<h2 style="color:#1a73e8;margin:0 0 12px;">服务即将到期提醒</h2>'
. '<p style="color:#1f2430;line-height:1.7;">你好 ' . h($name) . ',你在 ' . h($site) . ' 的订单 <strong>' . h($orderNo) . '</strong> 所对应的服务即将到期。</p>'
. '<div style="background:#f5f7fb;border-left:4px solid #f59e0b;padding:12px 16px;margin:12px 0;color:#1f2430;">'
. '<p style="margin:4px 0;">到期时间:<strong>' . h($exp) . '</strong></p>'
. '<p style="margin:4px 0;">剩余天数:<strong style="color:#b7791f;">约 ' . (int) $daysLeft . ' 天</strong></p>'
. '</div>'
. '<p style="color:#1f2430;line-height:1.7;">为避免服务中断,请在到期前通过「我的订单 → 对应订单 → 申请续期」提交工单,或联系客服办理续期。</p>'
. '<p style="color:#5f6b7a;font-size:.85rem;margin-top:16px;">若已办理续期请忽略此邮件。本邮件由系统自动发送,请勿直接回复。</p>'
. '</div>';
}
+280
View File
@@ -0,0 +1,280 @@
-- =================================
-- 自由云商城 数据库结构 + 初始数据
-- 安装程序会自动把 __PREFIX__ 替换为用户填写的表前缀
-- 编码:utf8mb4 / InnoDB
-- =================================
-- 用户表(普通用户与后台管理员共用,is_admin=1 为管理员;status=0 表示被封禁)
CREATE TABLE `__PREFIX__users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL,
`password` VARCHAR(255) NOT NULL,
`email` VARCHAR(120) NOT NULL DEFAULT '',
`is_admin` TINYINT(1) NOT NULL DEFAULT 0,
`role` VARCHAR(10) NOT NULL DEFAULT 'user' COMMENT 'admin管理员 cs客服 user普通用户(后台登录用 role IN admin/cs',
`avatar` VARCHAR(255) DEFAULT '' COMMENT '头像文件路径',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1正常 0已封禁',
`nickname` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '昵称',
`verified` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '邮箱是否验证 0未验证 1已验证',
`email_token` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '邮箱验证令牌',
`reg_ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '注册 IP(限频用)',
`points` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '积分余额',
`invite_code` VARCHAR(12) NOT NULL DEFAULT '' COMMENT '我的邀请码(唯一,用于邀请奖励)',
`invited_by` INT UNSIGNED DEFAULT NULL COMMENT '邀请人 user_id(通过邀请注册时记录)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_invite_code` (`invite_code`),
KEY `idx_invited_by` (`invited_by`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 商品表
CREATE TABLE `__PREFIX__products` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(120) NOT NULL,
`price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`stock` INT UNSIGNED NOT NULL DEFAULT 0,
`category` VARCHAR(40) NOT NULL DEFAULT '其他',
`icon` VARCHAR(40) NOT NULL DEFAULT 'fa-box',
`image` VARCHAR(255) DEFAULT '',
`description` TEXT,
`period_days` INT NOT NULL DEFAULT 0 COMMENT '周期天数:0=一次性/实物,>0=按天计的周期商品(如 30/90/365)',
`points_price` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '积分价:0=不可用积分购买,>0=可用该积分数购买',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1上架 0下架',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category` (`category`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 商品分组(后台可增删,用于首页分类筛选与商品归类)
CREATE TABLE `__PREFIX__product_groups` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(40) NOT NULL,
`icon` VARCHAR(40) NOT NULL DEFAULT 'fa-tags',
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 订单表(status: pending待付款 paid已付款 shipped已发货 completed已完成 cancelled已取消)
CREATE TABLE `__PREFIX__orders` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(32) NOT NULL,
`user_id` INT UNSIGNED NOT NULL,
`total` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`contact` VARCHAR(60) DEFAULT '',
`contact_email` VARCHAR(120) DEFAULT '' COMMENT '联系邮箱(下单人填写)',
`address` VARCHAR(255) DEFAULT '',
`note` VARCHAR(255) DEFAULT '',
`period_days` INT NOT NULL DEFAULT 0 COMMENT '本单周期天数(取商品最大值)',
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间(周期商品有值)',
`expire_notify_sent` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '到期前提醒邮件是否已发送 0未发 1已发',
`pay_type` VARCHAR(10) NOT NULL DEFAULT 'cash' COMMENT 'cash现金 points积分支付',
`points_used` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '积分支付时扣减的积分数',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user` (`user_id`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 订单明细表
CREATE TABLE `__PREFIX__order_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`product_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(120) NOT NULL,
`price` DECIMAL(10,2) NOT NULL,
`qty` INT UNSIGNED NOT NULL,
`subtotal` DECIMAL(10,2) NOT NULL,
`period_days` INT NOT NULL DEFAULT 0 COMMENT '下单时商品的周期天数',
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 发货信息表(管理员发货时填写:连接信息/登录凭据)
CREATE TABLE `__PREFIX__order_deliveries` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`server_ip` VARCHAR(60) DEFAULT '' COMMENT '服务器 IP',
`conn_addr` VARCHAR(255) DEFAULT '' COMMENT '连接地址/域名',
`login_user` VARCHAR(120) DEFAULT '' COMMENT '登录账户',
`login_pass` VARCHAR(255) DEFAULT '' COMMENT '登录密码',
`remark` TEXT,
`activated` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '激活状态:0未激活 1已激活(管理员填写,用户端展示)',
`product_info` TEXT COMMENT '商品信息(管理员填写后展示给用户)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 系统设置表(SMTP 等键值对)
CREATE TABLE `__PREFIX__settings` (
`k` VARCHAR(64) NOT NULL,
`v` TEXT,
PRIMARY KEY (`k`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 工单部门表
CREATE TABLE `__PREFIX__ticket_departments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(60) NOT NULL,
`description` VARCHAR(255) DEFAULT '',
`sort` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 工单表
CREATE TABLE `__PREFIX__tickets` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`dept_id` INT UNSIGNED NOT NULL DEFAULT 0,
`order_id` INT UNSIGNED DEFAULT NULL COMMENT '关联订单(续期工单用)',
`type` VARCHAR(20) NOT NULL DEFAULT 'general' COMMENT 'general普通 renewal续期申请',
`subject` VARCHAR(160) NOT NULL,
`message` TEXT NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'open' COMMENT 'open待处理 pending处理中 resolved已解决 closed已关闭',
`priority` TINYINT(1) NOT NULL DEFAULT 2 COMMENT '1低 2普通 3高',
`admin_reply` TEXT,
`admin_id` INT UNSIGNED DEFAULT NULL,
`assignee_id` INT UNSIGNED DEFAULT NULL COMMENT '负责人(客服/管理员)用户ID,NULL=未分配(仅管理员可见)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`closed_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_user` (`user_id`),
KEY `idx_dept` (`dept_id`),
KEY `idx_order` (`order_id`),
KEY `idx_assignee` (`assignee_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 工单回复表(多轮对话,is_admin=1 表示客服/管理员回复)
CREATE TABLE `__PREFIX__ticket_replies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ticket_id` INT UNSIGNED NOT NULL,
`user_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '回复人 user_id(管理员/客服回复时填其 id,用户回复时填下单用户 id)',
`is_admin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=管理员/客服回复 0=用户回复',
`message` TEXT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ticket` (`ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 公告中心
CREATE TABLE `__PREFIX__announcements` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(160) NOT NULL,
`content` TEXT NOT NULL,
`pinned` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1置顶',
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1显示 0隐藏',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_pinned` (`pinned`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 积分流水表(签到 / 邀请 / 消费 / 后台调整 等记录)
CREATE TABLE `__PREFIX__points_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`type` VARCHAR(20) NOT NULL DEFAULT 'sign' COMMENT 'sign签到 invite邀请 purchase消费 admin后台调整 refund退还',
`amount` INT NOT NULL DEFAULT 0 COMMENT '变动量:正=增加 负=扣减',
`balance` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '变动后余额',
`remark` VARCHAR(255) DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 每日签到记录(每人每天一条,防止重复签到)
CREATE TABLE `__PREFIX__sign_logs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`sign_date` DATE NOT NULL,
`points` INT UNSIGNED NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_date` (`user_id`, `sign_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- 初始示例商品(分类:周边 / 数码 / 虚拟 / 图书)
-- ============================================================
INSERT INTO `__PREFIX__products` (`name`, `price`, `stock`, `category`, `icon`, `image`, `description`) VALUES
('自由云定制帆布包', 39.00, 100, '周边', 'fa-shopping-bag', '', '印有自由云科技团队 Logo 的环保帆布包,简约实用,容量充足。'),
('自由云贴纸套装', 9.90, 500, '周边', 'fa-sticky-note', '', '多种尺寸防水贴纸,装点你的电脑、水杯与笔记本。'),
('机械键盘 87 键', 199.00, 50, '数码', 'fa-keyboard', '', '热插拔轴体,RGB 背光,打字手感顺滑,附赠拔轴器。'),
('10000mAh 移动电源', 79.00, 80, '数码', 'fa-battery-full', '', '轻薄便携,支持双向快充,可为手机平板多次续电。'),
('MC 服务器 VIP 月卡', 12.00, 9999, '虚拟', 'fa-cube', '', '自由 MC 服务器专属 VIP 月卡,享优先进入与专属昵称色。'),
('图床扩容包 10GB', 5.00, 9999, '虚拟', 'fa-images', '', '为你的公益图床账号扩容 10GB 存储空间。'),
('《从零学 PHP》实体书', 49.00, 30, '图书', 'fa-book', '', '面向中学生的 PHP 入门教程,案例丰富、通俗易懂。'),
('《网络安全小百科》', 35.00, 40, '图书', 'fa-shield-alt', '', '图文并茂的网络安全科普读物,适合青少年阅读。');
-- =======================
-- 初始商品分组(与上面商品 category 对应:周边 / 数码 / 虚拟 / 图书)
-- =======================
INSERT INTO `__PREFIX__product_groups` (`name`, `icon`, `sort_order`) VALUES
('周边', 'fa-shopping-bag', 1),
('数码', 'fa-microchip', 2),
('虚拟', 'fa-cube', 3),
('图书', 'fa-book', 4);
-- =====================
-- 初始工单部门
-- =====================
INSERT INTO `__PREFIX__ticket_departments` (`name`, `description`, `sort`) VALUES
('技术支持', '产品使用、功能咨询', 1),
('售后服务', '退换货、订单与物流问题', 2),
('财务与支付', '发票、支付异常', 3),
('其他', '其他无法归类的问题', 9);
-- =======================
-- 初始系统设置(SMTP 留空,安装后在后台「设置」中填写)
-- =======================
INSERT INTO `__PREFIX__settings` (`k`, `v`) VALUES
('smtp_host', ''),
('smtp_port', ''),
('smtp_enc', ''),
('smtp_user', ''),
('smtp_pass', ''),
('smtp_from', ''),
('smtp_fromname', '自由云商城'),
('notify_emails', ''),
('mall_about', '自由云商城是自由云科技团队旗下的公益电商,坚持「公益 · 实惠 · 用心挑选」的理念,为社区提供周边、数码、虚拟服务与图书等好物。所有收益用于支持团队的公益技术服务。'),
('points_enabled', '1'),
('points_sign', '5'),
('points_invite', '20');
-- 支付日志表(记录支付宝/微信支付流水)
CREATE TABLE `__PREFIX__pay_logs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` VARCHAR(64) NOT NULL COMMENT '商户订单号',
`pay_method` VARCHAR(20) NOT NULL DEFAULT 'alipay' COMMENT 'alipay / wechat',
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额',
`trade_no` VARCHAR(64) DEFAULT '' COMMENT '第三方交易号',
`status` ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending',
`raw_response TEXT DEFAULT NULL COMMENT '原始回调数据(调试用)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_method` (`order_id`, `pay_method`),
KEY `idx_status` (`status`),
KEY `idx_trade_no` (`trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 支付配置初始值
INSERT INTO `__PREFIX__settings` (`k`, `v`) VALUES
('pay_alipay_enabled', '0'),
('pay_alipay_appid', ''),
('pay_alipay_private_key',''),
('pay_alipay_public_key', ''),
('pay_alipay_sandbox', '0'),
('pay_wechat_enabled', '0'),
('pay_wechat_mch_id', ''),
('pay_wechat_api_key', ''),
('pay_wechat_appid', '');
+155
View File
@@ -0,0 +1,155 @@
<?php
/**
* 文件缓存系统
*
* 将数据序列化后写入 storage/cache/ 目录。
* 支持设置 TTL(过期时间)、标签清除、全局清除。
*
* 用法:
* Cache::put('user_123', $data, 3600); // 缓存 1 小时
* $data = Cache::get('user_123'); // 读取(过期返回 null)
* Cache::forget('user_123'); // 删除单个
* Cache::clear(); // 清除全部
*/
if (!defined('IN_APP')) exit('Forbidden');
class Cache
{
/** @var string 缓存目录 */
private static $dir;
/** @var bool 是否启用 */
private static $enabled = true;
/** @var string 缓存文件扩展名 */
const EXT = '.cache';
/**
* 初始化缓存系统。
* @param string $dir 缓存目录路径
* @param bool $enabled 是否启用
*/
public static function init($dir, $enabled = true)
{
self::$dir = rtrim($dir, '/\\');
self::$enabled = $enabled;
if (!is_dir(self::$dir)) {
@mkdir(self::$dir, 0755, true);
}
}
/**
* 写入缓存。
* @param string $key 缓存键
* @param mixed $value 值(任意可序列化类型)
* @param int $ttl 有效期(秒),0=永不过期
* @return bool
*/
public static function put($key, $value, $ttl = 3600)
{
if (!self::$enabled) return false;
$file = self::fileName($key);
$data = [
'expires' => $ttl > 0 ? time() + $ttl : 0,
'created' => time(),
'value' => $value,
];
$content = serialize($data);
return (bool)@file_put_contents($file, $content, LOCK_EX);
}
/**
* 读取缓存。过期或不存在返回默认值。
* @param string $key
* @param mixed $default 不存在时的默认值
* @return mixed
*/
public static function get($key, $default = null)
{
if (!self::$enabled) return $default;
$file = self::fileName($key);
if (!file_exists($file)) return $default;
$content = @file_get_contents($file);
if ($content === false) return $default;
$data = @unserialize($content);
if (!is_array($data)) {
@unlink($file);
return $default;
}
// 检查过期
if ($data['expires'] > 0 && time() > $data['expires']) {
@unlink($file);
return $default;
}
return $data['value'];
}
/**
* 检查缓存是否存在且未过期。
*/
public static function has($key)
{
return self::get($key, '__NOT_FOUND__') !== '__NOT_FOUND__';
}
/**
* 删除单个缓存。
*/
public static function forget($key)
{
$file = self::fileName($key);
if (file_exists($file)) {
return @unlink($file);
}
return true;
}
/**
* 清除所有缓存。
*/
public static function clear()
{
if (!is_dir(self::$dir)) return;
$files = glob(self::$dir . DIRECTORY_SEPARATOR . '*' . self::EXT);
foreach ($files as $f) {
@unlink($f);
}
}
/**
* 带回掉的缓存读取(缓存命中直接返回,未命中执行回调并缓存)。
* @param string $key
* @param int $ttl
* @param callable $callback 无参回调,返回要缓存的值
* @return mixed
*/
public static function remember($key, $ttl, callable $callback)
{
$value = self::get($key, null);
if ($value !== null) return $value;
$value = $callback();
self::put($key, $value, $ttl);
return $value;
}
/**
* 获取或设置(简化版 remember)。
*/
public static function getOrSet($key, $ttl, callable $callback)
{
return self::remember($key, $ttl, $callback);
}
// ─── 内部 ───
private static function fileName($key)
{
// 用 md5 确保文件名安全
return self::$dir . DIRECTORY_SEPARATOR . md5($key) . self::EXT;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
/**
* 数据库驱动抽象层
*
* 支持 MySQL (PDO/MySQL) 和 SQLite3 (PDO/SQLite)。
* 在 config.php 中通过 DB_DRIVER 配置:'mysql' 或 'sqlite'
*
* 安装时用户可选择驱动:
* - MySQL: 需要配置 DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASS
* - SQLite: 需要配置 DB_SQLITE_PATH(数据库文件路径)
*/
if (!defined('IN_APP')) exit('Forbidden');
class DbDriver
{
/** @var PDO|null 当前连接 */
private static $pdo = null;
/** @var string 驱动类型 'mysql' | 'sqlite' */
private static $driver = 'mysql';
/**
* 获取 PDO 实例(单例)。
* @return PDO
*/
public static function connect()
{
if (self::$pdo !== null) return self::$pdo;
self::$driver = defined('DB_DRIVER') ? DB_DRIVER : 'mysql';
try {
if (self::$driver === 'sqlite') {
$path = defined('DB_SQLITE_PATH') ? DB_SQLITE_PATH : (__DIR__ . '/../data/database.sqlite');
$dir = dirname($path);
if (!is_dir($dir)) mkdir($dir, 0755, true);
self::$pdo = new PDO('sqlite:' . $path);
self::$pdo->exec('PRAGMA journal_mode=WAL');
self::$pdo->exec('PRAGMA foreign_keys=ON');
} else {
// MySQL(默认)
$host = defined('DB_HOST') ? DB_HOST : 'localhost';
$port = defined('DB_PORT') ? DB_PORT : '3306';
$name = defined('DB_NAME') ? DB_NAME : 'mall';
$user = defined('DB_USER') ? DB_USER : 'root';
$pass = defined('DB_PASS') ? DB_PASS : '';
$charset = 'utf8mb4';
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset={$charset}";
self::$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
} catch (PDOException $e) {
if (defined('DEBUG') && DEBUG) {
throw $e;
}
die('数据库连接失败,请检查配置。' . (DEBUG ? ' [' . $e->getMessage() . ']' : ''));
}
return self::$pdo;
}
/**
* 获取当前驱动类型。
*/
public static function getDriver()
{
return self::$driver;
}
/**
* 是否为 SQLite。
*/
public static function isSqlite()
{
return self::$driver === 'sqlite';
}
/**
* 是否为 MySQL。
*/
public static function isMysql()
{
return self::$driver === 'mysql';
}
/**
* 获取原始 PDO 对象。
*/
public static function pdo()
{
return self::connect();
}
/**
* 关闭连接。
*/
public static function close()
{
self::$pdo = null;
}
/**
* 执行 SQL 并返回影响行数。
*/
public static function exec($sql)
{
return self::connect()->exec($sql);
}
/**
* 查询并返回全部行。
*/
public static function queryAll($sql, $params = [])
{
$stmt = self::connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* 查询并返回一行。
*/
public static function queryOne($sql, $params = [])
{
$stmt = self::connect()->prepare($sql);
$stmt->execute($params);
return $stmt->fetch();
}
/**
* 查询并返回单个值。
*/
public static function queryScalar($sql, $params = [])
{
$row = self::queryOne($sql, $params);
return $row ? reset($row) : null;
}
/**
* 获取最后插入 ID。
*/
public static function lastInsertId()
{
return self::connect()->lastInsertId();
}
/**
* 在事务中执行回调。
*/
public static function transaction(callable $callback)
{
$pdo = self::connect();
try {
$pdo->beginTransaction();
$result = $callback($pdo);
$pdo->commit();
return $result;
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
}
/**
* 检查表是否存在。
*/
public static function tableExists($table)
{
if (self::isSqlite()) {
$name = str_replace(self::getPrefix(), '', $table);
return self::queryScalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
[$name]
) > 0;
} else {
$db = defined('DB_NAME') ? DB_NAME : '';
return self::queryScalar(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name=?",
[$db, $table]
) > 0;
}
}
/**
* 获取表前缀。
*/
public static function getPrefix()
{
return defined('DB_PREFIX') ? DB_PREFIX : '';
}
/**
* 列出所有表名。
*/
public static function listTables()
{
if (self::isSqlite()) {
return self::queryAll("SELECT name FROM sqlite_master WHERE type='table'");
} else {
$db = defined('DB_NAME') ? DB_NAME : '';
return self::queryAll("SELECT table_name AS name FROM information_schema.tables WHERE table_schema=?", [$db]);
}
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
/**
* 多语言(i18n)系统
*
* 使用 JSON 语言包,支持动态切换。
* 用法:
* echo __('common.home'); // "首页"
* echo __('product.buy_now'); // "立即购买"
* echo __('sign.points_gained', 5); // "获得 5 积分"(支持 {points} 占位符)
* Lang::setLocale('en_US'); // 切换语言
*/
if (!defined('IN_APP')) exit('Forbidden');
class Lang
{
/** @var string 当前语言代码 */
private static $locale = 'zh_CN';
/** @var array 已加载的语言包缓存 [code => data] */
private static $cache = [];
/** @var string 语言文件目录 */
private static $dir;
/**
* 设置语言目录(通常在 init.php 中调用一次)。
*/
public static function setDir($dir)
{
self::$dir = rtrim($dir, '/\\');
}
/**
* 获取当前语言代码。
*/
public static function getLocale()
{
return self::$locale;
}
/**
* 切换语言。
* @param string $locale 如 'zh_CN', 'en_US'
* @return bool 是否成功
*/
public static function setLocale($locale)
{
$file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json';
if (!file_exists($file)) return false;
self::$locale = $locale;
// 清除旧缓存
if (isset(self::$cache[$locale])) unset(self::$cache[$locale]);
return true;
}
/**
* 从 cookie / session / 参数自动检测并设置语言。
*/
public static function detectLocale()
{
// 1. Session(由 lang.php 设置,优先级最高,保证整站会话内一致)
if (!empty($_SESSION['lang'])) {
$lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_SESSION['lang']);
if (self::setLocale($lang)) return;
}
// 2. URL 参数 ?lang=xx(一次性切换,写入 cookie + session
if (!empty($_GET['lang'])) {
$lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['lang']);
if (self::setLocale($lang)) {
$_SESSION['lang'] = $lang;
setcookie('fnw_lang', $lang, time() + 31536000, '/');
return;
}
}
// 3. Cookie
if (!empty($_COOKIE['fnw_lang'])) {
$lang = $_COOKIE['fnw_lang'];
if (self::setLocale($lang)) return;
}
// 4. 默认中文
self::$locale = 'zh_CN';
}
/**
* 翻译函数(核心)。
*
* @param string $key 点分隔键,如 'common.home' 或 'hero.title'
* @param mixed ...$args 占位参数(按顺序替换 {0}, {1}... 或命名参数)
* @return string 翻译后的文本,找不到则返回 key 本身
*/
public static function trans($key, ...$args)
{
$data = self::load(self::$locale);
$value = self::getNested($data, $key);
if ($value === null) {
// 回退到中文
if (self::$locale !== 'zh_CN') {
$dataZh = self::load('zh_CN');
$value = self::getNested($dataZh, $key);
}
if ($value === null) return $key;
}
// 替换占位符
if (!empty($args)) {
$value = self::replacePlaceholders($value, $args);
}
return $value;
}
/**
* 获取所有可用语言列表。
* @return array [['code'=>'zh_CN','name'=>'简体中文'], ...]
*/
public static function available()
{
$list = [];
if (!is_dir(self::$dir)) return $list;
foreach (glob(self::$dir . DIRECTORY_SEPARATOR . '*.json') as $f) {
$code = pathinfo($f, PATHINFO_FILENAME);
$data = json_decode(file_get_contents($f), true);
if (isset($data['_meta']['name'])) {
$list[] = [
'code' => $code,
'name' => $data['_meta']['name'],
'direction' => $data['_meta']['direction'] ?? 'ltr',
];
}
}
return $list;
}
/**
* 获取当前语言的 HTML lang 属性值。
*/
public static function htmlLang()
{
$map = ['zh_CN' => 'zh-CN', 'en_US' => 'en'];
return $map[self::$locale] ?? substr(self::$locale, 0, 2);
}
// ─── 内部方法 ───
/**
* 加载语言包(带缓存)。
*/
private static function load($locale)
{
if (isset(self::$cache[$locale])) return self::$cache[$locale];
$file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json';
if (!file_exists($file)) return [];
$json = file_get_contents($file);
self::$cache[$locale] = json_decode($json, true) ?: [];
return self::$cache[$locale];
}
/**
* 从嵌套数组中用点分隔 key 取值。
*/
private static function getNested($arr, $key)
{
$keys = explode('.', $key);
$val = $arr;
foreach ($keys as $k) {
if (!is_array($val) || !array_key_exists($k, $val)) return null;
$val = $val[$k];
}
return is_string($val) || is_numeric($val) ? (string)$val : null;
}
/**
* 替换文本中的占位符。
* 支持 {named} 和 {0}, {1} 格式。
*/
private static function replacePlaceholders($text, $args)
{
// 命名参数: __('key', ['points' => 5]) 或 __('key', 5)
if (count($args) === 1 && is_array($args[0])) {
$map = $args[0];
foreach ($map as $k => $v) {
$text = str_replace('{' . $k . '}', $v, $text);
}
} else {
// 位置参数: {0}, {1}
foreach ($args as $i => $v) {
$text = str_replace('{' . $i . '}', $v, $text);
}
}
return $text;
}
}
/**
* 全局翻译函数快捷方式。
*
* @param string $key 翻译键
* @param mixed ...$args 占位参数
* @return string
*/
if (!function_exists('__')) {
function __($key, ...$args)
{
return Lang::trans($key, ...$args);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* 系统日志类
*
* 将日志写入 storage/logs/ 目录,按日期自动分文件。
* 支持级别:DEBUG / INFO / WARN / ERROR
*
* 用法:
* Logger::info('用户登录', ['user_id' => 123]);
* Logger::error('支付失败', ['order_id' => 456, 'err' => $e->getMessage()]);
*/
if (!defined('IN_APP')) exit('Forbidden');
class Logger
{
/** @var string 日志根目录 */
private static $dir;
/** @var bool 是否启用(受 DEBUG 配置控制) */
private static $enabled = true;
/** @var int 最小记录级别 (DEBUG=0, INFO=1, WARN=2, ERROR=3) */
private static $minLevel = 0; // DEBUG 模式下记录全部
const DEBUG = 0;
const INFO = 1;
const WARN = 2;
const ERROR = 3;
/**
* 初始化日志系统。
* @param string $dir 日志目录路径
* @param bool $enabled 是否启用
* @param int $minLevel 最小级别
*/
public static function init($dir, $enabled = true, $minLevel = 0)
{
self::$dir = rtrim($dir, '/\\');
self::$enabled = $enabled;
self::$minLevel = $minLevel;
if (!is_dir(self::$dir)) {
@mkdir(self::$dir, 0755, true);
}
}
/**
* 记录 DEBUG 级别日志。
*/
public static function debug($message, array $context = [])
{
self::write(self::DEBUG, $message, $context);
}
/**
* 记录 INFO 级别日志。
*/
public static function info($message, array $context = [])
{
self::write(self::INFO, $message, $context);
}
/**
* 记录 WARN 级别日志。
*/
public static function warn($message, array $context = [])
{
self::write(self::WARN, $message, $context);
}
/**
* 记录 ERROR 级别日志。
*/
public static function error($message, array $context = [])
{
self::write(self::ERROR, $message, $context);
}
// ─── 内部实现 ───
private static function write($level, $message, array $context)
{
if (!self::$enabled || $level < self::$minLevel) return;
$levelNames = [self::DEBUG => 'DEBUG', self::INFO => 'INFO', self::WARN => 'WARN', self::ERROR => 'ERROR'];
$date = date('Y-m-d');
$time = date('Y-m-d H:i:s');
$levelName = $levelNames[$level] ?? 'LOG';
// 格式化上下文
$ctxStr = '';
if (!empty($context)) {
$parts = [];
foreach ($context as $k => $v) {
if (is_array($v) || is_object($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$parts[] = $k . '=' . $v;
}
$ctxStr = ' [' . implode(', ', $parts) . ']';
}
// 获取调用者信息(跳过 Logger 内部调用)
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
$caller = '';
if (isset($trace[2])) {
$file = basename($trace[2]['file'] ?? '');
$line = $trace[2]['line'] ?? '';
$caller = " [{$file}:{$line}]";
}
$logLine = "[{$time}] [{$levelName}]{$caller} {$message}{$ctxStr}" . PHP_EOL;
$file = self::$dir . DIRECTORY_SEPARATOR . $date . '.log';
@file_put_contents($file, $logLine, FILE_APPEND | LOCK_EX);
// 单文件最大 10MB,超出轮转
if (file_exists($file) && filesize($file) > 10 * 1024 * 1024) {
$rotated = self::$dir . DIRECTORY_SEPARATOR . $date . '.log.1';
@rename($file, $rotated);
}
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* 支付网关抽象层
*
* 统一支付宝、微信支付的接口,供结算页面调用。
* 每种支付方式实现 PaymentGatewayInterface 接口。
*
* 支付流程:
* 1. 用户选择支付方式 → 创建支付订单(Payment::createOrder
* 2. 跳转到支付页面/显示二维码(Payment::getPayUrl / getQrCode
* 3. 支付平台异步通知 → Payment::handleNotify
* 4. 用户完成支付 → 跳转回 return_url
*/
if (!defined('IN_APP')) exit('Forbidden');
// 显式加载网关实现(类名与文件名不一致,自动加载器无法定位)
require_once __DIR__ . '/PaymentAlipay.php';
require_once __DIR__ . '/PaymentWechat.php';
interface PaymentGatewayInterface
{
/**
* 创建支付订单,返回支付 URL 或表单 HTML。
* @param array $order ['order_no', 'amount', 'subject', 'notify_url', 'return_url']
* @return array ['url' => '...', 'form' => '...'] 至少包含一个
*/
public function createOrder(array $order);
/**
* 验证异步通知签名和数据。
* @param array $params POST/GET 参数
* @return bool 是否有效
*/
public function verifyNotify(array $params);
/**
* 从通知中获取交易号。
*/
public function getTradeNo(array $params);
/**
* 从通知中获取支付状态(是否成功)。
*/
public function isPaid(array $params);
/**
* 响应支付平台(输出 "success" 等)。
*/
public function respondSuccess();
/**
* 获取网关名称。
*/
public function getName();
}
/**
* 支付管理器:注册网关、创建订单、处理回调。
*/
class Payment
{
/** @var array 已注册的支付网关 [code => instance] */
private static $gateways = [];
/**
* 注册支付网关。
* @param string $code 如 'alipay', 'wechat'
* @param PaymentGatewayInterface $gateway
*/
public static function register($code, PaymentGatewayInterface $gateway)
{
self::$gateways[$code] = $gateway;
}
/**
* 获取已注册的支付网关。
* @param string $code
* @return PaymentGatewayInterface|null
*/
public static function get($code)
{
return self::$gateways[$code] ?? null;
}
/**
* 获取所有可用支付方式列表。
* @return array [['code'=>'alipay','name'=>'支付宝','enabled'=>bool], ...]
*/
public static function availableMethods()
{
$list = [];
foreach (self::$gateways as $code => $gw) {
$list[] = [
'code' => $code,
'name' => $gw->getName(),
'enabled' => true, // TODO: 可根据配置判断是否启用
];
}
return $list;
}
/**
* 根据配置自动初始化并注册所有启用的支付网关。
* 在 init.php 中调用一次即可。
*/
public static function initGateways()
{
$alipayEnabled = getSetting('pay_alipay_enabled', '0') === '1';
$wechatEnabled = getSetting('pay_wechat_enabled', '0') === '1';
if ($alipayEnabled) {
self::register('alipay', new AlipayGateway());
}
if ($wechatEnabled) {
self::register('wechat', new WechatGateway());
}
}
/**
* 创建支付记录到数据库。
* @return int pay_log ID
*/
public static function createPayLog($orderId, $method, $amount, $tradeNo = '')
{
db()->prepare(
'INSERT INTO ' . tn('pay_logs') . ' (order_id,pay_method,amount,trade_no,status,created_at) VALUES (?,?,?,?,?,NOW())'
)->execute([$orderId, $method, $amount, $tradeNo, 'pending']);
return (int)db()->lastInsertId();
}
/**
* 更新支付日志状态。
*/
public static function updatePayStatus($logId, $status, $tradeNo = '')
{
$sql = 'UPDATE ' . tn('pay_logs') . ' SET status=?, updated_at=NOW()';
$params = [$status];
if ($tradeNo) {
$sql .= ', trade_no=?';
$params[] = $tradeNo;
}
$sql .= ' WHERE id=?';
$params[] = $logId;
db()->prepare($sql)->execute($params);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
/**
* 支付宝支付网关(电脑网站支付 / 当面付)
*
* 配置项(settings 表):
* pay_alipay_enabled - 是否启用 (1/0)
* pay_alipay_appid - 应用 APPID
* pay_alipay_private_key - 应用私钥
* pay_alipay_public_key - 支付宝公钥
*
* 使用 RSA2 签名方式。
*/
if (!defined('IN_APP')) exit('Forbidden');
class AlipayGateway implements PaymentGatewayInterface
{
private $appId;
private $privateKey;
private $publicKey;
private $gatewayUrl = 'https://openapi.alipay.com/gateway.do';
private $sandboxUrl = 'https://openapi-sandbox.dl.alipaydev.com/gateway.do';
public function __construct()
{
$this->appId = getSetting('pay_alipay_appid', '');
$this->privateKey = getSetting('pay_alipay_private_key', '');
$this->publicKey = getSetting('pay_alipay_public_key', '');
// 沙箱模式(开发测试用)
if (getSetting('pay_alipay_sandbox', '0') === '1') {
$this->gatewayUrl = $this->sandboxUrl;
}
}
public function getName()
{
return '支付宝';
}
/**
* 创建支付宝电脑网站支付订单。
*/
public function createOrder(array $order)
{
$params = [
'app_id' => $this->appId,
'method' => 'alipay.trade.page.pay',
'format' => 'JSON',
'return_url' => $order['return_url'],
'notify_url' => $order['notify_url'],
'charset' => 'utf-8',
'sign_type' => 'RSA2',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0',
'biz_content' => json_encode([
'out_trade_no' => $order['order_no'],
'total_amount' => sprintf('%.2f', $order['amount']),
'subject' => $order['subject'],
'product_code' => 'FAST_INSTANT_TRADE_PAY',
], JSON_UNESCAPED_UNICODE),
];
$params['sign'] = $this->sign($params);
// 构建跳转 URLGET 方式)
$url = $this->gatewayUrl . '?' . http_build_query($params);
return ['url' => $url, 'form' => ''];
}
public function verifyNotify(array $params)
{
if (empty($params['sign'])) return false;
$sign = $params['sign'];
unset($params['sign'], $params['sign_type']);
return $this->rsaVerify($this->buildSignData($params), $sign);
}
public function getTradeNo(array $params)
{
return $params['trade_no'] ?? '';
}
public function isPaid(array $params)
{
return ($params['trade_status'] ?? '') === 'TRADE_SUCCESS';
}
public function respondSuccess()
{
echo 'success';
}
// ─── 签名相关 ───
private function sign($params)
{
ksort($params);
$data = $this->buildSignData($params);
$res = openssl_sign($data, $signature, $this->loadPrivateKey(), OPENSSL_ALGO_SHA256);
return base64_encode($signature);
}
private function rsaVerify($data, $sign)
{
$result = openssl_verify($data, base64_decode($sign), $this->loadPublicKey(), OPENSSL_ALGO_SHA256);
return $result === 1;
}
private function buildSignData($params)
{
$pairs = [];
foreach ($params as $k => $v) {
if ($v === '' || is_array($v)) continue;
$pairs[] = $k . '=' . $v;
}
return implode('&', $pairs);
}
private function loadPrivateKey()
{
$key = trim($this->privateKey);
$key = preg_replace('/-----BEGIN RSA PRIVATE KEY-----/', '', $key);
$key = preg_replace('/-----END RSA PRIVATE KEY-----/', '', $key);
$key = str_replace(["\r", "\n", " "], '', $key);
return "-----BEGIN RSA PRIVATE KEY-----\n" .
chunk_split($key, 64, "\n") .
"-----END RSA PRIVATE KEY-----";
}
private function loadPublicKey()
{
$key = trim($this->publicKey);
$key = preg_replace('/-----BEGIN PUBLIC KEY-----/', '', $key);
$key = preg_replace('/-----END PUBLIC KEY-----/', '', $key);
$key = str_replace(["\r", "\n", " "], '', $key);
return "-----BEGIN PUBLIC KEY-----\n" .
chunk_split($key, 64, "\n") .
"-----END PUBLIC KEY-----";
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* 微信支付网关(Native 扫码支付 / H5 支付)
*
* 配置项(settings 表):
* pay_wechat_enabled - 是否启用 (1/0)
* pay_wechat_mch_id - 商户号
* pay_wechat_api_key - API 密钥
* pay_wechat_appid - 公众号/小程序 APPID(可选)
* pay_wechat_cert_path - 证书路径(退款等需要)
*
* 使用 V3 接口规范,支持 Native 扫码支付。
*/
if (!defined('IN_APP')) exit('Forbidden');
class WechatGateway implements PaymentGatewayInterface
{
private $mchId;
private $apiKey;
private $appId;
private $apiUrl = 'https://api.mch.weixin.qq.com/v3';
public function __construct()
{
$this->mchId = getSetting('pay_wechat_mch_id', '');
$this->apiKey = getSetting('pay_wechat_api_key', '');
$this->appId = getSetting('pay_wechat_appid', '');
}
public function getName()
{
return '微信支付';
}
/**
* 创建微信 Native 支付订单(返回二维码链接)。
*/
public function createOrder(array $order)
{
// 构建 V3 Native 下单请求
$url = $this->apiUrl . '/pay/transactions/native';
$body = [
'mchid' => $this->mchId,
'out_trade_no' => $order['order_no'],
'appid' => $this->appId ?: '',
'description' => $order['subject'],
'notify_url' => $order['notify_url'],
'amount' => [
'total' => (int)round($order['amount'] * 100), // 单位:分
'currency' => 'CNY',
],
];
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: WECHATPAY2-SHA256-RSA2048 ' . $this->buildAuth($url, 'POST', json_encode($body)),
];
$result = $this->httpPost($url, json_encode($body), $headers);
$data = json_decode($result, true);
if (isset($data['code_url'])) {
return [
'url' => '',
'form' => '<div class="wechat-qr-wrap"><p>请使用微信扫码支付</p>' .
'<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x&data=' . urlencode($data['code_url']) . '" alt="微信支付二维码" width="200"></div>',
'qr_code' => $data['code_url'],
];
}
// 错误时返回错误信息
Logger::error('微信支付下单失败', ['response' => $data]);
return ['url' => '', 'form' => '<p style="color:red">支付创建失败: ' . h($data['message'] ?? '未知错误') . '</p>'];
}
public function verifyNotify(array $params)
{
// V3 回调验签
if (empty($_SERVER['HTTP_WECHATPAY_SIGNATURE'])) return false;
$serial = $_SERVER['HTTP_WECHATPAY_SERIAL'] ?? '';
$signature = $_SERVER['HTTP_WECHATPAY_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_WECHATPAY_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_WECHATPAY_NONCE'] ?? '';
// TODO: 完整的 V3 验签实现需加载微信平台证书
// 这里做基础验证
return !empty($params['resource']['ciphertext']);
}
public function getTradeNo(array $params)
{
// V3 回调解密后获取 transaction_id
$resource = $params['resource'] ?? [];
return $resource['ciphertext'] ? $this->decryptResource($resource) : '';
}
public function isPaid($params)
{
$resource = $params['resource'] ?? [];
$decrypted = $resource['ciphertext'] ? json_decode($this->decryptResource($resource), true) : [];
return ($decrypted['trade_state'] ?? '') === 'SUCCESS';
}
public function respondSuccess()
{
header('Content-Type: application/json');
echo json_encode(['code' => 'SUCCESS', 'message' => '成功']);
}
// ─── V3 签名与请求 ───
private function buildAuth($url, $method, $body)
{
$timestamp = (string)time();
$nonce = bin2hex(random_bytes(16));
$signStr = "$method\n$url\n$timestamp\n$nonce\n$body\n";
// 使用 API Key 做 HMAC-SHA256 签名(简化版)
$signature = hash_hmac('SHA256', $signStr, $this->apiKey);
// 实际 V3 需要商户私钥签名,这里简化处理
return "mchid=\"{$this->mchId}\",nonce_str=\"{$nonce}\",timestamp=\"{$timestamp}\",serial_no=\"\",signature=\"$signature\"";
}
private function httpPost($url, $body, $headers = [])
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
]);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
/**
* 解密 V3 回调资源。
*/
private function decryptResource($resource)
{
$ciphertext = base64_decode($resource['ciphertext']);
$nonce = $resource['nonce'];
$associatedData = $resource['associated_data'];
// AES-256-GCM 解密
$key = hash('sha256', $this->apiKey, true); // 用 API Key 派生密钥(简化)
if (function_exists('openssl_decrypt') && defined('AES_256_GCM')) {
$plain = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $associatedData);
return $plain !== false ? $plain : '';
}
return '';
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
// 未安装时计算 install.php 的正确 web 路径并 302 跳转。
// 在加载 config.php 之前被前台 init.php 与后台 admin/login.php 引入。
// 统一处理「根目录部署」与「子目录(mall/)部署」两种场景,杜绝 //install.php 协议相对 URL。
if (!function_exists('fnw_install_redirect')) {
function fnw_install_redirect()
{
$mallRoot = realpath(__DIR__ . '/..');
$docRoot = realpath($_SERVER['DOCUMENT_ROOT'] ?? __DIR__ . '/..');
$webPath = '';
if ($mallRoot && $docRoot) {
// 统一为正斜杠,避免 Windows 下 realpath 返回反斜杠与 DOCUMENT_ROOT 不匹配
$m = str_replace('\\', '/', $mallRoot);
$d = str_replace('\\', '/', $docRoot);
if (strpos($m, $d) === 0) {
$webPath = substr($m, strlen($d));
}
}
$webPath = '/' . ltrim($webPath, '/'); // 保证以单斜杠开头
$webPath = rtrim($webPath, '/'); // 去掉末尾斜杠,杜绝 //install.php
header('Location: ' . $webPath . '/install.php');
exit;
}
}
if (!file_exists(__DIR__ . '/../config.php')) {
fnw_install_redirect();
}
+43
View File
@@ -0,0 +1,43 @@
<?php
// 数据库连接(PDO)。被 install 之外的所有页面复用。
// 安全:禁止被直接通过浏览器请求。
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
require_once __DIR__ . '/../config.php';
/**
* 返回全局唯一的 PDO 实例(懒加载 + 单例)。
*/
function db()
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
DB_HOST, DB_PORT, DB_NAME
);
try {
$pdo = new PDO($dsn, 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('数据库连接失败,请检查 config.php 中的数据库配置。');
}
return $pdo;
}
/**
* 带前缀的表名。
*/
function tn($table)
{
return DB_PREFIX . $table;
}
+107
View File
@@ -0,0 +1,107 @@
<?php
// 前台页面公共页脚。
if (!defined('IN_APP')) { http_response_code(403); exit('Forbidden'); }
?>
</main>
<footer class="site-footer">
<div class="container footer-inner">
<div class="footer-brand">
<i class="fas fa-store"></i> <?= h(SITE_NAME) ?>
<p><?= __('footer.brand_slogan') ?></p>
</div>
<div class="footer-links">
<a href="index.php"><?= __('footer.nav_home') ?></a>
<a href="index.php#products"><?= __('footer.nav_all') ?></a>
<a href="cart.php"><?= __('footer.nav_cart') ?></a>
<a href="ticket.php"><?= __('footer.nav_ticket') ?></a>
<a href="announcements.php"><?= __('footer.nav_announce') ?></a>
<a href="mytickets.php"><?= __('footer.nav_mytickets') ?></a>
<a href="myorders.php"><?= __('footer.nav_myorders') ?></a>
</div>
</div>
<div class="footer-copy">
<p>&copy; <?= date('Y') ?> <?= h(SITE_NAME) ?>. <?= __('footer.copyright') ?></p>
</div>
</footer>
<script src="assets/app.js?v=<?= $cssVer ?>"></script>
<script>
// 主题切换:写入 cookie 并即时切换 data-theme
(function () {
var btn = document.getElementById('themeToggle');
if (!btn) return;
// 初始化:根据当前主题设置正确图标(解决首次加载深色模式图标不匹配问题)
(function initThemeIcon() {
var current = document.documentElement.getAttribute('data-theme');
if (current === 'dark') {
btn.querySelector('i').className = 'fas fa-sun';
}
})();
btn.addEventListener('click', function () {
var root = document.documentElement;
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
document.cookie = 'fnw_theme=' + next + ';path=/;max-age=31536000';
btn.querySelector('i').className = next === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
});
})();
// 头像卡片:点击切换、点击外部关闭
(function () {
var btn = document.getElementById('avatarBtn');
var card = document.getElementById('userCard');
if (!btn || !card) return;
card.hidden = true; // 初始化兜底隐藏
btn.addEventListener('click', function (e) {
e.stopPropagation();
card.hidden = !card.hidden;
btn.setAttribute('aria-expanded', card.hidden ? 'false' : 'true');
});
document.addEventListener('click', function (e) {
if (!card.hidden && !card.contains(e.target) && !btn.contains(e.target)) {
card.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
});
})();
// 移动端导航:汉堡菜单展开/收起
(function () {
var toggle = document.getElementById('navToggle');
var links = document.getElementById('navLinks');
if (!toggle || !links) return;
toggle.addEventListener('click', function (e) {
e.stopPropagation();
var open = links.classList.toggle('open');
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// 点击菜单内链接后自动收起
links.addEventListener('click', function (e) {
if (e.target.closest('a')) links.classList.remove('open');
});
document.addEventListener('click', function (e) {
if (!links.contains(e.target) && !toggle.contains(e.target)) {
links.classList.remove('open');
}
});
})();
// 发货信息:显示/隐藏敏感凭据(登录密码)
(function () {
document.querySelectorAll('.reveal-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var span = btn.previousElementSibling;
if (!span || !span.classList.contains('secret')) return;
var real = span.getAttribute('data-secret') || '';
if (btn.textContent === '显示') {
span.textContent = real;
btn.textContent = '隐藏';
} else {
span.textContent = real ? '●'.repeat(Math.max(6, real.length)) : '—';
btn.textContent = '显示';
}
});
});
})();
</script>
</body>
</html>
+649
View File
@@ -0,0 +1,649 @@
<?php
// 公共函数:转义、认证、模板助手、CSRF、主题等。
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
/**
* HTML 转义,防止 XSS。
*/
function h($str)
{
return htmlspecialchars((string) $str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* 格式化人民币价格。
*/
function money($n)
{
return '¥' . number_format((float) $n, 2);
}
/**
* 跳转。
*/
function redirect($url)
{
// 防开放重定向 / 协议相对 URL 被当作 host(如 //evil.com 或 http://x
if (is_string($url)
&& (strpos($url, '//') === 0
|| preg_match('#^[a-z][a-z0-9+.\-]*://#i', $url))) {
$url = 'index.php';
}
header('Location: ' . $url);
exit;
}
/**
* 获取访问者真实 IP(兼容 CDN / 反向代理)。
* 优先级:X-Forwarded-For(取第一个)→ X-Real-IP → Client-IP → REMOTE_ADDR。
*/
function getClientIp()
{
$candidates = [];
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$candidates = array_merge($candidates, explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
}
foreach (['HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'] as $k) {
if (!empty($_SERVER[$k])) {
$candidates[] = $_SERVER[$k];
}
}
foreach ($candidates as $ip) {
$ip = trim($ip);
// 过滤私有/保留地址,避免 CDN 内部地址误导
if ($ip !== '' && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
return trim((string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'));
}
/**
* 某 IP 今日已注册账号数(用于「每日每 IP 最多 2 个」限制)。
*/
function regCountToday($ip)
{
try {
$stmt = db()->prepare('SELECT COUNT(*) FROM ' . tn('users') . ' WHERE reg_ip = ? AND DATE(created_at) = CURDATE()');
$stmt->execute([$ip]);
return (int) $stmt->fetchColumn();
} catch (Throwable $e) {
return 0;
}
}
/**
* 站点基础 URL(用于拼接验证链接)。自动适配是否部署在子目录。
*/
function siteBaseUrl()
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? '') == '443' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$dir = dirname($_SERVER['SCRIPT_NAME'] ?? '/index.php');
$dir = ($dir === '/' || $dir === '\\') ? '' : rtrim($dir, '/');
return $scheme . '://' . $host . $dir;
}
/**
* 取昵称/用户名的首字(用于头像圆圈)。
*/
function avatarText($name)
{
$name = trim((string) $name);
if ($name === '') {
return '?';
}
return mb_strtoupper(mb_substr($name, 0, 1, 'UTF-8'), 'UTF-8');
}
/**
* 发送邮箱验证邮件:生成/更新 email_token 并投递。
* 返回 ['ok'=>bool, 'error'=>string, 'log'=>array]
*/
function sendVerifyMail($userId)
{
$stmt = db()->prepare('SELECT id, username, nickname, email FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || $user['email'] === '') {
return ['ok' => false, 'error' => '该账号没有可用邮箱', 'log' => []];
}
$token = bin2hex(random_bytes(24));
db()->prepare('UPDATE ' . tn('users') . ' SET email_token = ? WHERE id = ?')
->execute([$token, $user['id']]);
$url = siteBaseUrl() . '/verify.php?token=' . $token;
$name = $user['nickname'] ?: $user['username'];
$subject = '[' . SITE_NAME . '] 请验证你的邮箱';
$body = '<div style="font-family:Segoe UI,Helvetica,Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;'
. 'border:1px solid #e3e8ef;border-radius:16px;">'
. '<h2 style="color:#1a73e8;margin:0 0 12px;">验证你的邮箱</h2>'
. '<p style="color:#1f2430;line-height:1.7;">你好 ' . h($name) . ',感谢注册 ' . h(SITE_NAME) . '</p>'
. '<p style="color:#1f2430;line-height:1.7;">请点击下方按钮完成邮箱验证:</p>'
. '<p style="margin:18px 0;"><a href="' . h($url) . '" style="background:#1a73e8;color:#fff;padding:11px 22px;'
. 'border-radius:999px;text-decoration:none;display:inline-block;font-weight:600;">验证邮箱</a></p>'
. '<p style="color:#5f6b7a;font-size:.85rem;line-height:1.6;">如按钮无法点击,请复制以下链接到浏览器打开:<br>' . h($url) . '</p>'
. '<hr style="border:none;border-top:1px solid #eef1f6;margin:18px 0;">'
. '<p style="color:#9aa7b8;font-size:.8rem;margin:0;">若非本人操作,请忽略此邮件。</p>'
. '</div>';
return fnwSendMail($user['email'], $subject, $body);
}
/**
* 当前登录用户(普通用户),未登录返回 null。
*/
function currentUser($forceRefresh = false)
{
if (empty($_SESSION['user_id'])) {
return null;
}
static $cache = null;
if ($cache !== null && !$forceRefresh) {
return $cache;
}
$cache = null; // 强制刷新时先清掉旧缓存
$pdo = db();
$stmt = $pdo->prepare('SELECT id, username, email, nickname, verified, is_admin, status, points, invite_code, invited_by, avatar FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$row = $stmt->fetch();
// 账号被封禁则视为未登录,并清除会话
if (!$row || (int) $row['status'] !== 1) {
unset($_SESSION['user_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 清除当前登录用户的请求级缓存(后台修改用户数据后调用,使下次 currentUser() 重新查库)。
*/
function refreshCurrentUser()
{
currentUser(true);
}
function isLoggedIn()
{
return currentUser() !== null;
}
/**
* 当前登录的管理员,未登录返回 null。
*/
function currentAdmin()
{
if (empty($_SESSION['admin_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$pdo = db();
$stmt = $pdo->prepare('SELECT id, username, email, status FROM ' . tn('users') . ' WHERE id = ? AND is_admin = 1');
$stmt->execute([$_SESSION['admin_id']]);
$row = $stmt->fetch();
if (!$row || (int) $row['status'] !== 1) {
unset($_SESSION['admin_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 要求登录,否则跳转到登录页并带回跳地址。
*/
function requireLogin($next = '')
{
if (!isLoggedIn()) {
$q = $next ? '?next=' . urlencode($next) : '';
redirect('login.php' . $q);
}
}
/**
* 要求管理员(is_admin=1)登录(后台用)。
* 注意:仅校验会话标志不够——客服(role=cs)同样持有 admin_id 会话,
* 因此必须额外确认 is_admin=1,否则客服可通过直接访问 URL 进入管理员专属页面。
*/
function requireAdmin()
{
if (empty($_SESSION['admin_id']) || !currentAdmin()) {
redirect('login.php');
}
}
/**
* 当前登录的「员工」账号(管理员或客服),未登录/无权限返回 null。
* 与 currentAdmin() 的区别:currentAdmin() 仅限 is_admin=1;本函数额外允许 role='cs' 的客服。
*/
function currentStaff()
{
if (empty($_SESSION['admin_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$pdo = db();
$stmt = $pdo->prepare(
'SELECT id, username, email, status, role, is_admin FROM ' . tn('users') . ' WHERE id = ? AND status = 1 AND role IN (\'admin\', \'cs\')'
);
$stmt->execute([$_SESSION['admin_id']]);
$row = $stmt->fetch();
if (!$row) {
unset($_SESSION['admin_id']);
$cache = null;
return null;
}
$cache = $row;
return $cache;
}
/**
* 要求员工(管理员或客服)登录,否则跳转到后台登录页。
*/
function requireStaff()
{
if (empty($_SESSION['admin_id']) || !currentStaff()) {
redirect('login.php');
}
}
/**
* 当前员工是否为管理员(拥有全部后台权限)。
*/
function isAdminStaff()
{
$s = currentStaff();
return $s !== null && (int) $s['is_admin'] === 1;
}
/**
* 生成/获取 CSRF 令牌(存入 session)。
*/
function csrfToken()
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
/**
* 输出 CSRF 隐藏字段。
*/
function csrfField()
{
return '<input type="hidden" name="csrf" value="' . csrfToken() . '">';
}
/**
* 校验 CSRF(仅 POST)。失败直接 403。
*/
function verifyCsrf()
{
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($_POST['csrf']) || !hash_equals((string) $_SESSION['csrf'], (string) $_POST['csrf'])) {
http_response_code(403);
exit('CSRF 校验失败');
}
}
}
/**
* 校验 CSRF token 字符串(用于 AJAX/API 场景)。
* @param string $token 待校验的 token
* @return bool
*/
function verifyCsrfToken($token)
{
return !empty($token) && !empty($_SESSION['csrf']) && hash_equals((string)$_SESSION['csrf'], (string)$token);
}
/**
* 当前主题:浅色 / 深色。跟随 cookie,默认跟随系统(用 CSS 媒体查询兜底)。
*/
function currentTheme()
{
return (!empty($_COOKIE['fnw_theme']) && $_COOKIE['fnw_theme'] === 'dark') ? 'dark' : 'light';
}
/**
* 订单状态中文映射。
*/
function orderStatusLabel($status)
{
$map = [
'pending' => '待处理',
'paid' => '已受理',
'shipped' => '已发货',
'completed' => '已完成',
'cancelled' => '已取消',
];
return $map[$status] ?? $status;
}
/**
* 读取单个系统设置(settings 表)。
*/
function getSetting($k, $default = '')
{
try {
$stmt = db()->prepare('SELECT v FROM ' . tn('settings') . ' WHERE k = ?');
$stmt->execute([$k]);
$v = $stmt->fetchColumn();
return $v === false ? $default : $v;
} catch (Throwable $e) {
return $default;
}
}
/**
* 保存单个系统设置(不存在则插入,存在则更新)。
*/
function saveSetting($k, $v)
{
db()->prepare(
'INSERT INTO ' . tn('settings') . ' (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = ?'
)->execute([$k, $v, $v]);
}
/**
* 读取 SMTP 配置(带缓存)。
*/
function smtpConfig()
{
static $c = null;
if ($c !== null) {
return $c;
}
$keys = ['smtp_host', 'smtp_port', 'smtp_enc', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_fromname', 'notify_emails'];
$map = [];
try {
$pdo = db();
$ph = implode(',', array_fill(0, count($keys), '?'));
$stmt = $pdo->prepare('SELECT k, v FROM ' . tn('settings') . ' WHERE k IN (' . $ph . ')');
$stmt->execute($keys);
foreach ($stmt->fetchAll() as $row) {
$map[$row['k']] = $row['v'];
}
} catch (Throwable $e) {
// 配置表尚不可用时返回空配置
}
$c = [];
foreach ($keys as $k) {
$c[$k] = $map[$k] ?? '';
}
return $c;
}
/**
* 通过 SMTP 发送邮件(依赖 includes/mailer.php)。
* 返回 ['ok'=>true,'log'=>[...]] 或 ['ok'=>false,'error'=>string,'log'=>[...]]
*/
function fnwSendMail($to, $subject, $body)
{
$cfg = smtpConfig();
if (empty($cfg['smtp_host'])) {
return ['ok' => false, 'error' => 'SMTP 未配置'];
}
require_once __DIR__ . '/mailer.php';
$mailer = new SmtpMailer($cfg['smtp_host'], $cfg['smtp_port'], $cfg['smtp_enc'], $cfg['smtp_user'], $cfg['smtp_pass']);
$from = $cfg['smtp_from'] ?: $cfg['smtp_user'];
$ok = $mailer->send($to, $subject, $body, $from, $cfg['smtp_fromname'] ?: SITE_NAME);
if ($ok) {
return ['ok' => true, 'log' => $mailer->log];
}
return ['ok' => false, 'error' => $mailer->lastError, 'log' => $mailer->log];
}
/**
* 工单状态中文映射。
*/
function ticketStatusLabel($status)
{
$map = [
'open' => '待处理',
'pending' => '处理中',
'resolved' => '已解决',
'closed' => '已关闭',
];
return $map[$status] ?? $status;
}
/**
* 工单优先级中文映射。
*/
function ticketPriorityLabel($p)
{
$map = [1 => '低', 2 => '普通', 3 => '高'];
return $map[(int) $p] ?? '普通';
}
/**
* 周期天数 → 中文标签。0 表示一次性/实物。
*/
function periodLabel($days)
{
$days = (int) $days;
if ($days <= 0) {
return '一次性';
}
if ($days % 365 === 0) {
return ($days / 365) . ' 年';
}
if ($days % 30 === 0) {
return ($days / 30) . ' 个月';
}
return $days . ' 天';
}
/**
* 工单类型中文映射。
*/
function ticketTypeLabel($t)
{
$map = [
'general' => '普通',
'renewal' => '续期申请',
];
return $map[$t] ?? '普通';
}
/**
* 到期时间 → 展示文案(null 表示无到期/永久)。
*/
function expiryText($dt)
{
if ($dt === null || $dt === '') {
return '—';
}
return date('Y-m-d H:i', strtotime($dt));
}
/**
* 是否即将/已经到期(用于前台续期入口判断,由调用方按需使用)。
*/
function isExpired($dt)
{
if ($dt === null || $dt === '') {
return false;
}
return strtotime($dt) < time();
}
/**
* 到期状态分析:返回是否含到期时间、展示文案、剩余天数、等级(ok/warn/danger/expired/none)。
*/
function expiryState($dt)
{
if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') {
return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none'];
}
$ts = strtotime($dt);
if ($ts === false) {
return ['has' => false, 'text' => '—', 'days' => null, 'level' => 'none'];
}
$now = time();
$days = ($ts - $now) / 86400;
$daysLeft = (int) ceil($days);
if ($ts < $now) {
return ['has' => true, 'text' => '已到期(' . date('Y-m-d H:i', $ts) . '', 'days' => $daysLeft, 'level' => 'expired'];
}
$level = $daysLeft <= 3 ? 'danger' : ($daysLeft <= 7 ? 'warn' : 'ok');
return ['has' => true, 'text' => date('Y-m-d H:i', $ts) . '(剩 ' . $daysLeft . ' 天)', 'days' => $daysLeft, 'level' => $level];
}
/**
* 到期时间徽标(HTML),用于前台订单列表/详情与后台。
*/
function expiryBadge($dt)
{
$s = expiryState($dt);
if (!$s['has']) {
return '<span class="exp-badge exp-none">无到期时间</span>';
}
$icon = [
'ok' => 'fa-calendar-check',
'warn' => 'fa-clock',
'danger' => 'fa-triangle-exclamation',
'expired' => 'fa-circle-xmark',
][$s['level']] ?? 'fa-calendar';
return '<span class="exp-badge exp-' . $s['level'] . '"><i class="fas ' . $icon . '"></i> ' . h($s['text']) . '</span>';
}
/**
* 支付方式中文映射。
*/
function payTypeLabel($t)
{
$map = [
'cash' => '现金(历史)',
'points' => '积分支付',
'direct' => '直接下单',
];
return $map[$t] ?? '直接下单';
}
/**
* 读取用户当前积分余额。
*/
function getUserPoints($uid)
{
try {
$stmt = db()->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([(int) $uid]);
$v = $stmt->fetchColumn();
return $v === false ? 0 : (int) $v;
} catch (Throwable $e) {
return 0;
}
}
/**
* 增减用户积分并写入流水(points_log)。
* $amount:正数=增加,负数=扣减(扣减后余额最低为 0)。
* 返回 ['ok'=>bool, 'balance'=>int, 'error'=>string]。
*/
function addPoints($uid, $type, $amount, $remark = '')
{
$uid = (int) $uid;
$amount = (int) $amount;
try {
$pdo = db();
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT points FROM ' . tn('users') . ' WHERE id = ? FOR UPDATE');
$stmt->execute([$uid]);
$cur = (int) $stmt->fetchColumn();
$balance = max(0, $cur + $amount);
$pdo->prepare('UPDATE ' . tn('users') . ' SET points = ? WHERE id = ?')
->execute([$balance, $uid]);
$pdo->prepare(
'INSERT INTO ' . tn('points_log') . ' (user_id, type, amount, balance, remark, created_at) VALUES (?,?,?,?,?,NOW())'
)->execute([$uid, $type, $amount, $balance, $remark]);
$pdo->commit();
return ['ok' => true, 'balance' => $balance, 'error' => ''];
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'balance' => 0, 'error' => $e->getMessage()];
}
}
/**
* 生成唯一邀请码(大写字母+数字,8 位)。
*/
function genInviteCode()
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
for ($i = 0; $i < 20; $i++) {
$code = '';
for ($j = 0; $j < 8; $j++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
$stmt = db()->prepare('SELECT 1 FROM ' . tn('users') . ' WHERE invite_code = ?');
$stmt->execute([$code]);
if (!$stmt->fetch()) {
return $code;
}
}
return strtoupper(substr(md5(uniqid('', true)), 0, 8));
}
/**
* 根据邀请码查邀请人 ID(不存在返回 0)。
*/
function getInviterIdByCode($code)
{
$code = trim((string) $code);
if ($code === '') {
return 0;
}
$stmt = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE invite_code = ?');
$stmt->execute([$code]);
$id = $stmt->fetchColumn();
return $id === false ? 0 : (int) $id;
}
/**
* 今天是否已签到(防重复)。
*/
function hasSignedToday($uid)
{
try {
$stmt = db()->prepare('SELECT 1 FROM ' . tn('sign_logs') . ' WHERE user_id = ? AND sign_date = CURDATE()');
$stmt->execute([(int) $uid]);
return (bool) $stmt->fetchColumn();
} catch (Throwable $e) {
return false;
}
}
/**
* 获取站点名称(优先从数据库读取)。
*/
function getSiteName() {
global $_site_name;
if ($_site_name !== null) {
return $_site_name;
}
try {
$db = db();
$stmt = $db->query("SELECT v FROM " . tn('settings') . " WHERE k = 'site_name'");
$val = $stmt->fetchColumn();
$_site_name = ($val !== false && $val !== null && $val !== '') ? $val : '自由云商城';
} catch (Exception $e) {
$_site_name = '自由云商城';
}
return $_site_name;
}
+114
View File
@@ -0,0 +1,114 @@
<?php
if (!defined('IN_APP')) {
http_response_code(403);
header("HTTP/2 403 Forbidden");
exit();
}
$pageTitle = isset($pageTitle) ? $pageTitle : getSiteName();
$user = currentUser();
$cartCount = 0;
if (!empty($_SESSION['cart'])) {
$cartCount = array_sum(array_map('intval', $_SESSION['cart']));
}
$theme = currentTheme();
$cssVer = file_exists(__DIR__ . '/../assets/style.css') ? filemtime(__DIR__ . '/../assets/style.css') : time();
$verifyNotice = '';
$verifyOk = false;
if (isset($_SESSION['verify_notice'])) {
$verifyNotice = $_SESSION['verify_notice'] === 'sent'
? '验证邮件已发送至你的邮箱,请点击邮件中的链接完成验证。'
: '抱歉,验证邮件发送失败。请在「用户设置」中点击重发。';
$verifyOk = ($_SESSION['verify_notice'] === 'sent');
unset($_SESSION['verify_notice']);
}
?>
<!DOCTYPE html>
<html lang="<?= Lang::htmlLang() ?>" data-theme="<?= $theme ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($pageTitle) ?> - <?= h(getSiteName()) ?></title>
<?php
$favicon = getSetting('favicon', '');
if (!empty($favicon) && file_exists(__DIR__ . '/../' . $favicon)) {
echo '<link rel="icon" href="' . h($favicon) . '?v=' . filemtime(__DIR__ . '/../' . $favicon) . '">' . "\n ";
}
?>
<meta name="csrf" content="<?= csrfToken() ?>">
<link rel="stylesheet" href="assets/vendor/fontawesome/all.min.css">
<link rel="stylesheet" href="assets/style.css?v=<?= $cssVer ?>">
</head>
<body>
<header class="site-header">
<div class="container nav">
<button class="nav-toggle" id="navToggle" aria-label="展开菜单" aria-expanded="false"><i class="fas fa-bars"></i></button>
<a href="/" class="brand"><i class="fas fa-store"></i> <?= h(getSiteName()) ?></a>
<nav class="nav-links" id="navLinks">
<a href="/"><i class="fas fa-home"></i> <?= __('common.home') ?></a>
<a href="/#products"><i class="fas fa-tags"></i> <?= __('common.all_products') ?></a>
<a href="ticket"><i class="fas fa-ticket-alt"></i> <?= __('common.submit_ticket') ?></a>
<a href="announcements"><i class="fas fa-bullhorn"></i> <?= __('common.announcements') ?></a>
<a href="cart"><i class="fas fa-shopping-cart"></i> <?= __('common.cart') ?><?php if ($cartCount): ?><span class="badge"><?= $cartCount ?></span><?php endif; ?></a>
</nav>
<div class="nav-actions">
<button class="theme-toggle" id="themeToggle" title="<?= __('theme.switch_theme') ?>"><i class="fas fa-moon"></i></button>
<a href="lang.php?lang=<?= Lang::getLocale() === 'zh_CN' ? 'en_US' : 'zh_CN' ?>" class="lang-switch" title="<?= Lang::getLocale() === 'zh_CN' ? 'English' : '中文' ?>">
<i class="fas fa-language"></i> <span class="lang-label"><?= Lang::getLocale() === 'zh_CN' ? 'EN' : '中' ?></span>
</a>
<?php if ($user): ?>
<div class="user-menu" id="userMenu">
<button class="avatar" id="avatarBtn" aria-haspopup="true" aria-expanded="false" title="<?= h($user['nickname'] ?: $user['username']) ?>">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= h($user['avatar']) ?>" alt="<?= h(avatarText($user['nickname'] ?: $user['username'])) ?>" style="width:100%;height:100%;object-fit:cover;border-radius:50%">
<?php else: ?>
<?= h(avatarText($user['nickname'] ?: $user['username'])) ?>
<?php endif; ?>
</button>
<div class="user-card" id="userCard" hidden>
<div class="uc-head">
<span class="uc-avatar">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= h($user['avatar']) ?>" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">
<?php else: ?>
<?= h(avatarText($user['nickname'] ?: $user['username'])) ?>
<?php endif; ?>
</span>
<div class="uc-id">
<div class="uc-name"><?= h($user['nickname'] ?: $user['username']) ?></div>
<div class="uc-handle">@<?= h($user['username']) ?></div>
<?php if (empty($user['verified'])): ?>
<span class="uc-badge unverified"><i class="fas fa-times"></i> <?= __('common.email_unverified') ?></span>
<?php else: ?>
<span class="uc-badge verified"><i class="fas fa-check"></i> <?= __('common.email_verified') ?></span>
<?php endif; ?>
</div>
</div>
<div class="uc-points"><i class="fas fa-coins"></i> <?= __('common.points') ?><strong><?= (int) getUserPoints($user['id']) ?></strong></div>
<a class="uc-item" href="myorders"><i class="fas fa-receipt"></i> <?= __('common.my_orders') ?></a>
<a class="uc-item" href="mytickets"><i class="fas fa-ticket-alt"></i> <?= __('common.my_tickets') ?></a>
<a class="uc-item" href="sign"><i class="fas fa-calendar-days"></i> <?= __('common.sign_in') ?></a>
<a class="uc-item" href="invite"><i class="fas fa-user-plus"></i> <?= __('common.invite_friends') ?></a>
<a class="uc-item" href="settings"><i class="fas fa-gear"></i> <?= __('common.settings') ?></a>
<a class="uc-item danger" href="logout"><i class="fas fa-right-from-bracket"></i> <?= __('common.logout') ?></a>
</div>
</div>
<?php else: ?>
<a href="login" class="btn btn-primary"><i class="fas fa-sign-in"></i></a>
<?php endif; ?>
</div>
</div>
</header>
<?php if ($verifyNotice): ?>
<div class="verify-banner <?= $verifyOk ? 'ok' : 'warn' ?>">
<i class="fas <?= $verifyOk ? 'fa-circle-check' : 'fa-triangle-exclamation' ?>"></i>
<span><?= h($verifyNotice) ?></span>
</div>
<?php elseif ($user && empty($user['verified'])): ?>
<div class="verify-banner warn">
<i class="fas fa-triangle-exclamation"></i>
<span>你的邮箱尚未验证,<a href="verify?resend=1">点击重新发送验证邮件</a>。</span>
</div>
<?php endif; ?>
<main class="container main">
+27
View File
@@ -0,0 +1,27 @@
<?php
session_start();
require_once __DIR__ . '/_install_redirect.php';
define('IN_APP', true);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/functions.php';
// 自动加载 includes/ 下的类文件(类名需与文件名一致,如 Logger -> Logger.php
spl_autoload_register(function ($class) {
$file = __DIR__ . '/' . $class . '.php';
if (is_file($file)) {
require $file;
}
});
require_once __DIR__ . '/Lang.php';
// 初始化多语言系统
Lang::setDir(__DIR__ . '/../assets/lang');
Lang::detectLocale();
// 初始化日志系统
Logger::init(__DIR__ . '/../storage/logs', true, defined('DEBUG') && DEBUG ? Logger::DEBUG : Logger::INFO);
// 初始化缓存系统
Cache::init(__DIR__ . '/../storage/cache', true);
+197
View File
@@ -0,0 +1,197 @@
<?php
// 纯 PHP socket 实现的 SMTP 发信类,无需 Composer / PHPMailer。
// 支持 SSL465)与 STARTTLS587)以及无加密,使用 AUTH LOGIN 鉴权。
if (!defined('IN_APP')) {
http_response_code(403);
exit('Forbidden');
}
class SmtpMailer
{
public $log = [];
public $lastError = '';
private $host;
private $port;
private $enc; // '' | 'ssl' | 'tls'
private $user;
private $pass;
public function __construct($host, $port, $enc, $user, $pass)
{
$this->host = (string) $host;
$this->port = (int) $port;
$this->enc = (string) $enc;
$this->user = (string) $user;
$this->pass = (string) $pass;
}
/**
* 发送邮件。
* @param string $to 收件人,多个用逗号分隔
* @param string $subject 主题
* @param string $bodyHtml 正文(HTML
* @param string $fromEmail 发件人邮箱
* @param string $fromName 发件人名称
* @return bool
*/
public function send($to, $subject, $bodyHtml, $fromEmail, $fromName)
{
if ($this->host === '') {
$this->lastError = 'SMTP 主机未配置';
return false;
}
$port = $this->port ?: ($this->enc === 'ssl' ? 465 : 587);
$timeout = 20;
$scheme = $this->enc === 'ssl' ? 'ssl' : 'tcp';
$opts = [];
if ($this->enc === 'ssl' || $this->enc === 'tls') {
$opts['ssl'] = [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
];
}
$ctx = $opts ? stream_context_create($opts) : null;
$errno = 0;
$errstr = '';
$sock = @stream_socket_client(
$scheme . '://' . $this->host . ':' . $port,
$errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx
);
if (!$sock) {
$this->lastError = "无法连接 SMTP 服务器({$this->host}:{$port}):$errstr ($errno)";
return false;
}
stream_set_timeout($sock, $timeout);
// 读取服务商标语 220
if ($this->talk($sock, null, [220]) === false) {
fclose($sock);
return false;
}
$hostHeader = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
// EHLO
if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) {
fclose($sock);
return false;
}
// STARTTLS 协商
if ($this->enc === 'tls') {
if ($this->talk($sock, 'STARTTLS', [220]) === false) {
fclose($sock);
return false;
}
if (!@stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
$this->lastError = 'STARTTLS 加密协商失败';
fclose($sock);
return false;
}
if ($this->talk($sock, 'EHLO ' . $hostHeader, [250]) === false) {
fclose($sock);
return false;
}
}
// AUTH LOGIN
if ($this->talk($sock, 'AUTH LOGIN', [334]) === false) {
fclose($sock);
return false;
}
if ($this->talk($sock, base64_encode($this->user), [334]) === false) {
fclose($sock);
return false;
}
if ($this->talk($sock, base64_encode($this->pass), [235]) === false) {
fclose($sock);
return false;
}
// 信封
if ($this->talk($sock, 'MAIL FROM:<' . $fromEmail . '>', [250]) === false) {
fclose($sock);
return false;
}
$recipients = array_filter(array_map('trim', explode(',', $to)));
foreach ($recipients as $rcpt) {
$this->talk($sock, 'RCPT TO:<' . $rcpt . '>', [250, 251]);
}
// 信体
if ($this->talk($sock, 'DATA', [354]) === false) {
fclose($sock);
return false;
}
$data = $this->buildData($to, $subject, $bodyHtml, $fromEmail, $fromName);
fwrite($sock, $data);
if ($this->talk($sock, '.', [250]) === false) {
fclose($sock);
return false;
}
$this->talk($sock, 'QUIT', [221]);
fclose($sock);
return true;
}
private function buildData($to, $subject, $body, $fromEmail, $fromName)
{
$eol = "\r\n";
$headers = [];
$headers[] = 'From: ' . $this->encodeHeader($fromName) . ' <' . $fromEmail . '>';
$headers[] = 'To: ' . $to;
$headers[] = 'Subject: ' . $this->encodeHeader($subject);
$headers[] = 'Date: ' . date('r');
$headers[] = 'MIME-Version: 1.0';
$headers[] = 'Content-Type: text/html; charset=UTF-8';
$headers[] = 'Content-Transfer-Encoding: base64';
$msg = implode($eol, $headers) . $eol . $eol;
$msg .= chunk_split(base64_encode($body), 76, $eol);
return $msg;
}
private function encodeHeader($s)
{
if (preg_match('/[^\x00-\x7F]/', (string) $s)) {
return '=?UTF-8?B?' . base64_encode($s) . '?=';
}
return $s;
}
/**
* 与服务器交互:发送命令并读取响应。
* @param resource $sock
* @param string|null $cmd 为 null 时仅读取(用于读取初始标语)
* @param array $expectCodes 期望的状态码数组
* @return string|false
*/
private function talk($sock, $cmd, $expectCodes)
{
if ($cmd !== null) {
fwrite($sock, $cmd . "\r\n");
$this->log[] = 'C: ' . $cmd;
}
$resp = '';
while (true) {
$line = @fgets($sock, 515);
if ($line === false) {
break;
}
$resp .= $line;
// 末位第 4 个字符为空格表示响应结束(多行响应以 "- " 续接)
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
$this->log[] = 'S: ' . trim($resp);
$code = (int) substr($resp, 0, 3);
if (!in_array($code, (array) $expectCodes, true)) {
$this->lastError = '服务器返回错误:' . trim($resp);
return false;
}
return $resp;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
require __DIR__ . '/includes/init.php';
$pageTitle = '商城首页';
$cat = trim($_GET['cat'] ?? '');
$q = trim($_GET['q'] ?? '');
$where = ['status = 1'];
$params = [];
if ($cat !== '') { $where[] = 'category = ?'; $params[] = $cat; }
if ($q !== '') { $where[] = 'name LIKE ?'; $params[] = '%' . $q . '%'; }
$sql = 'SELECT * FROM ' . tn('products') . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY created_at DESC';
$stmt = db()->prepare($sql);
$stmt->execute($params);
$products = $stmt->fetchAll();
$anns = db()->query(
'SELECT * FROM ' . tn('announcements') . ' WHERE status = 1 ORDER BY pinned DESC, created_at DESC LIMIT 3'
)->fetchAll();
$groups = db()->query('SELECT name, icon FROM ' . tn('product_groups') . ' ORDER BY sort_order ASC, name ASC')->fetchAll();
$categories = array_column($groups, 'name');
$about = getSetting('mall_about', '');
require 'includes/header.php';
?>
<section class="hero">
<div class="hero-content">
<h1><i class="fas fa-store"></i> <?= __('hero.title') ?></h1>
<p><?= __('hero.subtitle') ?></p>
<form class="search-bar" method="get" action="/" id="searchForm">
<input type="text" name="q" value="<?= h($q) ?>" placeholder="<?= __('common.search_placeholder') ?>" id="searchInput" autocomplete="off">
<button type="submit" class="btn btn-primary"><i class="fas fa-search"></i></button>
</form>
</div>
</section>
<?php if (!empty($anns)): ?>
<section class="section ann-strip-wrap">
<div class="ann-strip">
<span class="ann-strip-label"><i class="fas fa-bullhorn"></i> <?= __('index.announcement') ?></span>
<div class="ann-strip-items">
<?php foreach ($anns as $a): ?>
<a class="ann-strip-item" href="announcement?id=<?= (int)$a['id'] ?>">
<?php if (!empty($a['pinned'])): ?><i class="fas fa-thumbtack ann-pin"></i><?php endif; ?>
<span class="ann-strip-title"><?= h($a['title']) ?></span>
<span class="ann-strip-date"><?= date('m-d', strtotime($a['created_at'])) ?></span>
</a>
<?php endforeach; ?>
</div>
<a href="announcements" class="ann-strip-more"><?= __('index.more') ?> <i class="fas fa-chevron-right"></i></a>
</div>
</section>
<?php endif; ?>
<?php if ($about !== ''): ?>
<section class="section mall-about">
<h2 class="sec-title"><i class="fas fa-info-circle"></i> <?= __('product.about_mall') ?></h2>
<div class="about-card">
<p><?= nl2br(h($about)) ?></p>
</div>
</section>
<?php endif; ?>
<section id="products" class="section">
<div class="cat-chips">
<a href="/" class="chip <?= $cat===''?'active':'' ?>"><i class="fas fa-th-large"></i> <?= __('index.all') ?></a>
<?php foreach ($groups as $g): ?>
<a href="/?cat=<?= urlencode($g['name']) ?>" class="chip <?= $cat===$g['name']?'active':'' ?>">
<?php if (!empty($g['icon'])): ?><i class="fas <?= h($g['icon']) ?>"></i> <?php endif; ?><?= h($g['name']) ?>
</a>
<?php endforeach; ?>
</div>
<?php if (empty($products)): ?>
<p class="empty"><?= __('index.no_result') ?></p>
<?php else: ?>
<div class="product-grid" id="productGrid">
<?php foreach ($products as $p): ?>
<a class="product-card" href="product?id=<?= (int)$p['id'] ?>">
<div class="pc-media">
<?php if (!empty($p['image'])): ?>
<img src="<?= h($p['image']) ?>" alt="<?= h($p['name']) ?>">
<?php else: ?>
<i class="fas <?= h($p['icon']) ?>"></i>
<?php endif; ?>
</div>
<div class="pc-body">
<span class="pc-cat"><?= h($p['category']) ?></span>
<h3 class="pc-name"><?= h($p['name']) ?></h3>
<div class="pc-meta">
<?php if (!empty($p['points_price'])): ?>
<span class="pc-price"><?= (int)$p['points_price'] ?> <?= __('index.points_unit') ?></span>
<?php else: ?>
<span class="pc-price"><?= __('product.cash_price') ?></span>
<?php endif; ?>
<span class="pc-stock"><?= __('common.remaining') ?> <?= (int)$p['stock'] ?> <?= __('common.items') ?></span>
</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php require 'includes/footer.php'; ?>
<script>
// AJAX 搜索增强:输入时实时搜索(防抖 400ms),回车/提交仍走表单刷新
(function () {
var I18N = {
noResult: '<?= __('index.no_result') ?>',
pointsUnit: '<?= __('index.points_unit') ?>',
cashPrice: '<?= __('product.cash_price') ?>',
remaining: '<?= __('common.remaining') ?>',
items: '<?= __('common.items') ?>'
};
var input = document.getElementById('searchInput');
var grid = document.getElementById('productGrid');
if (!input || !grid) return;
var timer = null;
input.addEventListener('input', function () {
var q = input.value.trim();
clearTimeout(timer);
if (q.length < 1) { return; } // 太短不搜索,保留原有结果
timer = setTimeout(function () {
Fnw.request({
action: 'search',
data: { q: q },
success: function (res) {
renderProducts(res.data.products);
},
error: function () {
// 静默失败,用户仍可回车提交
}
});
}, 400);
});
function renderProducts(products) {
if (!products || products.length === 0) {
grid.innerHTML = '<p class="empty">' + I18N.noResult + '</p>';
return;
}
var html = '';
for (var i = 0; i < products.length; i++) {
var p = products[i];
var media = p.image
? '<img src="' + encodeURIComponent(p.image) + '" alt="' + p.name + '">'
: '<i class="fas ' + (p.icon || 'fa-box') + '"></i>';
var price = p.points_price !== null && p.points_price !== ''
? '<span class="pc-price">' + p.points_price + ' ' + I18N.pointsUnit + '</span>'
: '<span class="pc-price">' + I18N.cashPrice + '</span>';
html += '<a class="product-card" href="product?id=' + p.id + '">' +
'<div class="pc-media">' + media + '</div>' +
'<div class="pc-body">' +
'<span class="pc-cat">' + (p.category || '') + '</span>' +
'<h3 class="pc-name">' + p.name + '</h3>' +
'<div class="pc-meta">' +
price +
'<span class="pc-stock">' + I18N.remaining + ' ' + p.stock + ' ' + I18N.items + '</span>' +
'</div>' +
'</div></a>';
}
grid.innerHTML = html;
}
})();
</script>
+248
View File
@@ -0,0 +1,248 @@
<?php
/**
* 自由云商城 · 安装向导
* 步骤:1 环境检测 → 2 填写数据库/管理员 → 3 执行安装 → done 完成
* 本文件为独立脚本,不依赖 config.php。
*/
session_start();
$step = isset($_GET['step']) ? (string) $_GET['step'] : '1';
$force = isset($_GET['force']);
$installed = file_exists(__DIR__ . '/config.php');
// 已安装且非强制重装:提示已安装
if ($installed && !$force && $step !== 'done') {
showInstalled();
exit;
}
// ====================== 步骤 3:执行安装 ======================
if ($step === '3' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$host = trim($_POST['db_host'] ?? '');
$port = trim($_POST['db_port'] ?? '3306');
$dbname = trim($_POST['db_name'] ?? '');
$dbuser = trim($_POST['db_user'] ?? '');
$dbpass = $_POST['db_pass'] ?? '';
$prefix = trim($_POST['db_prefix'] ?? 'fnw_');
$sitename = trim($_POST['site_name'] ?? '自由云商城');
$adminuser = trim($_POST['admin_user'] ?? '');
$adminpass = $_POST['admin_pass'] ?? '';
$adminpass2= $_POST['admin_pass2'] ?? '';
$adminemail= trim($_POST['admin_email'] ?? '');
$errors = [];
if ($host === '') $errors[] = '请填写数据库主机';
if (!ctype_digit($port)) $errors[] = '端口必须为数字';
if (!preg_match('/^[A-Za-z0-9_]+$/', $dbname)) $errors[] = '数据库名只能含字母、数字、下划线';
if ($dbuser === '') $errors[] = '请填写数据库用户名';
if (!preg_match('/^[A-Za-z0-9_]+$/', $prefix)) $errors[] = '表前缀只能含字母、数字、下划线';
if ($adminuser === '' || strlen($adminuser) < 3) $errors[] = '管理员账号至少 3 个字符';
if (strlen($adminpass) < 6) $errors[] = '管理员密码至少 6 位';
if ($adminpass !== $adminpass2) $errors[] = '两次输入的密码不一致';
if ($adminemail !== '' && !filter_var($adminemail, FILTER_VALIDATE_EMAIL)) $errors[] = '管理员邮箱格式不正确';
if (empty($errors)) {
try {
// 1) 连接 MySQL(不含库名),创建数据库
$opts = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO("mysql:host={$host};port={$port};charset=utf8mb4", $dbuser, $dbpass, $opts);
$pdo->exec("CREATE DATABASE IF NOT EXISTS `{$dbname}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$pdo = new PDO("mysql:host={$host};port={$port};dbname={$dbname};charset=utf8mb4", $dbuser, $dbpass, $opts);
// 2) 导入表结构与初始数据
$sql = file_get_contents(__DIR__ . '/db.sql');
$sql = preg_replace('/^--.*$/m', '', $sql); // 去掉 -- 注释行
$sql = str_replace('__PREFIX__', $prefix, $sql);
$stmts = preg_split('/;\s*\n/', $sql);
foreach ($stmts as $s) {
$s = trim($s);
if ($s === '') continue;
$pdo->exec($s);
}
// 3) 创建管理员账号(同时生成唯一邀请码,避免 uk_invite_code 冲突)
$hash = password_hash($adminpass, PASSWORD_DEFAULT);
$adminCode = strtoupper(substr(md5(uniqid('admin', true)), 0, 8));
$pdo->prepare("INSERT INTO `{$prefix}users` (username,password,email,invite_code,is_admin,role,created_at) VALUES (?,?,?,?,1,'admin',NOW())")
->execute([$adminuser, $hash, $adminemail, $adminCode]);
// 4) 生成 config.php
$cfg = "<?php\n";
$cfg .= "// 本文件由安装程序自动生成,请勿手动修改\n";
$cfg .= "define('DB_HOST', " . var_export($host, true) . ");\n";
$cfg .= "define('DB_PORT', " . var_export($port, true) . ");\n";
$cfg .= "define('DB_NAME', " . var_export($dbname, true) . ");\n";
$cfg .= "define('DB_USER', " . var_export($dbuser, true) . ");\n";
$cfg .= "define('DB_PASS', " . var_export($dbpass, true) . ");\n";
$cfg .= "define('DB_PREFIX', " . var_export($prefix, true) . ");\n";
$cfg .= "define('SITE_NAME', " . var_export($sitename, true) . ");\n";
$cfg .= "define('SITE_KEY', " . var_export(bin2hex(random_bytes(16)), true) . ");\n";
$cfg .= "define('INSTALLED', true);\n";
$cfgPath = __DIR__ . '/config.php';
$written = @file_put_contents($cfgPath, $cfg);
if ($written === false) {
// 无法写入:提示手动保存
showManualConfig($cfg);
exit;
}
header('Location: install.php?step=done');
exit;
} catch (Throwable $e) {
$errors[] = '安装失败:' . $e->getMessage();
}
}
// 出错则回到表单(下方会渲染步骤 2 并显示错误)
$step = '2';
}
// ====================== 视图渲染 ======================
$title = '自由云商城 · 安装向导';
$cssVer = file_exists(__DIR__ . '/assets/style.css') ? filemtime(__DIR__ . '/assets/style.css') : time();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($title) ?></title>
<link rel="stylesheet" href="assets/vendor/fontawesome/all.min.css">
<link rel="stylesheet" href="assets/style.css?v=<?= $cssVer ?>">
<style>
.install-wrap{max-width:720px;margin:40px auto;}
.install-card{background:var(--surface);border:1px solid var(--outline);border-radius:14px;padding:34px;box-shadow:0 10px 40px rgba(0,0,0,.12);}
.install-card h1{color:var(--primary);font-size:1.6rem;margin-bottom:6px;}
.install-sub{color:var(--muted);margin-bottom:22px;}
.check-list{list-style:none;padding:0;margin:0 0 22px;}
.check-list li{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--outline);border-radius:8px;margin-bottom:8px;}
.check-list .ok{color:#22c55e;}.check-list .bad{color:#ef4444;}
.err-box{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c;padding:12px 16px;border-radius:8px;margin-bottom:18px;}
.err-box li{margin-left:18px;}
.step-bar{display:flex;gap:8px;margin-bottom:24px;}
.step-bar .s{flex:1;text-align:center;padding:8px;font-size:.85rem;border-radius:8px;background:var(--secondary);color:var(--muted);}
.step-bar .s.active{background:var(--primary);color:#fff;}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:16px;}
.form-grid label{display:flex;flex-direction:column;gap:6px;font-size:.9rem;color:var(--muted);}
.form-grid input{padding:10px 12px;border:2px solid var(--outline);border-radius:8px;font-size:1rem;background:var(--bg);color:var(--text);font-family:inherit;}
.form-grid .span2{grid-column:1 / -1;}
@media (max-width:600px){.form-grid{grid-template-columns:1fr;}}
</style>
</head>
<body data-theme="light">
<div class="install-wrap">
<div class="install-card">
<h1><i class="fas fa-store"></i> 自由云商城安装向导</h1>
<div class="install-sub">只需几步,填写数据库信息即可完成安装。</div>
<div class="step-bar">
<div class="s <?= $step==='1'?'active':'' ?>">1. 环境检测</div>
<div class="s <?= $step==='2'?'active':'' ?>">2. 数据库设置</div>
<div class="s <?= $step==='3'||$step==='done'?'active':'' ?>">3. 完成</div>
</div>
<?php if ($step === '1'): ?>
<?php
$checks = [
['PHP 版本 ≥ 7.0', version_compare(PHP_VERSION, '7.0.0', '>='), '当前版本 ' . PHP_VERSION],
['PDO 扩展', extension_loaded('pdo'), '用于数据库连接'],
['PDO_MYSQL 扩展', extension_loaded('pdo_mysql'), '用于 MySQL 连接'],
['config.php 可写', is_writable(__DIR__), '需对商城目录有写权限'],
];
$allOk = true;
foreach ($checks as $c) { if (!$c[1]) $allOk = false; }
?>
<ul class="check-list">
<?php foreach ($checks as $c): ?>
<li>
<i class="fas <?= $c[1] ? 'fa-check-circle ok' : 'fa-times-circle bad' ?>"></i>
<span><strong><?= htmlspecialchars($c[0]) ?></strong> — <?= htmlspecialchars($c[2]) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php if ($allOk): ?>
<a href="install.php?step=2" class="btn btn-primary" style="display:inline-block;padding:12px 22px;">下一步:填写数据库信息 →</a>
<?php else: ?>
<p class="err-box">请先满足上述环境要求(尤其启用 pdo_mysql 扩展,并确保目录可写)后再继续。</p>
<?php endif; ?>
<?php elseif ($step === '2'): ?>
<?php if (!empty($errors)): ?>
<ul class="err-box"><?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul>
<?php endif; ?>
<form method="post" action="install.php?step=3">
<h3 style="margin:6px 0 12px;color:var(--primary)"><i class="fas fa-database"></i> 数据库信息</h3>
<div class="form-grid">
<label>数据库主机<input name="db_host" value="<?= htmlspecialchars($_POST['db_host'] ?? 'localhost') ?>" required></label>
<label>端口<input name="db_port" value="<?= htmlspecialchars($_POST['db_port'] ?? '3306') ?>" required></label>
<label>数据库名<input name="db_name" value="<?= htmlspecialchars($_POST['db_name'] ?? 'freenw_mall') ?>" required></label>
<label>表前缀<input name="db_prefix" value="<?= htmlspecialchars($_POST['db_prefix'] ?? 'fnw_') ?>"></label>
<label>数据库用户名<input name="db_user" value="<?= htmlspecialchars($_POST['db_user'] ?? 'root') ?>" required></label>
<label>数据库密码<input type="text" name="db_pass" value="<?= htmlspecialchars($_POST['db_pass'] ?? '') ?>" placeholder="无密码可留空"></label>
</div>
<h3 style="margin:18px 0 12px;color:var(--primary)"><i class="fas fa-cog"></i> 站点与管理员</h3>
<div class="form-grid">
<label>站点名称<input name="site_name" value="<?= htmlspecialchars($_POST['site_name'] ?? '自由云商城') ?>"></label>
<label>管理员账号<input name="admin_user" value="<?= htmlspecialchars($_POST['admin_user'] ?? '') ?>" placeholder="至少3位" required></label>
<label>管理员密码<input type="password" name="admin_pass" placeholder="至少6位" required></label>
<label>确认密码<input type="password" name="admin_pass2" required></label>
<label style="grid-column:1/-1">管理员邮箱<input name="admin_email" value="<?= htmlspecialchars($_POST['admin_email'] ?? '') ?>" placeholder="选填"></label>
</div>
<button class="btn btn-primary" style="margin-top:18px;width:100%;padding:13px;">开始安装</button>
</form>
<?php elseif ($step === 'done'): ?>
<div style="text-align:center;padding:10px 0;">
<i class="fas fa-check-circle" style="font-size:56px;color:#22c55e;"></i>
<h2 style="margin:14px 0 6px;color:var(--primary)">安装成功!</h2>
<p class="install-sub">数据库已创建、数据表与初始商品已写入,管理员账号已生成。</p>
<div style="display:flex;gap:12px;justify-content:center;margin-top:18px;flex-wrap:wrap;">
<a href="index.php" class="btn btn-primary" style="padding:12px 22px;">进入商城前台 →</a>
<a href="admin/" class="btn btn-ghost" style="padding:12px 22px;">进入后台管理</a>
</div>
<p style="margin-top:18px;font-size:.85rem;color:var(--text-muted);">为安全起见,建议安装完成后删除或重命名 <code>install.php</code>。</p>
</div>
<?php endif; ?>
</div>
<p style="text-align:center;color:var(--text-muted);font-size:.85rem;margin-top:14px;">自由云科技团队 · 公益技术团队</p>
</div>
</body>
</html>
<?php
// ====================== 辅助函数 ======================
function showInstalled() {
?>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
<title>已安装</title>
<link rel="stylesheet" href="assets/vendor/fontawesome/all.min.css">
<link rel="stylesheet" href="assets/style.css?v=<?= filemtime(__DIR__ . '/assets/style.css') ?>"></head>
<body data-theme="light"><div class="install-wrap"><div class="install-card">
<h1><i class="fas fa-info-circle"></i> 商城已安装</h1>
<p class="install-sub">检测到 <code>config.php</code> 已存在,无需重复安装。</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
<a href="index.php" class="btn btn-primary" style="padding:12px 22px;">进入商城前台</a>
<a href="admin/" class="btn btn-ghost" style="padding:12px 22px;">后台管理</a>
<a href="install.php?step=1&force=1" class="btn btn-ghost" style="padding:12px 22px;">强制重新安装</a>
</div>
<p style="margin-top:16px;font-size:.85rem;color:var(--text-muted);">如需重新安装,请先删除 <code>config.php</code>,或点击「强制重新安装」。</p>
</div></div></body></html>
<?php
}
function showManualConfig($cfg) {
?>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
<title>手动保存配置</title>
<link rel="stylesheet" href="assets/vendor/fontawesome/all.min.css">
<link rel="stylesheet" href="assets/style.css?v=<?= filemtime(__DIR__ . '/assets/style.css') ?>"></head>
<body data-theme="light"><div class="install-wrap"><div class="install-card">
<h1><i class="fas fa-exclamation-triangle"></i> 无法写入 config.php</h1>
<p class="install-sub">数据库已初始化成功,但目录不可写。请手动创建 <code>mall/config.php</code> 并粘贴以下内容:</p>
<textarea style="width:100%;height:240px;font-family:monospace;" readonly><?= htmlspecialchars($cfg) ?></textarea>
<p style="margin-top:12px;font-size:.85rem;color:var(--text-muted);">保存后访问 <a href="index.php">index.php</a> 即可使用。</p>
</div></div></body></html>
<?php
}
+109
View File
@@ -0,0 +1,109 @@
<?php
// 邀请好友:展示我的邀请码 / 邀请链接 / 邀请记录
require __DIR__ . '/includes/init.php';
requireLogin('invite.php');
$pageTitle = '邀请好友';
$user = currentUser();
$enabled = getSetting('points_enabled', '1') === '1';
$invPts = max(0, (int) getSetting('points_invite', '20'));
// 老用户(注册时尚未有邀请码)补一个
$myInviteCode = $user['invite_code'] ?? '';
if ($myInviteCode === '') {
$myInviteCode = genInviteCode();
db()->prepare('UPDATE ' . tn('users') . ' SET invite_code = ? WHERE id = ?')
->execute([$myInviteCode, $user['id']]);
}
$link = siteBaseUrl() . '/login.php?mode=register&inv=' . rawurlencode($myInviteCode);
// 邀请记录:谁通过我的邀请码注册
$stmt = db()->prepare(
'SELECT username, nickname, created_at FROM ' . tn('users') . ' WHERE invited_by = ? ORDER BY created_at DESC'
);
$stmt->execute([$user['id']]);
$invited = $stmt->fetchAll();
$earned = $invPts * count($invited);
?>
<?php require 'includes/header.php'; ?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-gift"></i> 邀请好友</h2>
<?php if (!$enabled): ?>
<p class="form-err">积分系统当前已关闭,邀请功能暂不可用。</p>
<?php else: ?>
<div class="invite-card">
<div class="invite-head">
<div class="invite-reward">每成功邀请 1 位好友注册,你可得 <strong><?= $invPts ?></strong> 积分</div>
<div class="invite-earned">已成功邀请 <strong><?= count($invited) ?></strong> 人,累计获得 <strong><?= $earned ?></strong> 积分</div>
</div>
<div class="invite-code-box">
<div class="ic-label">我的邀请码</div>
<div class="ic-code" id="invCode"><?= h($myInviteCode) ?></div>
<button type="button" class="mini-btn" id="copyCode">复制邀请码</button>
</div>
<div class="invite-link-box">
<div class="ic-label">邀请链接(发给好友,通过它注册你和好友都算)</div>
<div class="invite-link-row">
<input type="text" id="invLink" value="<?= h($link) ?>" readonly>
<button type="button" class="mini-btn" id="copyLink">复制链接</button>
</div>
</div>
<div class="invite-tip muted">
<i class="fas fa-info-circle"></i> 好友通过你的邀请链接注册成功,系统会自动给你的账号加上积分,无需手动操作。
</div>
</div>
<div class="panel">
<h3>邀请记录(<?= count($invited) ?> 人)</h3>
<?php if (empty($invited)): ?>
<p class="empty">还没有好友通过你的邀请注册,快分享链接试试吧~</p>
<?php else: ?>
<table class="data-table">
<thead><tr><th>用户名</th><th>昵称</th><th>注册时间</th><th>奖励</th></tr></thead>
<tbody>
<?php foreach ($invited as $f): ?>
<tr>
<td><?= h($f['username']) ?></td>
<td><?= h($f['nickname'] ?: '—') ?></td>
<td><?= date('Y-m-d H:i', strtotime($f['created_at'])) ?></td>
<td><span class="badge-on">+<?= $invPts ?> 积分</span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<script>
(function () {
function copy(text, btn) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
var old = btn.textContent;
btn.textContent = '已复制';
setTimeout(function () { btn.textContent = old; }, 1500);
});
} else {
var ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
}
}
var code = document.getElementById('copyCode');
if (code) code.addEventListener('click', function () {
copy(document.getElementById('invCode').textContent.trim(), code);
});
var link = document.getElementById('copyLink');
if (link) link.addEventListener('click', function () {
copy(document.getElementById('invLink').value, link);
});
})();
</script>
<?php require 'includes/footer.php'; ?>
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* 语言切换页(服务端)。
* 用法:<a href="lang.php?lang=en_US">English</a>
* 本页设置 $_SESSION['lang'] + cookie 后,重定向回来源页(或首页)。
*/
require __DIR__ . '/includes/init.php';
$lang = trim($_GET['lang'] ?? '');
$allowed = [];
foreach (Lang::available() as $l) {
$allowed[] = $l['code'];
}
if ($lang !== '' && in_array($lang, $allowed, true)) {
$_SESSION['lang'] = $lang;
setcookie('fnw_lang', $lang, time() + 31536000, '/');
Lang::setLocale($lang);
}
// 重定向回来源页,避免跳转到站外
$ref = $_SERVER['HTTP_REFERER'] ?? '';
$host = parse_url((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? ''), PHP_URL_HOST);
if ($ref === '' || parse_url($ref, PHP_URL_HOST) !== $host) {
$ref = '/';
}
header('Location: ' . $ref, true, 302);
exit;
+132
View File
@@ -0,0 +1,132 @@
<?php
// 登录 / 注册
require __DIR__ . '/includes/init.php';
$pageTitle = __('login.title');
$next = $_GET['next'] ?? '';
$mode = $_GET['mode'] ?? 'login';
$invite = trim($_GET['inv'] ?? '');
$err = '';
$ok = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf();
if (isset($_POST['login'])) {
$u = trim($_POST['username'] ?? '');
$pw = $_POST['password'] ?? '';
$stmt = db()->prepare('SELECT * FROM ' . tn('users') . ' WHERE username = ?');
$stmt->execute([$u]);
$row = $stmt->fetch();
if ($row && password_verify($pw, $row['password'])) {
$_SESSION['user_id'] = $row['id'];
unset($_SESSION['csrf']);
redirect($next ?: 'index.php');
}
$err = __('login.err_login_failed');
}
if (isset($_POST['register'])) {
$u = trim($_POST['username'] ?? '');
$pw = $_POST['password'] ?? '';
$pw2 = $_POST['password2'] ?? '';
$em = trim($_POST['email'] ?? '');
$cap = strtoupper(trim($_POST['captcha'] ?? ''));
// 邀请码:优先使用表单填写,其次使用 URL 参数
$postInvite = isset($_POST['invite_code']) ? trim($_POST['invite_code']) : null;
$invite = $postInvite !== null ? $postInvite : trim($_GET['inv'] ?? '');
// 自研图形验证码:一次性校验后清空
$sessCap = isset($_SESSION['captcha']) ? strtoupper($_SESSION['captcha']) : '';
unset($_SESSION['captcha']);
if ($cap === '' || $cap !== $sessCap) {
$err = __('login.err_captcha_wrong');
}
elseif (strlen($u) < 3) $err = __('login.err_username_short');
elseif (strlen($pw) < 6) $err = __('login.err_password_short');
elseif ($pw !== $pw2) $err = __('login.err_password_mismatch');
elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $err = __('login.err_email_invalid');
// 若用户在表单里手动填写了邀请码,则必须校验通过
elseif ($postInvite !== null && $postInvite !== '' && getInviterIdByCode($postInvite) === 0) {
$err = __('login.err_invite_invalid');
}
// 单 IP 每日最多注册 2 个账号
if ($err === '') {
$ip = getClientIp();
if (regCountToday($ip) >= 2) {
$err = __('login.err_ip_limit');
}
}
if ($err === '') {
$chk = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ?');
$chk->execute([$u]);
if ($chk->fetch()) {
$err = __('login.err_username_exists');
} else {
$hash = password_hash($pw, PASSWORD_DEFAULT);
$myCode = genInviteCode();
db()->prepare('INSERT INTO ' . tn('users') . ' (username,password,email,reg_ip,invite_code,verified,created_at) VALUES (?,?,?,?,?,0,NOW())')
->execute([$u, $hash, $em, $ip, $myCode]);
$id = db()->lastInsertId();
// 邀请奖励:通过他人邀请码注册,给邀请人加积分
if ($invite !== '') {
$invId = getInviterIdByCode($invite);
if ($invId > 0 && $invId != $id) {
db()->prepare('UPDATE ' . tn('users') . ' SET invited_by = ? WHERE id = ?')
->execute([$invId, $id]);
$invPts = max(0, (int) getSetting('points_invite', '20'));
if ($invPts > 0) {
addPoints($invId, 'invite', $invPts, '邀请好友 ' . $u . ' 注册成功');
}
}
}
$_SESSION['user_id'] = $id;
// 发送邮箱验证邮件(不阻塞注册:失败也可正常使用)
$sendRes = sendVerifyMail($id);
$_SESSION['verify_notice'] = $sendRes['ok'] ? 'sent' : 'fail';
unset($_SESSION['csrf']);
redirect($next ?: 'index.php');
}
}
}
}
require 'includes/header.php';
?>
<section class="section auth-section">
<div class="auth-card">
<div class="auth-tabs">
<a href="login.php?mode=login" class="<?= $mode==='login'?'active':'' ?>"><?= __('login.tab_login') ?></a>
<a href="login.php?mode=register" class="<?= $mode==='register'?'active':'' ?>"><?= __('login.tab_register') ?></a>
</div>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<?php if ($mode === 'login'): ?>
<form method="post" action="" class="auth-form">
<?= csrfField() ?>
<label><?= __('auth.username') ?><input type="text" name="username" required></label>
<label><?= __('auth.password') ?><input type="password" name="password" required></label>
<button type="submit" name="login" class="btn btn-primary"><?= __('login.btn_login') ?></button>
</form>
<?php else: ?>
<form method="post" action="" class="auth-form">
<?= csrfField() ?>
<label><?= __('auth.username') ?><input type="text" name="username" placeholder="<?= __('login.username_placeholder') ?>" required></label>
<label><?= __('login.label_email') ?> *<input type="email" name="email" placeholder="<?= __('login.email_placeholder') ?>" required></label>
<div class="captcha-box">
<img src="captcha.php" alt="CAPTCHA" class="captcha-img" onclick="this.src='captcha.php?'+Date.now()" title="<?= __('login.captcha_hint') ?>">
<label><?= __('login.label_captcha') ?><input type="text" name="captcha" maxlength="4" autocomplete="off" placeholder="<?= __('login.captcha_placeholder') ?>" required></label>
</div>
<label><?= __('auth.password') ?><input type="password" name="password" placeholder="<?= __('login.pwd_placeholder') ?>" required></label>
<label><?= __('auth.confirm_password') ?><input type="password" name="password2" required></label>
<label><?= __('login.label_invite') ?><input type="text" name="invite_code" placeholder="<?= __('login.invite_placeholder') ?>" value="<?= h($invite) ?>"></label>
<button type="submit" name="register" class="btn btn-primary"><?= __('login.btn_register') ?></button>
<p class="form-tip"><?= __('login.register_tip') ?></p>
</form>
<?php endif; ?>
</div>
</section>
<?php require 'includes/footer.php'; ?>
+7
View File
@@ -0,0 +1,7 @@
<?php
// 退出登录(前台)
session_start();
session_unset();
session_destroy();
header('Location: index.php');
exit;
+69
View File
@@ -0,0 +1,69 @@
<?php
// 我的订单
require __DIR__ . '/includes/init.php';
requireLogin('myorders.php');
$pageTitle = __('myorders.title');
$user = currentUser();
$paid = $_GET['paid'] ?? '';
$stmt = db()->prepare('SELECT * FROM ' . tn('orders') . ' WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user['id']]);
$orders = $stmt->fetchAll();
// 预取每笔订单的商品
$orderItems = [];
foreach ($orders as $o) {
$it = db()->prepare('SELECT * FROM ' . tn('order_items') . ' WHERE order_id = ?');
$it->execute([$o['id']]);
$orderItems[$o['id']] = $it->fetchAll();
}
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-receipt"></i> <?= __('myorders.title') ?></h2>
<?php if ($paid): ?>
<p class="banner-ok"><i class="fas fa-check-circle"></i> <?= str_replace('{no}', '<strong>' . h($paid) . '</strong>', __('myorders.paid_success')) ?></p>
<?php endif; ?>
<?php if (empty($orders)): ?>
<p class="empty"><?= str_replace('{link}', '<a href="index.php">' . __('myorders.shop_link') . '</a>', __('myorders.no_orders')) ?></p>
<?php else: ?>
<div class="order-list">
<?php foreach ($orders as $o): ?>
<div class="order-card">
<div class="oc-head">
<span class="oc-no"><?= __('myorders.order_no_label') ?><?= h($o['order_no']) ?></span>
<span class="badge-status status-<?= h($o['status']) ?>"><?= orderStatusLabel($o['status']) ?></span>
</div>
<div class="oc-items">
<?php foreach (($orderItems[$o['id']] ?? []) as $it): ?>
<div class="oc-row">
<span><?= str_replace(['{name}', '{qty}'], [h($it['name']), (int)$it['qty']], __('myorders.items_fmt')) ?></span>
<span><?= money($it['subtotal']) ?></span>
</div>
<?php endforeach; ?>
</div>
<div class="oc-foot">
<span class="oc-time"><?= date('Y-m-d H:i', strtotime($o['created_at'])) ?></span>
<a href="order_detail.php?id=<?= (int)$o['id'] ?>" class="oc-detail"><i class="fas fa-chevron-right"></i> <?= __('myorders.detail_btn') ?></a>
<span class="oc-pay pay-<?= h($o['pay_type']) ?>"><?= payTypeLabel($o['pay_type']) ?><?php $o['pay_type']==='points' ? ' ' . str_replace('{pts}', (int)$o['points_used'], __('myorders.pay_points')) : '' ?></span>
<span class="oc-total"><?= __('myorders.total_label') ?><strong><?= money($o['total']) ?></strong></span>
</div>
<?php if ($o['address']): ?>
<div class="oc-addr"><?= __('myorders.shipping_label') ?><?= h($o['contact']) ?> · <?= h($o['address']) ?></div>
<?php endif; ?>
<?php if (!empty($o['period_days'])): ?>
<div class="oc-period"><?= __('myorders.period_label') ?><?= periodLabel($o['period_days']) ?> · <?= __('myorders.expire_label') ?><?= expiryText($o['expires_at']) ?></div>
<?php endif; ?>
<?php if (!empty($o['period_days']) && in_array($o['status'], ['shipped','completed'], true)): ?>
<div class="oc-renew"><a href="ticket.php?order_id=<?= (int)$o['id'] ?>" class="btn btn-ghost btn-sm"><i class="fas fa-rotate"></i> <?= __('myorders.renew_btn') ?></a></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php require 'includes/footer.php'; ?>
+185
View File
@@ -0,0 +1,185 @@
<?php
// 前台:我的工单(列表 + 详情,支持多轮回复)。
require __DIR__ . '/includes/init.php';
requireLogin('mytickets.php');
$user = currentUser();
$pageTitle = __('mytickets.title');
$viewId = isset($_GET['view']) ? (int) $_GET['view'] : 0;
// 用户重新开启已关闭工单
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['reopen']) && $viewId > 0) {
verifyCsrf();
$stmt = db()->prepare('SELECT id, user_id, status FROM ' . tn('tickets') . ' WHERE id = ?');
$stmt->execute([$viewId]);
$t = $stmt->fetch();
if (!$t || (int) $t['user_id'] !== (int) $user['id']) {
$err = __('mytickets.err_no_perm');
} elseif ($t['status'] !== 'closed') {
$err = __('mytickets.err_not_closed');
} else {
db()->prepare('UPDATE ' . tn('tickets') . ' SET status = ?, updated_at = NOW(), closed_at = NULL WHERE id = ?')
->execute(['open', $viewId]);
redirect('mytickets.php?view=' . $viewId . '&ok=1');
}
}
// 用户追加回复
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['user_reply']) && $viewId > 0) {
verifyCsrf();
$reply = trim($_POST['reply_message'] ?? '');
$stmt = db()->prepare('SELECT id, user_id, status FROM ' . tn('tickets') . ' WHERE id = ?');
$stmt->execute([$viewId]);
$t = $stmt->fetch();
if (!$t || (int) $t['user_id'] !== (int) $user['id']) {
$err = __('mytickets.err_no_perm');
} elseif ($t['status'] === 'closed') {
$err = __('mytickets.err_closed_reply');
} elseif ($reply === '') {
$err = __('mytickets.err_reply_empty');
} elseif (strlen($reply) > 5000) {
$err = __('mytickets.err_reply_long');
} else {
db()->prepare('INSERT INTO ' . tn('ticket_replies') . ' (ticket_id, user_id, is_admin, message, created_at) VALUES (?,?,?,?,NOW())')
->execute([$viewId, $user['id'], 0, $reply]);
db()->prepare('UPDATE ' . tn('tickets') . ' SET status = ?, updated_at = NOW() WHERE id = ?')
->execute(['open', $viewId]);
redirect('mytickets.php?view=' . $viewId . '&ok=1');
}
}
$ticket = null;
$replies = [];
if ($viewId > 0) {
$stmt = db()->prepare(
'SELECT t.*, d.name AS dept_name FROM ' . tn('tickets') . ' t
LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id
WHERE t.id = ? AND t.user_id = ?'
);
$stmt->execute([$viewId, $user['id']]);
$ticket = $stmt->fetch();
if ($ticket) {
$rstmt = db()->prepare(
'SELECT r.*, u.username, u.nickname FROM ' . tn('ticket_replies') . ' r
LEFT JOIN ' . tn('users') . ' u ON u.id = r.user_id
WHERE r.ticket_id = ? ORDER BY r.created_at ASC'
);
$rstmt->execute([$viewId]);
$replies = $rstmt->fetchAll();
}
}
$list = db()->prepare(
'SELECT t.*, d.name AS dept_name FROM ' . tn('tickets') . ' t
LEFT JOIN ' . tn('ticket_departments') . ' d ON d.id = t.dept_id
WHERE t.user_id = ? ORDER BY t.updated_at DESC'
);
$list->execute([$user['id']]);
$list = $list->fetchAll();
require 'includes/header.php';
?>
<?php if (isset($_GET['ok'])): ?>
<p class="banner-ok" style="max-width:880px;margin:18px auto 0;"><i class="fas fa-check-circle"></i> <?= __('mytickets.ok_msg') ?></p>
<?php endif; ?>
<?php if ($err): ?>
<p class="form-err" style="max-width:880px;margin:18px auto 0;"><?= h($err) ?></p>
<?php endif; ?>
<?php if ($ticket): ?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-ticket-alt"></i> <?= str_replace('{id}', (int)$ticket['id'], __('mytickets.ticket_detail')) ?></h2>
<div class="panel">
<div class="ticket-meta">
<span class="badge-status status-<?= h($ticket['status']) ?>"><?= ticketStatusLabel($ticket['status']) ?></span>
<span class="muted"><?= __('mytickets.meta_type') ?><?= ticketTypeLabel($ticket['type']) ?></span>
<span class="muted"><?= __('mytickets.meta_dept') ?><?= h($ticket['dept_name'] ?? __('mytickets.meta_unspecified')) ?></span>
<span class="muted"><?= __('mytickets.meta_priority') ?><?= ticketPriorityLabel($ticket['priority']) ?></span>
<span class="muted"><?= __('mytickets.meta_time') ?><?= date('Y-m-d H:i', strtotime($ticket['created_at'])) ?></span>
</div>
<?php if ($ticket['type'] === 'renewal' && $ticket['order_id']): ?>
<p class="ticket-order"><?= str_replace('{link}', '<a href="order_detail.php?id=' . (int)$ticket['order_id'] . '">#' . (int)$ticket['order_id'] . '</a>', __('mytickets.renewal_note')) ?></p>
<?php endif; ?>
<div class="ticket-timeline">
<div class="ticket-bubble user-bubble">
<div class="bubble-head"><i class="fas fa-user"></i> <?= __('mytickets.bubble_me') ?><?= date('Y-m-d H:i', strtotime($ticket['created_at'])) ?></div>
<h3 style="margin:8px 0 6px;font-size:1.05rem;"><?= h($ticket['subject']) ?></h3>
<div class="ticket-msg"><?= nl2br(h($ticket['message'])) ?></div>
</div>
<?php foreach ($replies as $r): ?>
<?php if ((int)$r['is_admin'] === 1): ?>
<div class="ticket-bubble admin-bubble">
<div class="bubble-head"><i class="fas fa-user-shield"></i> <?= __('mytickets.bubble_staff') ?><?= date('Y-m-d H:i', strtotime($r['created_at'])) ?></div>
<div class="ticket-msg"><?= nl2br(h($r['message'])) ?></div>
</div>
<?php else: ?>
<div class="ticket-bubble user-bubble">
<div class="bubble-head"><i class="fas fa-user"></i> <?= __('mytickets.bubble_me') ?><?= date('Y-m-d H:i', strtotime($r['created_at'])) ?></div>
<div class="ticket-msg"><?= nl2br(h($r['message'])) ?></div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php if ($ticket['status'] !== 'closed'): ?>
<form method="post" action="" class="grid-form" style="margin-top:18px;">
<?= csrfField() ?>
<input type="hidden" name="user_reply" value="1">
<label class="span2"><?= __('mytickets.reply_label') ?>
<textarea name="reply_message" rows="4" autocomplete="off" placeholder="<?= __('mytickets.reply_placeholder') ?>" required></textarea>
</label>
<div class="form-actions span2">
<button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> <?= __('mytickets.send_reply') ?></button>
<a href="mytickets.php" class="btn btn-ghost"><?= __('mytickets.back_list') ?></a>
</div>
</form>
<?php else: ?>
<p class="muted" style="margin-top:14px;"><i class="fas fa-lock"></i> <?= __('mytickets.closed_lock') ?></p>
<form method="post" action="" style="margin-top:10px;display:inline-block;">
<?= csrfField() ?>
<input type="hidden" name="reopen" value="1">
<button type="submit" class="btn btn-primary"><i class="fas fa-undo"></i> <?= __('mytickets.reopen_btn') ?></button>
<a href="mytickets.php" class="btn btn-ghost"><i class="fas fa-arrow-left"></i> <?= __('mytickets.back_list') ?></a>
</form>
<?php endif; ?>
</div>
</section>
<?php else: ?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-ticket-alt"></i> <?= __('mytickets.list_title') ?></h2>
<div style="margin-bottom:14px;">
<a href="ticket.php" class="btn btn-primary"><i class="fas fa-plus"></i> <?= __('mytickets.new_ticket') ?></a>
</div>
<?php if (empty($list)): ?>
<p class="empty"><?= __('mytickets.no_tickets') ?></p>
<?php else: ?>
<div class="panel">
<table class="data-table">
<thead><tr><th><?= __('mytickets.col_id') ?></th><th><?= __('mytickets.col_subject') ?></th><th><?= __('mytickets.col_type') ?></th><th><?= __('mytickets.col_dept') ?></th><th><?= __('mytickets.col_priority') ?></th><th><?= __('mytickets.col_status') ?></th><th><?= __('mytickets.col_updated') ?></th><th></th></tr></thead>
<tbody>
<?php foreach ($list as $t): ?>
<tr>
<td>#<?= (int) $t['id'] ?></td>
<td><?= h($t['subject']) ?></td>
<td><?= ticketTypeLabel($t['type']) ?></td>
<td><?= h($t['dept_name'] ?? __('mytickets.meta_unspecified')) ?></td>
<td><?= ticketPriorityLabel($t['priority']) ?></td>
<td><span class="badge-status status-<?= h($t['status']) ?>"><?= ticketStatusLabel($t['status']) ?></span></td>
<td><?= date('m-d H:i', strtotime($t['updated_at'])) ?></td>
<td><a href="mytickets.php?view=<?= (int) $t['id'] ?>" class="mini-btn"><?= __('mytickets.view_btn') ?></a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php require 'includes/footer.php'; ?>
+122
View File
@@ -0,0 +1,122 @@
<?php
// 订单详情(仅本人可见)
require __DIR__ . '/includes/init.php';
requireLogin('order_detail.php?id=' . ($_GET['id'] ?? ''));
$pageTitle = '订单详情';
$user = currentUser();
$id = (int) ($_GET['id'] ?? 0);
$stmt = db()->prepare('SELECT o.*, u.username AS u_name, u.email AS u_email FROM ' . tn('orders') . ' o LEFT JOIN ' . tn('users') . ' u ON u.id = o.user_id WHERE o.id = ? AND o.user_id = ?');
$stmt->execute([$id, $user['id']]);
$order = $stmt->fetch();
if (!$order) {
http_response_code(404);
require 'includes/header.php';
echo '<section class="section"><div class="auth-card" style="text-align:center">'
. '<p class="form-err">订单不存在,或不属于当前账号。</p>'
. '<a href="myorders.php" class="btn btn-primary">返回我的订单</a></div></section>';
require 'includes/footer.php';
exit;
}
$itStmt = db()->prepare('SELECT * FROM ' . tn('order_items') . ' WHERE order_id = ?');
$itStmt->execute([$order['id']]);
$items = $itStmt->fetchAll();
$totalQty = array_sum(array_column($items, 'qty'));
// 发货信息(管理员发货后才有)
$dlvStmt = db()->prepare('SELECT * FROM ' . tn('order_deliveries') . ' WHERE order_id = ? ORDER BY created_at DESC LIMIT 1');
$dlvStmt->execute([$order['id']]);
$delivery = $dlvStmt->fetch();
// 是否可发起续期(周期商品且已发货/已完成)
$canRenew = ($order['period_days'] > 0) && in_array($order['status'], ['shipped', 'completed'], true);
require 'includes/header.php';
?>
<section class="section">
<a href="myorders.php" class="back-link"><i class="fas fa-arrow-left"></i> 返回我的订单</a>
<h2 class="sec-title"><i class="fas fa-receipt"></i> 订单详情</h2>
<div class="order-detail">
<div class="od-head">
<div>
<div class="od-no">订单号:<?= h($order['order_no']) ?></div>
<div class="od-time"><?= date('Y-m-d H:i', strtotime($order['created_at'])) ?></div>
</div>
<span class="badge-status status-<?= h($order['status']) ?>"><?= orderStatusLabel($order['status']) ?></span>
</div>
<div class="od-items">
<div class="od-th"><span>商品</span><span>单价</span><span>数量</span><span>小计</span></div>
<?php foreach ($items as $it): ?>
<div class="od-row">
<span class="od-name"><?= h($it['name']) ?><?= !empty($it['period_days']) ? ' <em class="od-period">· ' . periodLabel($it['period_days']) . '</em>' : '' ?></span>
<span><?= money($it['price']) ?></span>
<span>×<?= (int) $it['qty'] ?></span>
<span class="od-sub"><?= money($it['subtotal']) ?></span>
</div>
<?php endforeach; ?>
</div>
<div class="od-info">
<div class="od-block">
<div class="od-label">订单发起人</div>
<div class="od-line"><?= h($order['u_name'] ?? '未知') ?></div>
<div class="od-line">账号邮箱:<?= h($order['u_email'] ?? '未填写') ?></div>
</div>
<div class="od-block">
<div class="od-label">联系信息</div>
<div class="od-line">联系人:<?= $order['contact'] !== '' ? h($order['contact']) : '—' ?></div>
<div class="od-line">联系邮箱:<?= $order['contact_email'] !== '' ? h($order['contact_email']) : '未填写' ?></div>
<div class="od-line">收货地址:<?= $order['address'] !== '' ? h($order['address']) : '(未填写)' ?></div>
</div>
<div class="od-block">
<div class="od-label">周期 / 到期</div>
<div class="od-line">周期:<?= periodLabel($order['period_days']) ?></div>
<div class="od-line">到期时间:<?= expiryText($order['expires_at']) ?></div>
</div>
<div class="od-block">
<div class="od-label">备注</div>
<div class="od-line"><?= $order['note'] !== '' ? nl2br(h($order['note'])) : '(无)' ?></div>
</div>
<div class="od-block">
<div class="od-label">支付方式</div>
<div class="od-line"><?= payTypeLabel($order['pay_type']) ?><?= $order['pay_type']==='points' ? '(消耗 ' . (int)$order['points_used'] . ' 积分)' : '' ?></div>
</div>
</div>
<?php if ($delivery): ?>
<div class="od-block od-delivery">
<div class="od-label"><i class="fas fa-truck"></i> 发货信息(连接与登录凭据 / 商品信息)</div>
<div class="od-line">激活状态:<span class="badge-<?= !empty($delivery['activated']) ? 'on' : 'off' ?>"><?= !empty($delivery['activated']) ? '已激活' : '未激活' ?></span></div>
<div class="od-line">服务器 IP<?= h($delivery['server_ip']) ?: '—' ?></div>
<div class="od-line">连接地址:<?= h($delivery['conn_addr']) ?: '—' ?></div>
<div class="od-line">登录账户:<?= h($delivery['login_user']) ?: '—' ?></div>
<div class="od-line">
登录密码:
<span class="secret" data-secret="<?= h($delivery['login_pass']) ?>"><?= $delivery['login_pass'] !== '' ? str_repeat('●', max(6, mb_strlen($delivery['login_pass']))) : '—' ?></span>
<?php if ($delivery['login_pass'] !== ''): ?><button type="button" class="mini-btn reveal-btn" data-reveal>显示</button><?php endif; ?>
</div>
<?php if ($delivery['product_info'] !== ''): ?>
<div class="od-line od-info-block">商品信息:<?= nl2br(h($delivery['product_info'])) ?></div>
<?php endif; ?>
<?php if ($delivery['remark'] !== ''): ?>
<div class="od-line">备注:<?= nl2br(h($delivery['remark'])) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="od-foot">
<span class="muted">共 <?= (int) $totalQty ?> 件</span>
<span class="od-total">合计:<strong><?= money($order['total']) ?></strong></span>
</div>
<?php if ($canRenew): ?>
<div class="od-renew">
<a href="ticket.php?order_id=<?= (int) $order['id'] ?>" class="btn btn-ghost"><i class="fas fa-rotate"></i> 申请续期</a>
<span class="muted">本订单为周期商品,可在到期后通过工单申请续期。</span>
</div>
<?php endif; ?>
</div>
</section>
<?php require 'includes/footer.php'; ?>
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* 支付异步通知回调(支付宝 / 微信支付)
*
* 支付宝 POST 到此页面
* 微信支付 POST 到此页面
*
* 路由方式:通过 ?gateway=alipay 或 ?gateway=wechat 区分
*/
require __DIR__ . '/includes/init.php';
// 关闭输出缓冲,确保原始响应
ob_clean();
$gateway = trim($_GET['gateway'] ?? $_POST['gateway'] ?? '');
if (empty($gateway)) {
http_response_code(400);
exit('missing gateway param');
}
Payment::initGateways();
$gw = Payment::get($gateway);
if (!$gw) {
http_response_code(404);
exit('unknown gateway');
}
Logger::info('收到支付通知', ['gateway' => $gateway, 'raw_input' => file_get_contents('php://input')]);
// 收集所有参数
$params = array_merge($_GET, $_POST);
unset($params['gateway']);
if (!$gw->verifyNotify($params)) {
Logger::warn('支付通知验签失败', ['gateway' => $gateway]);
http_response_code(403);
exit('signature mismatch');
}
$tradeNo = $gw->getTradeNo($params);
$isPaid = $gw->isPaid($params);
// 查找对应的支付记录
$outTradeNo = $params['out_trade_no'] ?? '';
$stmt = db()->prepare('SELECT id,order_id,status FROM ' . tn('pay_logs') . ' WHERE order_id=? AND pay_method=? ORDER BY id DESC LIMIT 1');
$stmt->execute([$outTradeNo, $gateway]);
$payLog = $stmt->fetch();
if (!$payLog) {
Logger::error('支付通知:未找到支付记录', ['out_trade_no' => $outTradeNo]);
// 即使找不到记录也返回成功,避免重复通知
$gw->respondSuccess();
exit;
}
if ($payLog['status'] === 'paid') {
// 已处理过,直接返回成功
$gw->respondSuccess();
exit;
}
if ($isPaid) {
// 支付成功 → 更新订单状态
db()->beginTransaction();
try {
Payment::updatePayStatus($payLog['id'], 'paid', $tradeNo);
// 更新订单状态为已付款
db()->prepare('UPDATE ' . tn('orders') . ' SET status=\'paid\', paid_at=NOW() WHERE id=?')
->execute([$payLog['order_id']]);
db()->commit();
Logger::info('支付成功', ['order_id' => $payLog['order_id'], 'trade_no' => $tradeNo, 'gateway' => $gateway]);
// TODO: 发送支付成功通知邮件
} catch (Exception $e) {
db()->rollBack();
Logger::error('支付成功后更新订单失败', ['error' => $e->getMessage()]);
}
} else {
Logger::warn('支付未成功', ['trade_status' => $params['trade_status'] ?? '']);
}
$gw->respondSuccess();
exit;
+43
View File
@@ -0,0 +1,43 @@
<?php
// 诊断脚本:打印当前商城实际连接的数据库 + 指定用户真实积分
// 用完请删除本文件。
if (!defined('IN_APP')) { define('IN_APP', true); }
require __DIR__ . '/includes/config.php';
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/functions.php';
header('Content-Type: text/plain; charset=utf-8');
echo "=== 当前商城实际连接的数据库 ===\n";
echo "DB_HOST = " . DB_HOST . "\n";
echo "DB_NAME = " . DB_NAME . "\n";
echo "DB_PREFIX= " . DB_PREFIX . "\n\n";
$target = $_GET['u'] ?? 'freenw';
echo "=== 查询用户「{$target}」的真实积分(来自数据库)===\n";
$stmt = db()->prepare('SELECT id, username, is_admin, points FROM ' . tn('users') . ' WHERE username = ?');
$stmt->execute([$target]);
$rows = $stmt->fetchAll();
if (empty($rows)) {
echo "未找到用户「{$target}\n";
} else {
foreach ($rows as $r) {
echo "id=" . (int)$r['id']
. " username=" . $r['username']
. " is_admin=" . (int)$r['is_admin']
. " points=" . (int)$r['points'] . "\n";
}
// 用和前台完全相同的方式再取一次,验证 getCurrentUser 路径
$u = currentUser();
echo "\n=== 当前登录会话(currentUser===\n";
if ($u) {
echo "id=" . (int)$u['id'] . " username=" . $u['username'] . " points(对象)=" . (int)$u['points']
. " points(getUserPoints)=" . getUserPoints($u['id']) . "\n";
} else {
echo "(未登录)\n";
}
}
echo "\n提示:如果这里显示的 points 就是 900,说明数据库没错,问题在页面缓存/没上传最新代码;\n";
echo "如果这里也是 130,说明后台加的那笔积分写到了别的数据库——检查云服务器上商城自己的 config.php 的 DB_HOST。\n";
+143
View File
@@ -0,0 +1,143 @@
<?php
// 商品详情 + 加入购物车
require __DIR__ . '/includes/init.php';
$pageTitle = __('product.detail');
$id = (int) ($_GET['id'] ?? 0);
$stmt = db()->prepare('SELECT * FROM ' . tn('products') . ' WHERE id = ? AND status = 1');
$stmt->execute([$id]);
$product = $stmt->fetch();
if (!$product) {
header('Location: index.php');
exit;
}
// 当前登录用户是否已购买该商品,并取最新发货 / 商品信息
$hasBought = false;
$purchase = null;
if ($user = currentUser()) {
$bstmt = db()->prepare(
'SELECT 1 FROM ' . tn('orders') . ' o
INNER JOIN ' . tn('order_items') . ' oi ON oi.order_id = o.id
WHERE o.user_id = ? AND oi.product_id = ? AND o.status IN ("paid","shipped","completed") LIMIT 1'
);
$bstmt->execute([$user['id'], $id]);
$hasBought = (bool) $bstmt->fetchColumn();
$pstmt = db()->prepare(
'SELECT d.* FROM ' . tn('order_deliveries') . ' d
INNER JOIN ' . tn('orders') . ' o ON o.id = d.order_id
INNER JOIN ' . tn('order_items') . ' oi ON oi.order_id = o.id
WHERE o.user_id = ? AND oi.product_id = ? AND o.status IN ("paid","shipped","completed")
ORDER BY d.id DESC LIMIT 1'
);
$pstmt->execute([$user['id'], $id]);
$purchase = $pstmt->fetch();
}
$err = $ok = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add'])) {
verifyCsrf();
$qty = max(1, (int) ($_POST['qty'] ?? 1));
if ($product['stock'] <= 0) {
$err = __('product_page.err_out_of_stock');
} elseif ($qty > $product['stock']) {
$err = str_replace('{max}', $product['stock'], __('product_page.err_low_stock'));
} else {
if (!isset($_SESSION['cart'])) $_SESSION['cart'] = [];
$cur = (int) ($_SESSION['cart'][$id] ?? 0);
$_SESSION['cart'][$id] = $cur + $qty;
header('Location: cart.php');
exit;
}
}
require 'includes/header.php';
?>
<section class="section">
<a href="index.php" class="back-link"><i class="fas fa-arrow-left"></i> <?= __('product_page.back_list') ?></a>
<div class="product-detail">
<div class="pd-media">
<?php if (!empty($product['image'])): ?>
<img src="<?= h($product['image']) ?>" alt="<?= h($product['name']) ?>">
<?php else: ?>
<i class="fas <?= h($product['icon']) ?>"></i>
<?php endif; ?>
</div>
<div class="pd-info">
<span class="pc-cat"><?= h($product['category']) ?></span>
<h1 class="pd-name"><?= h($product['name']) ?></h1>
<?php if (!empty($product['points_price'])): ?>
<div class="pd-price"><?= __('product_page.points_price_label') ?><strong><?= (int) $product['points_price'] ?></strong><?= __('product_page.points_suffix') ?></div>
<?php else: ?>
<div class="pd-price"><?= __('product_page.cash_price_label') ?></div>
<?php endif; ?>
<div class="pd-stock"><?= __('product_page.stock_label') ?><?= (int) $product['stock'] ?><?= __('product_page.stock_unit') ?></div>
<?php if (!empty($product['period_days'])): ?>
<div class="pd-period"><i class="fas fa-clock"></i> <?= __('product_page.period_label') ?><?= periodLabel($product['period_days']) ?></div>
<?php endif; ?>
<p class="pd-desc"><?= nl2br(h($product['description'] ?? '')) ?></p>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<?php if ($product['stock'] > 0): ?>
<form method="post" action="" class="pd-buy">
<?= csrfField() ?>
<label><?= __('product_page.qty_label') ?>
<input type="number" name="qty" value="1" min="1" max="<?= (int)$product['stock'] ?>" class="qty-input">
</label>
<button type="submit" name="add" class="btn btn-primary"><i class="fas fa-cart-plus"></i> <?= __('product_page.add_cart_btn') ?></button>
</form>
<?php else: ?>
<p class="sold-out"><?= __('product_page.sold_out') ?></p>
<?php endif; ?>
</div>
</div>
</section>
<?php if ($hasBought): ?>
<section class="section">
<div class="pd-purchased">
<div class="pp-head">
<i class="fas fa-check-circle"></i>
<span class="pp-title"><?= __('product_page.purchased_title') ?></span>
<button type="button" class="btn btn-ghost btn-sm" id="ppToggle"><?= __('product_page.purchased_toggle') ?></button>
</div>
<div class="pp-detail" id="ppDetail" style="display:none;">
<?php if ($purchase): ?>
<div class="od-line"><?= __('status') ?><span class="badge-<?= !empty($purchase['activated']) ? 'on' : 'off' ?>"><?= !empty($purchase['activated']) ? __('product_page.purchased_activated') : __('product_page.purchased_not_activated') ?></span></div>
<?php if ($purchase['product_info'] !== ''): ?>
<div class="od-line od-info-block"><?= __('product_page.purchased_product_info') ?><?= nl2br(h($purchase['product_info'])) ?></div>
<?php endif; ?>
<?php if ($purchase['server_ip'] !== '' || $purchase['conn_addr'] !== '' || $purchase['login_user'] !== '' || $purchase['login_pass'] !== ''): ?>
<div class="od-line"><?= __('product_page.server_ip') ?><?= h($purchase['server_ip']) ?: __('product_page.none_value') ?></div>
<div class="od-line"><?= __('product_page.conn_addr') ?><?= h($purchase['conn_addr']) ?: __('product_page.none_value') ?></div>
<div class="od-line"><?= __('product_page.login_user') ?><?= h($purchase['login_user']) ?: __('product_page.none_value') ?></div>
<div class="od-line"><?= __('product_page.login_pass') ?>
<span class="secret" data-secret="<?= h($purchase['login_pass']) ?>"><?= $purchase['login_pass'] !== '' ? str_repeat('●', max(6, mb_strlen($purchase['login_pass']))) : __('product_page.none_value') ?></span>
<?php if ($purchase['login_pass'] !== ''): ?><button type="button" class="mini-btn reveal-btn"><?= __('product_page.reveal_btn') ?></button><?php endif; ?>
</div>
<?php endif; ?>
<?php if ($purchase['remark'] !== ''): ?>
<div class="od-line"><?= __('product_page.remark_label') ?><?= nl2br(h($purchase['remark'])) ?></div>
<?php endif; ?>
<?php else: ?>
<p class="muted"><?= __('product_page.pending_delivery') ?></p>
<?php endif; ?>
</div>
</div>
</section>
<script>
(function () {
var btn = document.getElementById('ppToggle');
var box = document.getElementById('ppDetail');
if (!btn || !box) return;
btn.addEventListener('click', function () {
var open = box.style.display === 'none';
box.style.display = open ? 'block' : 'none';
btn.textContent = open ? '<?= __('product_page.purchased_toggle_close') ?>' : '<?= __('product_page.purchased_toggle') ?>';
});
})();
</script>
<?php endif; ?>
<?php require 'includes/footer.php'; ?>
+166
View File
@@ -0,0 +1,166 @@
<?php
// 用户设置:修改用户名 / 邮箱 / 昵称
require __DIR__ . '/includes/init.php';
requireLogin('settings.php');
$pageTitle = __('settings_page.title');
$stmt = db()->prepare('SELECT id, username, email, nickname, verified, is_admin, status, avatar FROM ' . tn('users') . ' WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
$msg = '';
$msgOk = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf();
$u = trim($_POST['username'] ?? '');
$em = trim($_POST['email'] ?? '');
$nk = trim($_POST['nickname'] ?? '');
if (mb_strlen($u) < 3) $msg = __('settings_page.err_username_short');
elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $msg = __('settings_page.err_email_invalid');
elseif ($nk !== '' && mb_strlen($nk) > 30) $msg = __('settings_page.err_nickname_long');
if ($msg === '') {
$chk = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ? AND id <> ?');
$chk->execute([$u, $user['id']]);
if ($chk->fetch()) {
$msg = __('settings_page.err_username_taken');
} else {
$emailChanged = ($em !== $user['email']);
db()->prepare('UPDATE ' . tn('users') . ' SET username = ?, email = ?, nickname = ? WHERE id = ?')
->execute([$u, $em, $nk, $user['id']]);
// 更换邮箱则取消验证状态,要求重新验证
if ($emailChanged && !empty($user['verified'])) {
db()->prepare('UPDATE ' . tn('users') . ' SET verified = 0 WHERE id = ?')
->execute([$user['id']]);
}
// 重新读取最新资料
$stmt->execute([$user['id']]);
$user = $stmt->fetch();
$msg = $emailChanged ? __('settings_page.saved_email_change') : __('settings_page.saved');
$msgOk = true;
}
}
}
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-cog"></i> <?= __('settings_page.title') ?></h2>
<?php if ($msg): ?>
<p class="<?= $msgOk ? 'banner-ok' : 'form-err' ?>"><?= h($msg) ?></p>
<?php endif; ?>
<form method="post" action="" class="grid-form settings-form">
<?= csrfField() ?>
<!-- 头像上传 -->
<div class="span2" style="margin-bottom:18px">
<h3 style="color:var(--primary);font-size:1.05rem;margin-bottom:12px"><i class="fas fa-camera"></i> <?= __('settings_page.avatar_section') ?></h3>
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<div id="avatarPreviewWrap" style="width:80px;height:80px;border-radius:50%;overflow:hidden;background:var(--primary-container);display:flex;align-items:center;justify-content:center;border:3px solid var(--outline);flex-shrink:0">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= h($user['avatar']) ?>" alt="" style="width:100%;height:100%;object-fit:cover">
<?php else: ?>
<span style="font-size:1.8rem;font-weight:700;color:var(--on-primary-container)"><?= h(avatarText($user['nickname'] ?: $user['username'])) ?></span>
<?php endif; ?>
</div>
<div>
<label for="avatarInput" class="btn btn-sm btn-ghost" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<i class="fas fa-upload"></i> <?= __('settings_page.select_image') ?>
</label>
<input type="file" id="avatarInput" accept="image/jpeg,image/png,image/webp,image/gif" style="display:none">
<p style="font-size:.8rem;color:var(--muted);margin-top:4px"><?= __('settings_page.avatar_hint') ?></p>
</div>
</div>
<p id="avatarMsg" style="font-size:.88rem;margin-top:6px"></p>
</div>
<label><?= __('auth.username') ?><input type="text" name="username" value="<?= h($user['username']) ?>" required></label>
<label><?= __('auth.email') ?><input type="email" name="email" value="<?= h($user['email']) ?>" required></label>
<label class="span2"><?= __('user.nickname') ?><input type="text" name="nickname" value="<?= h($user['nickname']) ?>" placeholder="<?= __('settings_page.nickname_placeholder') ?>" maxlength="30"></label>
<div class="form-actions span2">
<button type="submit" class="btn btn-primary"><?= __('settings_page.save_btn') ?></button>
<a href="myorders.php" class="btn btn-ghost"><?= __('settings_page.back_orders') ?></a>
</div>
</form>
<div class="panel verify-panel">
<h3><i class="fas fa-envelope-circle-check"></i> <?= __('settings_page.verify_section') ?></h3>
<?php if (!empty($user['verified'])): ?>
<p class="ok-line"><i class="fas fa-circle-check"></i> <?= __('settings_page.verify_done') ?></p>
<?php else: ?>
<p class="warn-line"><i class="fas fa-triangle-exclamation"></i> <?= str_replace('{link}', '<a href="verify.php?resend=1">' . __('settings_page.verify_send_link') . '</a>', __('settings_page.verify_not_done')) ?></p>
<?php endif; ?>
</div>
</section>
<script>
// 头像上传(AJAX
(function () {
var input = document.getElementById('avatarInput');
var msg = document.getElementById('avatarMsg');
var wrap = document.getElementById('avatarPreviewWrap');
if (!input) return;
input.addEventListener('change', function () {
if (!input.files || !input.files[0]) return;
var file = input.files[0];
// 前端预校验
if (file.size > 2 * 1024 * 1024) {
msg.textContent = '<?= __('settings_page.img_too_big') ?>';
msg.style.color = 'var(--danger)';
return;
}
// 即时预览
var reader = new FileReader();
reader.onload = function (e) {
wrap.innerHTML = '<img src="' + e.target.result + '" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">';
};
reader.readAsDataURL(file);
// 上传
msg.textContent = '<?= __('settings_page.uploading') ?>';
msg.style.color = 'var(--muted)';
var fd = new FormData();
fd.append('avatar', file);
fd.append('csrf_token', Fnw.csrf());
var xhr = new XMLHttpRequest();
xhr.open('POST', 'avatar_upload.php', true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onload = function () {
if (xhr.status === 200) {
try { var res = JSON.parse(xhr.responseText); } catch (e) { res = { ok: false, msg: '<?= __('settings_page.response_err') ?>' }; }
if (res.ok) {
msg.textContent = res.msg || '<?= __('settings_page.upload_ok') ?>';
msg.style.color = 'var(--ok)';
updateAvatars(res.data.url);
} else {
msg.textContent = res.msg || '<?= __('settings_page.upload_fail') ?>';
msg.style.color = 'var(--danger)';
}
} else {
msg.textContent = '<?= str_replace('{code}', "' + xhr.status + '", __('settings_page.network_err')) ?>';
msg.style.color = 'var(--danger)';
}
};
xhr.onerror = function () {
msg.textContent = '<?= __('settings_page.network_retry') ?>';
msg.style.color = 'var(--danger)';
};
xhr.send(fd);
});
function updateAvatars(url) {
document.querySelectorAll('.avatar, .uc-avatar').forEach(function (el) {
el.innerHTML = '<img src="' + url + '" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">';
});
}
})();
</script>
<?php require 'includes/footer.php'; ?>
+78
View File
@@ -0,0 +1,78 @@
<?php
// 每日签到:登录用户领取积分
require __DIR__ . '/includes/init.php';
requireLogin('sign.php');
$pageTitle = '每日签到';
$user = currentUser();
$enabled = getSetting('points_enabled', '1') === '1';
$signPoints = max(0, (int) getSetting('points_sign', '5'));
$points = getUserPoints($user['id']);
$signed = hasSignedToday($user['id']);
$msg = '';
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['dosign'])) {
verifyCsrf();
if (!$enabled) {
$err = '积分系统已关闭';
} elseif ($signed) {
$err = '今天已经签到过了,明天再来吧~';
} else {
$pdo = db();
try {
$pdo->beginTransaction();
// 写入签到记录(uk_user_date 唯一,重复签到会抛异常)
$pdo->prepare('INSERT INTO ' . tn('sign_logs') . ' (user_id, sign_date, points, created_at) VALUES (?, CURDATE(), ?, NOW())')
->execute([$user['id'], $signPoints]);
// 同事务内加积分并写流水(避免嵌套事务)
$pdo->prepare('UPDATE ' . tn('users') . ' SET points = points + ? WHERE id = ?')
->execute([$signPoints, $user['id']]);
$bal = getUserPoints($user['id']);
$pdo->prepare('INSERT INTO ' . tn('points_log') . ' (user_id, type, amount, balance, remark, created_at) VALUES (?, ?, ?, ?, ?, NOW())')
->execute([$user['id'], 'sign', $signPoints, $bal, '每日签到']);
$pdo->commit();
$points = getUserPoints($user['id']);
$signed = true;
$msg = '签到成功,获得 ' . $signPoints . ' 积分!';
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$err = '签到失败:' . $e->getMessage();
}
}
}
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-calendar-check"></i> 每日签到</h2>
<div class="sign-card">
<div class="sign-points">
<div class="sp-num"><?= (int) $points ?></div>
<div class="sp-label">我的积分</div>
</div>
<div class="sign-tip">
<?php if (!$enabled): ?>
<p class="muted">积分系统当前已关闭,暂不可签到。</p>
<?php elseif ($signed): ?>
<p class="banner-ok"><i class="fas fa-check-circle"></i> 今天已签到,明日可再得 <?= $signPoints ?> 积分。</p>
<?php else: ?>
<p>每日签到可领取 <strong><?= $signPoints ?></strong> 积分,积分可用于兑换设置了「积分价」的商品。</p>
<?php endif; ?>
</div>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<?php if ($msg): ?><p class="banner-ok"><i class="fas fa-check-circle"></i> <?= h($msg) ?></p><?php endif; ?>
<?php if ($enabled && !$signed): ?>
<form method="post" action="" class="sign-form">
<?= csrfField() ?>
<button type="submit" name="dosign" class="btn btn-primary"><i class="fas fa-calendar-check"></i> 立即签到(+<?= $signPoints ?> 积分)</button>
</form>
<?php endif; ?>
</div>
<p class="muted" style="margin-top:16px;">
<i class="fas fa-gift"></i> 想赚更多积分?<a href="invite.php">邀请好友</a> 注册成功可得 <?= (int) getSetting('points_invite', '20') ?> 积分。
</p>
</section>
<?php require 'includes/footer.php'; ?>
+147
View File
@@ -0,0 +1,147 @@
<?php
// 前台提交工单(须登录)。支持普通工单与「续期申请」(关联周期订单)。
require __DIR__ . '/includes/init.php';
requireLogin('ticket.php');
$user = currentUser();
$pageTitle = __('ticket_page.title');
$depts = db()->query('SELECT * FROM ' . tn('ticket_departments') . ' ORDER BY sort, id')->fetchAll();
if (empty($depts)) {
$depts = [['id' => 0, 'name' => __('ticket.meta_unspecified'), 'description' => '']];
}
// 当前用户可续期的订单(周期商品 + 已发货/已完成)
$renewOrders = db()->prepare(
'SELECT o.id, o.order_no, o.period_days, o.expires_at FROM ' . tn('orders') . ' o
WHERE o.user_id = ? AND o.period_days > 0 AND o.status IN ("shipped","completed") ORDER BY o.created_at DESC'
);
$renewOrders->execute([$user['id']]);
$renewOrders = $renewOrders->fetchAll();
$renewOrderId = (int) ($_GET['order_id'] ?? 0);
$msg = '';
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verifyCsrf();
$dept = (int) ($_POST['dept_id'] ?? 0);
$subject = trim($_POST['subject'] ?? '');
$message = trim($_POST['message'] ?? '');
$priority = (int) ($_POST['priority'] ?? 2);
$type = in_array($_POST['type'] ?? 'general', ['general', 'renewal'], true) ? $_POST['type'] : 'general';
$orderId = (int) ($_POST['order_id'] ?? 0);
if ($type === 'renewal') {
// 校验关联订单确实属于本人且可续期
$okOrder = false;
foreach ($renewOrders as $ro) {
if ((int) $ro['id'] === $orderId) { $okOrder = true; break; }
}
if (!$okOrder) {
$err = __('ticket_page.err_order_invalid');
} elseif ($subject === '') {
$subject = __('ticket_page.subject_renewal_prefix') . $orderId;
}
}
if ($subject === '') $err = __('ticket_page.err_subject_empty');
elseif (strlen($subject) > 160) $err = __('ticket_page.err_subject_long');
elseif ($message === '') $err = __('ticket_page.err_message_empty');
elseif (!in_array($priority, [1, 2, 3], true)) $err = __('ticket_page.err_priority_bad');
if ($err === '') {
db()->prepare(
'INSERT INTO ' . tn('tickets') .
' (user_id, dept_id, order_id, type, subject, message, priority, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,NOW(),NOW())'
)->execute([$user['id'], $dept, $type === 'renewal' ? $orderId : null, $type, $subject, $message, $priority, 'open']);
$tid = db()->lastInsertId();
// 通过 SMTP 通知处理人员(失败不影响工单入库)
$deptNameMap = [];
foreach ($depts as $d) { $deptNameMap[$d['id']] = $d['name']; }
$deptName = $deptNameMap[$dept] ?? __('ticket.meta_unspecified');
$cfg = smtpConfig();
if (!empty($cfg['notify_emails'])) {
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . dirname($_SERVER['SCRIPT_NAME']);
$url = rtrim($base, '/') . '/admin/tickets.php?view=' . $tid;
$typeLabel = ticketTypeLabel($type);
$orderLine = $type === 'renewal'
? '<p><strong>类型:</strong>续期申请(关联订单 #' . $orderId . '</p>'
: '<p><strong>类型:strong>普通工单</p>';
$body = '<div style="font-family:sans-serif;max-width:560px;margin:auto">'
. '<h2 style="color:#1a73e8">[' . h(SITE_NAME) . '] 收到新工单 #' . $tid . '</h2>'
. '<p><strong>提交人:</strong>' . h($user['username']) . '' . h($user['email']) . '</p>'
. '<p><strong>部门:</strong>' . h($deptName) . '</p>'
. '<p><strong>优先级:</strong>' . ticketPriorityLabel($priority) . '</p>'
. $orderLine
. '<p><strong>标题:</strong>' . h($subject) . '</p>'
. '<div style="background:#f5f7fb;border-left:4px solid #1a73e8;padding:12px 16px;margin:10px 0;white-space:pre-wrap">' . h($message) . '</div>'
. '<p><a href="' . $url . '" style="background:#1a73e8;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none;display:inline-block">前往后台处理</a></p>'
. '</div>';
fnwSendMail($cfg['notify_emails'], '[' . SITE_NAME . '] 新工单 #' . $tid . ' ' . $subject, $body);
}
redirect('mytickets.php?ok=1');
}
}
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-ticket-alt"></i> <?= __('ticket_page.title') ?></h2>
<p class="muted" style="margin-bottom:18px;"><?= __('ticket_page.desc_hint') ?></p>
<?php if ($err): ?><p class="form-err"><?= h($err) ?></p><?php endif; ?>
<div class="panel" style="max-width:680px;">
<form method="post" action="" class="grid-form">
<?= csrfField() ?>
<label><?= __('ticket.type_label', ['type' => ''])
?><select name="type" id="ticketType">
<option value="general" <?= $renewOrderId ? '' : 'selected' ?>><?= __('ticket_page.type_general') ?></option>
<?php if (!empty($renewOrders)): ?>
<option value="renewal" <?= $renewOrderId ? 'selected' : '' ?>><?= __('ticket_page.type_renewal') ?></option>
<?php endif; ?>
</select>
</label>
<label><?= __('ticket_page.select_dept') ?>
<select name="dept_id">
<?php foreach ($depts as $d): ?>
<option value="<?= (int) $d['id'] ?>"><?= h($d['name']) ?><?= $d['description'] ? '' . h($d['description']) . '' : '' ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if (!empty($renewOrders)): ?>
<label class="span2"><?= __('ticket_page.related_order_label') ?>
<select name="order_id" id="ticketOrder">
<option value="0"><?= __('ticket_page.order_no_link') ?></option>
<?php foreach ($renewOrders as $ro): ?>
<option value="<?= (int) $ro['id'] ?>" <?= $renewOrderId === (int) $ro['id'] ? 'selected' : '' ?>>
#<?= (int) $ro['id'] ?> · <?= h($ro['order_no']) ?> · <?= __('ticket_page.renewal_period') ?> <?= periodLabel($ro['period_days']) ?> · <?= __('ticket_page.renewal_expire') ?> <?= expiryText($ro['expires_at']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<label class="span2"><?= __('ticket.subject') ?> *<input type="text" name="subject" id="ticketSubject" maxlength="160" value="<?= $renewOrderId ? __('ticket_page.subject_renewal_prefix') . $renewOrderId : '' ?>" placeholder="<?= __('ticket_page.subject_placeholder') ?>" required></label>
<label class="span2"><?= __('ticket.content') ?> *<textarea name="message" rows="6" placeholder="<?= __('ticket_page.message_placeholder') ?>" required></textarea></label>
<label><?= __('ticket.priority') ?>
<select name="priority">
<option value="1"><?= __('ticket.low') ?></option>
<option value="2" selected><?= __('ticket.normal') ?></option>
<option value="3"><?= __('ticket.high') ?></option>
</select>
</label>
<label><?= __('ticket_page.contact_email') ?>
<input type="text" value="<?= h($user['email']) ?>" readonly>
</label>
<div class="form-actions span2">
<button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> <?= __('ticket_page.submit_btn') ?></button>
<a href="mytickets.php" class="btn btn-ghost"><?= __('ticket_page.view_my_tickets') ?></a>
</div>
</form>
</div>
</section>
<?php require 'includes/footer.php'; ?>
+76
View File
@@ -0,0 +1,76 @@
<?php
// 邮箱验证 / 重发验证邮件
require __DIR__ . '/includes/init.php';
$pageTitle = '邮箱验证';
$msg = '';
$ok = false;
// 重发验证邮件(需登录)
if (isset($_GET['resend'])) {
requireLogin('verify.php?resend=1');
$user = currentUser();
if (!empty($user['verified'])) {
$msg = '你的邮箱已完成验证,无需重复操作。';
$ok = true;
} else {
$res = sendVerifyMail($user['id']);
$msg = $res['ok']
? '验证邮件已重新发送至 ' . h($user['email']) . ',请查收并点击其中的链接。'
: '发送失败:' . h($res['error']);
$ok = $res['ok'];
}
} elseif (isset($_GET['token'])) {
// 点击邮件链接完成验证
$token = trim($_GET['token']);
if ($token === '') {
$msg = '验证链接无效。';
} else {
$stmt = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE email_token = ?');
$stmt->execute([$token]);
$row = $stmt->fetch();
if (!$row) {
// 可能是重复点击:若当前登录用户已完成验证,则提示成功而非报错
$uid = $_SESSION['user_id'] ?? 0;
if ($uid > 0) {
$chk = db()->prepare('SELECT verified FROM ' . tn('users') . ' WHERE id = ?');
$chk->execute([$uid]);
$verified = (int) $chk->fetchColumn();
if ($verified === 1) {
$msg = '你的邮箱已经完成验证,无需重复操作。';
$ok = true;
} else {
$msg = '验证链接无效或已使用,请重新获取。';
}
} else {
$msg = '验证链接无效或已使用,请重新获取。';
}
} else {
db()->prepare('UPDATE ' . tn('users') . ' SET verified = 1, email_token = ? WHERE id = ?')
->execute(['', $row['id']]);
$msg = '邮箱验证成功!你也可以在「用户设置」中完善昵称与资料。';
$ok = true;
}
}
} else {
$msg = '链接不完整,请使用邮件中的完整验证链接。';
}
require 'includes/header.php';
?>
<section class="section auth-section">
<div class="auth-card verify-card">
<div class="verify-icon <?= $ok ? 'ok' : 'bad' ?>">
<i class="fas <?= $ok ? 'fa-check-circle' : 'fa-exclamation-triangle' ?>"></i>
</div>
<h2 class="verify-title"><?= $ok ? '验证成功' : '验证提示' ?></h2>
<p class="verify-msg"><?= h($msg) ?></p>
<div class="verify-actions">
<a href="index.php" class="btn btn-primary">返回商城首页</a>
<?php if ($ok && !empty($_SESSION['user_id'])): ?>
<a href="settings.php" class="btn btn-ghost">前往用户设置</a>
<?php endif; ?>
</div>
</div>
</section>
<?php require 'includes/footer.php'; ?>