Hongmu Notes
Home Program Notes PHP Questionnaire Survey System
Program Notes PHP Hobbies Practical Collection

PHP Questionnaire Survey System

PHP Questionnaire Survey System

Questionnaire Survey System · Comprehensive Tutorial

I. System Architecture

data flow

用户填写问卷 (index.php)
       │
       │  POST FormData
       ▼
   .api.php  ←── 接收 + 记录环境信息(IP/OS/浏览器/设备/时间)
       │
       │  写入 JSON
       ▼
   data/submissions.json
       │
       │  读取
       ▼
   admin.php ←── 后台查看 / 搜索 / 导出 / 删除 / 清空

The responsibilities of the three documents

Document | Purpose | Access Method index.php Front-end questionnaire page – the form displayed to users; publicly accessible. .api.php Backend API: receives data, identifies the environment, and writes to JSON; only POST requests are accepted. admin.php Admin Console: After logging in, view all submitted data requiring a password; Data Storage Structure

data/submissions.json Record format:

{
  "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": "很满意"
  }
}
  • Outer fixed field:id / created_at / ip / os / browser / device / ua – – – Automatically recorded via the interface
  • data Fields:The frontend submits whatever is entered and stores it.No limit on quantity or name

Certification Information Storage

data/admin_auth.txt(Backend Account Password):

# Admin Auth File
# 格式:gzcompress(json) → base64,每行 76 字符
H4sIAAAAAAAAA6tWykvMTVWyUjA0MFDSUUrOz0lNzQOKGZqba0FluQo5qc...
  • Storage Link:json_encodegzcompressbase64 → Newline after every 76 characters
  • The password itself is used. password_hash() Secondary Encryption
  • Document corrupted or deleted → Auto-recover to default admin / 123456

II. Directory Structure

网站根目录/
├── index.php             ← 前端问卷
├── .api.php              ← 提交接口
├── admin.php             ← 管理后台
└── data/                 ← 自动生成
    ├── submissions.json  ← 提交数据
    ├── admin_auth.txt    ← 后台账号密码
    └── backups/          ← 清空时的自动备份

III. Document 1:index.php(Front-end Questionnaire)

<?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>

IV. Document II:.api.php(Submit Interface)

<?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']]);

V. Document III:admin.php(back-end management)

<?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()">&times;</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()">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }
})();
</script>
</body>
</html>

Front-end display

微信赞赏

WeChat

支付宝赞赏

Alipay

✍️ Author: Hong Mu

webmaster · Thanks for reading, stay tuned for more exciting content

Author homepage View home page →

Related articles

PHPStudy 8.2 Extension Fix

PHPStudy 8.2 Extension Fix Program Notes PHP Hobbies

I don’t know what’s wrong with php. After opening the 8.2 version of php, the extension cannot be installed. When opening the php.ini configuration, there is nothing. All, find a piece of content, fill it in, and the problem will be solved. [PHP] engine = On short_open_tag = On precision = 14 output_buffering = 4096 zli…
👁 1
What is the optimal number of data sub-tables for EmpireCMS? How should published data be distributed across sub-tables? How can a million records in EmpireCMS be evenly distributed across multiple sub-tables?

What is the optimal number of data sub-tables for EmpireCMS? How should published data be distributed across sub-tables? How can a million records in EmpireCMS be evenly distributed across multiple sub-tables? Program Notes Empire cms Practical Collection empire plugin

How many data rows should be split into separate tables for EmpireCMS? 1. For a database size of 50 GB, it is advisable to create a new main table; 2. For a dataset of 50,000 rows or more, create a new secondary table and set this newly created secondary table as the current storage table; [Some recommend splitting the entire dataset into a single table when the data volume reaches 100,000 rows.] An excessively large dataset has resulted in extremely high I/O read/write operations on the MySQL database, leading to excessive server load. This is particularly noticeable when performing backend operations in EmpireCMS – especially for sections with large data volumes; this was the case on my website before I implemented table partitioning...
👁 683
EmpireCMS uses PHP to directly submit data for updating articles.

EmpireCMS uses PHP to directly submit data for updating articles. Program Notes Empire cms Language Notes PHP

Submitted code: While having some free time, I took the time to thoroughly study the Train Engine module and integrate it with Empire CMS, resulting in the following code. Experienced PHP developers are welcome to modify this code; however, this is merely a simple submission script – the data must be configured manually! <?php $url = "http://www.4s5.cn/e/admin/123.php"; // Replace this with the actual URL where the data should be received...
👁 385
sqlite database online php read operation view, pbootcms database online view

