红穆笔记
首頁 程序筆記 php問卷調查系統
程序筆記 PHP 業餘愛好 實用收藏

php問卷調查系統

php问卷调查系统

問卷調查系統 · 完整教程

一、系統架構

數據流

用戶填寫問卷 (index.php)
       │
       │  POST FormData
       ▼
   .api.php  ←── 接收 + 記錄環境信息(IP/OS/瀏覽器/設備/時間)
       │
       │  寫入 JSON
       ▼
   data/submissions.json
       │
       │  讀取
       ▼
   admin.php ←── 後臺查看 / 搜索 / 導出 / 刪除 / 清空

三個文件的職責

文件 作用 訪問方式 index.php 前端問卷頁面,用戶看到的表單 公開訪問 .api.php 後端接口,接收數據、識別環境、寫入 JSON 只接受 POST admin.php 管理後臺,登錄後查看所有提交 需密碼 數據存儲結構

data/submissions.json 每條記錄的格式:

{
  "id": "20260916143022-a1b2c3d4e5f6",
  "created_at": "2026-09-16 14:30:22",
  "ip": "1.2.3.4",
  "os": "Windows 10/11",
  "browser": "Chrome",
  "device": "電腦",
  "ua": "Mozilla/5.0 ...",
  "data": {
    "name": "張三",
    "email": "zhangsan@mail.com",
    "age_group": "26-35歲",
    "rating": "5",
    "message": "很滿意"
  }
}
  • 外層固定字段:id / created_at / ip / os / browser / device / ua —— 由接口自動記錄
  • data 裏的字段:前端提交什麼就存什麼,不限數量和名稱

認證信息存儲

data/admin_auth.txt(後臺賬號密碼):

# Admin Auth File
# 格式:gzcompress(json) → base64,每行 76 字符
H4sIAAAAAAAAA6tWykvMTVWyUjA0MFDSUUrOz0lNzQOKGZqba0FluQo5qc...
  • 存儲鏈路:json_encodegzcompressbase64 → 每 76 字符換行
  • 密碼本身用 password_hash() 二次加密
  • 文件損壞或刪除 → 自動恢復爲默認 admin / 123456

二、目錄結構

網站根目錄/
├── index.php             ← 前端問卷
├── .api.php              ← 提交接口
├── admin.php             ← 管理後臺
└── data/                 ← 自動生成
    ├── submissions.json  ← 提交數據
    ├── admin_auth.txt    ← 後臺賬號密碼
    └── backups/          ← 清空時的自動備份

三、文件一:index.php(前端問卷)

