文件
2026-08-16 17:03:10 +08:00

338 行
19 KiB
PHP

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<?php
require __DIR__ . '/includes/init.php';
requireLogin('settings.php');
$pageTitle = '用户设置';
$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;
$activeTab = 'profile';
if (isset($_POST['save_profile'])) {
verifyCsrf();
$activeTab = 'profile';
$u = trim($_POST['username'] ?? '');
$em = trim($_POST['email'] ?? '');
$nk = trim($_POST['nickname'] ?? '');
if (mb_strlen($u) < 3) $msg = '用户名至少 3 个字符';
elseif (!filter_var($em, FILTER_VALIDATE_EMAIL)) $msg = '邮箱格式不正确';
elseif ($nk !== '' && mb_strlen($nk) > 30) $msg = '昵称过长(最多 30 字)';
if ($msg === '') {
$chk = db()->prepare('SELECT id FROM ' . tn('users') . ' WHERE username = ? AND id <> ?');
$chk->execute([$u, $user['id']]);
if ($chk->fetch()) {
$msg = '该用户名已被占用';
} 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 ? '资料已保存,邮箱已变更需重新验证' : '资料已保存';
$msgOk = true;
}
}
}
if (isset($_POST['change_password'])) {
verifyCsrf();
$activeTab = 'password';
$cur = $_POST['current_password'] ?? '';
$pw = $_POST['new_password'] ?? '';
$pw2 = $_POST['new_password2'] ?? '';
if ($cur === '' || $pw === '' || $pw2 === '') {
$msg = '请填写完整密码信息';
} elseif (!password_verify($cur, $user['password'])) {
$msg = '当前密码不正确';
} elseif (strlen($pw) < 6) {
$msg = '新密码至少 6 位';
} elseif ($pw !== $pw2) {
$msg = '两次输入的新密码不一致';
} else {
db()->prepare('UPDATE ' . tn('users') . ' SET password = ? WHERE id = ?')
->execute([password_hash($pw, PASSWORD_DEFAULT), $user['id']]);
$msg = '密码修改成功,请使用新密码登录';
$msgOk = true;
}
}
if (isset($_POST['save_notify'])) {
verifyCsrf();
$activeTab = 'notify';
saveNotifyPrefs($user['id'], [
'email_enabled' => $_POST['email_enabled'] ?? 0,
'order_shipped' => $_POST['order_shipped'] ?? 0,
'expiry_reminder' => $_POST['expiry_reminder'] ?? 0,
'promotions' => $_POST['promotions'] ?? 0,
]);
$msg = '通知偏好已保存';
$msgOk = true;
}
$notify = getNotifyPrefs($user['id']);
$realname = getUserRealname($user['id']);
$realnameVerified = $realname !== null && (int) $realname['status'] === 1;
$vapidPublic = getSetting('pwa_vapid_public', '');
require 'includes/header.php';
?>
<section class="section">
<h2 class="sec-title"><i class="fas fa-gear"></i> 用户设置</h2>
<?php if ($msg): ?>
<p class="<?= $msgOk ? 'banner-ok' : 'form-err' ?>"><?= h($msg) ?></p>
<?php endif; ?>
<div class="settings-tabs">
<button class="stab <?= $activeTab==='profile'?'active':'' ?>" data-tab="profile">资料</button>
<button class="stab <?= $activeTab==='password'?'active':'' ?>" data-tab="password">密码</button>
<button class="stab <?= $activeTab==='realname'?'active':'' ?>" data-tab="realname">实名认证</button>
<button class="stab <?= $activeTab==='notify'?'active':'' ?>" data-tab="notify">通知偏好</button>
<button class="stab <?= $activeTab==='pwa'?'active':'' ?>" data-tab="pwa">桌面通知</button>
</div>
<!-- 资料 -->
<div class="spanel" id="panel-profile" style="<?= $activeTab==='profile'?'':'display:none' ?>">
<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> 头像</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> 选择图片
</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">支持 JPG/PNG/WebP/GIF,≤2MB</p>
</div>
</div>
<p id="avatarMsg" style="font-size:.88rem;margin-top:6px"></p>
</div>
<label>用户名<input type="text" name="username" value="<?= h($user['username']) ?>" required></label>
<label>邮箱<input type="email" name="email" value="<?= h($user['email']) ?>" required></label>
<label class="span2">昵称<input type="text" name="nickname" value="<?= h($user['nickname']) ?>" placeholder="选填,最多 30 字" maxlength="30"></label>
<div class="form-actions span2">
<button type="submit" name="save_profile" class="btn btn-primary"><i class="fas fa-save"></i> 保存资料</button>
<a href="myorders.php" class="btn btn-ghost">我的订单</a>
</div>
</form>
<div class="panel verify-panel" style="margin-top:18px">
<h3><i class="fas fa-envelope-circle-check"></i> 邮箱验证</h3>
<?php if (!empty($user['verified'])): ?>
<p class="ok-line"><i class="fas fa-circle-check"></i> 邮箱已验证</p>
<?php else: ?>
<p class="warn-line"><i class="fas fa-triangle-exclamation"></i> 邮箱未验证,<a href="verify.php?resend=1">点击重发验证邮件</a></p>
<?php endif; ?>
</div>
</div>
<!-- 密码 -->
<div class="spanel" id="panel-password" style="<?= $activeTab==='password'?'':'display:none' ?>">
<form method="post" action="" class="grid-form" style="max-width:420px">
<?= csrfField() ?>
<label class="span2">当前密码<input type="password" name="current_password" autocomplete="current-password" required></label>
<label class="span2">新密码<input type="password" name="new_password" autocomplete="new-password" required></label>
<label class="span2">确认新密码<input type="password" name="new_password2" autocomplete="new-password" required></label>
<div class="form-actions span2">
<button type="submit" name="change_password" class="btn btn-primary"><i class="fas fa-key"></i> 修改密码</button>
<a href="login.php?mode=login" class="btn btn-ghost">重新登录</a>
</div>
</form>
<p class="muted" style="font-size:.85rem;max-width:420px">忘记密码?可在登录页点击「忘记密码」通过邮箱重置。</p>
</div>
<!-- 实名认证 -->
<div class="spanel" id="panel-realname" style="<?= $activeTab==='realname'?'':'display:none' ?>">
<div class="panel">
<h3><i class="fas fa-id-card"></i> 实名认证</h3>
<?php if ($realnameVerified): ?>
<p class="ok-line"><i class="fas fa-circle-check"></i> 你已完成实名认证(信息已加密保管)。</p>
<p class="muted" style="font-size:.85rem">姓名:<?= h(maskName(rnDecrypt($realname['real_name_enc']))) ?> 身份证号:<?= h(maskIdno(rnDecrypt($realname['idno_enc']))) ?></p>
<p style="font-size:.85rem">如需更换实名信息,<a href="realname.php">点此重新提交</a>。</p>
<?php else: ?>
<p class="warn-line"><i class="fas fa-triangle-exclamation"></i> 你尚未完成实名认证。部分商品(标注「需实名」)购买前必须完成实名。</p>
<a href="realname.php" class="btn btn-primary"><i class="fas fa-id-card"></i> 去实名认证</a>
<?php endif; ?>
<p class="muted" style="font-size:.82rem;margin-top:10px">实名信息仅用于核对「姓名 + 身份证号」是否合法,采用 AES 加密存储,严格保管,不会向第三方泄露。</p>
</div>
</div>
<!-- 通知偏好 -->
<div class="spanel" id="panel-notify" style="<?= $activeTab==='notify'?'':'display:none' ?>">
<form method="post" action="" class="notify-form">
<?= csrfField() ?>
<div class="notify-row">
<div><strong>接收邮件通知</strong><small>总开关,关闭后不再收到任何邮件</small></div>
<label class="switch"><input type="checkbox" name="email_enabled" value="1" <?= !empty($notify['email_enabled'])?'checked':'' ?>><span class="slider"></span></label>
</div>
<div class="notify-row">
<div><strong>订单/发货通知</strong><small>下单成功、商品发货等状态变更</small></div>
<label class="switch"><input type="checkbox" name="order_shipped" value="1" <?= !empty($notify['order_shipped'])?'checked':'' ?>><span class="slider"></span></label>
</div>
<div class="notify-row">
<div><strong>到期前提醒</strong><small>周期性商品到期前发送续费提醒</small></div>
<label class="switch"><input type="checkbox" name="expiry_reminder" value="1" <?= !empty($notify['expiry_reminder'])?'checked':'' ?>><span class="slider"></span></label>
</div>
<div class="notify-row">
<div><strong>营销/活动通知</strong><small>新品上架、优惠活动(默认关闭)</small></div>
<label class="switch"><input type="checkbox" name="promotions" value="1" <?= !empty($notify['promotions'])?'checked':'' ?>><span class="slider"></span></label>
</div>
<div class="form-actions">
<button type="submit" name="save_notify" class="btn btn-primary"><i class="fas fa-save"></i> 保存偏好</button>
</div>
</form>
</div>
<!-- 桌面通知 / PWA -->
<div class="spanel" id="panel-pwa" style="<?= $activeTab==='pwa'?'':'display:none' ?>">
<div class="panel">
<h3><i class="fas fa-bell"></i> 桌面通知(PWA</h3>
<p>将本站「安装」到桌面/手机主屏,可像 App 一样打开;开启通知后,订单、到期等重要提醒将推送到你的设备。</p>
<div class="pwa-actions">
<button id="pwaInstallBtn" class="btn btn-primary" style="display:none"><i class="fas fa-download"></i> 安装到桌面</button>
<button id="pwaSubscribeBtn" class="btn btn-ghost"><i class="fas fa-bell"></i> 启用浏览器通知</button>
<button id="pwaUnsubscribeBtn" class="btn btn-ghost" style="display:none"><i class="fas fa-bell-slash"></i> 取消订阅</button>
</div>
<p id="pwaMsg" class="muted" style="font-size:.85rem;margin-top:8px"></p>
<?php if (empty($vapidPublic)): ?>
<p class="warn-line" style="font-size:.82rem"><i class="fas fa-circle-info"></i> 当前未配置推送 VAPID 公钥,仅支持「安装为应用」;如需推送,请在后台「设置」中填入 pwa_vapid_public。</p>
<?php endif; ?>
</div>
</div>
</section>
<script>
(function () {
var btns = document.querySelectorAll('.stab');
btns.forEach(function (b) {
b.addEventListener('click', function () {
var t = b.getAttribute('data-tab');
btns.forEach(function (x) { x.classList.toggle('active', x === b); });
document.querySelectorAll('.spanel').forEach(function (p) {
p.style.display = (p.id === 'panel-' + t) ? '' : 'none';
});
});
});
})();
(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 = '图片过大(≤2MB'; 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 = '上传中…'; 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 () {
try { var res = JSON.parse(xhr.responseText); } catch (e) { res = { ok:false, msg:'响应异常' }; }
if (res.ok) { msg.textContent = res.msg || '上传成功'; msg.style.color = 'var(--ok)'; updateAvatars(res.data.url); }
else { msg.textContent = res.msg || '上传失败'; msg.style.color = 'var(--danger)'; }
};
xhr.onerror = function () { msg.textContent = '网络错误'; 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%">';
});
}
})();
(function () {
var installBtn = document.getElementById('pwaInstallBtn');
var subBtn = document.getElementById('pwaSubscribeBtn');
var unsubBtn = document.getElementById('pwaUnsubscribeBtn');
var pwaMsg = document.getElementById('pwaMsg');
var deferredPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
deferredPrompt = e;
installBtn.style.display = '';
});
if (installBtn) {
installBtn.addEventListener('click', function () {
if (!deferredPrompt) { pwaMsg.textContent = '当前浏览器不支持直接安装,可手动通过「菜单 → 添加到主屏幕」。'; return; }
deferredPrompt.prompt();
deferredPrompt.userChoice.then(function () { deferredPrompt = null; installBtn.style.display = 'none'; });
});
}
function regSw() {
if (!('serviceWorker' in navigator)) return Promise.reject('no-sw');
return navigator.serviceWorker.register('sw.js').catch(function () {});
}
function urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var raw = atob(base64);
var arr = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
function postSub(action, sub) {
return fetch('push_subscribe.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ csrf_token: Fnw.csrf(), action: action, subscription: sub })
}).then(function (r) { return r.json(); });
}
if (subBtn) {
subBtn.addEventListener('click', function () {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
pwaMsg.textContent = '当前浏览器不支持 Web 推送通知。'; return;
}
regSw().then(function () {
return navigator.serviceWorker.ready;
}).then(function (reg) {
return reg.pushManager.getSubscription().then(function (existing) {
if (existing) return existing;
var vapid = '<?= h($vapidPublic) ?>';
if (!vapid) { pwaMsg.textContent = '推送未配置(VAPID 公钥缺失),但仍可将本站安装为应用。'; throw 'no-vapid'; }
return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapid) });
});
}).then(function (sub) {
if (!sub) return;
return postSub('subscribe', sub).then(function (res) {
if (res.ok) {
pwaMsg.textContent = '已启用浏览器通知,重要消息将推送到此设备。';
subBtn.style.display = 'none'; unsubBtn.style.display = '';
} else { pwaMsg.textContent = res.msg || '订阅失败'; }
});
}).catch(function (e) {
if (e === 'no-vapid') return;
pwaMsg.textContent = '订阅失败:' + (typeof e === 'string' ? e : '浏览器拒绝或网络异常');
});
});
}
if (unsubBtn) {
unsubBtn.addEventListener('click', function () {
regSw().then(function () { return navigator.serviceWorker.ready; }).then(function (reg) {
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (!sub) { unsubBtn.style.display = 'none'; subBtn.style.display = ''; return; }
return sub.unsubscribe().then(function () {
return postSub('unsubscribe', sub);
});
}).then(function () {
pwaMsg.textContent = '已取消订阅。';
unsubBtn.style.display = 'none'; subBtn.style.display = '';
}).catch(function () { pwaMsg.textContent = '取消订阅失败'; });
});
}
if ('serviceWorker' in navigator && 'PushManager' in window) {
regSw().then(function () { return navigator.serviceWorker.ready; }).then(function (reg) {
return reg.pushManager.getSubscription();
}).then(function (sub) {
if (sub) { subBtn.style.display = 'none'; unsubBtn.style.display = ''; }
}).catch(function () {});
}
})();
</script>
<?php require 'includes/footer.php'; ?>