sqlite database online php read operation view, pbootcms database online view Program Notes PbootCms Language Notes PHP

When using pbootcms, a database is often used, but the database of pb is a .db file, so it is very troublesome. There is no ready-made visualization page to view the database, so a php visualization page for php online viewing is developed here. This will be much more convenient. How to use? In the folder of the db file, create a php, put the following code in it, modify your database name, and then access p...
👁 167
Resource website typecho001 template

Resource website typecho001 template Program Notes Typecho

typecho001 template template please do not modify the folder name of this template. The folder name is: typecho001 1.4 Fix the js output problem on the article page 1.3 Fix the error prompt when the plug-in is not installed Optimize the list page code output 1.2 Fix the comment function and add a custom homepage title 1.1 Fix the comment reply asymmetry function Add a separate title setting function on the homepage Add the website favicon.ico icon Add one...
👁 197

Recommended reading

Electronic Components Foreign Trade Responsive English Website Template 1116

Electronic Components Foreign Trade Responsive English Website Template 1116 Practical Collection Yiyou template

This EyouCMS responsive English-language website template is ideal for the electronic components export industry. Its modern, international design style effectively showcases electronic component products, technical specifications, export advantages, and global market presence, helping electronics companies enhance their brand visibility in the international market. Template Overview | Installation Instructions | Website Backend: /login.php | Username: admin | Password: admin | Related Articles: Summary of Common Installation Issues for EyouCMS | EyouCMS (...
👁 41
Responsive NBA Sports Event News Template 0132

Responsive NBA Sports Event News Template 0132 Practical Collection Yiyou template

An eyouCMS responsive template designed for NBA sports event coverage. Its dynamic and energetic design enables seamless display of NBA live streams, score updates, team news, and in-depth analyses – helping sports media outlets attract basketball enthusiasts online. Template Display | Installation Instructions | Website Backend: /login.php | Username: admin | Password: admin | Related Articles: Summary of Common Installation Issues for EyouCMS | EyouCMS (Ey...
👁 59
(Adaptive mobile phone version) Industrial and commercial registration website pbootcms template Financial agency accounting website source code download 0343

(Adaptive mobile phone version) Industrial and commercial registration website pbootcms template Financial agency accounting website source code download 0343 Practical Collection pbootcms Template

A PbootCMS website template that adapts to mobile phones for industrial and commercial registration and financial agency accounting. The design style is simple and professional, suitable for finance and taxation companies to display one-stop services such as accounting and tax filing, industrial and commercial registration. It helps financial service institutions establish their brand image online and acquire corporate customers. Template display Installation instructions Website backend:/admin.php Account: admin Password: admin Unzip password: www.4s5.c…
👁 39
php split a string into its constituent characters

php split a string into its constituent characters Language Notes PHP

Question: How to split a string into its constituent characters in PHP? For example, hello -> [h, e, l, l, o] There are three methods: This is the string that needs to be split: $str = 'Hello sample'; The length of the string: $len = mb_strlen($str, 'utf8'); // 7 The first way: $arr = str_…
👁 182
Responsive Furniture Sales Website Template 0370

Responsive Furniture Sales Website Template 0370 Practical Collection Yiyou template

An EyouCMS responsive website template designed specifically for the furniture and home furnishings sales industry. Its modern and practical design is ideal for showcasing furniture products, furniture collections, home decor combinations, and brand stories. This template helps furniture brands showcase their products online and attract home consumers. Template Overview | Installation Instructions | Website Backend: /login.php | Username: admin | Password: admin | Related Articles: Summary of Common Installation Issues for EyouCMS | EyouCMS...
👁 37
(PC+WAP) Blue Hardware and Machinery Website Template (pbootcms Enterprise Website Template) – Download General Marketing Website Source Code – 0583

(PC+WAP) Blue Hardware and Machinery Website Template (pbootcms Enterprise Website Template) – Download General Marketing Website Source Code – 0583 Practical Collection pbootcms Template

A versatile blue hardware and machinery PbootCMS corporate website template designed for various machinery manufacturing and hardware processing industries, compatible with both PC and mobile devices. It features a clean, professional design and comprehensive functional modules. This template is an ideal choice for machinery and equipment companies looking to quickly build a marketing-oriented branded official website and enhance their online promotion effectiveness. Template Preview | Installation Instructions | Website Backend: /admin.php | Username: admin | Password: admin | Extract password...
👁 39