홍무 노트
홈페이지 프로그램 노트 PHP 설문조사 시스템
프로그램 노트 PHP 아마추어 취미 실용적인 저장하기

PHP 설문조사 시스템

PHP 설문조사 시스템

설문조사 시스템 · 포괄적인 사용자 가이드

1. 시스템 아키텍처

데이터 스트림

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

3개 문서의 역할

문서 | 기능 | 접근 방식 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_encodegzcompressbase64 → 76자마다 줄 바꾸기
  • 암호 자체 사용 password_hash() 二次 암호화
  • 문서 손상 또는 삭제 시 → 기본값으로 자동 복구 admin / 123456

II. 목차 구조

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

III. 문서 1: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>

IV. 문서 2:.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']]);

5. 문서 3: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()">&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>

프론트엔드 뷰

微信赞赏

위챗

支付宝赞赏

알리페이

✍️ 저자: 홍무

웹사이트 관리자 · 읽으셔서 감사합니다. 더 많은 흥미로운 콘텐츠를 확인하시려면 계속 방문해 주세요.

저자 프로필 홈페이지 보기 →

관련 기사

phpstudy 8.2 확장 기능 복구

phpstudy 8.2 확장 기능 복구 프로그램 노트 PHP 아마추어 취미

PHP에서 왠지 모르게 이상한 현상이 발생하는 것 같습니다. PHP 8.2 버전을 실행했을 때 확장 프로그램이 설치되지 않았습니다. php.ini 설정 파일을 확인해 보니 아무 내용도 없었습니다. 이에 따라 특정 코드를 찾아 넣어보니 문제는 해결되었습니다. [PHP] engine = On short_open_tag = On precision = 14 output_buffering = 4096 …
👁 36
EmpireCMS에서 데이터를 몇 개의 분할 테이블로 분할하는 것이 적절한가요? 이미 게시된 데이터는 어떻게 분할 테이블로 분할해야 하나요? EmpireCMS의 100만 건의 데이터를 여러 분할 테이블에 어떻게 균등하게 분배해야 하나요?

EmpireCMS에서 데이터를 몇 개의 분할 테이블로 분할하는 것이 적절한가요? 이미 게시된 데이터는 어떻게 분할 테이블로 분할해야 하나요? EmpireCMS의 100만 건의 데이터를 여러 분할 테이블에 어떻게 균등하게 분배해야 하나요? 프로그램 노트 Empire CMS 실용적인 저장하기 Imperial 플러그인

EmpireCMS에서 데이터를 몇 개의 별도 테이블로 분할하는 것이 적절한가요? 1. 데이터베이스 용량이 50GB라면 주 테이블을 새로 생성하는 것이 좋습니다; 2. 데이터가 5만 건에 달하는 경우 부 테이블을 새로 생성하고, 새로 생성한 부 테이블을 현재 저장 테이블로 설정하는 것이 좋습니다. [다른 의견으로는 데이터가 10만 건에 달할 경우 한 번에 하나의 테이블로 분할하는 방법도 제안됩니다.] 데이터량이 너무 많아져 MySQL의 데이터 I/O 작업량이 매우 커져서 전체 서버 부하가 과도해지는 문제가 발생합니다. 특히 EmpireCMS의 백엔드 작업이 느려지는 현상이 두드러지는데, 이는 특히 데이터량이 많은 칼럼의 경우에 해당합니다. 본인의 사이트는 데이터 분할을 하지 않은 상태였습니다…
👁 692
EmpireCMS는 PHP를 사용하여 데이터를 직접 전송하여 게시물을 업데이트합니다.

EmpireCMS는 PHP를 사용하여 데이터를 직접 전송하여 게시물을 업데이트합니다. 프로그램 노트 Empire CMS 언어 노트 PHP

