问卷调查系统 · 完整教程
一、系统架构
数据流
用户填写问卷 (index.php)
│
│ POST FormData
▼
.api.php ←── 接收 + 记录环境信息(IP/OS/浏览器/设备/时间)
│
│ 写入 JSON
▼
data/submissions.json
│
│ 读取
▼
admin.php ←── 后台查看 / 搜索 / 导出 / 删除 / 清空
三个文件的职责
文件 作用 访问方式 index.php 前端问卷页面,用户看到的表单 公开访问 .api.php 后端接口,接收数据、识别环境、写入 JSON 只接受 POST admin.php 管理后台,登录后查看所有提交 需密码 数据存储结构
data/submissions.json 每条记录的格式:
{
"id": "20260916143022-a1b2c3d4e5f6",
"created_at": "2026-09-16 14:30:22",
"ip": "1.2.3.4",
"os": "Windows 10/11",
"browser": "Chrome",
"device": "电脑",
"ua": "Mozilla/5.0 ...",
"data": {
"name": "张三",
"email": "zhangsan@mail.com",
"age_group": "26-35岁",
"rating": "5",
"message": "很满意"
}
}
- 外层固定字段:
id / created_at / ip / os / browser / device / ua—— 由接口自动记录 data里的字段:前端提交什么就存什么,不限数量和名称
认证信息存储
data/admin_auth.txt(后台账号密码):
# Admin Auth File # 格式:gzcompress(json) → base64,每行 76 字符 H4sIAAAAAAAAA6tWykvMTVWyUjA0MFDSUUrOz0lNzQOKGZqba0FluQo5qc...
- 存储链路:
json_encode→gzcompress→base64→ 每 76 字符换行 - 密码本身用
password_hash()二次加密 - 文件损坏或删除 → 自动恢复为默认
admin / 123456
二、目录结构
网站根目录/
├── index.php ← 前端问卷
├── .api.php ← 提交接口
├── admin.php ← 管理后台
└── data/ ← 自动生成
├── submissions.json ← 提交数据
├── admin_auth.txt ← 后台账号密码
└── backups/ ← 清空时的自动备份
三、文件一:index.php(前端问卷)
<?php
/**
* 前端问卷首页
* 表单提交 → /.api.php
*/
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>用户满意度调查问卷</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{
min-height:100vh;padding:32px 16px;
background:
radial-gradient(900px 600px at 10% -10%, #dbeafe 0%, transparent 55%),
radial-gradient(800px 600px at 110% 110%, #ede9fe 0%, transparent 55%),
linear-gradient(135deg,#f8fafc 0%,#eef2f7 100%);
font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
color:#0f172a;
}
.page{max-width:680px;margin:0 auto}
.hero{
background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
color:#fff;border-radius:22px;padding:34px 32px;position:relative;overflow:hidden;
box-shadow:0 24px 50px -20px rgba(99,102,241,.55);margin-bottom:20px;
}
.hero::after{
content:"";position:absolute;right:-40px;bottom:-60px;width:220px;height:220px;
background:radial-gradient(circle, rgba(255,255,255,.25) 0%, transparent 70%);
border-radius:50%;
}
.hero-tag{
display:inline-flex;align-items:center;gap:6px;
background:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.3);
padding:5px 12px;border-radius:20px;font-size:12.5px;font-weight:600;margin-bottom:14px;
}
.hero h1{font-size:24px;font-weight:800;letter-spacing:-.3px;margin-bottom:8px;position:relative}
.hero p{font-size:14px;opacity:.9;max-width:480px;position:relative}
.card{
background:#fff;border:1px solid #e5e7eb;border-radius:20px;padding:28px;
box-shadow:0 12px 30px -18px rgba(15,23,42,.25);
}
.form-row{margin-bottom:22px}
.form-row:last-of-type{margin-bottom:8px}
.label{display:block;font-size:13.5px;font-weight:700;color:#334155;margin-bottom:9px}
.label .req{color:#ef4444;margin-left:3px}
.label .hint{font-weight:500;color:#94a3b8;font-size:12.5px;margin-left:6px}
input[type=text], input[type=email], input[type=tel], input[type=number], textarea{
width:100%;padding:0 14px;font-family:inherit;font-size:14px;color:#0f172a;
background:#fbfcfe;border:1.5px solid #e2e8f0;border-radius:12px;outline:none;
transition:border-color .18s ease, box-shadow .18s ease, background .18s ease;
}
input[type=text], input[type=email], input[type=tel], input[type=number]{height:46px}
textarea{padding:12px 14px;min-height:110px;resize:vertical;line-height:1.6}
input:focus, textarea:focus{
border-color:#a5b4fc;background:#fff;box-shadow:0 0 0 4px rgba(139,92,246,.13);
}
input::placeholder, textarea::placeholder{color:#cbd5e1}
.options{display:flex;flex-wrap:wrap;gap:9px}
.opt{
position:relative;display:inline-flex;align-items:center;gap:8px;
padding:10px 16px;background:#fbfcfe;border:1.5px solid #e2e8f0;border-radius:11px;
font-size:13.5px;font-weight:500;cursor:pointer;color:#475569;
transition:all .18s ease;user-select:none;
}
.opt:hover{border-color:#c7d2fe;background:#f5f3ff}
.opt input{position:absolute;opacity:0;pointer-events:none}
.opt::before{
content:"";width:16px;height:16px;border:2px solid #cbd5e1;border-radius:50%;
transition:all .18s ease;flex:0 0 16px;
}
.opt.multi::before{border-radius:5px}
.opt:has(input:checked){
border-color:#8b5cf6;background:#f5f3ff;color:#6d28d9;font-weight:600;
box-shadow:0 4px 12px -6px rgba(139,92,246,.4);
}
.opt:has(input:checked)::before{background:#8b5cf6;border-color:#8b5cf6;box-shadow:inset 0 0 0 3px #fff}
.stars{display:flex;gap:8px;flex-direction:row-reverse;justify-content:flex-end}
.stars input{display:none}
.stars label{
font-size:32px;color:#e2e8f0;cursor:pointer;line-height:1;
transition:color .15s ease, transform .15s ease;
}
.stars label:hover,
.stars label:hover ~ label,
.stars input:checked ~ label{color:#f59e0b}
.stars label:hover{transform:scale(1.12)}
.stars input:checked + label{transform:scale(1.1)}
.submit-wrap{margin-top:26px;text-align:center}
.btn-submit{
width:100%;height:52px;border:none;border-radius:14px;cursor:pointer;
font-family:inherit;font-size:16px;font-weight:700;letter-spacing:.4px;color:#fff;
background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
box-shadow:0 16px 32px -14px rgba(99,102,241,.75);
transition:transform .15s ease, box-shadow .2s ease, filter .2s ease;
}
.btn-submit:hover{transform:translateY(-2px);box-shadow:0 20px 40px -14px rgba(99,102,241,.85)}
.btn-submit:active{transform:translateY(0)}
.btn-submit:disabled{opacity:.7;cursor:not-allowed;transform:none}
.foot-note{margin-top:14px;font-size:12.5px;color:#94a3b8;text-align:center}
.alert{
display:none;margin-bottom:18px;padding:12px 16px;border-radius:12px;font-size:13.5px;
color:#b91c1c;background:#fef2f2;border:1px solid #fecaca;
}
.alert.is-show{display:block}
.success{display:none;text-align:center;padding:56px 28px}
.success.is-show{display:block}
.success-icon{
width:88px;height:88px;margin:0 auto 22px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;
box-shadow:0 18px 40px -14px rgba(99,102,241,.6);
animation:pop .5s cubic-bezier(.34,1.56,.64,1);
}
@keyframes pop{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}}
.success-icon svg{width:44px;height:44px;fill:none;stroke:#fff;stroke-width:3;
stroke-linecap:round;stroke-linejoin:round;
stroke-dasharray:60;stroke-dashoffset:60;
animation:draw .6s .2s ease forwards;
}
@keyframes draw{to{stroke-dashoffset:0}}
.success h2{font-size:22px;font-weight:800;margin-bottom:10px;color:#0f172a}
.success p{color:#64748b;font-size:14px}
@media (max-width:520px){
.hero{padding:26px 22px;border-radius:18px}
.hero h1{font-size:20px}
.card{padding:22px 18px;border-radius:16px}
.opt{font-size:13px;padding:9px 13px}
}
</style>
</head>
<body>
<div class="page">
<div class="hero" id="heroBlock">
<div class="hero-tag">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
用户满意度调查
</div>
<h1>感谢您抽出宝贵时间</h1>
<p>本问卷大约需要 1 分钟完成,您的反馈将帮助我们持续改进产品和服务。</p>
</div>
<div class="card" id="formCard">
<div class="alert" id="alertBox"></div>
<form id="surveyForm" novalidate>
<div class="form-row">
<label class="label" for="f-name">您的姓名<span class="req">*</span></label>
<input type="text" id="f-name" name="name" placeholder="请输入您的称呼" required>
</div>
<div class="form-row">
<label class="label" for="f-email">电子邮箱<span class="req">*</span></label>
<input type="email" id="f-email" name="email" placeholder="example@mail.com" required>
</div>
<div class="form-row">
<label class="label" for="f-phone">联系电话<span class="hint">(选填)</span></label>
<input type="tel" id="f-phone" name="phone" placeholder="便于我们后续回访" maxlength="20">
</div>
<div class="form-row">
<label class="label" for="f-company">公司 / 机构<span class="hint">(选填)</span></label>
<input type="text" id="f-company" name="company" placeholder="您所在的公司或团队名称">
</div>
<div class="form-row">
<label class="label">年龄段<span class="req">*</span></label>
<div class="options">
<label class="opt"><input type="radio" name="age_group" value="18岁以下" required>18 岁以下</label>
<label class="opt"><input type="radio" name="age_group" value="18-25岁">18–25 岁</label>
<label class="opt"><input type="radio" name="age_group" value="26-35岁">26–35 岁</label>
<label class="opt"><input type="radio" name="age_group" value="36-45岁">36–45 岁</label>
<label class="opt"><input type="radio" name="age_group" value="46岁以上">46 岁以上</label>
</div>
</div>
<div class="form-row">
<label class="label">职业<span class="req">*</span></label>
<div class="options">
<label class="opt"><input type="radio" name="occupation" value="学生" required>学生</label>
<label class="opt"><input type="radio" name="occupation" value="上班族">上班族</label>
<label class="opt"><input type="radio" name="occupation" value="自由职业">自由职业</label>
<label class="opt"><input type="radio" name="occupation" value="企业主">企业主</label>
<label class="opt"><input type="radio" name="occupation" value="其他">其他</label>
</div>
</div>
<div class="form-row">
<label class="label">最常使用的社交平台<span class="hint">(可多选)</span></label>
<div class="options">
<label class="opt multi"><input type="checkbox" name="platforms[]" value="微信">微信</label>
<label class="opt multi"><input type="checkbox" name="platforms[]" value="微博">微博</label>
<label class="opt multi"><input type="checkbox" name="platforms[]" value="抖音">抖音</label>
<label class="opt multi"><input type="checkbox" name="platforms[]" value="小红书">小红书</label>
<label class="opt multi"><input type="checkbox" name="platforms[]" value="B站">B 站</label>
<label class="opt multi"><input type="checkbox" name="platforms[]" value="Telegram">Telegram</label>
<label class="opt multi"><input type="checkbox" name="platforms[]" value="Twitter">Twitter</label>
<label class="opt multi"><input type="checkbox" name="platforms[]" value="其他">其他</label>
</div>
</div>
<div class="form-row">
<label class="label">您对本次服务的整体满意度<span class="req">*</span></label>
<div class="stars">
<input type="radio" name="rating" id="rate5" value="5" required><label for="rate5">★</label>
<input type="radio" name="rating" id="rate4" value="4"><label for="rate4">★</label>
<input type="radio" name="rating" id="rate3" value="3"><label for="rate3">★</label>
<input type="radio" name="rating" id="rate2" value="2"><label for="rate2">★</label>
<input type="radio" name="rating" id="rate1" value="1"><label for="rate1">★</label>
</div>
</div>
<div class="form-row">
<label class="label" for="f-message">您的建议或意见<span class="hint">(选填)</span></label>
<textarea id="f-message" name="message" placeholder="欢迎留下任何想法,我们会认真阅读每一条反馈…"></textarea>
</div>
<div class="submit-wrap">
<button type="submit" class="btn-submit" id="submitBtn">提交问卷</button>
<div class="foot-note">提交即表示您同意我们记录本次反馈内容及相关环境信息</div>
</div>
</form>
</div>
<div class="card success" id="successCard">
<div class="success-icon">
<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<h2>提交成功</h2>
<p>感谢您的参与!我们会认真查看您的反馈。</p>
</div>
</div>
<script>
(function () {
'use strict';
var API_URL = '/.api.php';
var form = document.getElementById('surveyForm');
var btn = document.getElementById('submitBtn');
var alertBox = document.getElementById('alertBox');
var formCard = document.getElementById('formCard');
var successCard = document.getElementById('successCard');
var heroBlock = document.getElementById('heroBlock');
function showError(msg) {
alertBox.textContent = msg;
alertBox.classList.add('is-show');
alertBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function hideError() {
alertBox.classList.remove('is-show');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
hideError();
var name = form.querySelector('[name="name"]').value.trim();
var email = form.querySelector('[name="email"]').value.trim();
if (!name) { showError('请填写您的姓名'); return; }
if (!email) { showError('请填写电子邮箱'); return; }
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { showError('邮箱格式不正确'); return; }
if (!form.querySelector('[name="age_group"]:checked')) { showError('请选择年龄段'); return; }
if (!form.querySelector('[name="occupation"]:checked')) { showError('请选择职业'); return; }
if (!form.querySelector('[name="rating"]:checked')) { showError('请选择满意度评分'); return; }
btn.disabled = true;
btn.textContent = '提交中…';
var fd = new FormData(form);
// 多选值合并成逗号分隔字符串
var platforms = fd.getAll('platforms[]');
fd.delete('platforms[]');
fd.append('platforms', platforms.join('、'));
fetch(API_URL, {
method: 'POST',
body: fd,
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.text(); })
.then(function (text) {
var res = null;
try { res = JSON.parse(text); } catch (err) { res = null; }
if (res && Number(res.code) === 0) {
heroBlock.style.display = 'none';
formCard.style.display = 'none';
successCard.classList.add('is-show');
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
var msg = (res && (res.msg || res.message)) || '提交失败,请稍后重试';
showError(msg);
btn.disabled = false;
btn.textContent = '提交问卷';
}
})
.catch(function () {
showError('网络异常,请检查网络后重试');
btn.disabled = false;
btn.textContent = '提交问卷';
});
});
})();
</script>
</body>
</html>
四、文件二:.api.php(提交接口)
<?php
declare(strict_types=1);
/**
* 通用表单提交 API
* 路径:/.api.php
* 功能:接收任意字段,自动记录 IP / OS / 浏览器 / 设备 / User-Agent / 时间
*/
define('DISPLAY_ERRORS', false);
if (DISPLAY_ERRORS) {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
}
/* ======================= 配置 ======================= */
define('DATA_FILE', __DIR__ . '/data/submissions.json');
define('BACKUP_DIR', __DIR__ . '/data/backups');
define('API_TOKEN', '');
define('RATE_LIMIT_SECONDS', 3);
define('MAX_BODY_SIZE', 10 * 1024 * 1024);
define('MAX_FIELDS', 100);
define('MAX_FIELD_NAME_LEN', 80);
define('MAX_FIELD_VALUE_LEN', 10000);
define('RECORD_UA', true);
define('ALLOWED_ORIGINS', ['*']);
define('EXPAND_BRACKET_FIELDS', true);
/* ======================= 基础响应 ======================= */
function api_out(int $code, string $msg, array $extra = []): void {
if (!headers_sent()) {
header('Content-Type: application/json; charset=UTF-8');
header('X-Content-Type-Options: nosniff');
}
echo json_encode(array_merge(['code' => $code, 'msg' => $msg], $extra),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/* ======================= CORS ======================= */
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '') {
$allowed = ALLOWED_ORIGINS;
if (in_array('*', $allowed, true)) {
header('Access-Control-Allow-Origin: *');
} elseif (in_array($origin, $allowed, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
header('Access-Control-Max-Age: 86400');
}
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
header('Allow: POST, OPTIONS');
api_out(405, '请使用 POST 方式提交');
}
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($contentLength > MAX_BODY_SIZE) {
http_response_code(413);
api_out(413, '提交内容过大');
}
/* ======================= 工具函数 ======================= */
function client_ip(): string {
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $k) {
if (empty($_SERVER[$k])) continue;
$ip = trim(explode(',', (string)$_SERVER[$k])[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
return (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
function parse_os(string $ua): string {
if ($ua === '') return '未知';
$s = strtolower($ua);
if (strpos($s, 'harmony') !== false) return 'HarmonyOS';
if (strpos($s, 'iphone') !== false) return 'iOS (iPhone)';
if (strpos($s, 'ipad') !== false) return 'iPadOS';
if (strpos($s, 'ipod') !== false) return 'iOS (iPod)';
if (strpos($s, 'windows nt 10') !== false) return 'Windows 10/11';
if (strpos($s, 'windows nt 6.3') !== false) return 'Windows 8.1';
if (strpos($s, 'windows nt 6.2') !== false) return 'Windows 8';
if (strpos($s, 'windows nt 6.1') !== false) return 'Windows 7';
if (strpos($s, 'windows') !== false) return 'Windows';
if (strpos($s, 'android') !== false) return 'Android';
if (strpos($s, 'mac os x') !== false) return 'macOS';
if (strpos($s, 'cros') !== false) return 'ChromeOS';
if (strpos($s, 'linux') !== false) return 'Linux';
return '其他';
}
function parse_browser(string $ua): string {
if ($ua === '') return '未知';
if (strpos($ua, 'Edg/') !== false) return 'Edge';
if (strpos($ua, 'OPR/') !== false) return 'Opera';
if (strpos($ua, 'Opera') !== false) return 'Opera';
if (strpos($ua, 'Vivaldi') !== false) return 'Vivaldi';
if (strpos($ua, 'Brave') !== false) return 'Brave';
if (strpos($ua, 'YaBrowser') !== false) return 'Yandex';
if (strpos($ua, 'UCBrowser') !== false) return 'UC';
if (strpos($ua, 'QQBrowser') !== false) return 'QQ浏览器';
if (strpos($ua, 'Chrome/') !== false) return 'Chrome';
if (strpos($ua, 'Firefox/') !== false) return 'Firefox';
if (strpos($ua, 'Safari/') !== false) return 'Safari';
if (strpos($ua, 'MSIE') !== false || strpos($ua, 'Trident') !== false) return 'IE';
return '其他';
}
function parse_device(string $ua): string {
if ($ua === '') return '未知';
if (preg_match('/iPad|Tablet|PlayBook|Silk|Kindle/i', $ua)) return '平板';
if (preg_match('/Mobile|iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry/i', $ua)) return '手机';
return '电脑';
}
function gen_id(): string {
try { $rand = bin2hex(random_bytes(6)); }
catch (Throwable $e) { $rand = substr(md5(uniqid('', true)), 0, 12); }
return date('YmdHis') . '-' . $rand;
}
function str_len(string $s): int {
return function_exists('mb_strlen') ? mb_strlen($s, 'UTF-8') : strlen($s);
}
function sanitize($value, int $depth = 0): string {
if ($depth > 3) return '';
if (is_array($value) || is_object($value)) {
$tmp = [];
foreach ((array)$value as $k => $v) $tmp[(string)$k] = sanitize($v, $depth + 1);
$s = json_encode($tmp, JSON_UNESCAPED_UNICODE);
} elseif (is_bool($value)) {
$s = $value ? 'true' : 'false';
} elseif ($value === null) {
$s = '';
} else {
$s = (string)$value;
}
$s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s) ?? '';
$s = trim($s);
if (str_len($s) > MAX_FIELD_VALUE_LEN) {
$s = function_exists('mb_substr')
? mb_substr($s, 0, MAX_FIELD_VALUE_LEN, 'UTF-8') . '…'
: substr($s, 0, MAX_FIELD_VALUE_LEN) . '…';
}
return $s;
}
function clean_key(string $k): string {
$k = preg_replace('/[\x00-\x1F\x7F]/u', '', $k) ?? '';
$k = trim($k);
if ($k === '') return 'field';
return function_exists('mb_substr')
? mb_substr($k, 0, MAX_FIELD_NAME_LEN, 'UTF-8')
: substr($k, 0, MAX_FIELD_NAME_LEN);
}
function save_rows(array $rows): bool {
$dir = dirname(DATA_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
if (!is_writable($dir)) return false;
$json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
if ($json === false) return false;
$fp = @fopen(DATA_FILE, 'cb');
if (!$fp) return false;
if (!flock($fp, LOCK_EX)) { fclose($fp); return false; }
ftruncate($fp, 0);
rewind($fp);
$ok = fwrite($fp, $json) !== false;
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);
return $ok;
}
function load_rows(): array {
if (!is_file(DATA_FILE)) return [];
$fp = @fopen(DATA_FILE, 'rb');
if (!$fp) return [];
flock($fp, LOCK_SH);
$raw = stream_get_contents($fp);
flock($fp, LOCK_UN);
fclose($fp);
$arr = json_decode((string)$raw, true);
return is_array($arr) ? $arr : [];
}
/* ======================= 令牌校验 ======================= */
if (API_TOKEN !== '') {
$token = (string)($_POST['_token'] ?? $_GET['_token'] ?? ($_SERVER['HTTP_X_API_TOKEN'] ?? ''));
if (!hash_equals(API_TOKEN, $token)) {
http_response_code(403);
api_out(403, '令牌校验失败');
}
}
/* ======================= 解析请求体 ======================= */
$contentType = strtolower((string)($_SERVER['CONTENT_TYPE'] ?? ''));
$rawInput = '';
if (strpos($contentType, 'multipart/form-data') === false) {
$rawInput = (string)file_get_contents('php://input');
if (strlen($rawInput) > MAX_BODY_SIZE) {
http_response_code(413);
api_out(413, '提交内容过大');
}
}
$payload = [];
if (strpos($contentType, 'application/json') !== false) {
$decoded = json_decode($rawInput, true);
if (!is_array($decoded)) {
http_response_code(400);
api_out(400, 'JSON 格式错误');
}
$payload = $decoded;
} else {
$payload = $_POST;
if (!$payload && $rawInput !== '') {
$decoded = json_decode($rawInput, true);
if (is_array($decoded)) {
$payload = $decoded;
} else {
parse_str($rawInput, $parsed);
$payload = is_array($parsed) ? $parsed : [];
}
}
}
if (!is_array($payload) || !$payload) {
http_response_code(400);
api_out(400, '没有收到任何数据');
}
/* ======================= 剔除内部字段 ======================= */
foreach (['action', '_csrf', '_token', 'submit', '提交'] as $k) unset($payload[$k]);
/* ======================= 展开方括号字段 ======================= */
if (EXPAND_BRACKET_FIELDS) {
$normalized = [];
foreach ($payload as $k => $v) {
if (preg_match('/^([\w\-]+)\[(.+)\]$/', (string)$k, $m)) {
$inner = trim($m[2], '"\'');
if (array_key_exists($inner, $payload) || array_key_exists($inner, $normalized)) continue;
$normalized[$inner] = $v;
} else {
$normalized[$k] = $v;
}
}
$payload = $normalized;
}
/* ======================= 字段清洗 ======================= */
$data = [];
foreach ($payload as $k => $v) {
if (count($data) >= MAX_FIELDS) break;
$key = clean_key((string)$k);
if (isset($data[$key])) $key = clean_key($key . '_' . count($data));
$data[$key] = sanitize($v);
}
/* ======================= 内容为空判断 ======================= */
$hasContent = false;
foreach ($data as $v) {
if ($v !== '') { $hasContent = true; break; }
}
if (!$hasContent) api_out(400, '表单内容为空');
/* ======================= 限流 ======================= */
$ip = client_ip();
if (RATE_LIMIT_SECONDS > 0) {
$rows = load_rows();
$now = time();
$tail = array_slice(array_reverse($rows), 0, 50);
foreach ($tail as $r) {
if (($r['ip'] ?? '') === $ip) {
$t = strtotime((string)($r['created_at'] ?? ''));
if ($t && ($now - $t) < RATE_LIMIT_SECONDS) {
http_response_code(429);
header('Retry-After: ' . RATE_LIMIT_SECONDS);
api_out(429, '提交过于频繁,请 ' . RATE_LIMIT_SECONDS . ' 秒后再试');
}
break;
}
}
}
/* ======================= 组装记录 ======================= */
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
$record = [
'id' => gen_id(),
'created_at' => date('Y-m-d H:i:s'),
'ip' => $ip,
'os' => parse_os($ua),
'browser' => parse_browser($ua),
'device' => parse_device($ua),
'data' => $data,
];
if (RECORD_UA && $ua !== '') $record['ua'] = sanitize($ua);
/* ======================= 写入 ======================= */
$rows = load_rows();
$rows[] = $record;
if (!save_rows($rows)) {
http_response_code(500);
api_out(500, '数据保存失败,请检查 data 目录是否存在且可写');
}
api_out(0, '提交成功', ['id' => $record['id']]);
五、文件三:admin.php(管理后台)
<?php
declare(strict_types=1);
/**
* 问卷管理后台
* 默认账号:admin / 123456
*/
session_start();
/* ======================= 数据路径自动探测 ======================= */
$__candidates = [
__DIR__ . '/data/submissions.json',
dirname(__DIR__) . '/data/submissions.json',
];
$__docRoot = rtrim((string)($_SERVER['DOCUMENT_ROOT'] ?? ''), '/\\');
if ($__docRoot !== '') $__candidates[] = $__docRoot . '/data/submissions.json';
$__dataFile = '';
foreach ($__candidates as $__f) {
if ($__f !== '' && is_file($__f)) { $__dataFile = $__f; break; }
}
if ($__dataFile === '') {
foreach ($__candidates as $__f) {
if ($__f !== '' && is_dir(dirname($__f))) { $__dataFile = $__f; break; }
}
}
if ($__dataFile === '') $__dataFile = $__candidates[1];
define('DATA_FILE', $__dataFile);
define('BACKUP_DIR', dirname($__dataFile) . '/backups');
define('AUTH_FILE', dirname($__dataFile) . '/admin_auth.txt');
/* ======================= 配置 ======================= */
define('DEFAULT_USER', 'admin');
define('DEFAULT_PASS', '123456');
define('PER_PAGE', 20);
define('TITLE', '问卷调查管理后台');
define('CAPTCHA_TTL', 300);
define('MIN_NEW_USER_LEN', 3);
define('MIN_NEW_PASS_LEN', 6);
define('PRIORITY_COLUMNS', ['name', 'email', 'phone', 'company', 'message', 'remark']);
define('MASK_KEYWORDS', ['password', 'passwd', 'pwd', 'secret', 'token', 'idcard', '身份证', 'bank', 'card']);
/* ======================= 认证读写 ======================= */
function load_auth(): array {
$default = [
'username' => DEFAULT_USER,
'password' => password_hash(DEFAULT_PASS, PASSWORD_DEFAULT),
'updated_at' => date('Y-m-d H:i:s'),
];
if (!is_file(AUTH_FILE)) { save_auth($default); return $default; }
$raw = (string)@file_get_contents(AUTH_FILE);
$lines = preg_split('/\R/', $raw) ?: [];
$b64 = '';
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
$b64 .= $line;
}
if ($b64 === '') return $default;
$compressed = @base64_decode($b64, true);
if ($compressed === false) return $default;
$json = @gzuncompress($compressed);
if ($json === false) return $default;
$arr = json_decode((string)$json, true);
if (!is_array($arr) || empty($arr['username']) || empty($arr['password'])) return $default;
return $arr;
}
function save_auth(array $auth): bool {
$dir = dirname(AUTH_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
$json = (string)json_encode($auth, JSON_UNESCAPED_UNICODE);
$compressed = (string)gzcompress($json, 9);
$encoded = chunk_split(base64_encode($compressed), 76, "\n");
$content = "# Admin Auth File\n";
$content .= "# 格式:gzcompress(json) → base64,每行 76 字符\n";
$content .= "# 请勿手动编辑,如损坏将恢复为默认账号\n";
$content .= "# Updated: " . date('Y-m-d H:i:s') . "\n";
$content .= $encoded;
return @file_put_contents(AUTH_FILE, $content, LOCK_EX) !== false;
}
function verify_password(string $input, string $stored): bool {
if (preg_match('/^\$(2[aby]|argon2)/', $stored)) return password_verify($input, $stored);
return hash_equals($stored, $input);
}
/* ======================= 图形验证码 ======================= */
function captcha_generate(): string {
$chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
$len = strlen($chars);
$code = '';
for ($i = 0; $i < 4; $i++) {
try { $idx = random_int(0, $len - 1); }
catch (Throwable $e) { $idx = mt_rand(0, $len - 1); }
$code .= $chars[$idx];
}
$_SESSION['captcha'] = $code;
$_SESSION['captcha_time'] = time();
return $code;
}
function captcha_check(string $input): bool {
$input = strtoupper(trim($input));
$code = (string)($_SESSION['captcha'] ?? '');
$time = (int)($_SESSION['captcha_time'] ?? 0);
unset($_SESSION['captcha'], $_SESSION['captcha_time']);
if ($code === '' || $input === '') return false;
if (time() - $time > CAPTCHA_TTL) return false;
return hash_equals($code, $input);
}
function captcha_output_svg(string $code): void {
$w = 130; $h = 44;
header('Content-Type: image/svg+xml; charset=UTF-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' . $w . '" height="' . $h . '" viewBox="0 0 ' . $w . ' ' . $h . '">';
$svg .= '<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">';
$svg .= '<stop offset="0%" stop-color="#eef2ff"/><stop offset="100%" stop-color="#f5f3ff"/>';
$svg .= '</linearGradient></defs>';
$svg .= '<rect width="100%" height="100%" fill="url(#bg)" rx="8"/>';
for ($i = 0; $i < 4; $i++) {
$svg .= sprintf(
'<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="rgb(%d,%d,%d)" stroke-width="1" opacity="0.35"/>',
mt_rand(0, $w), mt_rand(0, $h), mt_rand(0, $w), mt_rand(0, $h),
mt_rand(120, 190), mt_rand(120, 190), mt_rand(180, 230)
);
}
for ($i = 0; $i < 24; $i++) {
$svg .= sprintf(
'<circle cx="%d" cy="%d" r="1" fill="rgb(%d,%d,%d)" opacity="0.4"/>',
mt_rand(0, $w), mt_rand(0, $h),
mt_rand(120, 190), mt_rand(120, 190), mt_rand(180, 230)
);
}
$chars = str_split($code);
$step = (int)(($w - 24) / max(1, count($chars)));
foreach ($chars as $i => $ch) {
$x = 16 + $i * $step + mt_rand(-2, 2);
$y = (int)($h / 2) + mt_rand(-2, 2) + 8;
$rot = mt_rand(-18, 18);
$r = mt_rand(49, 79); $g = mt_rand(46, 90); $b = mt_rand(129, 200);
$svg .= sprintf(
'<text x="%d" y="%d" font-family="Arial,Helvetica,sans-serif" font-size="24" font-weight="700" '
. 'fill="rgb(%d,%d,%d)" text-anchor="middle" '
. 'transform="rotate(%d %d %d)">%s</text>',
$x, $y, $r, $g, $b, $rot, $x, $y, htmlspecialchars($ch, ENT_QUOTES, 'UTF-8')
);
}
$svg .= '</svg>';
echo $svg;
exit;
}
/* ======================= 工具函数 ======================= */
function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function csrf_check(): void {
if (!hash_equals($_SESSION['csrf'] ?? '', (string)($_POST['_csrf'] ?? ''))) {
http_response_code(419); exit('会话已过期,请刷新页面后重试。');
}
}
function current_url(array $override = []): string {
$qs = $_GET; unset($qs['action']);
foreach ($override as $k => $v) { if ($v === null) unset($qs[$k]); else $qs[$k] = $v; }
$base = strtok($_SERVER['REQUEST_URI'], '?');
return $base . ($qs ? '?' . http_build_query($qs) : '');
}
function flash_and_back(string $msg, string $type = 'ok'): void {
$_SESSION['flash'] = ['type' => $type, 'msg' => $msg];
header('Location: ' . current_url()); exit;
}
function load_rows(): array {
if (!is_file(DATA_FILE)) return [];
$fp = @fopen(DATA_FILE, 'rb'); if (!$fp) return [];
flock($fp, LOCK_SH); $raw = stream_get_contents($fp);
flock($fp, LOCK_UN); fclose($fp);
$arr = json_decode((string)$raw, true);
return is_array($arr) ? $arr : [];
}
function save_rows(array $rows): bool {
$dir = dirname(DATA_FILE);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
$json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$fp = @fopen(DATA_FILE, 'cb'); if (!$fp) return false;
flock($fp, LOCK_EX); ftruncate($fp, 0); rewind($fp);
$ok = fwrite($fp, (string)$json) !== false;
fflush($fp); flock($fp, LOCK_UN); fclose($fp);
return $ok;
}
function backup_rows(array $rows): bool {
if (!is_dir(BACKUP_DIR) && !@mkdir(BACKUP_DIR, 0755, true)) return false;
$file = BACKUP_DIR . '/backup_' . date('Ymd_His') . '.json';
return @file_put_contents($file,
(string)json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX) !== false;
}
function flat_value($v): string {
if (is_array($v) || is_object($v)) return (string)json_encode($v, JSON_UNESCAPED_UNICODE);
if (is_bool($v)) return $v ? 'true' : 'false';
if ($v === null) return '';
return (string)$v;
}
function contains_kw(string $hay, string $kw): bool {
if (function_exists('mb_stripos')) return mb_stripos($hay, $kw) !== false;
return stripos($hay, $kw) !== false;
}
function shorten(string $s, int $len = 60): string {
if (function_exists('mb_strimwidth')) return mb_strimwidth($s, 0, $len, '…', 'UTF-8');
return strlen($s) > $len ? substr($s, 0, $len) . '…' : $s;
}
function is_sensitive_field(string $key): bool {
$lower = strtolower($key);
foreach (MASK_KEYWORDS as $kw) {
if ($kw !== '' && strpos($lower, $kw) !== false) return true;
}
return false;
}
function mask_value(string $key, string $value): string {
if ($value === '') return '';
$lower = strtolower($key);
if (preg_match('/(password|passwd|pwd|secret|token)/i', $lower)) return '******';
if (preg_match('/(phone|mobile|tel|手机)/i', $lower)) {
if (preg_match('/^(\d{3})\d{3,5}(\d{4})$/', preg_replace('/\D/', '', $value), $m)) {
return $m[1] . '****' . $m[2];
}
}
if (preg_match('/(email|mail|邮箱)/i', $lower)) {
if (strpos($value, '@') !== false) {
[$user, $domain] = explode('@', $value, 2);
$len = function_exists('mb_strlen') ? mb_strlen($user, 'UTF-8') : strlen($user);
if ($len <= 2) {
$user = function_exists('mb_substr') ? mb_substr($user, 0, 1, 'UTF-8') . '*' : substr($user, 0, 1) . '*';
} else {
$head = function_exists('mb_substr') ? mb_substr($user, 0, 2, 'UTF-8') : substr($user, 0, 2);
$user = $head . str_repeat('*', max(1, $len - 2));
}
return $user . '@' . $domain;
}
}
return $value;
}
function collect_columns(array $rows, int $limit = 60): array {
$cols = [];
foreach ($rows as $r) {
if (!isset($r['data']) || !is_array($r['data'])) continue;
foreach (array_keys($r['data']) as $k) {
if (!in_array($k, $cols, true)) {
$cols[] = $k;
if (count($cols) >= $limit * 2) break 2;
}
}
}
$head = [];
foreach (PRIORITY_COLUMNS as $p) {
foreach ($cols as $i => $c) {
if (strcasecmp($c, $p) === 0) { $head[] = $c; unset($cols[$i]); }
}
}
$cols = array_values($cols);
return array_slice(array_merge($head, $cols), 0, $limit);
}
function build_table(array $rows, array $columns, bool $mask = false): array {
$header = array_merge(['ID', '提交时间', 'IP', '操作系统', '浏览器', '设备'], $columns);
$body = [];
foreach ($rows as $r) {
$line = [
(string)($r['id'] ?? ''),
(string)($r['created_at'] ?? ''),
(string)($r['ip'] ?? ''),
(string)($r['os'] ?? ''),
(string)($r['browser'] ?? ''),
(string)($r['device'] ?? ''),
];
foreach ($columns as $c) {
$v = flat_value($r['data'][$c] ?? '');
if ($mask && $v !== '') $v = mask_value($c, $v);
$line[] = $v;
}
$body[] = $line;
}
return [$header, $body];
}
function page_link(int $p, string $kw): string {
$qs = ['page' => $p];
if ($kw !== '') $qs['q'] = $kw;
return '?' . http_build_query($qs);
}
function diag(): array {
$exists = is_file(DATA_FILE);
$size = $exists ? (int)filesize(DATA_FILE) : 0;
$mtime = $exists ? (int)filemtime(DATA_FILE) : 0;
$writable = $exists ? is_writable(DATA_FILE) : is_writable(dirname(DATA_FILE));
$dirExist = is_dir(dirname(DATA_FILE));
return compact('exists', 'size', 'mtime', 'writable', 'dirExist');
}
/* ======================= 初始化 ======================= */
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));
$CSRF = $_SESSION['csrf'];
$action = (string)($_POST['action'] ?? $_GET['action'] ?? '');
$method = $_SERVER['REQUEST_METHOD'];
$isLogged = !empty($_SESSION['logged']);
$auth = load_auth();
if ($action === 'captcha') captcha_output_svg(captcha_generate());
$loginError = '';
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
/* ======================= 未登录 ======================= */
if (!$isLogged) {
if ($action === 'login' && $method === 'POST') {
$u = trim((string)($_POST['username'] ?? ''));
$p = (string)($_POST['password'] ?? '');
$captcha = (string)($_POST['captcha'] ?? '');
if (!captcha_check($captcha)) {
$loginError = '验证码不正确或已过期';
} elseif ($u === (string)$auth['username'] && verify_password($p, (string)$auth['password'])) {
session_regenerate_id(true);
$_SESSION['logged'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
exit;
} else {
$loginError = '用户名或密码不正确';
}
usleep(400000);
}
$refreshTs = time();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>登录 · <?= h(TITLE) ?></title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;
background:radial-gradient(1200px 600px at 10% -10%, #e0e7ff 0%, transparent 60%),
radial-gradient(900px 500px at 110% 110%, #ede9fe 0%, transparent 55%),
linear-gradient(135deg,#f8fafc 0%,#eef1f6 100%);
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
color:#1e293b;}
.card{width:100%;max-width:400px;background:rgba(255,255,255,.92);
border:1px solid rgba(226,232,240,.9);border-radius:20px;
padding:36px 30px 30px;position:relative;overflow:hidden;backdrop-filter: blur(14px);
box-shadow: 0 24px 60px -20px rgba(30,41,59,.22), 0 6px 20px -8px rgba(30,41,59,.10);
animation: cardIn .45s cubic-bezier(.34,1.56,.64,1) both;}
@keyframes cardIn{from{opacity:0;transform:translateY(16px) scale(.97)}to{opacity:1;transform:none}}
.card::before{content:"";position:absolute;top:0;left:0;right:0;height:5px;
background:linear-gradient(90deg,#6366f1 0%,#8b5cf6 55%,#a78bfa 100%);}
.brand{display:flex;align-items:center;gap:12px;margin-bottom:22px}
.brand .logo{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;
background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
color:#fff;font-weight:800;font-size:16px;box-shadow: 0 8px 20px -6px rgba(99,102,241,.55);}
.brand h1{font-size:17px;font-weight:700;color:#0f172a}
.brand p{font-size:12px;color:#94a3b8;margin-top:2px}
.field{margin-bottom:14px}
.field label{display:block;font-size:12.5px;color:#475569;margin-bottom:6px;font-weight:600}
.field input{width:100%;height:44px;padding:0 14px;border:1.5px solid #e2e8f0;border-radius:10px;
outline:none;font-size:14px;background:#fbfcfe;color:#0f172a;font-family: inherit;
transition: border-color .18s, box-shadow .18s, background .18s;}
.field input:focus{border-color:#8b5cf6;background:#fff;box-shadow: 0 0 0 4px rgba(139,92,246,.13);}
.field-row{display:flex;gap:10px;align-items:flex-end}
.field-row .field{flex:1}
.captcha-box{width:130px;height:44px;flex:0 0 130px;border-radius:10px;overflow:hidden;
border:1.5px solid #e2e8f0;background:#eef2ff;cursor:pointer;transition: border-color .18s;}
.captcha-box:hover{border-color:#c7d2fe}
.captcha-box img{display:block;width:100%;height:100%;object-fit:cover}
.err{display:none;margin-bottom:14px;padding:10px 12px;font-size:13px;line-height:1.5;
color:#b91c1c;background:#fef2f2;border:1px solid #fecaca;border-radius:10px;}
.err.is-show{display:block}
.submit{width:100%;height:46px;margin-top:6px;border:none;border-radius:11px;color:#fff;
font-size:15px;font-weight:700;letter-spacing:.5px;cursor:pointer;font-family: inherit;
background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
box-shadow: 0 12px 26px -10px rgba(99,102,241,.65);
transition: transform .15s, box-shadow .2s, filter .2s;}
.submit:hover{transform:translateY(-1px);box-shadow:0 16px 30px -10px rgba(99,102,241,.72)}
.submit:disabled{opacity:.7;cursor:not-allowed;transform:none}
.foot{text-align:center;font-size:11.5px;color:#94a3b8;margin-top:18px}
</style>
</head>
<body>
<div class="card">
<div class="brand">
<div class="logo">问卷</div>
<div>
<h1><?= h(TITLE) ?></h1>
<p>请登录以继续操作</p>
</div>
</div>
<div class="err <?= $loginError !== '' ? 'is-show' : '' ?>" id="errBox"><?= h($loginError) ?></div>
<form method="post" autocomplete="off" id="loginForm">
<div class="field">
<label>用户名</label>
<input type="text" name="username" id="fUser" autocomplete="username" placeholder="请输入用户名" required autofocus>
</div>
<div class="field">
<label>密码</label>
<input type="password" name="password" id="fPass" autocomplete="current-password" placeholder="请输入密码" required>
</div>
<div class="field-row">
<div class="field">
<label>验证码</label>
<input type="text" name="captcha" id="fCap" maxlength="6" autocomplete="off" placeholder="4 位字符" required>
</div>
<div class="captcha-box" id="capBox" title="点击刷新验证码">
<img id="capImg" src="?action=captcha&t=<?= $refreshTs ?>" alt="验证码">
</div>
</div>
<input type="hidden" name="action" value="login">
<button class="submit" type="submit" id="submitBtn">登 录</button>
</form>
<div class="foot">默认账号 admin / 123456,登录后请及时修改</div>
</div>
<script>
(function () {
var box = document.getElementById('capBox');
var img = document.getElementById('capImg');
var form = document.getElementById('loginForm');
var btn = document.getElementById('submitBtn');
var err = document.getElementById('errBox');
box.addEventListener('click', function () { img.src = '?action=captcha&t=' + Date.now(); });
form.addEventListener('submit', function (e) {
var u = document.getElementById('fUser').value.trim();
var p = document.getElementById('fPass').value;
var c = document.getElementById('fCap').value.trim();
err.classList.remove('is-show');
if (!u) { err.textContent='请输入用户名'; err.classList.add('is-show'); e.preventDefault(); return; }
if (!p) { err.textContent='请输入密码'; err.classList.add('is-show'); e.preventDefault(); return; }
if (!c) { err.textContent='请输入验证码'; err.classList.add('is-show'); e.preventDefault(); return; }
btn.disabled = true; btn.textContent = '登录中…';
});
})();
</script>
</body>
</html>
<?php
exit;
}
/* ======================= 登出 ======================= */
if ($action === 'logout') {
$_SESSION = [];
if (ini_get('session.use_cookies')) setcookie(session_name(), '', time() - 42000, '/');
session_destroy();
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
exit;
}
/* ======================= POST 操作 ======================= */
if ($method === 'POST') {
switch ($action) {
case 'change_auth': {
csrf_check();
$cur = (string)($_POST['current_password'] ?? '');
$newUser = trim((string)($_POST['new_username'] ?? ''));
$newPass = (string)($_POST['new_password'] ?? '');
$confirm = (string)($_POST['confirm_password'] ?? '');
if (!verify_password($cur, (string)$auth['password'])) flash_and_back('当前密码不正确', 'err');
$uLen = function_exists('mb_strlen') ? mb_strlen($newUser, 'UTF-8') : strlen($newUser);
$pLen = function_exists('mb_strlen') ? mb_strlen($newPass, 'UTF-8') : strlen($newPass);
if ($uLen < MIN_NEW_USER_LEN) flash_and_back('用户名至少 ' . MIN_NEW_USER_LEN . ' 位', 'err');
if ($pLen < MIN_NEW_PASS_LEN) flash_and_back('新密码至少 ' . MIN_NEW_PASS_LEN . ' 位', 'err');
if ($newPass !== $confirm) flash_and_back('两次输入的新密码不一致', 'err');
$newAuth = [
'username' => $newUser,
'password' => password_hash($newPass, PASSWORD_DEFAULT),
'updated_at' => date('Y-m-d H:i:s'),
];
if (!save_auth($newAuth)) flash_and_back('保存失败,请检查 data 目录写入权限', 'err');
flash_and_back('账号密码已更新');
}
case 'delete': {
csrf_check();
$id = (string)($_POST['id'] ?? '');
$rows = load_rows();
$before = count($rows);
$rows = array_values(array_filter($rows, fn($r) => (string)($r['id'] ?? '') !== $id));
save_rows($rows);
$n = $before - count($rows);
flash_and_back($n > 0 ? '已删除 1 条数据' : '未找到该条数据', $n > 0 ? 'ok' : 'warn');
}
case 'bulk_delete': {
csrf_check();
$ids = array_map('strval', (array)($_POST['ids'] ?? []));
if (!$ids) flash_and_back('请先勾选要删除的数据', 'warn');
$rows = load_rows();
$before = count($rows);
$rows = array_values(array_filter($rows, fn($r) => !in_array((string)($r['id'] ?? ''), $ids, true)));
save_rows($rows);
flash_and_back('已删除 ' . ($before - count($rows)) . ' 条数据');
}
case 'clear': {
csrf_check();
$rows = load_rows();
if (!$rows) flash_and_back('当前没有数据', 'warn');
$backed = backup_rows($rows);
save_rows([]);
flash_and_back(
'已清空 ' . count($rows) . ' 条数据' . ($backed ? '(已自动备份)' : '(备份失败)'),
$backed ? 'ok' : 'warn'
);
}
case 'export': {
csrf_check();
while (ob_get_level()) ob_end_clean();
$fmt = strtolower((string)($_POST['format'] ?? 'csv'));
$ids = array_map('strval', (array)($_POST['ids'] ?? []));
$kw = trim((string)($_POST['q'] ?? ''));
$mask = !empty($_POST['mask_sensitive']);
$all = array_reverse(load_rows());
if ($ids) {
$all = array_values(array_filter($all, fn($r) => in_array((string)($r['id'] ?? ''), $ids, true)));
} elseif ($kw !== '') {
$all = array_values(array_filter($all, function ($r) use ($kw) {
return contains_kw((string)json_encode($r, JSON_UNESCAPED_UNICODE), $kw);
}));
}
if (!$all) {
$_SESSION['flash'] = ['type' => 'warn', 'msg' => '没有可导出的数据'];
header('Location: ' . current_url()); exit;
}
$columns = collect_columns($all);
[$header, $body] = build_table($all, $columns, $mask);
$base = 'survey_' . date('Ymd_His') . ($ids ? '_selected' : '');
switch ($fmt) {
case 'txt':
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $base . '.txt"');
echo "# 导出时间: " . date('Y-m-d H:i:s') . " 共 " . count($body) . " 条\r\n";
echo implode("\t", $header), "\r\n";
foreach ($body as $line) {
$line = array_map(fn($v) => str_replace(["\t", "\r", "\n"], ' ', $v), $line);
echo implode("\t", $line), "\r\n";
}
break;
case 'json':
header('Content-Type: application/json; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $base . '.json"');
echo json_encode($all, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'xls':
header('Content-Type: application/vnd.ms-excel; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $base . '.xls"');
echo "\xEF\xBB\xBF<html><head><meta charset=\"utf-8\"></head><body><table border=\"1\"><tr>";
foreach ($header as $hh) echo '<th>' . h($hh) . '</th>';
echo '</tr>';
foreach ($body as $line) {
echo '<tr>';
foreach ($line as $v) echo '<td>' . h($v) . '</td>';
echo '</tr>';
}
echo '</table></body></html>';
break;
case 'csv':
default:
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $base . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $header, ',', '"', '');
foreach ($body as $line) fputcsv($out, $line, ',', '"', '');
fclose($out);
break;
}
exit;
}
}
}
/* ======================= 列表数据准备 ======================= */
$kw = trim((string)($_GET['q'] ?? ''));
$page = max(1, (int)($_GET['page'] ?? 1));
$showRaw = !empty($_GET['showraw']);
$allRows = array_reverse(load_rows());
$filtered = $allRows;
if ($kw !== '') {
$filtered = array_values(array_filter($allRows, function ($r) use ($kw) {
return contains_kw((string)json_encode($r, JSON_UNESCAPED_UNICODE), $kw);
}));
}
$total = count($filtered);
$pages = max(1, (int)ceil($total / PER_PAGE));
$page = min($page, $pages);
$offset = ($page - 1) * PER_PAGE;
$pageRows = array_slice($filtered, $offset, PER_PAGE);
$columns = collect_columns($allRows);
$__diag = diag();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title><?= h(TITLE) ?></title>
<style>
*{box-sizing:border-box}
:root{--indigo:#6366f1;--violet:#8b5cf6;--ink:#0f172a;--muted:#64748b;--line:#e5e7eb}
body{margin:0;background:
radial-gradient(900px 500px at -10% -10%, #e0e7ff 0%, transparent 55%),
radial-gradient(800px 500px at 110% 0%, #ede9fe 0%, transparent 55%),
#f1f3f9;color:var(--ink);
font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
a{color:var(--indigo);text-decoration:none}
.wrap{max-width:1600px;margin:0 auto;padding:24px}
.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;
background:rgba(255,255,255,.92);backdrop-filter:blur(10px);
border:1px solid rgba(226,232,240,.9);border-radius:16px;
padding:16px 22px;margin-bottom:16px;box-shadow:0 6px 22px -14px rgba(15,23,42,.28);}
.topbar .brand{display:flex;align-items:center;gap:12px}
.topbar .logo{width:38px;height:38px;border-radius:10px;display:flex;align-items:center;justify-content:center;
background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-weight:800;font-size:14px;
box-shadow: 0 8px 18px -6px rgba(99,102,241,.55);}
.topbar h1{font-size:16px;margin:0;font-weight:700}
.topbar .sub{font-size:12px;color:var(--muted);margin-top:1px}
.topbar .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
button,.btn{height:36px;padding:0 16px;border:1px solid var(--line);background:#fff;border-radius:9px;
cursor:pointer;font-size:13.5px;color:var(--ink);line-height:34px;display:inline-block;
font-family:inherit;font-weight:600;
transition: background .18s, border-color .18s, transform .12s, box-shadow .18s;}
button:hover,.btn:hover{background:#f8fafc;border-color:#cbd5e1}
button:active,.btn:active{transform:translateY(1px)}
.btn-pri{background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);border-color:transparent;color:#fff;
box-shadow: 0 10px 22px -12px rgba(99,102,241,.8);}
.btn-pri:hover{background:linear-gradient(135deg,#5558e8 0%,#7c4ff0 100%)}
.btn-ghost{background:#fff;color:#334155}
.btn-danger{background:#fff;border-color:#fecaca;color:#dc2626}
.btn-danger:hover{background:#fef2f2;border-color:#fca5a5}
.btn-danger-solid{background:#ef4444;border-color:#ef4444;color:#fff}
.btn-danger-solid:hover{background:#dc2626;border-color:#dc2626}
.btn-sm{height:30px;padding:0 12px;font-size:12.5px;line-height:28px;border-radius:8px}
.card{background:#fff;border:1px solid var(--line);border-radius:14px;
padding:16px 18px;margin-bottom:14px;box-shadow:0 4px 14px -12px rgba(15,23,42,.3);}
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.stats{color:var(--muted);font-size:13px}
input[type=text],input[type=search],input[type=password]{
height:36px;padding:0 12px;border:1.5px solid #e2e8f0;border-radius:9px;
outline:none;font-size:13.5px;background:#fbfcfe;font-family:inherit;color:var(--ink);
transition:border-color .18s, box-shadow .18s, background .18s;}
input:focus{border-color:#a5b4fc;background:#fff;box-shadow:0 0 0 4px rgba(139,92,246,.12)}
.switch{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#475569;cursor:pointer;user-select:none}
.switch input{margin:0;width:auto;height:auto}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:14px;background:#fff;
max-height:68vh;box-shadow:0 4px 14px -12px rgba(15,23,42,.3);}
table{border-collapse:separate;border-spacing:0;width:100%;font-size:13px;white-space:nowrap}
th,td{padding:10px 12px;border-bottom:1px solid #f1f5f9;text-align:left;vertical-align:top}
thead th{background:linear-gradient(180deg,#fafbfd 0%,#f4f6fa 100%);
position:sticky;top:0;font-weight:700;color:#334155;z-index:2;
border-bottom:1px solid #e5e7eb;font-size:12.5px;letter-spacing:.2px;}
tbody tr{transition:background .15s ease}
tbody tr:hover{background:#fafbff}
td .v{max-width:280px;overflow:hidden;text-overflow:ellipsis;display:inline-block;vertical-align:middle}
.chk{width:40px;text-align:center}
.empty{padding:70px 0;text-align:center;color:#94a3b8;font-size:13px}
.pager{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:16px;font-size:13px}
.pager a,.pager span{padding:6px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;line-height:1.2}
.pager a:hover{background:#f8fafc;border-color:#cbd5e1}
.pager .cur{background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
border-color:transparent;color:#fff;font-weight:700;box-shadow:0 6px 14px -8px rgba(99,102,241,.8)}
.pager .gap{border:none;background:transparent;padding:6px 2px;color:#cbd5e1}
.flash{padding:12px 16px;border-radius:12px;margin-bottom:14px;font-size:13px;font-weight:500}
.flash.ok{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0}
.flash.warn{background:#fffbeb;color:#92400e;border:1px solid #fde68a}
.flash.err{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}
.tip{color:#94a3b8;font-size:12px;margin:8px 0 0}
.badge{display:inline-block;padding:1px 7px;font-size:11px;border-radius:6px;background:#fef0e8;color:#c25611;margin-left:4px;font-weight:600}
.badge-os{background:#e0f2fe;color:#0369a1}
.badge-dev{background:#f3e8ff;color:#7e22ce}
.diag{background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px 16px;margin-bottom:14px;
font-size:12.5px;display:flex;flex-wrap:wrap;gap:16px;align-items:center;
box-shadow:0 4px 14px -12px rgba(15,23,42,.3);}
.diag code{font-family:ui-monospace,Menlo,Consolas,monospace;background:#f1f5f9;padding:2px 7px;
border-radius:5px;font-size:11.5px;word-break:break-all;color:#334155}
.diag .ok{color:#065f46;background:#ecfdf5;border:1px solid #a7f3d0;padding:2px 9px;border-radius:6px;font-size:11.5px;font-weight:600}
.diag .bad{color:#991b1b;background:#fef2f2;border:1px solid #fecaca;padding:2px 9px;border-radius:6px;font-size:11.5px;font-weight:600}
.diag .warn{color:#92400e;background:#fffbeb;border:1px solid #fde68a;padding:2px 9px;border-radius:6px;font-size:11.5px;font-weight:600}
.diag .meta{color:var(--muted);font-size:11.5px}
.modal-mask{position:fixed;inset:0;background:rgba(15,23,42,.5);
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
display:none;align-items:center;justify-content:center;z-index:9999;padding:20px;
opacity:0;transition:opacity .2s ease;}
.modal-mask.is-open{display:flex;opacity:1}
.modal{background:#fff;border-radius:18px;width:100%;max-width:640px;max-height:85vh;
display:flex;flex-direction:column;overflow:hidden;
box-shadow:0 30px 70px -20px rgba(15,23,42,.4);
transform:translateY(14px) scale(.96);opacity:0;
transition:transform .28s cubic-bezier(.34,1.56,.64,1),opacity .2s ease;}
.modal-mask.is-open .modal{transform:translateY(0) scale(1);opacity:1}
.modal--wide{max-width:680px}
.modal__hd{display:flex;align-items:center;justify-content:space-between;
padding:18px 22px;border-bottom:1px solid #eef2f7;}
.modal__hd h3{margin:0;font-size:15px;font-weight:700}
.modal__close{border:none;background:transparent;font-size:22px;line-height:1;color:#94a3b8;cursor:pointer;
width:32px;height:32px;border-radius:8px;transition:background .15s,color .15s;
display:flex;align-items:center;justify-content:center;}
.modal__close:hover{color:#ef4444;background:#fef2f2}
.modal__bd{padding:20px 22px;overflow:auto}
.modal__ft{padding:14px 22px 20px;display:flex;gap:10px;justify-content:flex-end;border-top:1px solid #eef2f7}
.form-row{margin-bottom:14px}
.form-row label{display:block;font-size:12.5px;color:#475569;font-weight:600;margin-bottom:6px}
.form-row input{width:100%}
.kv{display:grid;grid-template-columns:150px 1fr;gap:8px 12px;font-size:13px}
.kv dt{color:#64748b;font-weight:600;word-break:break-all}
.kv dd{margin:0;color:var(--ink);word-break:break-all;white-space:pre-wrap}
.kv dd.mono{font-family:ui-monospace,Menlo,Consolas,monospace;
background:#f8fafc;padding:5px 9px;border-radius:6px;border:1px solid #eef2f7;}
.kv-sep{grid-column:1 / -1;height:1px;background:#eef2f7;margin:8px 0}
@media (max-width:640px){.wrap{padding:14px}.topbar{padding:14px 16px}}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="brand">
<div class="logo">问卷</div>
<div>
<h1><?= h(TITLE) ?></h1>
<div class="sub">当前账号:<strong><?= h((string)$auth['username']) ?></strong> · 共 <strong><?= $total ?></strong> 条提交</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn-ghost" onclick="openAuthModal()">修改账号密码</button>
<a class="btn btn-danger" href="<?= h(current_url(['action' => 'logout'])) ?>">退出登录</a>
</div>
</div>
<div class="diag">
<div><strong>📁 数据文件:</strong><code><?= h(DATA_FILE) ?></code></div>
<div>
<?php if (!$__diag['dirExist']): ?>
<span class="bad">⚠ 目录不存在</span>
<?php elseif (!$__diag['exists']): ?>
<span class="warn">⚠ 文件不存在</span>
<?php elseif (!$__diag['writable']): ?>
<span class="bad">⚠ 文件不可写</span>
<?php else: ?>
<span class="ok">✓ 正常</span>
<?php endif; ?>
<?php if ($__diag['exists']): ?>
<span class="meta"><?= number_format($__diag['size']) ?> 字节</span>
<span class="meta">最后修改:<?= h(date('Y-m-d H:i:s', $__diag['mtime'])) ?></span>
<?php endif; ?>
</div>
</div>
<?php if ($flash): ?>
<div class="flash <?= h($flash['type'] ?? 'ok') ?>"><?= h($flash['msg'] ?? '') ?></div>
<?php endif; ?>
<div class="card">
<form method="get" class="toolbar">
<input type="search" name="q" value="<?= h($kw) ?>" placeholder="搜索任意关键词(姓名 / 邮箱 / IP / 内容…)" style="min-width:300px">
<button type="submit" class="btn-pri">搜索</button>
<?php if ($kw !== ''): ?><a class="btn btn-ghost" href="?">重置</a><?php endif; ?>
<span style="width:12px"></span>
<label class="switch" title="勾选后,手机号/邮箱等敏感字段显示原文">
<input type="checkbox" name="showraw" value="1" onchange="this.form.submit()" <?= $showRaw ? 'checked' : '' ?>>
显示敏感字段原文
</label>
<?php if ($kw !== ''): ?><input type="hidden" name="q" value="<?= h($kw) ?>"><?php endif; ?>
</form>
</div>
<form method="post" id="mainForm" action="">
<input type="hidden" name="_csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="action" id="formAction" value="">
<input type="hidden" name="format" id="formFormat" value="">
<input type="hidden" name="q" value="<?= h($kw) ?>">
<input type="hidden" name="id" id="delId" value="">
<input type="hidden" name="mask_sensitive" id="maskField" value="0">
<div class="card">
<div class="toolbar">
<button type="button" class="btn-pri" onclick="doExport('csv')">导出 CSV</button>
<button type="button" class="btn-ghost" onclick="doExport('txt')">导出 TXT</button>
<button type="button" class="btn-ghost" onclick="doExport('json')">导出 JSON</button>
<button type="button" class="btn-ghost" onclick="doExport('xls')">导出 Excel</button>
<label class="switch" title="导出时把手机号、邮箱等敏感字段部分打码">
<input type="checkbox" id="exportMask" checked>
导出时脱敏
</label>
<span style="flex:1"></span>
<button type="button" class="btn-danger" onclick="doBulkDelete()">删除选中</button>
<button type="button" class="btn-danger-solid" onclick="doClear()">清空全部</button>
</div>
<p class="tip">不勾选任何行时,「导出」= 导出当前筛选条件下的全部数据;勾选后 = 只导出选中项。</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="chk"><input type="checkbox" id="checkAll"></th>
<th>#</th>
<th>提交时间</th>
<th>IP</th>
<th>操作系统</th>
<th>浏览器</th>
<th>设备</th>
<?php foreach ($columns as $c): ?>
<th><?= h($c) ?><?php if (is_sensitive_field($c)): ?><span class="badge">敏感</span><?php endif; ?></th>
<?php endforeach; ?>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php if (!$pageRows): ?>
<tr><td colspan="<?= 8 + count($columns) ?>" class="empty">
暂无数据<br>
<span style="font-size:12px;color:#c0c4cc">前端提交后,这里会显示所有字段</span>
</td></tr>
<?php else: ?>
<?php foreach ($pageRows as $i => $r):
$rowId = (string)($r['id'] ?? '');
?>
<tr>
<td class="chk"><input type="checkbox" name="ids[]" value="<?= h($rowId) ?>"></td>
<td><?= $offset + $i + 1 ?></td>
<td><?= h((string)($r['created_at'] ?? '')) ?></td>
<td><?= h((string)($r['ip'] ?? '')) ?></td>
<td><span class="badge badge-os"><?= h((string)($r['os'] ?? '未知')) ?></span></td>
<td><?= h((string)($r['browser'] ?? '未知')) ?></td>
<td><span class="badge badge-dev"><?= h((string)($r['device'] ?? '未知')) ?></span></td>
<?php foreach ($columns as $c):
$val = flat_value($r['data'][$c] ?? '');
$isSensitive = is_sensitive_field($c) || preg_match('/(phone|mobile|tel|email|mail|手机|邮箱)/i', $c);
$display = ($isSensitive && $val !== '' && !$showRaw) ? h(mask_value($c, $val)) : h(shorten($val));
?>
<td><span class="v" title="<?= h($val) ?>"><?= $display ?></span></td>
<?php endforeach; ?>
<td>
<button type="button" class="btn-sm" onclick='showDetail(<?= json_encode($r, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>详情</button>
<button type="button" class="btn-danger btn-sm" onclick="delOne('<?= h($rowId) ?>')">删除</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($pages > 1): ?>
<div class="pager">
<?php if ($page > 1): ?><a href="<?= h(page_link($page - 1, $kw)) ?>">上一页</a><?php endif; ?>
<?php
$start = max(1, $page - 2);
$end = min($pages, $page + 2);
if ($start > 1) {
echo '<a href="' . h(page_link(1, $kw)) . '">1</a>';
if ($start > 2) echo '<span class="gap">…</span>';
}
for ($i = $start; $i <= $end; $i++) {
echo $i === $page ? '<span class="cur">' . $i . '</span>' : '<a href="' . h(page_link($i, $kw)) . '">' . $i . '</a>';
}
if ($end < $pages) {
if ($end < $pages - 1) echo '<span class="gap">…</span>';
echo '<a href="' . h(page_link($pages, $kw)) . '">' . $pages . '</a>';
}
?>
<?php if ($page < $pages): ?><a href="<?= h(page_link($page + 1, $kw)) ?>">下一页</a><?php endif; ?>
<span class="stats" style="margin-left:8px">第 <?= $page ?> / <?= $pages ?> 页</span>
</div>
<?php endif; ?>
</form>
</div>
<div class="modal-mask" id="authModal">
<div class="modal" role="dialog">
<div class="modal__hd">
<h3>修改账号密码</h3>
<button type="button" class="modal__close" onclick="closeAuthModal()">×</button>
</div>
<form method="post" id="authForm">
<div class="modal__bd">
<input type="hidden" name="_csrf" value="<?= h($CSRF) ?>">
<input type="hidden" name="action" value="change_auth">
<div class="form-row">
<label>当前密码(用于验证身份)</label>
<input type="password" name="current_password" required autocomplete="current-password">
</div>
<div class="form-row">
<label>新用户名(至少 <?= MIN_NEW_USER_LEN ?> 位)</label>
<input type="text" name="new_username" required autocomplete="off" value="<?= h((string)$auth['username']) ?>">
</div>
<div class="form-row">
<label>新密码(至少 <?= MIN_NEW_PASS_LEN ?> 位)</label>
<input type="password" name="new_password" required autocomplete="new-password">
</div>
<div class="form-row">
<label>确认新密码</label>
<input type="password" name="confirm_password" required autocomplete="new-password">
</div>
<p class="tip">保存后,认证信息以 gzcompress + Base64 写入 <code style="font-size:11px">data/admin_auth.txt</code>。</p>
</div>
<div class="modal__ft">
<button type="button" class="btn-ghost" onclick="closeAuthModal()">取消</button>
<button type="submit" class="btn-pri">保存修改</button>
</div>
</form>
</div>
</div>
<div class="modal-mask" id="detailModal">
<div class="modal modal--wide" role="dialog">
<div class="modal__hd">
<h3>提交详情</h3>
<button type="button" class="modal__close" onclick="hideDetail()">×</button>
</div>
<div class="modal__bd" id="detailBody"></div>
</div>
</div>
<script>
(function () {
const form = document.getElementById('mainForm');
const actEl = document.getElementById('formAction');
const fmtEl = document.getElementById('formFormat');
const idEl = document.getElementById('delId');
const maskEl = document.getElementById('maskField');
const maskChk = document.getElementById('exportMask');
const SENSITIVE_KEYS = ['password','passwd','pwd','secret','token','idcard','身份证','bank','card'];
const PII_KEYS = ['phone','mobile','tel','email','mail','手机','邮箱'];
function isSensitive(key) {
const k = String(key).toLowerCase();
return SENSITIVE_KEYS.concat(PII_KEYS).some(w => k.indexOf(w) !== -1);
}
function checkedIds() {
return Array.from(document.querySelectorAll('input[name="ids[]"]:checked')).map(c => c.value);
}
window.doExport = function (fmt) {
actEl.value = 'export';
fmtEl.value = fmt;
maskEl.value = (maskChk && maskChk.checked) ? '1' : '0';
form.submit();
setTimeout(() => { actEl.value = ''; fmtEl.value = ''; }, 800);
};
window.delOne = function (id) {
if (!confirm('确定删除这条数据吗?')) return;
idEl.value = id;
actEl.value = 'delete';
form.submit();
};
window.doBulkDelete = function () {
const ids = checkedIds();
if (!ids.length) { alert('请先勾选要删除的数据'); return; }
if (!confirm('确定删除选中的 ' + ids.length + ' 条数据吗?')) return;
actEl.value = 'bulk_delete';
form.submit();
};
window.doClear = function () {
if (!confirm('确定要清空【全部】数据吗?\n\n系统会自动备份一份到 data/backups 目录。')) return;
actEl.value = 'clear';
form.submit();
};
const checkAll = document.getElementById('checkAll');
if (checkAll) {
checkAll.addEventListener('change', function () {
document.querySelectorAll('input[name="ids[]"]').forEach(c => { c.checked = checkAll.checked; });
});
}
const modal = document.getElementById('detailModal');
const detailBody = document.getElementById('detailBody');
window.showDetail = function (row) {
const data = (row && row.data) || {};
let html = '<dl class="kv">';
html += '<dt>ID</dt><dd class="mono">' + esc(row.id || '') + '</dd>';
html += '<dt>提交时间</dt><dd>' + esc(row.created_at || '') + '</dd>';
html += '<dt>IP 地址</dt><dd class="mono">' + esc(row.ip || '') + '</dd>';
html += '<dt>操作系统</dt><dd>' + esc(row.os || '') + '</dd>';
html += '<dt>浏览器</dt><dd>' + esc(row.browser || '') + '</dd>';
html += '<dt>设备类型</dt><dd>' + esc(row.device || '') + '</dd>';
if (row.ua) html += '<dt>User-Agent</dt><dd class="mono">' + esc(row.ua) + '</dd>';
html += '<div class="kv-sep"></div>';
Object.keys(data).forEach(function (k) {
html += '<dt>' + esc(k) + (isSensitive(k) ? ' <span class="badge">敏感</span>' : '') + '</dt>';
html += '<dd class="mono">' + esc(data[k]) + '</dd>';
});
html += '</dl>';
detailBody.innerHTML = html;
modal.classList.add('is-open');
document.body.style.overflow = 'hidden';
};
window.hideDetail = function () {
modal.classList.remove('is-open');
document.body.style.overflow = '';
};
modal.addEventListener('click', function (e) { if (e.target === modal) hideDetail(); });
const authModal = document.getElementById('authModal');
window.openAuthModal = function () {
authModal.classList.add('is-open');
document.body.style.overflow = 'hidden';
setTimeout(function () {
const el = authModal.querySelector('input[name="current_password"]');
if (el) el.focus();
}, 200);
};
window.closeAuthModal = function () {
authModal.classList.remove('is-open');
document.body.style.overflow = '';
};
authModal.addEventListener('click', function (e) { if (e.target === authModal) closeAuthModal(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (modal.classList.contains('is-open')) hideDetail();
if (authModal.classList.contains('is-open')) closeAuthModal();
}
});
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
})();
</script>
</body>
</html>
前端展示