<?php
/**
 * 前端問卷首頁
 * 表單提交 → /.api.php
 */
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>用戶滿意度調查問卷</title>
<style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{
        min-height:100vh;padding:32px 16px;
        background:
            radial-gradient(900px 600px at 10% -10%, #dbeafe 0%, transparent 55%),
            radial-gradient(800px 600px at 110% 110%, #ede9fe 0%, transparent 55%),
            linear-gradient(135deg,#f8fafc 0%,#eef2f7 100%);
        font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
        color:#0f172a;
    }
    .page{max-width:680px;margin:0 auto}

    .hero{
        background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
        color:#fff;border-radius:22px;padding:34px 32px;position:relative;overflow:hidden;
        box-shadow:0 24px 50px -20px rgba(99,102,241,.55);margin-bottom:20px;
    }
    .hero::after{
        content:"";position:absolute;right:-40px;bottom:-60px;width:220px;height:220px;
        background:radial-gradient(circle, rgba(255,255,255,.25) 0%, transparent 70%);
        border-radius:50%;
    }
    .hero-tag{
        display:inline-flex;align-items:center;gap:6px;
        background:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.3);
        padding:5px 12px;border-radius:20px;font-size:12.5px;font-weight:600;margin-bottom:14px;
    }
    .hero h1{font-size:24px;font-weight:800;letter-spacing:-.3px;margin-bottom:8px;position:relative}
    .hero p{font-size:14px;opacity:.9;max-width:480px;position:relative}

    .card{
        background:#fff;border:1px solid #e5e7eb;border-radius:20px;padding:28px;
        box-shadow:0 12px 30px -18px rgba(15,23,42,.25);
    }
    .form-row{margin-bottom:22px}
    .form-row:last-of-type{margin-bottom:8px}

    .label{display:block;font-size:13.5px;font-weight:700;color:#334155;margin-bottom:9px}
    .label .req{color:#ef4444;margin-left:3px}
    .label .hint{font-weight:500;color:#94a3b8;font-size:12.5px;margin-left:6px}

    input[type=text], input[type=email], input[type=tel], input[type=number], textarea{
        width:100%;padding:0 14px;font-family:inherit;font-size:14px;color:#0f172a;
        background:#fbfcfe;border:1.5px solid #e2e8f0;border-radius:12px;outline:none;
        transition:border-color .18s ease, box-shadow .18s ease, background .18s ease;
    }
    input[type=text], input[type=email], input[type=tel], input[type=number]{height:46px}
    textarea{padding:12px 14px;min-height:110px;resize:vertical;line-height:1.6}
    input:focus, textarea:focus{
        border-color:#a5b4fc;background:#fff;box-shadow:0 0 0 4px rgba(139,92,246,.13);
    }
    input::placeholder, textarea::placeholder{color:#cbd5e1}

    .options{display:flex;flex-wrap:wrap;gap:9px}
    .opt{
        position:relative;display:inline-flex;align-items:center;gap:8px;
        padding:10px 16px;background:#fbfcfe;border:1.5px solid #e2e8f0;border-radius:11px;
        font-size:13.5px;font-weight:500;cursor:pointer;color:#475569;
        transition:all .18s ease;user-select:none;
    }
    .opt:hover{border-color:#c7d2fe;background:#f5f3ff}
    .opt input{position:absolute;opacity:0;pointer-events:none}
    .opt::before{
        content:"";width:16px;height:16px;border:2px solid #cbd5e1;border-radius:50%;
        transition:all .18s ease;flex:0 0 16px;
    }
    .opt.multi::before{border-radius:5px}
    .opt:has(input:checked){
        border-color:#8b5cf6;background:#f5f3ff;color:#6d28d9;font-weight:600;
        box-shadow:0 4px 12px -6px rgba(139,92,246,.4);
    }
    .opt:has(input:checked)::before{background:#8b5cf6;border-color:#8b5cf6;box-shadow:inset 0 0 0 3px #fff}

    .stars{display:flex;gap:8px;flex-direction:row-reverse;justify-content:flex-end}
    .stars input{display:none}
    .stars label{
        font-size:32px;color:#e2e8f0;cursor:pointer;line-height:1;
        transition:color .15s ease, transform .15s ease;
    }
    .stars label:hover,
    .stars label:hover ~ label,
    .stars input:checked ~ label{color:#f59e0b}
    .stars label:hover{transform:scale(1.12)}
    .stars input:checked + label{transform:scale(1.1)}

    .submit-wrap{margin-top:26px;text-align:center}
    .btn-submit{
        width:100%;height:52px;border:none;border-radius:14px;cursor:pointer;
        font-family:inherit;font-size:16px;font-weight:700;letter-spacing:.4px;color:#fff;
        background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
        box-shadow:0 16px 32px -14px rgba(99,102,241,.75);
        transition:transform .15s ease, box-shadow .2s ease, filter .2s ease;
    }
    .btn-submit:hover{transform:translateY(-2px);box-shadow:0 20px 40px -14px rgba(99,102,241,.85)}
    .btn-submit:active{transform:translateY(0)}
    .btn-submit:disabled{opacity:.7;cursor:not-allowed;transform:none}
    .foot-note{margin-top:14px;font-size:12.5px;color:#94a3b8;text-align:center}

    .alert{
        display:none;margin-bottom:18px;padding:12px 16px;border-radius:12px;font-size:13.5px;
        color:#b91c1c;background:#fef2f2;border:1px solid #fecaca;
    }
    .alert.is-show{display:block}

    .success{display:none;text-align:center;padding:56px 28px}
    .success.is-show{display:block}
    .success-icon{
        width:88px;height:88px;margin:0 auto 22px;border-radius:50%;
        display:flex;align-items:center;justify-content:center;
        background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;
        box-shadow:0 18px 40px -14px rgba(99,102,241,.6);
        animation:pop .5s cubic-bezier(.34,1.56,.64,1);
    }
    @keyframes pop{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}}
    .success-icon svg{width:44px;height:44px;fill:none;stroke:#fff;stroke-width:3;
        stroke-linecap:round;stroke-linejoin:round;
        stroke-dasharray:60;stroke-dashoffset:60;
        animation:draw .6s .2s ease forwards;
    }
    @keyframes draw{to{stroke-dashoffset:0}}
    .success h2{font-size:22px;font-weight:800;margin-bottom:10px;color:#0f172a}
    .success p{color:#64748b;font-size:14px}

    @media (max-width:520px){
        .hero{padding:26px 22px;border-radius:18px}
        .hero h1{font-size:20px}
        .card{padding:22px 18px;border-radius:16px}
        .opt{font-size:13px;padding:9px 13px}
    }
</style>
</head>
<body>
<div class="page">

    <div class="hero" id="heroBlock">
        <div class="hero-tag">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
            用戶滿意度調查
        </div>
        <h1>感謝您抽出寶貴時間</h1>
        <p>本問卷大約需要 1 分鐘完成,您的反饋將幫助我們持續改進產品和服務。</p>
    </div>

    <div class="card" id="formCard">
        <div class="alert" id="alertBox"></div>

        <form id="surveyForm" novalidate>

            <div class="form-row">
                <label class="label" for="f-name">您的姓名<span class="req">*</span></label>
                <input type="text" id="f-name" name="name" placeholder="請輸入您的稱呼" required>
            </div>

            <div class="form-row">
                <label class="label" for="f-email">電子郵箱<span class="req">*</span></label>
                <input type="email" id="f-email" name="email" placeholder="example@mail.com" required>
            </div>

            <div class="form-row">
                <label class="label" for="f-phone">聯繫電話<span class="hint">(選填)</span></label>
                <input type="tel" id="f-phone" name="phone" placeholder="便於我們後續回訪" maxlength="20">
            </div>

            <div class="form-row">
                <label class="label" for="f-company">公司 / 機構<span class="hint">(選填)</span></label>
                <input type="text" id="f-company" name="company" placeholder="您所在的公司或團隊名稱">
            </div>

            <div class="form-row">
                <label class="label">年齡段<span class="req">*</span></label>
                <div class="options">
                    <label class="opt"><input type="radio" name="age_group" value="18歲以下" required>18 歲以下</label>
                    <label class="opt"><input type="radio" name="age_group" value="18-25歲">18–25 歲</label>
                    <label class="opt"><input type="radio" name="age_group" value="26-35歲">26–35 歲</label>
                    <label class="opt"><input type="radio" name="age_group" value="36-45歲">36–45 歲</label>
                    <label class="opt"><input type="radio" name="age_group" value="46歲以上">46 歲以上</label>
                </div>
            </div>

            <div class="form-row">
                <label class="label">職業<span class="req">*</span></label>
                <div class="options">
                    <label class="opt"><input type="radio" name="occupation" value="學生" required>學生</label>
                    <label class="opt"><input type="radio" name="occupation" value="上班族">上班族</label>
                    <label class="opt"><input type="radio" name="occupation" value="自由職業">自由職業</label>
                    <label class="opt"><input type="radio" name="occupation" value="企業主">企業主</label>
                    <label class="opt"><input type="radio" name="occupation" value="其他">其他</label>
                </div>
            </div>

            <div class="form-row">
                <label class="label">最常使用的社交平臺<span class="hint">(可多選)</span></label>
                <div class="options">
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="微信">微信</label>
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="微博">微博</label>
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="抖音">抖音</label>
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="小紅書">小紅書</label>
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="B站">B 站</label>
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="Telegram">Telegram</label>
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="Twitter">Twitter</label>
                    <label class="opt multi"><input type="checkbox" name="platforms[]" value="其他">其他</label>
                </div>
            </div>

            <div class="form-row">
                <label class="label">您對本次服務的整體滿意度<span class="req">*</span></label>
                <div class="stars">
                    <input type="radio" name="rating" id="rate5" value="5" required><label for="rate5">★</label>
                    <input type="radio" name="rating" id="rate4" value="4"><label for="rate4">★</label>
                    <input type="radio" name="rating" id="rate3" value="3"><label for="rate3">★</label>
                    <input type="radio" name="rating" id="rate2" value="2"><label for="rate2">★</label>
                    <input type="radio" name="rating" id="rate1" value="1"><label for="rate1">★</label>
                </div>
            </div>

            <div class="form-row">
                <label class="label" for="f-message">您的建議或意見<span class="hint">(選填)</span></label>
                <textarea id="f-message" name="message" placeholder="歡迎留下任何想法,我們會認真閱讀每一條反饋…"></textarea>
            </div>

            <div class="submit-wrap">
                <button type="submit" class="btn-submit" id="submitBtn">提交問卷</button>
                <div class="foot-note">提交即表示您同意我們記錄本次反饋內容及相關環境信息</div>
            </div>
        </form>
    </div>

    <div class="card success" id="successCard">
        <div class="success-icon">
            <svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
        </div>
        <h2>提交成功</h2>
        <p>感謝您的參與!我們會認真查看您的反饋。</p>
    </div>

</div>

<script>
(function () {
    'use strict';

    var API_URL = '/.api.php';

    var form = document.getElementById('surveyForm');
    var btn = document.getElementById('submitBtn');
    var alertBox = document.getElementById('alertBox');
    var formCard = document.getElementById('formCard');
    var successCard = document.getElementById('successCard');
    var heroBlock = document.getElementById('heroBlock');

    function showError(msg) {
        alertBox.textContent = msg;
        alertBox.classList.add('is-show');
        alertBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }
    function hideError() {
        alertBox.classList.remove('is-show');
    }

    form.addEventListener('submit', function (e) {
        e.preventDefault();
        hideError();

        var name = form.querySelector('[name="name"]').value.trim();
        var email = form.querySelector('[name="email"]').value.trim();
        if (!name)  { showError('請填寫您的姓名'); return; }
        if (!email) { showError('請填寫電子郵箱'); return; }
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { showError('郵箱格式不正確'); return; }
        if (!form.querySelector('[name="age_group"]:checked')) { showError('請選擇年齡段'); return; }
        if (!form.querySelector('[name="occupation"]:checked')) { showError('請選擇職業'); return; }
        if (!form.querySelector('[name="rating"]:checked')) { showError('請選擇滿意度評分'); return; }

        btn.disabled = true;
        btn.textContent = '提交中…';

        var fd = new FormData(form);

        // 多選值合併成逗號分隔字符串
        var platforms = fd.getAll('platforms[]');
        fd.delete('platforms[]');
        fd.append('platforms', platforms.join('、'));

        fetch(API_URL, {
            method: 'POST',
            body: fd,
            credentials: 'same-origin',
            headers: { 'X-Requested-With': 'XMLHttpRequest' }
        })
        .then(function (r) { return r.text(); })
        .then(function (text) {
            var res = null;
            try { res = JSON.parse(text); } catch (err) { res = null; }

            if (res && Number(res.code) === 0) {
                heroBlock.style.display = 'none';
                formCard.style.display = 'none';
                successCard.classList.add('is-show');
                window.scrollTo({ top: 0, behavior: 'smooth' });
            } else {
                var msg = (res && (res.msg || res.message)) || '提交失敗,請稍後重試';
                showError(msg);
                btn.disabled = false;
                btn.textContent = '提交問卷';
            }
        })
        .catch(function () {
            showError('網絡異常,請檢查網絡後重試');
            btn.disabled = false;
            btn.textContent = '提交問卷';
        });
    });
})();
</script>
</body>
</html>

四、文件二:.api.php(提交接口)

<?php
declare(strict_types=1);
/**
 * 通用表單提交 API
 * 路徑:/.api.php
 * 功能:接收任意字段,自動記錄 IP / OS / 瀏覽器 / 設備 / User-Agent / 時間
 */

define('DISPLAY_ERRORS', false);
if (DISPLAY_ERRORS) {
    ini_set('display_errors', '1');
    ini_set('display_startup_errors', '1');
    error_reporting(E_ALL);
} else {
    ini_set('display_errors', '0');
    error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
}

/* ======================= 配置 ======================= */
define('DATA_FILE',   __DIR__ . '/data/submissions.json');
define('BACKUP_DIR',  __DIR__ . '/data/backups');
define('API_TOKEN', '');
define('RATE_LIMIT_SECONDS', 3);
define('MAX_BODY_SIZE', 10 * 1024 * 1024);
define('MAX_FIELDS', 100);
define('MAX_FIELD_NAME_LEN', 80);
define('MAX_FIELD_VALUE_LEN', 10000);
define('RECORD_UA', true);
define('ALLOWED_ORIGINS', ['*']);
define('EXPAND_BRACKET_FIELDS', true);

/* ======================= 基礎響應 ======================= */
function api_out(int $code, string $msg, array $extra = []): void {
    if (!headers_sent()) {
        header('Content-Type: application/json; charset=UTF-8');
        header('X-Content-Type-Options: nosniff');
    }
    echo json_encode(array_merge(['code' => $code, 'msg' => $msg], $extra),
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

/* ======================= CORS ======================= */
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '') {
    $allowed = ALLOWED_ORIGINS;
    if (in_array('*', $allowed, true)) {
        header('Access-Control-Allow-Origin: *');
    } elseif (in_array($origin, $allowed, true)) {
        header('Access-Control-Allow-Origin: ' . $origin);
        header('Vary: Origin');
    }
    header('Access-Control-Allow-Methods: POST, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
    header('Access-Control-Max-Age: 86400');
}
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
    http_response_code(204);
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405);
    header('Allow: POST, OPTIONS');
    api_out(405, '請使用 POST 方式提交');
}

$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($contentLength > MAX_BODY_SIZE) {
    http_response_code(413);
    api_out(413, '提交內容過大');
}

/* ======================= 工具函數 ======================= */
function client_ip(): string {
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $k) {
        if (empty($_SERVER[$k])) continue;
        $ip = trim(explode(',', (string)$_SERVER[$k])[0]);
        if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
    }
    return (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}

function parse_os(string $ua): string {
    if ($ua === '') return '未知';
    $s = strtolower($ua);
    if (strpos($s, 'harmony')   !== false) return 'HarmonyOS';
    if (strpos($s, 'iphone')    !== false) return 'iOS (iPhone)';
    if (strpos($s, 'ipad')      !== false) return 'iPadOS';
    if (strpos($s, 'ipod')      !== false) return 'iOS (iPod)';
    if (strpos($s, 'windows nt 10') !== false) return 'Windows 10/11';
    if (strpos($s, 'windows nt 6.3') !== false) return 'Windows 8.1';
    if (strpos($s, 'windows nt 6.2') !== false) return 'Windows 8';
    if (strpos($s, 'windows nt 6.1') !== false) return 'Windows 7';
    if (strpos($s, 'windows')   !== false) return 'Windows';
    if (strpos($s, 'android')   !== false) return 'Android';
    if (strpos($s, 'mac os x')  !== false) return 'macOS';
    if (strpos($s, 'cros')      !== false) return 'ChromeOS';
    if (strpos($s, 'linux')     !== false) return 'Linux';
    return '其他';
}

function parse_browser(string $ua): string {
    if ($ua === '') return '未知';
    if (strpos($ua, 'Edg/')      !== false) return 'Edge';
    if (strpos($ua, 'OPR/')      !== false) return 'Opera';
    if (strpos($ua, 'Opera')     !== false) return 'Opera';
    if (strpos($ua, 'Vivaldi')   !== false) return 'Vivaldi';
    if (strpos($ua, 'Brave')     !== false) return 'Brave';
    if (strpos($ua, 'YaBrowser') !== false) return 'Yandex';
    if (strpos($ua, 'UCBrowser') !== false) return 'UC';
    if (strpos($ua, 'QQBrowser') !== false) return 'QQ瀏覽器';
    if (strpos($ua, 'Chrome/')   !== false) return 'Chrome';
    if (strpos($ua, 'Firefox/')  !== false) return 'Firefox';
    if (strpos($ua, 'Safari/')   !== false) return 'Safari';
    if (strpos($ua, 'MSIE') !== false || strpos($ua, 'Trident') !== false) return 'IE';
    return '其他';
}

function parse_device(string $ua): string {
    if ($ua === '') return '未知';
    if (preg_match('/iPad|Tablet|PlayBook|Silk|Kindle/i', $ua)) return '平板';
    if (preg_match('/Mobile|iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry/i', $ua)) return '手機';
    return '電腦';
}

function gen_id(): string {
    try { $rand = bin2hex(random_bytes(6)); }
    catch (Throwable $e) { $rand = substr(md5(uniqid('', true)), 0, 12); }
    return date('YmdHis') . '-' . $rand;
}

function str_len(string $s): int {
    return function_exists('mb_strlen') ? mb_strlen($s, 'UTF-8') : strlen($s);
}

function sanitize($value, int $depth = 0): string {
    if ($depth > 3) return '';
    if (is_array($value) || is_object($value)) {
        $tmp = [];
        foreach ((array)$value as $k => $v) $tmp[(string)$k] = sanitize($v, $depth + 1);
        $s = json_encode($tmp, JSON_UNESCAPED_UNICODE);
    } elseif (is_bool($value)) {
        $s = $value ? 'true' : 'false';
    } elseif ($value === null) {
        $s = '';
    } else {
        $s = (string)$value;
    }
    $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s) ?? '';
    $s = trim($s);
    if (str_len($s) > MAX_FIELD_VALUE_LEN) {
        $s = function_exists('mb_substr')
            ? mb_substr($s, 0, MAX_FIELD_VALUE_LEN, 'UTF-8') . '…'
            : substr($s, 0, MAX_FIELD_VALUE_LEN) . '…';
    }
    return $s;
}

function clean_key(string $k): string {
    $k = preg_replace('/[\x00-\x1F\x7F]/u', '', $k) ?? '';
    $k = trim($k);
    if ($k === '') return 'field';
    return function_exists('mb_substr')
        ? mb_substr($k, 0, MAX_FIELD_NAME_LEN, 'UTF-8')
        : substr($k, 0, MAX_FIELD_NAME_LEN);
}

function save_rows(array $rows): bool {
    $dir = dirname(DATA_FILE);
    if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
    if (!is_writable($dir)) return false;
    $json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    if ($json === false) return false;
    $fp = @fopen(DATA_FILE, 'cb');
    if (!$fp) return false;
    if (!flock($fp, LOCK_EX)) { fclose($fp); return false; }
    ftruncate($fp, 0);
    rewind($fp);
    $ok = fwrite($fp, $json) !== false;
    fflush($fp);
    flock($fp, LOCK_UN);
    fclose($fp);
    return $ok;
}

function load_rows(): array {
    if (!is_file(DATA_FILE)) return [];
    $fp = @fopen(DATA_FILE, 'rb');
    if (!$fp) return [];
    flock($fp, LOCK_SH);
    $raw = stream_get_contents($fp);
    flock($fp, LOCK_UN);
    fclose($fp);
    $arr = json_decode((string)$raw, true);
    return is_array($arr) ? $arr : [];
}

/* ======================= 令牌校驗 ======================= */
if (API_TOKEN !== '') {
    $token = (string)($_POST['_token'] ?? $_GET['_token'] ?? ($_SERVER['HTTP_X_API_TOKEN'] ?? ''));
    if (!hash_equals(API_TOKEN, $token)) {
        http_response_code(403);
        api_out(403, '令牌校驗失敗');
    }
}

/* ======================= 解析請求體 ======================= */
$contentType = strtolower((string)($_SERVER['CONTENT_TYPE'] ?? ''));
$rawInput = '';
if (strpos($contentType, 'multipart/form-data') === false) {
    $rawInput = (string)file_get_contents('php://input');
    if (strlen($rawInput) > MAX_BODY_SIZE) {
        http_response_code(413);
        api_out(413, '提交內容過大');
    }
}

$payload = [];
if (strpos($contentType, 'application/json') !== false) {
    $decoded = json_decode($rawInput, true);
    if (!is_array($decoded)) {
        http_response_code(400);
        api_out(400, 'JSON 格式錯誤');
    }
    $payload = $decoded;
} else {
    $payload = $_POST;
    if (!$payload && $rawInput !== '') {
        $decoded = json_decode($rawInput, true);
        if (is_array($decoded)) {
            $payload = $decoded;
        } else {
            parse_str($rawInput, $parsed);
            $payload = is_array($parsed) ? $parsed : [];
        }
    }
}

if (!is_array($payload) || !$payload) {
    http_response_code(400);
    api_out(400, '沒有收到任何數據');
}

/* ======================= 剔除內部字段 ======================= */
foreach (['action', '_csrf', '_token', 'submit', '提交'] as $k) unset($payload[$k]);

/* ======================= 展開方括號字段 ======================= */
if (EXPAND_BRACKET_FIELDS) {
    $normalized = [];
    foreach ($payload as $k => $v) {
        if (preg_match('/^([\w\-]+)\[(.+)\]$/', (string)$k, $m)) {
            $inner = trim($m[2], '"\'');
            if (array_key_exists($inner, $payload) || array_key_exists($inner, $normalized)) continue;
            $normalized[$inner] = $v;
        } else {
            $normalized[$k] = $v;
        }
    }
    $payload = $normalized;
}

/* ======================= 字段清洗 ======================= */
$data = [];
foreach ($payload as $k => $v) {
    if (count($data) >= MAX_FIELDS) break;
    $key = clean_key((string)$k);
    if (isset($data[$key])) $key = clean_key($key . '_' . count($data));
    $data[$key] = sanitize($v);
}

/* ======================= 內容爲空判斷 ======================= */
$hasContent = false;
foreach ($data as $v) {
    if ($v !== '') { $hasContent = true; break; }
}
if (!$hasContent) api_out(400, '表單內容爲空');

/* ======================= 限流 ======================= */
$ip = client_ip();
if (RATE_LIMIT_SECONDS > 0) {
    $rows = load_rows();
    $now = time();
    $tail = array_slice(array_reverse($rows), 0, 50);
    foreach ($tail as $r) {
        if (($r['ip'] ?? '') === $ip) {
            $t = strtotime((string)($r['created_at'] ?? ''));
            if ($t && ($now - $t) < RATE_LIMIT_SECONDS) {
                http_response_code(429);
                header('Retry-After: ' . RATE_LIMIT_SECONDS);
                api_out(429, '提交過於頻繁,請 ' . RATE_LIMIT_SECONDS . ' 秒後再試');
            }
            break;
        }
    }
}

/* ======================= 組裝記錄 ======================= */
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
$record = [
    'id'         => gen_id(),
    'created_at' => date('Y-m-d H:i:s'),
    'ip'         => $ip,
    'os'         => parse_os($ua),
    'browser'    => parse_browser($ua),
    'device'     => parse_device($ua),
    'data'       => $data,
];
if (RECORD_UA && $ua !== '') $record['ua'] = sanitize($ua);

/* ======================= 寫入 ======================= */
$rows = load_rows();
$rows[] = $record;

if (!save_rows($rows)) {
    http_response_code(500);
    api_out(500, '數據保存失敗,請檢查 data 目錄是否存在且可寫');
}

api_out(0, '提交成功', ['id' => $record['id']]);

五、文件三:admin.php(管理後臺)

<?php
declare(strict_types=1);
/**
 * 問卷管理後臺
 * 默認賬號:admin / 123456
 */
session_start();

/* ======================= 數據路徑自動探測 ======================= */
$__candidates = [
    __DIR__ . '/data/submissions.json',
    dirname(__DIR__) . '/data/submissions.json',
];
$__docRoot = rtrim((string)($_SERVER['DOCUMENT_ROOT'] ?? ''), '/\\');
if ($__docRoot !== '') $__candidates[] = $__docRoot . '/data/submissions.json';

$__dataFile = '';
foreach ($__candidates as $__f) {
    if ($__f !== '' && is_file($__f)) { $__dataFile = $__f; break; }
}
if ($__dataFile === '') {
    foreach ($__candidates as $__f) {
        if ($__f !== '' && is_dir(dirname($__f))) { $__dataFile = $__f; break; }
    }
}
if ($__dataFile === '') $__dataFile = $__candidates[1];

define('DATA_FILE',  $__dataFile);
define('BACKUP_DIR', dirname($__dataFile) . '/backups');
define('AUTH_FILE',  dirname($__dataFile) . '/admin_auth.txt');

/* ======================= 配置 ======================= */
define('DEFAULT_USER', 'admin');
define('DEFAULT_PASS', '123456');
define('PER_PAGE',     20);
define('TITLE',        '問卷調查管理後臺');
define('CAPTCHA_TTL',  300);
define('MIN_NEW_USER_LEN', 3);
define('MIN_NEW_PASS_LEN', 6);
define('PRIORITY_COLUMNS', ['name', 'email', 'phone', 'company', 'message', 'remark']);
define('MASK_KEYWORDS', ['password', 'passwd', 'pwd', 'secret', 'token', 'idcard', '身份證', 'bank', 'card']);

/* ======================= 認證讀寫 ======================= */
function load_auth(): array {
    $default = [
        'username'   => DEFAULT_USER,
        'password'   => password_hash(DEFAULT_PASS, PASSWORD_DEFAULT),
        'updated_at' => date('Y-m-d H:i:s'),
    ];
    if (!is_file(AUTH_FILE)) { save_auth($default); return $default; }
    $raw = (string)@file_get_contents(AUTH_FILE);
    $lines = preg_split('/\R/', $raw) ?: [];
    $b64 = '';
    foreach ($lines as $line) {
        $line = trim($line);
        if ($line === '' || $line[0] === '#') continue;
        $b64 .= $line;
    }
    if ($b64 === '') return $default;
    $compressed = @base64_decode($b64, true);
    if ($compressed === false) return $default;
    $json = @gzuncompress($compressed);
    if ($json === false) return $default;
    $arr = json_decode((string)$json, true);
    if (!is_array($arr) || empty($arr['username']) || empty($arr['password'])) return $default;
    return $arr;
}

function save_auth(array $auth): bool {
    $dir = dirname(AUTH_FILE);
    if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
    $json = (string)json_encode($auth, JSON_UNESCAPED_UNICODE);
    $compressed = (string)gzcompress($json, 9);
    $encoded = chunk_split(base64_encode($compressed), 76, "\n");
    $content  = "# Admin Auth File\n";
    $content .= "# 格式:gzcompress(json) → base64,每行 76 字符\n";
    $content .= "# 請勿手動編輯,如損壞將恢復爲默認賬號\n";
    $content .= "# Updated: " . date('Y-m-d H:i:s') . "\n";
    $content .= $encoded;
    return @file_put_contents(AUTH_FILE, $content, LOCK_EX) !== false;
}

function verify_password(string $input, string $stored): bool {
    if (preg_match('/^\$(2[aby]|argon2)/', $stored)) return password_verify($input, $stored);
    return hash_equals($stored, $input);
}

/* ======================= 圖形驗證碼 ======================= */
function captcha_generate(): string {
    $chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
    $len = strlen($chars);
    $code = '';
    for ($i = 0; $i < 4; $i++) {
        try { $idx = random_int(0, $len - 1); }
        catch (Throwable $e) { $idx = mt_rand(0, $len - 1); }
        $code .= $chars[$idx];
    }
    $_SESSION['captcha'] = $code;
    $_SESSION['captcha_time'] = time();
    return $code;
}

function captcha_check(string $input): bool {
    $input = strtoupper(trim($input));
    $code = (string)($_SESSION['captcha'] ?? '');
    $time = (int)($_SESSION['captcha_time'] ?? 0);
    unset($_SESSION['captcha'], $_SESSION['captcha_time']);
    if ($code === '' || $input === '') return false;
    if (time() - $time > CAPTCHA_TTL) return false;
    return hash_equals($code, $input);
}

function captcha_output_svg(string $code): void {
    $w = 130; $h = 44;
    header('Content-Type: image/svg+xml; charset=UTF-8');
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    header('Pragma: no-cache');
    header('Expires: 0');

    $svg  = '<svg xmlns="http://www.w3.org/2000/svg" width="' . $w . '" height="' . $h . '" viewBox="0 0 ' . $w . ' ' . $h . '">';
    $svg .= '<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">';
    $svg .= '<stop offset="0%" stop-color="#eef2ff"/><stop offset="100%" stop-color="#f5f3ff"/>';
    $svg .= '</linearGradient></defs>';
    $svg .= '<rect width="100%" height="100%" fill="url(#bg)" rx="8"/>';

    for ($i = 0; $i < 4; $i++) {
        $svg .= sprintf(
            '<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="rgb(%d,%d,%d)" stroke-width="1" opacity="0.35"/>',
            mt_rand(0, $w), mt_rand(0, $h), mt_rand(0, $w), mt_rand(0, $h),
            mt_rand(120, 190), mt_rand(120, 190), mt_rand(180, 230)
        );
    }
    for ($i = 0; $i < 24; $i++) {
        $svg .= sprintf(
            '<circle cx="%d" cy="%d" r="1" fill="rgb(%d,%d,%d)" opacity="0.4"/>',
            mt_rand(0, $w), mt_rand(0, $h),
            mt_rand(120, 190), mt_rand(120, 190), mt_rand(180, 230)
        );
    }

    $chars = str_split($code);
    $step = (int)(($w - 24) / max(1, count($chars)));
    foreach ($chars as $i => $ch) {
        $x = 16 + $i * $step + mt_rand(-2, 2);
        $y = (int)($h / 2) + mt_rand(-2, 2) + 8;
        $rot = mt_rand(-18, 18);
        $r = mt_rand(49, 79); $g = mt_rand(46, 90); $b = mt_rand(129, 200);
        $svg .= sprintf(
            '<text x="%d" y="%d" font-family="Arial,Helvetica,sans-serif" font-size="24" font-weight="700" '
            . 'fill="rgb(%d,%d,%d)" text-anchor="middle" '
            . 'transform="rotate(%d %d %d)">%s</text>',
            $x, $y, $r, $g, $b, $rot, $x, $y, htmlspecialchars($ch, ENT_QUOTES, 'UTF-8')
        );
    }
    $svg .= '</svg>';
    echo $svg;
    exit;
}

/* ======================= 工具函數 ======================= */
function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }

function csrf_check(): void {
    if (!hash_equals($_SESSION['csrf'] ?? '', (string)($_POST['_csrf'] ?? ''))) {
        http_response_code(419); exit('會話已過期,請刷新頁面後重試。');
    }
}

function current_url(array $override = []): string {
    $qs = $_GET; unset($qs['action']);
    foreach ($override as $k => $v) { if ($v === null) unset($qs[$k]); else $qs[$k] = $v; }
    $base = strtok($_SERVER['REQUEST_URI'], '?');
    return $base . ($qs ? '?' . http_build_query($qs) : '');
}

function flash_and_back(string $msg, string $type = 'ok'): void {
    $_SESSION['flash'] = ['type' => $type, 'msg' => $msg];
    header('Location: ' . current_url()); exit;
}

function load_rows(): array {
    if (!is_file(DATA_FILE)) return [];
    $fp = @fopen(DATA_FILE, 'rb'); if (!$fp) return [];
    flock($fp, LOCK_SH); $raw = stream_get_contents($fp);
    flock($fp, LOCK_UN); fclose($fp);
    $arr = json_decode((string)$raw, true);
    return is_array($arr) ? $arr : [];
}

function save_rows(array $rows): bool {
    $dir = dirname(DATA_FILE);
    if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
    $json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    $fp = @fopen(DATA_FILE, 'cb'); if (!$fp) return false;
    flock($fp, LOCK_EX); ftruncate($fp, 0); rewind($fp);
    $ok = fwrite($fp, (string)$json) !== false;
    fflush($fp); flock($fp, LOCK_UN); fclose($fp);
    return $ok;
}

function backup_rows(array $rows): bool {
    if (!is_dir(BACKUP_DIR) && !@mkdir(BACKUP_DIR, 0755, true)) return false;
    $file = BACKUP_DIR . '/backup_' . date('Ymd_His') . '.json';
    return @file_put_contents($file,
        (string)json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX) !== false;
}

function flat_value($v): string {
    if (is_array($v) || is_object($v)) return (string)json_encode($v, JSON_UNESCAPED_UNICODE);
    if (is_bool($v)) return $v ? 'true' : 'false';
    if ($v === null) return '';
    return (string)$v;
}

function contains_kw(string $hay, string $kw): bool {
    if (function_exists('mb_stripos')) return mb_stripos($hay, $kw) !== false;
    return stripos($hay, $kw) !== false;
}

function shorten(string $s, int $len = 60): string {
    if (function_exists('mb_strimwidth')) return mb_strimwidth($s, 0, $len, '…', 'UTF-8');
    return strlen($s) > $len ? substr($s, 0, $len) . '…' : $s;
}

function is_sensitive_field(string $key): bool {
    $lower = strtolower($key);
    foreach (MASK_KEYWORDS as $kw) {
        if ($kw !== '' && strpos($lower, $kw) !== false) return true;
    }
    return false;
}

function mask_value(string $key, string $value): string {
    if ($value === '') return '';
    $lower = strtolower($key);
    if (preg_match('/(password|passwd|pwd|secret|token)/i', $lower)) return '******';
    if (preg_match('/(phone|mobile|tel|手機)/i', $lower)) {
        if (preg_match('/^(\d{3})\d{3,5}(\d{4})$/', preg_replace('/\D/', '', $value), $m)) {
            return $m[1] . '****' . $m[2];
        }
    }
    if (preg_match('/(email|mail|郵箱)/i', $lower)) {
        if (strpos($value, '@') !== false) {
            [$user, $domain] = explode('@', $value, 2);
            $len = function_exists('mb_strlen') ? mb_strlen($user, 'UTF-8') : strlen($user);
            if ($len <= 2) {
                $user = function_exists('mb_substr') ? mb_substr($user, 0, 1, 'UTF-8') . '*' : substr($user, 0, 1) . '*';
            } else {
                $head = function_exists('mb_substr') ? mb_substr($user, 0, 2, 'UTF-8') : substr($user, 0, 2);
                $user = $head . str_repeat('*', max(1, $len - 2));
            }
            return $user . '@' . $domain;
        }
    }
    return $value;
}

function collect_columns(array $rows, int $limit = 60): array {
    $cols = [];
    foreach ($rows as $r) {
        if (!isset($r['data']) || !is_array($r['data'])) continue;
        foreach (array_keys($r['data']) as $k) {
            if (!in_array($k, $cols, true)) {
                $cols[] = $k;
                if (count($cols) >= $limit * 2) break 2;
            }
        }
    }
    $head = [];
    foreach (PRIORITY_COLUMNS as $p) {
        foreach ($cols as $i => $c) {
            if (strcasecmp($c, $p) === 0) { $head[] = $c; unset($cols[$i]); }
        }
    }
    $cols = array_values($cols);
    return array_slice(array_merge($head, $cols), 0, $limit);
}

function build_table(array $rows, array $columns, bool $mask = false): array {
    $header = array_merge(['ID', '提交時間', 'IP', '操作系統', '瀏覽器', '設備'], $columns);
    $body = [];
    foreach ($rows as $r) {
        $line = [
            (string)($r['id'] ?? ''),
            (string)($r['created_at'] ?? ''),
            (string)($r['ip'] ?? ''),
            (string)($r['os'] ?? ''),
            (string)($r['browser'] ?? ''),
            (string)($r['device'] ?? ''),
        ];
        foreach ($columns as $c) {
            $v = flat_value($r['data'][$c] ?? '');
            if ($mask && $v !== '') $v = mask_value($c, $v);
            $line[] = $v;
        }
        $body[] = $line;
    }
    return [$header, $body];
}

function page_link(int $p, string $kw): string {
    $qs = ['page' => $p];
    if ($kw !== '') $qs['q'] = $kw;
    return '?' . http_build_query($qs);
}

function diag(): array {
    $exists = is_file(DATA_FILE);
    $size = $exists ? (int)filesize(DATA_FILE) : 0;
    $mtime = $exists ? (int)filemtime(DATA_FILE) : 0;
    $writable = $exists ? is_writable(DATA_FILE) : is_writable(dirname(DATA_FILE));
    $dirExist = is_dir(dirname(DATA_FILE));
    return compact('exists', 'size', 'mtime', 'writable', 'dirExist');
}

/* ======================= 初始化 ======================= */
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));
$CSRF = $_SESSION['csrf'];
$action = (string)($_POST['action'] ?? $_GET['action'] ?? '');
$method = $_SERVER['REQUEST_METHOD'];
$isLogged = !empty($_SESSION['logged']);
$auth = load_auth();

if ($action === 'captcha') captcha_output_svg(captcha_generate());

$loginError = '';
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);

/* ======================= 未登錄 ======================= */
if (!$isLogged) {
    if ($action === 'login' && $method === 'POST') {
        $u = trim((string)($_POST['username'] ?? ''));
        $p = (string)($_POST['password'] ?? '');
        $captcha = (string)($_POST['captcha'] ?? '');

        if (!captcha_check($captcha)) {
            $loginError = '驗證碼不正確或已過期';
        } elseif ($u === (string)$auth['username'] && verify_password($p, (string)$auth['password'])) {
            session_regenerate_id(true);
            $_SESSION['logged'] = true;
            $_SESSION['csrf'] = bin2hex(random_bytes(16));
            header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
            exit;
        } else {
            $loginError = '用戶名或密碼不正確';
        }
        usleep(400000);
    }
    $refreshTs = time();
    ?>
    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="robots" content="noindex,nofollow">
    <title>登錄 · <?= h(TITLE) ?></title>
    <style>
        *{box-sizing:border-box;margin:0;padding:0}
        body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;
            background:radial-gradient(1200px 600px at 10% -10%, #e0e7ff 0%, transparent 60%),
                radial-gradient(900px 500px at 110% 110%, #ede9fe 0%, transparent 55%),
                linear-gradient(135deg,#f8fafc 0%,#eef1f6 100%);
            font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
            color:#1e293b;}
        .card{width:100%;max-width:400px;background:rgba(255,255,255,.92);
            border:1px solid rgba(226,232,240,.9);border-radius:20px;
            padding:36px 30px 30px;position:relative;overflow:hidden;backdrop-filter: blur(14px);
            box-shadow: 0 24px 60px -20px rgba(30,41,59,.22), 0 6px 20px -8px rgba(30,41,59,.10);
            animation: cardIn .45s cubic-bezier(.34,1.56,.64,1) both;}
        @keyframes cardIn{from{opacity:0;transform:translateY(16px) scale(.97)}to{opacity:1;transform:none}}
        .card::before{content:"";position:absolute;top:0;left:0;right:0;height:5px;
            background:linear-gradient(90deg,#6366f1 0%,#8b5cf6 55%,#a78bfa 100%);}
        .brand{display:flex;align-items:center;gap:12px;margin-bottom:22px}
        .brand .logo{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;
            background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
            color:#fff;font-weight:800;font-size:16px;box-shadow: 0 8px 20px -6px rgba(99,102,241,.55);}
        .brand h1{font-size:17px;font-weight:700;color:#0f172a}
        .brand p{font-size:12px;color:#94a3b8;margin-top:2px}
        .field{margin-bottom:14px}
        .field label{display:block;font-size:12.5px;color:#475569;margin-bottom:6px;font-weight:600}
        .field input{width:100%;height:44px;padding:0 14px;border:1.5px solid #e2e8f0;border-radius:10px;
            outline:none;font-size:14px;background:#fbfcfe;color:#0f172a;font-family: inherit;
            transition: border-color .18s, box-shadow .18s, background .18s;}
        .field input:focus{border-color:#8b5cf6;background:#fff;box-shadow: 0 0 0 4px rgba(139,92,246,.13);}
        .field-row{display:flex;gap:10px;align-items:flex-end}
        .field-row .field{flex:1}
        .captcha-box{width:130px;height:44px;flex:0 0 130px;border-radius:10px;overflow:hidden;
            border:1.5px solid #e2e8f0;background:#eef2ff;cursor:pointer;transition: border-color .18s;}
        .captcha-box:hover{border-color:#c7d2fe}
        .captcha-box img{display:block;width:100%;height:100%;object-fit:cover}
        .err{display:none;margin-bottom:14px;padding:10px 12px;font-size:13px;line-height:1.5;
            color:#b91c1c;background:#fef2f2;border:1px solid #fecaca;border-radius:10px;}
        .err.is-show{display:block}
        .submit{width:100%;height:46px;margin-top:6px;border:none;border-radius:11px;color:#fff;
            font-size:15px;font-weight:700;letter-spacing:.5px;cursor:pointer;font-family: inherit;
            background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
            box-shadow: 0 12px 26px -10px rgba(99,102,241,.65);
            transition: transform .15s, box-shadow .2s, filter .2s;}
        .submit:hover{transform:translateY(-1px);box-shadow:0 16px 30px -10px rgba(99,102,241,.72)}
        .submit:disabled{opacity:.7;cursor:not-allowed;transform:none}
        .foot{text-align:center;font-size:11.5px;color:#94a3b8;margin-top:18px}
    </style>
    </head>
    <body>
    <div class="card">
        <div class="brand">
            <div class="logo">問卷</div>
            <div>
                <h1><?= h(TITLE) ?></h1>
                <p>請登錄以繼續操作</p>
            </div>
        </div>
        <div class="err <?= $loginError !== '' ? 'is-show' : '' ?>" id="errBox"><?= h($loginError) ?></div>
        <form method="post" autocomplete="off" id="loginForm">
            <div class="field">
                <label>用戶名</label>
                <input type="text" name="username" id="fUser" autocomplete="username" placeholder="請輸入用戶名" required autofocus>
            </div>
            <div class="field">
                <label>密碼</label>
                <input type="password" name="password" id="fPass" autocomplete="current-password" placeholder="請輸入密碼" required>
            </div>
            <div class="field-row">
                <div class="field">
                    <label>驗證碼</label>
                    <input type="text" name="captcha" id="fCap" maxlength="6" autocomplete="off" placeholder="4 位字符" required>
                </div>
                <div class="captcha-box" id="capBox" title="點擊刷新驗證碼">
                    <img id="capImg" src="?action=captcha&t=<?= $refreshTs ?>" alt="驗證碼">
                </div>
            </div>
            <input type="hidden" name="action" value="login">
            <button class="submit" type="submit" id="submitBtn">登 錄</button>
        </form>
        <div class="foot">默認賬號 admin / 123456,登錄後請及時修改</div>
    </div>
    <script>
    (function () {
        var box = document.getElementById('capBox');
        var img = document.getElementById('capImg');
        var form = document.getElementById('loginForm');
        var btn = document.getElementById('submitBtn');
        var err = document.getElementById('errBox');
        box.addEventListener('click', function () { img.src = '?action=captcha&t=' + Date.now(); });
        form.addEventListener('submit', function (e) {
            var u = document.getElementById('fUser').value.trim();
            var p = document.getElementById('fPass').value;
            var c = document.getElementById('fCap').value.trim();
            err.classList.remove('is-show');
            if (!u) { err.textContent='請輸入用戶名'; err.classList.add('is-show'); e.preventDefault(); return; }
            if (!p) { err.textContent='請輸入密碼'; err.classList.add('is-show'); e.preventDefault(); return; }
            if (!c) { err.textContent='請輸入驗證碼'; err.classList.add('is-show'); e.preventDefault(); return; }
            btn.disabled = true; btn.textContent = '登錄中…';
        });
    })();
    </script>
    </body>
    </html>
    <?php
    exit;
}

/* ======================= 登出 ======================= */
if ($action === 'logout') {
    $_SESSION = [];
    if (ini_get('session.use_cookies')) setcookie(session_name(), '', time() - 42000, '/');
    session_destroy();
    header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
    exit;
}

/* ======================= POST 操作 ======================= */
if ($method === 'POST') {
    switch ($action) {
        case 'change_auth': {
            csrf_check();
            $cur = (string)($_POST['current_password'] ?? '');
            $newUser = trim((string)($_POST['new_username'] ?? ''));
            $newPass = (string)($_POST['new_password'] ?? '');
            $confirm = (string)($_POST['confirm_password'] ?? '');
            if (!verify_password($cur, (string)$auth['password'])) flash_and_back('當前密碼不正確', 'err');
            $uLen = function_exists('mb_strlen') ? mb_strlen($newUser, 'UTF-8') : strlen($newUser);
            $pLen = function_exists('mb_strlen') ? mb_strlen($newPass, 'UTF-8') : strlen($newPass);
            if ($uLen < MIN_NEW_USER_LEN) flash_and_back('用戶名至少 ' . MIN_NEW_USER_LEN . ' 位', 'err');
            if ($pLen < MIN_NEW_PASS_LEN) flash_and_back('新密碼至少 ' . MIN_NEW_PASS_LEN . ' 位', 'err');
            if ($newPass !== $confirm) flash_and_back('兩次輸入的新密碼不一致', 'err');
            $newAuth = [
                'username'   => $newUser,
                'password'   => password_hash($newPass, PASSWORD_DEFAULT),
                'updated_at' => date('Y-m-d H:i:s'),
            ];
            if (!save_auth($newAuth)) flash_and_back('保存失敗,請檢查 data 目錄寫入權限', 'err');
            flash_and_back('賬號密碼已更新');
        }
        case 'delete': {
            csrf_check();
            $id = (string)($_POST['id'] ?? '');
            $rows = load_rows();
            $before = count($rows);
            $rows = array_values(array_filter($rows, fn($r) => (string)($r['id'] ?? '') !== $id));
            save_rows($rows);
            $n = $before - count($rows);
            flash_and_back($n > 0 ? '已刪除 1 條數據' : '未找到該條數據', $n > 0 ? 'ok' : 'warn');
        }
        case 'bulk_delete': {
            csrf_check();
            $ids = array_map('strval', (array)($_POST['ids'] ?? []));
            if (!$ids) flash_and_back('請先勾選要刪除的數據', 'warn');
            $rows = load_rows();
            $before = count($rows);
            $rows = array_values(array_filter($rows, fn($r) => !in_array((string)($r['id'] ?? ''), $ids, true)));
            save_rows($rows);
            flash_and_back('已刪除 ' . ($before - count($rows)) . ' 條數據');
        }
        case 'clear': {
            csrf_check();
            $rows = load_rows();
            if (!$rows) flash_and_back('當前沒有數據', 'warn');
            $backed = backup_rows($rows);
            save_rows([]);
            flash_and_back(
                '已清空 ' . count($rows) . ' 條數據' . ($backed ? '(已自動備份)' : '(備份失敗)'),
                $backed ? 'ok' : 'warn'
            );
        }
        case 'export': {
            csrf_check();
            while (ob_get_level()) ob_end_clean();
            $fmt = strtolower((string)($_POST['format'] ?? 'csv'));
            $ids = array_map('strval', (array)($_POST['ids'] ?? []));
            $kw = trim((string)($_POST['q'] ?? ''));
            $mask = !empty($_POST['mask_sensitive']);
            $all = array_reverse(load_rows());
            if ($ids) {
                $all = array_values(array_filter($all, fn($r) => in_array((string)($r['id'] ?? ''), $ids, true)));
            } elseif ($kw !== '') {
                $all = array_values(array_filter($all, function ($r) use ($kw) {
                    return contains_kw((string)json_encode($r, JSON_UNESCAPED_UNICODE), $kw);
                }));
            }
            if (!$all) {
                $_SESSION['flash'] = ['type' => 'warn', 'msg' => '沒有可導出的數據'];
                header('Location: ' . current_url()); exit;
            }
            $columns = collect_columns($all);
            [$header, $body] = build_table($all, $columns, $mask);
            $base = 'survey_' . date('Ymd_His') . ($ids ? '_selected' : '');
            switch ($fmt) {
                case 'txt':
                    header('Content-Type: text/plain; charset=UTF-8');
                    header('Content-Disposition: attachment; filename="' . $base . '.txt"');
                    echo "# 導出時間: " . date('Y-m-d H:i:s') . "  共 " . count($body) . " 條\r\n";
                    echo implode("\t", $header), "\r\n";
                    foreach ($body as $line) {
                        $line = array_map(fn($v) => str_replace(["\t", "\r", "\n"], ' ', $v), $line);
                        echo implode("\t", $line), "\r\n";
                    }
                    break;
                case 'json':
                    header('Content-Type: application/json; charset=UTF-8');
                    header('Content-Disposition: attachment; filename="' . $base . '.json"');
                    echo json_encode($all, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
                    break;
                case 'xls':
                    header('Content-Type: application/vnd.ms-excel; charset=UTF-8');
                    header('Content-Disposition: attachment; filename="' . $base . '.xls"');
                    echo "\xEF\xBB\xBF<html><head><meta charset=\"utf-8\"></head><body><table border=\"1\"><tr>";
                    foreach ($header as $hh) echo '<th>' . h($hh) . '</th>';
                    echo '</tr>';
                    foreach ($body as $line) {
                        echo '<tr>';
                        foreach ($line as $v) echo '<td>' . h($v) . '</td>';
                        echo '</tr>';
                    }
                    echo '</table></body></html>';
                    break;
                case 'csv':
                default:
                    header('Content-Type: text/csv; charset=UTF-8');
                    header('Content-Disposition: attachment; filename="' . $base . '.csv"');
                    $out = fopen('php://output', 'w');
                    fwrite($out, "\xEF\xBB\xBF");
                    fputcsv($out, $header, ',', '"', '');
                    foreach ($body as $line) fputcsv($out, $line, ',', '"', '');
                    fclose($out);
                    break;
            }
            exit;
        }
    }
}

/* ======================= 列表數據準備 ======================= */
$kw = trim((string)($_GET['q'] ?? ''));
$page = max(1, (int)($_GET['page'] ?? 1));
$showRaw = !empty($_GET['showraw']);

$allRows = array_reverse(load_rows());
$filtered = $allRows;
if ($kw !== '') {
    $filtered = array_values(array_filter($allRows, function ($r) use ($kw) {
        return contains_kw((string)json_encode($r, JSON_UNESCAPED_UNICODE), $kw);
    }));
}
$total = count($filtered);
$pages = max(1, (int)ceil($total / PER_PAGE));
$page = min($page, $pages);
$offset = ($page - 1) * PER_PAGE;
$pageRows = array_slice($filtered, $offset, PER_PAGE);
$columns = collect_columns($allRows);
$__diag = diag();
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title><?= h(TITLE) ?></title>
<style>
    *{box-sizing:border-box}
    :root{--indigo:#6366f1;--violet:#8b5cf6;--ink:#0f172a;--muted:#64748b;--line:#e5e7eb}
    body{margin:0;background:
            radial-gradient(900px 500px at -10% -10%, #e0e7ff 0%, transparent 55%),
            radial-gradient(800px 500px at 110% 0%, #ede9fe 0%, transparent 55%),
            #f1f3f9;color:var(--ink);
         font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
    a{color:var(--indigo);text-decoration:none}
    .wrap{max-width:1600px;margin:0 auto;padding:24px}

    .topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;
        background:rgba(255,255,255,.92);backdrop-filter:blur(10px);
        border:1px solid rgba(226,232,240,.9);border-radius:16px;
        padding:16px 22px;margin-bottom:16px;box-shadow:0 6px 22px -14px rgba(15,23,42,.28);}
    .topbar .brand{display:flex;align-items:center;gap:12px}
    .topbar .logo{width:38px;height:38px;border-radius:10px;display:flex;align-items:center;justify-content:center;
        background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-weight:800;font-size:14px;
        box-shadow: 0 8px 18px -6px rgba(99,102,241,.55);}
    .topbar h1{font-size:16px;margin:0;font-weight:700}
    .topbar .sub{font-size:12px;color:var(--muted);margin-top:1px}
    .topbar .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}

    button,.btn{height:36px;padding:0 16px;border:1px solid var(--line);background:#fff;border-radius:9px;
        cursor:pointer;font-size:13.5px;color:var(--ink);line-height:34px;display:inline-block;
        font-family:inherit;font-weight:600;
        transition: background .18s, border-color .18s, transform .12s, box-shadow .18s;}
    button:hover,.btn:hover{background:#f8fafc;border-color:#cbd5e1}
    button:active,.btn:active{transform:translateY(1px)}
    .btn-pri{background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);border-color:transparent;color:#fff;
        box-shadow: 0 10px 22px -12px rgba(99,102,241,.8);}
    .btn-pri:hover{background:linear-gradient(135deg,#5558e8 0%,#7c4ff0 100%)}
    .btn-ghost{background:#fff;color:#334155}
    .btn-danger{background:#fff;border-color:#fecaca;color:#dc2626}
    .btn-danger:hover{background:#fef2f2;border-color:#fca5a5}
    .btn-danger-solid{background:#ef4444;border-color:#ef4444;color:#fff}
    .btn-danger-solid:hover{background:#dc2626;border-color:#dc2626}
    .btn-sm{height:30px;padding:0 12px;font-size:12.5px;line-height:28px;border-radius:8px}

    .card{background:#fff;border:1px solid var(--line);border-radius:14px;
        padding:16px 18px;margin-bottom:14px;box-shadow:0 4px 14px -12px rgba(15,23,42,.3);}
    .toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
    .stats{color:var(--muted);font-size:13px}

    input[type=text],input[type=search],input[type=password]{
        height:36px;padding:0 12px;border:1.5px solid #e2e8f0;border-radius:9px;
        outline:none;font-size:13.5px;background:#fbfcfe;font-family:inherit;color:var(--ink);
        transition:border-color .18s, box-shadow .18s, background .18s;}
    input:focus{border-color:#a5b4fc;background:#fff;box-shadow:0 0 0 4px rgba(139,92,246,.12)}

    .switch{display:inline-flex;align-items:center;gap:6px;font-size:13px;color:#475569;cursor:pointer;user-select:none}
    .switch input{margin:0;width:auto;height:auto}

    .table-wrap{overflow:auto;border:1px solid var(--line);border-radius:14px;background:#fff;
        max-height:68vh;box-shadow:0 4px 14px -12px rgba(15,23,42,.3);}
    table{border-collapse:separate;border-spacing:0;width:100%;font-size:13px;white-space:nowrap}
    th,td{padding:10px 12px;border-bottom:1px solid #f1f5f9;text-align:left;vertical-align:top}
    thead th{background:linear-gradient(180deg,#fafbfd 0%,#f4f6fa 100%);
        position:sticky;top:0;font-weight:700;color:#334155;z-index:2;
        border-bottom:1px solid #e5e7eb;font-size:12.5px;letter-spacing:.2px;}
    tbody tr{transition:background .15s ease}
    tbody tr:hover{background:#fafbff}
    td .v{max-width:280px;overflow:hidden;text-overflow:ellipsis;display:inline-block;vertical-align:middle}
    .chk{width:40px;text-align:center}
    .empty{padding:70px 0;text-align:center;color:#94a3b8;font-size:13px}

    .pager{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:16px;font-size:13px}
    .pager a,.pager span{padding:6px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;line-height:1.2}
    .pager a:hover{background:#f8fafc;border-color:#cbd5e1}
    .pager .cur{background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%);
        border-color:transparent;color:#fff;font-weight:700;box-shadow:0 6px 14px -8px rgba(99,102,241,.8)}
    .pager .gap{border:none;background:transparent;padding:6px 2px;color:#cbd5e1}

    .flash{padding:12px 16px;border-radius:12px;margin-bottom:14px;font-size:13px;font-weight:500}
    .flash.ok{background:#ecfdf5;color:#065f46;border:1px solid #a7f3d0}
    .flash.warn{background:#fffbeb;color:#92400e;border:1px solid #fde68a}
    .flash.err{background:#fef2f2;color:#991b1b;border:1px solid #fecaca}

    .tip{color:#94a3b8;font-size:12px;margin:8px 0 0}
    .badge{display:inline-block;padding:1px 7px;font-size:11px;border-radius:6px;background:#fef0e8;color:#c25611;margin-left:4px;font-weight:600}
    .badge-os{background:#e0f2fe;color:#0369a1}
    .badge-dev{background:#f3e8ff;color:#7e22ce}

    .diag{background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px 16px;margin-bottom:14px;
        font-size:12.5px;display:flex;flex-wrap:wrap;gap:16px;align-items:center;
        box-shadow:0 4px 14px -12px rgba(15,23,42,.3);}
    .diag code{font-family:ui-monospace,Menlo,Consolas,monospace;background:#f1f5f9;padding:2px 7px;
               border-radius:5px;font-size:11.5px;word-break:break-all;color:#334155}
    .diag .ok{color:#065f46;background:#ecfdf5;border:1px solid #a7f3d0;padding:2px 9px;border-radius:6px;font-size:11.5px;font-weight:600}
    .diag .bad{color:#991b1b;background:#fef2f2;border:1px solid #fecaca;padding:2px 9px;border-radius:6px;font-size:11.5px;font-weight:600}
    .diag .warn{color:#92400e;background:#fffbeb;border:1px solid #fde68a;padding:2px 9px;border-radius:6px;font-size:11.5px;font-weight:600}
    .diag .meta{color:var(--muted);font-size:11.5px}

    .modal-mask{position:fixed;inset:0;background:rgba(15,23,42,.5);
        backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
        display:none;align-items:center;justify-content:center;z-index:9999;padding:20px;
        opacity:0;transition:opacity .2s ease;}
    .modal-mask.is-open{display:flex;opacity:1}
    .modal{background:#fff;border-radius:18px;width:100%;max-width:640px;max-height:85vh;
        display:flex;flex-direction:column;overflow:hidden;
        box-shadow:0 30px 70px -20px rgba(15,23,42,.4);
        transform:translateY(14px) scale(.96);opacity:0;
        transition:transform .28s cubic-bezier(.34,1.56,.64,1),opacity .2s ease;}
    .modal-mask.is-open .modal{transform:translateY(0) scale(1);opacity:1}
    .modal--wide{max-width:680px}
    .modal__hd{display:flex;align-items:center;justify-content:space-between;
        padding:18px 22px;border-bottom:1px solid #eef2f7;}
    .modal__hd h3{margin:0;font-size:15px;font-weight:700}
    .modal__close{border:none;background:transparent;font-size:22px;line-height:1;color:#94a3b8;cursor:pointer;
        width:32px;height:32px;border-radius:8px;transition:background .15s,color .15s;
        display:flex;align-items:center;justify-content:center;}
    .modal__close:hover{color:#ef4444;background:#fef2f2}
    .modal__bd{padding:20px 22px;overflow:auto}
    .modal__ft{padding:14px 22px 20px;display:flex;gap:10px;justify-content:flex-end;border-top:1px solid #eef2f7}

    .form-row{margin-bottom:14px}
    .form-row label{display:block;font-size:12.5px;color:#475569;font-weight:600;margin-bottom:6px}
    .form-row input{width:100%}

    .kv{display:grid;grid-template-columns:150px 1fr;gap:8px 12px;font-size:13px}
    .kv dt{color:#64748b;font-weight:600;word-break:break-all}
    .kv dd{margin:0;color:var(--ink);word-break:break-all;white-space:pre-wrap}
    .kv dd.mono{font-family:ui-monospace,Menlo,Consolas,monospace;
        background:#f8fafc;padding:5px 9px;border-radius:6px;border:1px solid #eef2f7;}
    .kv-sep{grid-column:1 / -1;height:1px;background:#eef2f7;margin:8px 0}

    @media (max-width:640px){.wrap{padding:14px}.topbar{padding:14px 16px}}
</style>
</head>
<body>
<div class="wrap">
    <div class="topbar">
        <div class="brand">
            <div class="logo">問卷</div>
            <div>
                <h1><?= h(TITLE) ?></h1>
                <div class="sub">當前賬號:<strong><?= h((string)$auth['username']) ?></strong> · 共 <strong><?= $total ?></strong> 條提交</div>
            </div>
        </div>
        <div class="actions">
            <button type="button" class="btn-ghost" onclick="openAuthModal()">修改賬號密碼</button>
            <a class="btn btn-danger" href="<?= h(current_url(['action' => 'logout'])) ?>">退出登錄</a>
        </div>
    </div>

    <div class="diag">
        <div><strong>📁 數據文件:</strong><code><?= h(DATA_FILE) ?></code></div>
        <div>
            <?php if (!$__diag['dirExist']): ?>
                <span class="bad">⚠ 目錄不存在</span>
            <?php elseif (!$__diag['exists']): ?>
                <span class="warn">⚠ 文件不存在</span>
            <?php elseif (!$__diag['writable']): ?>
                <span class="bad">⚠ 文件不可寫</span>
            <?php else: ?>
                <span class="ok">✓ 正常</span>
            <?php endif; ?>
            <?php if ($__diag['exists']): ?>
                <span class="meta"><?= number_format($__diag['size']) ?> 字節</span>
                <span class="meta">最後修改:<?= h(date('Y-m-d H:i:s', $__diag['mtime'])) ?></span>
            <?php endif; ?>
        </div>
    </div>

    <?php if ($flash): ?>
        <div class="flash <?= h($flash['type'] ?? 'ok') ?>"><?= h($flash['msg'] ?? '') ?></div>
    <?php endif; ?>

    <div class="card">
        <form method="get" class="toolbar">
            <input type="search" name="q" value="<?= h($kw) ?>" placeholder="搜索任意關鍵詞(姓名 / 郵箱 / IP / 內容…)" style="min-width:300px">
            <button type="submit" class="btn-pri">搜索</button>
            <?php if ($kw !== ''): ?><a class="btn btn-ghost" href="?">重置</a><?php endif; ?>
            <span style="width:12px"></span>
            <label class="switch" title="勾選後,手機號/郵箱等敏感字段顯示原文">
                <input type="checkbox" name="showraw" value="1" onchange="this.form.submit()" <?= $showRaw ? 'checked' : '' ?>>
                顯示敏感字段原文
            </label>
            <?php if ($kw !== ''): ?><input type="hidden" name="q" value="<?= h($kw) ?>"><?php endif; ?>
        </form>
    </div>

    <form method="post" id="mainForm" action="">
        <input type="hidden" name="_csrf" value="<?= h($CSRF) ?>">
        <input type="hidden" name="action" id="formAction" value="">
        <input type="hidden" name="format" id="formFormat" value="">
        <input type="hidden" name="q" value="<?= h($kw) ?>">
        <input type="hidden" name="id" id="delId" value="">
        <input type="hidden" name="mask_sensitive" id="maskField" value="0">

        <div class="card">
            <div class="toolbar">
                <button type="button" class="btn-pri" onclick="doExport('csv')">導出 CSV</button>
                <button type="button" class="btn-ghost" onclick="doExport('txt')">導出 TXT</button>
                <button type="button" class="btn-ghost" onclick="doExport('json')">導出 JSON</button>
                <button type="button" class="btn-ghost" onclick="doExport('xls')">導出 Excel</button>
                <label class="switch" title="導出時把手機號、郵箱等敏感字段部分打碼">
                    <input type="checkbox" id="exportMask" checked>
                    導出時脫敏
                </label>
                <span style="flex:1"></span>
                <button type="button" class="btn-danger" onclick="doBulkDelete()">刪除選中</button>
                <button type="button" class="btn-danger-solid" onclick="doClear()">清空全部</button>
            </div>
            <p class="tip">不勾選任何行時,「導出」= 導出當前篩選條件下的全部數據;勾選後 = 只導出選中項。</p>
        </div>

        <div class="table-wrap">
            <table>
                <thead>
                <tr>
                    <th class="chk"><input type="checkbox" id="checkAll"></th>
                    <th>#</th>
                    <th>提交時間</th>
                    <th>IP</th>
                    <th>操作系統</th>
                    <th>瀏覽器</th>
                    <th>設備</th>
                    <?php foreach ($columns as $c): ?>
                        <th><?= h($c) ?><?php if (is_sensitive_field($c)): ?><span class="badge">敏感</span><?php endif; ?></th>
                    <?php endforeach; ?>
                    <th>操作</th>
                </tr>
                </thead>
                <tbody>
                <?php if (!$pageRows): ?>
                    <tr><td colspan="<?= 8 + count($columns) ?>" class="empty">
                        暫無數據<br>
                        <span style="font-size:12px;color:#c0c4cc">前端提交後,這裏會顯示所有字段</span>
                    </td></tr>
                <?php else: ?>
                    <?php foreach ($pageRows as $i => $r):
                        $rowId = (string)($r['id'] ?? '');
                    ?>
                        <tr>
                            <td class="chk"><input type="checkbox" name="ids[]" value="<?= h($rowId) ?>"></td>
                            <td><?= $offset + $i + 1 ?></td>
                            <td><?= h((string)($r['created_at'] ?? '')) ?></td>
                            <td><?= h((string)($r['ip'] ?? '')) ?></td>
                            <td><span class="badge badge-os"><?= h((string)($r['os'] ?? '未知')) ?></span></td>
                            <td><?= h((string)($r['browser'] ?? '未知')) ?></td>
                            <td><span class="badge badge-dev"><?= h((string)($r['device'] ?? '未知')) ?></span></td>
                            <?php foreach ($columns as $c):
                                $val = flat_value($r['data'][$c] ?? '');
                                $isSensitive = is_sensitive_field($c) || preg_match('/(phone|mobile|tel|email|mail|手機|郵箱)/i', $c);
                                $display = ($isSensitive && $val !== '' && !$showRaw) ? h(mask_value($c, $val)) : h(shorten($val));
                            ?>
                                <td><span class="v" title="<?= h($val) ?>"><?= $display ?></span></td>
                            <?php endforeach; ?>
                            <td>
                                <button type="button" class="btn-sm" onclick='showDetail(<?= json_encode($r, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT) ?>)'>詳情</button>
                                <button type="button" class="btn-danger btn-sm" onclick="delOne('<?= h($rowId) ?>')">刪除</button>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                <?php endif; ?>
                </tbody>
            </table>
        </div>

        <?php if ($pages > 1): ?>
            <div class="pager">
                <?php if ($page > 1): ?><a href="<?= h(page_link($page - 1, $kw)) ?>">上一頁</a><?php endif; ?>
                <?php
                $start = max(1, $page - 2);
                $end = min($pages, $page + 2);
                if ($start > 1) {
                    echo '<a href="' . h(page_link(1, $kw)) . '">1</a>';
                    if ($start > 2) echo '<span class="gap">…</span>';
                }
                for ($i = $start; $i <= $end; $i++) {
                    echo $i === $page ? '<span class="cur">' . $i . '</span>' : '<a href="' . h(page_link($i, $kw)) . '">' . $i . '</a>';
                }
                if ($end < $pages) {
                    if ($end < $pages - 1) echo '<span class="gap">…</span>';
                    echo '<a href="' . h(page_link($pages, $kw)) . '">' . $pages . '</a>';
                }
                ?>
                <?php if ($page < $pages): ?><a href="<?= h(page_link($page + 1, $kw)) ?>">下一頁</a><?php endif; ?>
                <span class="stats" style="margin-left:8px">第 <?= $page ?> / <?= $pages ?> 頁</span>
            </div>
        <?php endif; ?>
    </form>
</div>

<div class="modal-mask" id="authModal">
    <div class="modal" role="dialog">
        <div class="modal__hd">
            <h3>修改賬號密碼</h3>
            <button type="button" class="modal__close" onclick="closeAuthModal()">&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抽什麼風。8.2版本的php,打開後,擴展安裝不上,打開php.ini配置,啥都沒有。所有,找來一段內容,填進去,問題就解決了。[PHP] engine = On short_open_tag = On precision = 14 output_buffering = 4096 zli…
👁 1
帝国CMS 多少数据分表好?已发布的数据如何进行分表操作?帝国cms百万数据如何平均分配到分表?

帝國CMS 多少數據分表好?已發佈的數據如何進行分表操作?帝國cms百萬數據如何平均分配到分表? 程序筆記 帝國cms 實用收藏 帝國插件

帝國CMS 多少數據分表好?1、數據庫50G,應新建主表;2、滿5W條數據新建副表,並將最新建的副表設爲當前存放表;【也有建議10W條數據分一次表的】數據量過大,已導致MYSQL數據對IO的操作讀寫量巨大,導致整個服務器負載過大。特別是帝國cms後臺操作緩慢,尤其是數據量大的欄目,本人的站點沒分表前…
👁 683
sqlite数据库在线php读取操作查看,pbootcms数据库在线查看

sqlite數據庫在線php讀取操作查看,pbootcms數據庫在線查看 程序筆記 PbootCms 語言筆記 PHP

在使用pbootcms的時候,很多時候會用到數據庫,但是pb的數據庫是.db文件,所以就很麻煩。沒有現成的可視化頁面可以查看數據庫,因此,這裏開發了一個php在線查看的php可視化頁面。這樣會方便許多。如何使用?在db文件的文件夾中,創建一個php,將下面代碼放進去,修改自己的數據庫名稱,然後訪問p…
👁 167
资源网站typecho001模板

資源網站typecho001模板 程序筆記 Typecho

typecho001模板板本請勿修改本模板文件夾名稱 文件夾名稱爲:typecho001 1.4修復文章頁面js輸出問題1.3修復未安裝插件時的報錯提示優化列表頁代碼輸出1.2修復評論功能增加自定義首頁標題1.1修復評論回覆不對稱功能添加首頁單獨的標題設置功能增加網站favicon.ico圖標增加一…
👁 197

推薦閱讀

电子元件外贸响应式英文网站模板 1116

電子元件外貿響應式英文網站模板 1116 實用收藏 易優模板

此套eyoucms響應式英文網站模板適用於電子元件外貿行業,設計風格科技國際,能夠展示電子元件產品、技術參數、出口優勢及全球市場。有助於電子企業在國際市場上展示品牌。模板展示 安裝說明 網站後臺:/login.php 賬號:admin 密碼:admin 相關文章易優CMS安裝常見問題總結易優CMS(…
👁 41
响应式NBA体育赛事资讯模板 0132

響應式NBA體育賽事資訊模板 0132 實用收藏 易優模板

一款針對NBA體育賽事資訊的eyoucms響應式模板。設計風格動感激情,能夠展示NBA賽事直播、比分數據、球隊動態及深度分析。有助於體育媒體在線上吸引籃球愛好者。模板展示 安裝說明 網站後臺:/login.php 賬號:admin 密碼:admin 相關文章易優CMS安裝常見問題總結易優CMS(Ey…
👁 59
(自适应手机端)工商注册类网站pbootcms模板 财务代理记账网站源码下载 0343

(自適應手機端)工商註冊類網站pbootcms模板 財務代理記賬網站源碼下載 0343 實用收藏 pbootcms模板

一款自適應手機端的工商註冊與財務代理記賬PbootCMS網站模板。設計風格簡潔專業,適合財稅公司展示記賬報稅、工商註冊等一站式服務。有助於財務服務機構在線上建立品牌形象,獲取企業客戶。模板展示 安裝說明 網站後臺:/admin.php 賬號:admin 密碼:admin 解壓密碼:www.4s5.c…
👁 39
php 将一个字符串分割为组成它的字符

php 將一個字符串分割爲組成它的字符 語言筆記 PHP

問: php裏如何將一個字符串分割爲組成它的字符?比如hello -&gt; [h, e, l, l, o] 以下有三種方法:這是需要被分割的字符串: $str = 'Hello小樣';字符串的長度:$len = mb_strlen($str, 'utf8');// 7第一種:$arr = str_…
👁 182
响应式家具家私销售网站模板 0370

響應式傢俱傢俬銷售網站模板 0370 實用收藏 易優模板

一款針對傢俱傢俬銷售行業的eyoucms響應式網站模板。設計風格現代實用,能夠展示傢俱產品、傢俬系列、家居搭配及品牌故事。有助於傢俱品牌在線上展示產品,吸引家庭消費者。模板展示 安裝說明 網站後臺:/login.php 賬號:admin 密碼:admin 相關文章易優CMS安裝常見問題總結易優CMS…
👁 37
(PC+WAP)蓝色五金机械设备pbootcms企业网站模板 通用营销型网站源码下载 0583

(PC+WAP)藍色五金機械設備pbootcms企業網站模板 通用營銷型網站源碼下載 0583 實用收藏 pbootcms模板

一款通用的藍色五金機械設備PbootCMS企業網站模板,適用於各類機械製造與五金加工行業,支持PC和移動端。設計風格簡潔專業,功能模塊齊全。是機械設備公司快速搭建營銷型品牌官網、提升線上推廣效果的基礎選擇。模板展示 安裝說明 網站後臺:/admin.php 賬號:admin 密碼:admin 解壓密…
👁 39