제출된 코드: 여유 시간이 생긴 틈을 이용해 'Trainhead' 모듈을 꼼꼼히 분석한 뒤 이를 EmpireCMS와 결합하여 아래와 같은 코드를 작성했습니다. PHP 숙련자라면 이 코드를 수정할 수 있습니다. 이 코드는 단순한 제출용 코드이며, 데이터는 직접 설정해야 합니다! <?php $url = "http://www.4s5.cn/e/admin/123.php"; // 실제 데이터 수신 URL로 변경하세요…
👁 390
SQLite 데이터베이스를 PHP를 사용하여 온라인으로 읽고 조회하기, PBootCMS 데이터베이스를 온라인으로 확인하기

SQLite 데이터베이스를 PHP를 사용하여 온라인으로 읽고 조회하기, PBootCMS 데이터베이스를 온라인으로 확인하기 프로그램 노트 PbootCms 언어 노트 PHP

pbootcms를 사용할 때는 종종 데이터베이스를 활용해야 하는데, PB의 데이터베이스는 .db 파일 형태이므로 다소 번거로운 점이 있습니다. 데이터베이스를 확인할 수 있는 일괄 시각화 페이지가 제공되지 않아, 이에 따라 PHP 기반의 온라인 데이터베이스 확인용 시각화 페이지를 개발하였습니다. 이를 통해 작업이 훨씬 편리해집니다. 어떻게 사용하나요? .db 파일이 있는 폴더 내에 PHP 파일을 생성한 후 아래 코드를 작성하고, 사용자의 데이터베이스 이름을 수정한 다음 p…를 접속하세요.
👁 179
Empire CMS 8.0의 검색 목록 페이지에서 동적 PHP 코드 표시 기능을 지원합니다.

Empire CMS 8.0의 검색 목록 페이지에서 동적 PHP 코드 표시 기능을 지원합니다. 프로그램 노트 Empire CMS PHP 사고 사례 요약

EmpireCMS를 8.0 버전으로 업데이트했습니다. 일상적인 사용 과정에서 검색 목록 페이지가 동적 PHP 코드 표시를 지원하지 않는다는 점을 발견하였으므로, 이 코드를 수정하여 여기에 삽입하였습니다. <?php require("../../class/connect.php"); require("../../data/dbcache/class.php"); …
👁 101

추천 읽을거리

EmpireCMS 소프트웨어 다운로드 사이트 – WAP 및 PC용 이중 플랫폼 자동 적응형 템플릿 021

EmpireCMS 소프트웨어 다운로드 사이트 – WAP 및 PC용 이중 플랫폼 자동 적응형 템플릿 021 실용적인 저장하기 제국 템플릿

매우 훌륭한 템플릿입니다. 테스트 과정에서 데이터베이스 내의 다른 웹사이트 링크들이 이미 대체되었으므로 안심하여 사용하시기 바랍니다! 미리보기 영상에는 수집기가 포함되어 있으나, 자세히 확인하지 않으셨다면 압축 해제 비밀번호가 필요합니다🔒 숨겨진 내용: 댓글을 남기신 후 확인하십시오. 다운로드 페이지에 비밀번호 입력 안내가 나타나지 않는 경우, 기본 비밀번호는 www.4s5.cn입니다.
👁 312
WordPress의 `wp_cache_set` 함수

WordPress의 `wp_cache_set` 함수 프로그램 노트 wordpress

`wp_cache_set()`는 WordPress에 내장된 함수로, 캐시에 데이터를 저장하는 데 사용됩니다. 이 함수의 프로토타입은 다음과 같습니다: `wp_cache_set($key, $data, $group = '', $expire = 0)`. 매개변수 설명: - `$key`: 데이터를 저장할 키 이름으로, 일반적으로 문자열 형식입니다. - `$data`: 저장할 데이터입니다.
👁 182
Baidu API를 호출할 때 오류가 발생했습니다. 오류 코드: `redirect_uri_mismatch`, 오류 메시지: `Invalid redirect uri`

Baidu API를 호출할 때 오류가 발생했습니다. 오류 코드: `redirect_uri_mismatch`, 오류 메시지: `Invalid redirect uri` 사고 사례 요약

이 오류는 일반적으로 Baidu API를 사용할 때 허용된 콜백 주소(redirect_uri)가 앱 관리 섹션에서 설정한 콜백 주소와 일치하지 않을 때 발생합니다. 다음 항목들을 확인하여 오류가 없는지 점검하세요: 앱 관리 섹션에서 설정한 콜백 주소가 코드에서 사용하는 콜백 주소와 일치하는지 확인하세요. 콜백 주소가 올바른지 확인하세요. 콜백 주소는 귀하의 앱에 대해 유효한 도메인명 또는 IP 주소여야 합니다. …
👁 663
PHP를 사용하여 데이터베이스에 연결하고 SQL 파일을 가져오세요.

PHP를 사용하여 데이터베이스에 연결하고 SQL 파일을 가져오세요. 언어 노트 PHP

PHP를 사용하여 데이터베이스에 연결하고 SQL 파일을 가져오려면 PHP의 MySQLi 확장 기능을 사용할 수 있습니다. 다음은 데이터베이스에 연결하고 SQL 파일을 실행하는 방법을 보여주는 간단한 예시 코드입니다: // 데이터베이스 연결 정보 설정 $host = "localhost"; // 데이터베이스 호스트 이름 $user = "username"; // 데이터베이스 사용자 이름…
👁 156
(자동 적응형 모바일용) HTML5 리스폰시브 피트니스 클럽용 pbootCMS 웹사이트 템플릿 – 녹색 피트니스 웹사이트 소스 코드 다운로드 (0186)

(자동 적응형 모바일용) HTML5 리스폰시브 피트니스 클럽용 pbootCMS 웹사이트 템플릿 – 녹색 피트니스 웹사이트 소스 코드 다운로드 (0186) 실용적인 저장하기 pbootcms 템플릿

자체 적응형 모바일 HTML5 리소시브 피트니스 클럽용 PbootCMS 웹사이트 템플릿으로, 녹색 스타일을 적용했습니다. 생동감 있는 디자인 스타일은 헬스장 환경, 강좌 일정 및 코치 팀을 효과적으로 소개하는 데 적합합니다. 이 템플릿을 활용하면 피트니스 클럽이 온라인으로 회원을 유치하고 브랜드 인지도를 높일 수 있습니다. 템플릿 확인 및 설치 안내: 웹사이트 백엔드: /admin.php; 사용자 이름: admin; 비밀번호: admin; 압축 해제 비밀번호: www.4s…
👁 38
(PC+WAP) pbootCMS 초·중등학교 교육훈련 기관 웹사이트 템플릿 – 녹색 초등학교 웹사이트 소스 코드 다운로드 (0638)

(PC+WAP) pbootCMS 초·중등학교 교육훈련 기관 웹사이트 템플릿 – 녹색 초등학교 웹사이트 소스 코드 다운로드 (0638) 실용적인 저장하기 pbootcms 템플릿

초·중등학교 및 교육훈련 기관에 적합한 PbootCMS 녹색 캠퍼스 웹사이트 템플릿으로, PC 및 WAP 기기에서 원활하게 이용할 수 있습니다. 디자인 스타일은 신선하고 자연스러운 느낌을 주며, 주色调을 녹색으로 설정하여 학교의 활기와 교육 분위기를 효과적으로 표현합니다. 이 템플릿에는 과목 소개, 교사 소개, 뉴스 동향, 입학 정보 등 핵심 모듈이 내장되어 있어 학교나 교육기관이 공식 웹사이트를 신속하게 구축할 수 있도록 도와주며, 교육 브랜드의 신뢰도를 효과적으로 높여줍니다. 템플릿 확인 및 설치 안내 …
👁 52