类似phpMyAdmin的数据库管理页面

phpMyAdmin页面虽然好用但是关闭开启很麻烦,如果一直挂在公网很不安全,所以我就做了个网站页面管理服务器里的数据库,需要的可以拿去用。

代码;

<?php
declare(strict_types=1);

session_start();

// ========== 请修改访问密钥(浏览器首次访问需带 ?key= 或表单填写) ==========
const ACCESS_KEY = '7758';

// 可选:仅允许这些 IP 访问(留空 [] 表示不限制)
const ALLOW_IPS = [];

const SQL_RESULT_MAX_ROWS = 500;
const SQL_BATCH_MAX_STATEMENTS = 50;
const BROWSE_PAGE_SIZE = 50;

function clientIp(): string
{
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', '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 '0.0.0.0';
}

function ipAllowed(): bool
{
    if (ALLOW_IPS === []) {
        return true;
    }
    return in_array(clientIp(), ALLOW_IPS, true);
}

if (!ipAllowed()) {
    http_response_code(403);
    exit('Forbidden (IP).');
}

function ensureCsrfToken(): string
{
    if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function csrfValid(): bool
{
    $posted = $_POST['csrf_token'] ?? '';
    return is_string($posted)
        && isset($_SESSION['csrf_token'])
        && is_string($_SESSION['csrf_token'])
        && hash_equals($_SESSION['csrf_token'], $posted);
}

function csrfField(): void
{
    echo '<input type="hidden" name="csrf_token" value="' . h(ensureCsrfToken()) . '">';
}

function gateLoginSuccess(): void
{
    $_SESSION['gate_ok'] = true;
    session_regenerate_id(true);
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

$gateError = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_SESSION['gate_ok']) && ($_POST['gate_action'] ?? '') === 'login') {
    $key = (string) ($_POST['key'] ?? '');
    if ($key !== '' && hash_equals(ACCESS_KEY, $key)) {
        gateLoginSuccess();
        header('Location: index.php');
        exit;
    }
    $gateError = '密钥错误';
}

if (empty($_SESSION['gate_ok'])) {
    $key = isset($_GET['key']) ? (string) $_GET['key'] : '';
    if ($key !== '' && hash_equals(ACCESS_KEY, $key)) {
        gateLoginSuccess();
    }
}

if (!empty($_SESSION['gate_ok'])) {
    ensureCsrfToken();
}

if (!empty($_GET['logout'])) {
    $_SESSION = [];
    session_destroy();
    header('Location: ' . strtok($_SERVER['REQUEST_URI'] ?? 'index.php', '?'));
    exit;
}

// ---------- PDO ----------
function pdoConnect(string $host, string $port, string $user, string $pass, string $dbname): PDO
{
    $dsn = sprintf(
        'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
        $host,
        $port !== '' ? $port : '3306',
        $dbname
    );
    return new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);
}

function sessionDb(): ?array
{
    return isset($_SESSION['db_config']) && is_array($_SESSION['db_config'])
        ? $_SESSION['db_config']
        : null;
}

function getPdo(): ?PDO
{
    $cfg = sessionDb();
    if ($cfg === null) {
        return null;
    }
    try {
        return pdoConnect(
            $cfg['host'],
            $cfg['port'],
            $cfg['user'],
            $cfg['pass'],
            $cfg['dbname']
        );
    } catch (Throwable $e) {
        unset($_SESSION['db_config']);
        return null;
    }
}

function isReadOnlySql(string $sql): bool
{
    $s = ltrim($sql);
    if ($s === '') {
        return true;
    }
    $head = strtolower(substr($s, 0, 20));
    foreach (['select', 'show', 'describe', 'desc', 'explain', 'with'] as $prefix) {
        if (strpos($head, $prefix) === 0) {
            return true;
        }
    }
    return false;
}

function sqlBlocked(string $sql): ?string
{
    $bad = [
        'into outfile', 'into dumpfile', 'load_file', 'load data',
        'grant ', 'revoke ', 'create user', 'drop user',
    ];
    $lower = strtolower($sql);
    foreach ($bad as $needle) {
        if (strpos($lower, $needle) !== false) {
            return '已拦截高风险语句: ' . $needle;
        }
    }
    return null;
}

function h(?string $s): string
{
    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function tableFieldNames(?array $tableMeta): array
{
    if ($tableMeta === null) {
        return [];
    }
    $names = [];
    foreach ($tableMeta as $col) {
        $f = (string) ($col['Field'] ?? '');
        if ($f !== '' && preg_match('/^[a-zA-Z0-9_]+$/', $f)) {
            $names[] = $f;
        }
    }
    return $names;
}

/** @return array{0: string, 1: array<int, string>} */
function buildBrowseSearchWhere(array $tableMeta, string $keyword, string $field): array
{
    $keyword = trim($keyword);
    if ($keyword === '') {
        return ['', []];
    }
    $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword) . '%';
    $fields = tableFieldNames($tableMeta);
    if ($field !== '' && in_array($field, $fields, true)) {
        $quoted = '`' . str_replace('`', '``', $field) . '`';
        return [' WHERE ' . $quoted . ' LIKE ?', [$like]];
    }
    $parts = [];
    $params = [];
    foreach ($fields as $f) {
        $quoted = '`' . str_replace('`', '``', $f) . '`';
        $parts[] = 'CAST(' . $quoted . ' AS CHAR) LIKE ?';
        $params[] = $like;
    }
    if ($parts === []) {
        return ['', []];
    }
    return [' WHERE (' . implode(' OR ', $parts) . ')', $params];
}

/** @return list<string> */
function splitSqlStatements(string $sql): array
{
    $statements = [];
    foreach (explode(';', $sql) as $chunk) {
        $s = trim($chunk);
        if ($s === '') {
            continue;
        }
        $statements[] = $s;
    }
    return $statements;
}

/**
 * @param list<string> $statements
 * @return list<array{index:int,total:int,sql:string,ok:bool,error:?string,rows:?array,info:?string}>
 */
function executeSqlBatch(PDO $pdo, array $statements): array
{
    $results = [];
    $total = count($statements);
    foreach ($statements as $i => $stmtSql) {
        $index = $i + 1;
        $block = sqlBlocked($stmtSql);
        if ($block !== null) {
            $results[] = [
                'index' => $index,
                'total' => $total,
                'sql' => $stmtSql,
                'ok' => false,
                'error' => $block,
                'rows' => null,
                'info' => null,
            ];
            break;
        }
        try {
            if (isReadOnlySql($stmtSql)) {
                $stmt = $pdo->query($stmtSql);
                $rows = $stmt ? $stmt->fetchAll() : [];
                $n = count($rows);
                $info = "共 {$n} 行";
                if ($n > SQL_RESULT_MAX_ROWS) {
                    $rows = array_slice($rows, 0, SQL_RESULT_MAX_ROWS);
                    $info = "共 {$n} 行,仅显示前 " . SQL_RESULT_MAX_ROWS . ' 行';
                }
                $results[] = [
                    'index' => $index,
                    'total' => $total,
                    'sql' => $stmtSql,
                    'ok' => true,
                    'error' => null,
                    'rows' => $rows,
                    'info' => $info,
                ];
            } else {
                $aff = $pdo->exec($stmtSql);
                $results[] = [
                    'index' => $index,
                    'total' => $total,
                    'sql' => $stmtSql,
                    'ok' => true,
                    'error' => null,
                    'rows' => null,
                    'info' => '执行成功,影响行数: ' . ($aff === false ? '未知' : (string) $aff),
                ];
            }
        } catch (Throwable $e) {
            $results[] = [
                'index' => $index,
                'total' => $total,
                'sql' => $stmtSql,
                'ok' => false,
                'error' => $e->getMessage(),
                'rows' => null,
                'info' => null,
            ];
            break;
        }
    }
    return $results;
}

// ---------- 处理 POST ----------
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
$error = null;
$resultRows = null;
$resultInfo = null;
$sqlBatchResults = [];
$sqlDefault = '';
$browsePage = max(1, (int) ($_GET['page'] ?? 1));
$browseTotal = 0;
$browseTotalPages = 1;
$browseSearchQ = trim((string) ($_GET['q'] ?? ''));
$browseSearchCol = (string) ($_GET['col'] ?? '');
if ($browseSearchCol !== '' && !preg_match('/^[a-zA-Z0-9_]+$/', $browseSearchCol)) {
    $browseSearchCol = '';
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_SESSION['gate_ok'])) {
    $action = $_POST['action'] ?? '';

    if (!csrfValid()) {
        $error = '安全校验失败,请刷新页面后重试';
        $action = '';
    }

    if ($action === 'connect') {
        $host = trim((string) ($_POST['host'] ?? '127.0.0.1'));
        $port = trim((string) ($_POST['port'] ?? '3306'));
        $user = (string) ($_POST['user'] ?? '');
        $pass = (string) ($_POST['pass'] ?? '');
        $dbname = trim((string) ($_POST['dbname'] ?? ''));
        try {
            pdoConnect($host, $port, $user, $pass, $dbname);
            $_SESSION['db_config'] = compact('host', 'port', 'user', 'pass', 'dbname');
            $_SESSION['flash'] = '已连接数据库: ' . $dbname;
            header('Location: index.php');
            exit;
        } catch (Throwable $e) {
            $error = '连接失败: ' . $e->getMessage();
        }
    }

    if ($action === 'disconnect') {
        unset($_SESSION['db_config']);
        $_SESSION['flash'] = '已断开数据库连接';
        header('Location: index.php');
        exit;
    }

    if ($action === 'run_sql') {
        $sql = trim((string) ($_POST['sql'] ?? ''));
        $sqlDefault = $sql;
        if (!empty($_POST['ctx_table']) && preg_match('/^[a-zA-Z0-9_]+$/', (string) $_POST['ctx_table'])) {
            $_SESSION['ctx_table'] = (string) $_POST['ctx_table'];
        } else {
            unset($_SESSION['ctx_table']);
        }
        if (!empty($_POST['ctx_tab']) && $_POST['ctx_tab'] === 'sql') {
            $_SESSION['ctx_main_tab'] = 'sql';
        } else {
            unset($_SESSION['ctx_main_tab']);
        }
        $pdo = getPdo();
        if ($pdo === null) {
            $error = '请先连接数据库';
        } elseif ($sql === '') {
            $error = 'SQL 不能为空';
        } else {
            $statements = splitSqlStatements($sql);
            if ($statements === []) {
                $error = 'SQL 不能为空';
            } elseif (count($statements) > SQL_BATCH_MAX_STATEMENTS) {
                $error = '单次最多执行 ' . SQL_BATCH_MAX_STATEMENTS . ' 条语句(用分号分隔)';
            } else {
                $sqlBatchResults = executeSqlBatch($pdo, $statements);
                $failed = null;
                foreach ($sqlBatchResults as $item) {
                    if (!$item['ok']) {
                        $failed = $item;
                        break;
                    }
                }
                if ($failed !== null) {
                    $error = '第 ' . $failed['index'] . ' 条失败:' . $failed['error'];
                } elseif (count($sqlBatchResults) === 1 && $sqlBatchResults[0]['ok']) {
                    $resultRows = $sqlBatchResults[0]['rows'];
                    $resultInfo = $sqlBatchResults[0]['info'];
                }
            }
        }
    }
}

$pdo = !empty($_SESSION['gate_ok']) ? getPdo() : null;
$tables = [];
$tableMeta = null;
$createSql = null;
$tableStatus = null;
$columnComments = [];
$activeTable = isset($_GET['table']) ? (string) $_GET['table'] : '';
$mainTab = isset($_GET['tab']) ? (string) $_GET['tab'] : '';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'run_sql') {
    if (!empty($_SESSION['ctx_table'])) {
        $activeTable = (string) $_SESSION['ctx_table'];
    }
    if (!empty($_SESSION['ctx_main_tab'])) {
        $mainTab = 'sql';
    }
}
unset($_SESSION['ctx_table'], $_SESSION['ctx_main_tab']);

$rawView = $_GET['view'] ?? 'browse';
if ($rawView === 'data') {
    $rawView = 'browse';
}
$tableView = in_array($rawView, ['browse', 'structure', 'sql'], true) ? $rawView : 'browse';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'run_sql') {
    $tableView = 'sql';
    $mainTab = '';
}

if ($pdo !== null && $activeTable !== '') {
    if (!preg_match('/^[a-zA-Z0-9_]+$/', $activeTable)) {
        $error = '无效表名';
        $activeTable = '';
    } else {
        try {
            $q = '`' . str_replace('`', '``', $activeTable) . '`';
            $st = $pdo->query('SHOW TABLE STATUS LIKE ' . $pdo->quote($activeTable))->fetch();
            $tableStatus = $st ?: null;
            if (in_array($tableView, ['structure', 'browse', 'sql'], true)) {
                $tableMeta = $pdo->query('SHOW FULL COLUMNS FROM ' . $q)->fetchAll();
                foreach ($tableMeta as $col) {
                    $field = (string) ($col['Field'] ?? '');
                    $comment = trim((string) ($col['Comment'] ?? ''));
                    if ($field !== '' && $comment !== '') {
                        $columnComments[$field] = $comment;
                    }
                }
            }
            if ($tableView === 'structure') {
                $cr = $pdo->query('SHOW CREATE TABLE ' . $q)->fetch();
                $createSql = $cr['Create Table'] ?? ($cr[1] ?? null);
            }

            $loadBrowse = $tableView === 'browse'
                && $_SERVER['REQUEST_METHOD'] !== 'POST'
                && $resultRows === null;
            if ($loadBrowse) {
                [$whereSql, $whereParams] = buildBrowseSearchWhere($tableMeta ?? [], $browseSearchQ, $browseSearchCol);
                $countStmt = $pdo->prepare('SELECT COUNT(*) FROM ' . $q . $whereSql);
                $countStmt->execute($whereParams);
                $browseTotal = (int) $countStmt->fetchColumn();
                $browseTotalPages = max(1, (int) ceil($browseTotal / BROWSE_PAGE_SIZE));
                if ($browsePage > $browseTotalPages) {
                    $browsePage = $browseTotalPages;
                }
                $offset = ($browsePage - 1) * BROWSE_PAGE_SIZE;
                $dataStmt = $pdo->prepare(
                    'SELECT * FROM ' . $q . $whereSql . ' LIMIT ' . (int) $offset . ', ' . BROWSE_PAGE_SIZE
                );
                $dataStmt->execute($whereParams);
                $resultRows = $dataStmt->fetchAll();
                $shown = count($resultRows);
                $from = $browseTotal === 0 ? 0 : $offset + 1;
                $to = $offset + $shown;
                $resultInfo = "第 {$browsePage} / {$browseTotalPages} 页,本页 {$shown} 行(第 {$from}–{$to} 条,共 {$browseTotal} 行)";
                if ($browseSearchQ !== '') {
                    $scope = $browseSearchCol !== '' ? $browseSearchCol : '全部字段';
                    $resultInfo .= ";搜索「" . $browseSearchQ . "」(" . $scope . ')';
                }
            }
            if ($tableView === 'sql' && $sqlDefault === '' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
                $sqlDefault = 'SELECT * FROM `' . $activeTable . '` LIMIT 100;';
            }
        } catch (Throwable $e) {
            $error = $e->getMessage();
        }
    }
}

if ($mainTab === 'sql' && $sqlDefault === '' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
    $sqlDefault = '';
}

if ($pdo !== null) {
    try {
        foreach ($pdo->query('SHOW TABLE STATUS')->fetchAll() as $row) {
            $tables[] = $row;
        }
    } catch (Throwable $e) {
        $error = $e->getMessage();
    }
}

$cfg = sessionDb();

function renderBrowsePager(string $table, int $page, int $totalPages, string $searchQ = '', string $searchCol = ''): void
{
    if ($totalPages <= 1) {
        return;
    }
    $extra = '';
    if ($searchQ !== '') {
        $extra .= '&q=' . rawurlencode($searchQ);
    }
    if ($searchCol !== '') {
        $extra .= '&col=' . rawurlencode($searchCol);
    }
    $base = '?table=' . rawurlencode($table) . '&view=browse' . $extra . '&page=';
    echo '<div class="pager">';
    echo '<p style="text-align:left;margin:0;">';
    if ($page > 1) {
        echo '<a href="' . h($base . '1') . '">首页</a> | ';
        echo '<a href="' . h($base . ($page - 1)) . '">上一页</a> | ';
    }
    echo '<strong>第 ' . (int) $page . ' / ' . (int) $totalPages . ' 页</strong>';
    if ($page < $totalPages) {
        echo ' | <a href="' . h($base . ($page + 1)) . '">下一页</a>';
        echo ' | <a href="' . h($base . $totalPages) . '">末页</a>';
    }
    echo '</p></div>';
}

function renderResultTable(?array $rows, ?string $info, bool $wideGrid = true, array $columnComments = []): void
{
    if ($info !== null && $info !== '') {
        echo '<p class="msg-ok" style="margin:0 0 10px;">' . h($info) . '</p>';
    }
    if ($rows === null) {
        return;
    }
    if ($rows === []) {
        echo '<p class="hint" style="text-align:left;margin:0;">查询成功,结果为空。</p>';
        return;
    }
    $tblClass = 'data' . ($wideGrid ? ' data-grid' : '');
    $wrapClass = 'data-scroll' . ($wideGrid ? ' data-scroll-wide' : '');
    echo '<div class="' . h($wrapClass) . '"><table class="' . h($tblClass) . '"><tr>';
    foreach (array_keys($rows[0]) as $col) {
        $col = (string) $col;
        echo '<th>';
        echo '<span class="col-name">' . h($col) . '</span>';
        if (!empty($columnComments[$col])) {
            echo '<span class="col-comment">' . h($columnComments[$col]) . '</span>';
        }
        echo '</th>';
    }
    echo '</tr>';
    foreach ($rows as $row) {
        echo '<tr>';
        foreach ($row as $v) {
            echo '<td>' . h($v === null ? 'NULL' : (string) $v) . '</td>';
        }
        echo '</tr>';
    }
    echo '</table></div>';
}

/** @param list<array{index:int,total:int,sql:string,ok:bool,error:?string,rows:?array,info:?string}> $batch */
function renderSqlBatchResults(array $batch, array $columnComments = []): void
{
    foreach ($batch as $item) {
        echo '<div class="sql-batch-item">';
        echo '<p class="sql-batch-title">语句 ' . (int) $item['index'] . ' / ' . (int) $item['total'] . '</p>';
        echo '<pre class="sql-batch-sql">' . h($item['sql']) . '</pre>';
        if (!$item['ok']) {
            echo '<p class="msg-err" style="margin:0;">' . h((string) $item['error']) . '</p>';
        } elseif ($item['rows'] !== null) {
            renderResultTable($item['rows'], $item['info'], true, $columnComments);
        } elseif ($item['info'] !== null) {
            echo '<p class="msg-ok" style="margin:0;">' . h($item['info']) . '</p>';
        }
        echo '</div>';
    }
}

function formatBytes($n): string
{
    $n = (int) $n;
    if ($n < 1024) {
        return $n . ' B';
    }
    if ($n < 1024 * 1024) {
        return round($n / 1024, 1) . ' KB';
    }
    return round($n / 1024 / 1024, 2) . ' MB';
}

function quoteSqlTable(string $table): string
{
    return '`' . str_replace('`', '``', $table) . '`';
}

/** @return array<string, list<array{label: string, sql: string}>> */
function getSqlSnippets(string $table): array
{
    $q = quoteSqlTable($table);
    return [
        '表维护' => [
            ['label' => '优化表 OPTIMIZE', 'sql' => "OPTIMIZE TABLE {$q};"],
            ['label' => '检查表 CHECK', 'sql' => "CHECK TABLE {$q};"],
            ['label' => '分析表 ANALYZE', 'sql' => "ANALYZE TABLE {$q};"],
            ['label' => '修复表 REPAIR', 'sql' => "REPAIR TABLE {$q};"],
        ],
        '查看表' => [
            ['label' => '字段结构', 'sql' => "SHOW FULL COLUMNS FROM {$q};"],
            ['label' => '所有索引', 'sql' => "SHOW INDEX FROM {$q};"],
            ['label' => '建表语句', 'sql' => "SHOW CREATE TABLE {$q};"],
            ['label' => '行数统计', 'sql' => "SELECT COUNT(*) AS total FROM {$q};"],
            ['label' => '预览 20 行', 'sql' => "SELECT * FROM {$q} LIMIT 20;"],
            ['label' => '表状态', 'sql' => "SHOW TABLE STATUS LIKE '" . addslashes($table) . "';"],
        ],
        '全文索引' => [
            ['label' => '本表全文索引', 'sql' => "SHOW INDEX FROM {$q} WHERE Index_type = 'FULLTEXT';"],
            [
                'label' => '全库全文索引',
                'sql' => "SELECT TABLE_NAME, INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols\n"
                    . "FROM information_schema.STATISTICS\n"
                    . "WHERE TABLE_SCHEMA = DATABASE() AND INDEX_TYPE = 'FULLTEXT'\n"
                    . "GROUP BY TABLE_NAME, INDEX_NAME;",
            ],
        ],
        '整库' => [
            ['label' => '当前库名', 'sql' => 'SELECT DATABASE() AS db;'],
            ['label' => '所有表', 'sql' => 'SHOW TABLES;'],
            ['label' => '所有表状态', 'sql' => 'SHOW TABLE STATUS;'],
        ],
        'MySQL 变量' => [
            [
                'label' => '全文相关变量',
                'sql' => "SHOW VARIABLES LIKE 'ft_min_word_len';\n"
                    . "SHOW VARIABLES LIKE 'innodb_ft_min_token_size';\n"
                    . "SHOW VARIABLES LIKE 'ngram_token_size';",
            ],
            ['label' => '连接数', 'sql' => "SHOW STATUS LIKE 'Threads_connected';"],
        ],
    ];
}

function renderSqlSnippets(string $table): void
{
    echo '<div class="sql-snippets">';
    echo '<p class="sql-snippets-hint">常用命令 · 点击填入上方输入框(模板表:<code>' . h($table) . '</code>'
        . ',在左侧选中表时会自动换成本表)</p>';
    foreach (getSqlSnippets($table) as $group => $items) {
        echo '<div class="sql-snippet-group">';
        echo '<span class="sql-snippet-group-title">' . h($group) . '</span>';
        echo '<div class="sql-snippet-btns">';
        foreach ($items as $item) {
            echo '<button type="button" class="sql-snippet-btn" data-sql="' . h($item['sql']) . '">'
                . h($item['label']) . '</button>';
        }
        echo '</div></div>';
    }
    echo '</div>';
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>数据库管理</title>
    <style>
        :root {
            --bg: #e2e5eb;
            --panel: #f4f5f7;
            --border: #c8cdd6;
            --text: #333a42;
            --muted: #6a7380;
            --accent: #5a8ec2;
            --ok: #3a8a62;
            --err: #c25252;
        }
        * { box-sizing: border-box; }
        body { margin:0; font:14px/1.5 system-ui, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
        a { color:#4a78a8; text-decoration:none; }
        a:hover { text-decoration:underline; }
        header {
            padding:12px 20px; border-bottom:1px solid var(--border);
            display:flex; flex-wrap:wrap; gap:12px; align-items:center; justify-content:space-between;
            background:#ebecef;
        }
        header h1 { margin:0; font-size:16px; font-weight:600; color:var(--text); }
        .page-center {
            display:flex; justify-content:center; align-items:flex-start;
            min-height:calc(100vh - 49px); padding:40px 16px;
        }
        .card-narrow { width:100%; max-width:400px; }
        .card-narrow h2 { text-align:center; }
        .card-narrow form { text-align:left; }
        .card-narrow .form-actions { text-align:center; margin-top:16px; }
        .wrap { display:flex; min-height:calc(100vh - 49px); }
        aside {
            width:260px; flex-shrink:0; border-right:1px solid var(--border);
            background:var(--panel); overflow:auto; max-height:calc(100vh - 49px);
        }
        main { flex:1; padding:16px 20px; overflow:auto; background:var(--bg); }
        .tbl-list { list-style:none; margin:0; padding:8px 0; }
        .tbl-list li a { display:block; padding:6px 16px; color:var(--text); }
        .tbl-list li a:hover, .tbl-list li a.active { background:#dde2e9; text-decoration:none; }
        .tbl-list .meta { font-size:11px; color:var(--muted); padding:0 16px 4px; }
        .card {
            background:var(--panel); border:1px solid var(--border); border-radius:10px;
            padding:20px 22px; margin-bottom:16px; box-shadow:0 1px 2px rgba(0,0,0,.04);
        }
        label { display:block; margin:10px 0 4px; color:var(--muted); font-size:12px; }
        input[type=text], input[type=password], textarea {
            width:100%; padding:9px 11px; border:1px solid var(--border); border-radius:6px;
            background:#fafbfc; color:var(--text);
        }
        input:focus, textarea:focus { outline:none; border-color:var(--accent); background:#fff; }
        textarea { min-height:140px; font-family:ui-monospace, monospace; font-size:13px; }
        button, .btn {
            padding:9px 18px; border-radius:6px; border:0; cursor:pointer; font-size:13px;
            background:var(--accent); color:#fff;
        }
        button:hover { filter:brightness(.96); }
        button.secondary { background:#8b96a3; }
        button.danger { background:#b05050; }
        .msg-ok { color:var(--ok); margin-bottom:12px; }
        .msg-err { color:var(--err); margin-bottom:12px; }
        table.data { width:100%; border-collapse:collapse; font-size:12px; background:#fafbfc; }
        table.data th, table.data td {
            border:1px solid var(--border); padding:6px 10px; text-align:left; vertical-align:top;
        }
        table.data th { background:#e4e8ee; position:sticky; top:0; font-weight:600; z-index:1; }
        table.data-grid { width:max-content; min-width:100%; table-layout:auto; }
        table.data-grid th, table.data-grid td {
            white-space:nowrap; word-break:normal; max-width:280px; overflow:hidden;
            text-overflow:ellipsis; vertical-align:middle;
        }
        table.data-grid th { position:sticky; top:0; white-space:normal; vertical-align:bottom; }
        table.data-grid th .col-name { display:block; font-weight:600; white-space:nowrap; }
        table.data-grid th .col-comment {
            display:block; font-weight:normal; font-size:11px; color:#5c6b7a;
            margin-top:4px; white-space:normal; line-height:1.35; max-width:260px;
        }
        .data-scroll-wide { overflow-x:auto; overflow-y:auto; -webkit-overflow-scrolling:touch; }
        .pager { margin-top:12px; font-size:13px; }
        .pager a { margin:0 2px; }
        .search-bar {
            display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom:14px;
            padding-bottom:14px; border-bottom:1px solid var(--border);
        }
        .search-bar select, .search-bar input[type=text] { width:auto; min-width:140px; max-width:100%; flex:1 1 160px; }
        .search-bar .search-q { flex:2 1 220px; min-width:180px; }
        .search-bar button.secondary { background:#9aa5b1; }
        .btn-link { font-size:13px; color:#4a78a8; align-self:center; }
        .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); border:0; }
        .sql-batch-item { margin-top:16px; padding-top:12px; border-top:1px dashed var(--border); }
        .sql-batch-item:first-of-type { margin-top:12px; border-top:0; padding-top:0; }
        .sql-batch-title { margin:0 0 6px; font-size:13px; font-weight:600; color:var(--muted); }
        .sql-batch-sql {
            margin:0 0 10px; font-size:12px; background:#eceff3; padding:8px 10px;
            border-radius:6px; border:1px solid var(--border); white-space:pre-wrap; word-break:break-all;
        }
        .sql-snippets {
            margin-top:16px; padding:12px; background:#eef1f5; border:1px solid var(--border); border-radius:8px;
        }
        .sql-snippets-hint { margin:0 0 10px; font-size:12px; color:var(--muted); text-align:left; }
        .sql-snippet-group { margin-bottom:10px; }
        .sql-snippet-group:last-child { margin-bottom:0; }
        .sql-snippet-group-title {
            display:inline-block; min-width:4.5em; font-size:12px; font-weight:600; color:#4a5a6a;
            vertical-align:top; margin-top:6px;
        }
        .sql-snippet-btns { display:inline-flex; flex-wrap:wrap; gap:6px; max-width:calc(100% - 5em); }
        .sql-snippet-btn {
            padding:5px 10px; font-size:12px; border-radius:5px; border:1px solid #b8c4d0;
            background:#fff; color:#333a42; cursor:pointer;
        }
        .sql-snippet-btn:hover { background:#e8f0f8; border-color:var(--accent); }
        pre.create {
            white-space:pre-wrap; word-break:break-all; font-size:12px;
            background:#eceff3; padding:12px; border-radius:6px; overflow:auto; border:1px solid var(--border);
        }
        .breadcrumb { font-size:13px; color:var(--muted); margin-bottom:10px; }
        .breadcrumb strong { color:var(--text); }
        .tabs { display:flex; flex-wrap:wrap; gap:0; margin-bottom:14px; border-bottom:1px solid var(--border); }
        .tabs a {
            padding:8px 16px; margin-bottom:-1px; border:1px solid transparent;
            color:var(--muted); text-decoration:none; font-size:13px;
        }
        .tabs a:hover { color:var(--text); background:#e8ebf0; text-decoration:none; }
        .tabs a.active {
            color:var(--text); background:var(--panel); border-color:var(--border);
            border-bottom-color:var(--panel); font-weight:600;
        }
        .data-scroll { overflow:auto; max-height:min(70vh, 640px); border:1px solid var(--border); border-radius:6px; }
        .data-scroll-wide { max-height:min(75vh, 720px); }
        .aside-head { padding:12px 16px 8px; font-size:13px; font-weight:600; border-bottom:1px solid var(--border); }
        .aside-head a { font-weight:normal; font-size:12px; margin-left:8px; }
        .hint { font-size:12px; color:var(--muted); margin-top:16px; line-height:1.55; text-align:center; }
        form.inline { display:inline; }
        h3.sec-title { margin:0 0 12px; font-size:14px; font-weight:600; }
    </style>
</head>
<body>
<header>
    <h1>数据库管理</h1>
    <div>
        <?php if (!empty($_SESSION['gate_ok'])): ?>
            <?php if ($cfg): ?>
                <span style="color:var(--muted);font-size:13px;">
                    <?= h($cfg['user'] . '@' . $cfg['host'] . '/' . $cfg['dbname']) ?>
                </span>
                <form class="inline" method="post" style="margin-left:8px;">
                    <?php csrfField(); ?>
                    <input type="hidden" name="action" value="disconnect">
                    <button type="submit" class="secondary">断开</button>
                </form>
            <?php endif; ?>
            <a href="?logout=1" style="margin-left:8px;">退出访问</a>
        <?php endif; ?>
    </div>
</header>

<?php if (empty($_SESSION['gate_ok'])): ?>
    <div class="page-center">
        <div class="card card-narrow">
            <h2 style="margin-top:0;font-size:15px;">访问验证</h2>
            <p class="hint" style="text-align:left;margin-top:0;">请输入访问密钥。</p>
            <?php if ($gateError): ?><p class="msg-err"><?= h($gateError) ?></p><?php endif; ?>
            <form method="post">
                <input type="hidden" name="gate_action" value="login">
                <label>访问密钥</label>
                <input type="password" name="key" autocomplete="off" required>
                <div class="form-actions"><button type="submit">进入</button></div>
            </form>
        </div>
    </div>
<?php elseif ($pdo === null): ?>
    <div class="page-center">
        <div class="card card-narrow">
            <h2 style="margin-top:0;font-size:15px;">连接 MySQL</h2>
            <?php if ($flash): ?><p class="msg-ok"><?= h($flash) ?></p><?php endif; ?>
            <?php if ($error): ?><p class="msg-err"><?= h($error) ?></p><?php endif; ?>
            <form method="post">
                <?php csrfField(); ?>
                <input type="hidden" name="action" value="connect">
                <label>主机</label>
                <input type="text" name="host" value="<?= h($_POST['host'] ?? '127.0.0.1') ?>">
                <label>端口</label>
                <input type="text" name="port" value="<?= h($_POST['port'] ?? '3306') ?>">
                <label>用户名</label>
                <input type="text" name="user" id="connect-user" value="<?= h($_POST['user'] ?? '') ?>" required autocomplete="off">
                <label>密码</label>
                <input type="password" name="pass" autocomplete="off">
                <label>数据库名</label>
                <input type="text" name="dbname" id="connect-dbname" value="<?= h($_POST['dbname'] ?? '') ?>" required autocomplete="off">
                <div class="form-actions"><button type="submit">连接</button></div>
            </form>
            <p class="hint">凭证仅存于当前 PHP 会话,关闭浏览器或点「断开」后清除;不落盘配置文件。</p>
        </div>
    </div>
<?php else:
    $dbName = $cfg['dbname'] ?? '';
    $showSqlPanel = ($activeTable !== '' && $tableView === 'sql') || ($activeTable === '' && $mainTab === 'sql');
    ?>
    <div class="wrap">
        <aside>
            <div class="aside-head">
                <?= h($dbName) ?>
                <a href="index.php">结构</a>
            </div>
            <p class="meta" style="padding:8px 16px 4px;">表(<?= count($tables) ?>)</p>
            <ul class="tbl-list">
                <?php foreach ($tables as $t):
                    $name = $t['Name'] ?? '';
                    $active = $name === $activeTable;
                    ?>
                    <li>
                        <a class="<?= $active ? 'active' : '' ?>" href="?table=<?= urlencode($name) ?>&view=browse">
                            <?= h($name) ?>
                        </a>
                    </li>
                <?php endforeach; ?>
            </ul>
        </aside>
        <main>
            <?php if ($flash): ?><p class="msg-ok"><?= h($flash) ?></p><?php endif; ?>
            <?php if ($error): ?><p class="msg-err"><?= h($error) ?></p><?php endif; ?>

            <?php if ($activeTable === ''): ?>
                <p class="breadcrumb">服务器:<?= h($cfg['host'] ?? '') ?> / 数据库:<strong><?= h($dbName) ?></strong></p>
                <nav class="tabs">
                    <a class="active" href="index.php">结构</a>
                    <a class="<?= $mainTab === 'sql' ? 'active' : '' ?>" href="?tab=sql">SQL</a>
                </nav>

                <?php if ($mainTab !== 'sql'): ?>
                    <div class="card" style="padding:16px;">
                        <h3 class="sec-title">数据表一览</h3>
                        <div class="data-scroll" style="max-height:none;">
                            <table class="data">
                                <tr>
                                    <th>表名</th>
                                    <th>操作</th>
                                    <th>行数</th>
                                    <th>引擎</th>
                                    <th>数据大小</th>
                                    <th>备注</th>
                                </tr>
                                <?php foreach ($tables as $t):
                                    $name = $t['Name'] ?? '';
                                    ?>
                                    <tr>
                                        <td><a href="?table=<?= urlencode($name) ?>&view=browse"><?= h($name) ?></a></td>
                                        <td>
                                            <a href="?table=<?= urlencode($name) ?>&view=browse">浏览</a>
                                            |
                                            <a href="?table=<?= urlencode($name) ?>&view=structure">结构</a>
                                        </td>
                                        <td><?= h((string) ($t['Rows'] ?? '')) ?></td>
                                        <td><?= h((string) ($t['Engine'] ?? '')) ?></td>
                                        <td><?= h(formatBytes($t['Data_length'] ?? 0)) ?></td>
                                        <td><?= h((string) ($t['Comment'] ?? '')) ?></td>
                                    </tr>
                                <?php endforeach; ?>
                            </table>
                        </div>
                    </div>
                <?php endif; ?>

            <?php else: ?>
                <p class="breadcrumb">
                    <a href="index.php"><?= h($dbName) ?></a>
                    &nbsp;/&nbsp;
                    <strong><?= h($activeTable) ?></strong>
                    <?php if ($tableStatus): ?>
                        <span style="margin-left:8px;">(约 <?= h((string) ($tableStatus['Rows'] ?? '?')) ?> 行)</span>
                    <?php endif; ?>
                </p>
                <nav class="tabs">
                    <a class="<?= $tableView === 'browse' ? 'active' : '' ?>" href="?table=<?= urlencode($activeTable) ?>&view=browse">浏览</a>
                    <a class="<?= $tableView === 'structure' ? 'active' : '' ?>" href="?table=<?= urlencode($activeTable) ?>&view=structure">结构</a>
                    <a class="<?= $tableView === 'sql' ? 'active' : '' ?>" href="?table=<?= urlencode($activeTable) ?>&view=sql">SQL</a>
                </nav>

                <?php if ($tableView === 'browse'): ?>
                    <div class="card">
                        <h3 class="sec-title">浏览:<?= h($activeTable) ?></h3>
                        <form method="get" class="search-bar">
                            <input type="hidden" name="table" value="<?= h($activeTable) ?>">
                            <input type="hidden" name="view" value="browse">
                            <label class="sr-only" for="search-col">字段</label>
                            <select name="col" id="search-col" title="搜索字段">
                                <option value="">全部字段</option>
                                <?php foreach (tableFieldNames($tableMeta ?? []) as $fn): ?>
                                    <option value="<?= h($fn) ?>"<?= $browseSearchCol === $fn ? ' selected' : '' ?>>
                                        <?= h($fn) ?><?= !empty($columnComments[$fn]) ? ' — ' . h($columnComments[$fn]) : '' ?>
                                    </option>
                                <?php endforeach; ?>
                            </select>
                            <input type="text" class="search-q" name="q" value="<?= h($browseSearchQ) ?>" placeholder="输入关键词搜索(模糊匹配)" autocomplete="off">
                            <button type="submit">搜索</button>
                            <?php if ($browseSearchQ !== ''): ?>
                                <a class="btn-link" href="?table=<?= urlencode($activeTable) ?>&view=browse">清除搜索</a>
                            <?php endif; ?>
                        </form>
                        <?php renderResultTable($resultRows, $resultInfo, true, $columnComments); ?>
                        <?php renderBrowsePager($activeTable, $browsePage, $browseTotalPages, $browseSearchQ, $browseSearchCol); ?>
                    </div>
                <?php elseif ($tableView === 'structure'): ?>
                    <?php if ($tableMeta): ?>
                        <div class="card">
                            <h3 class="sec-title">表结构</h3>
                            <div class="data-scroll" style="max-height:none;">
                                <table class="data">
                                    <tr>
                                        <th>字段</th><th>类型</th><th>排序规则</th><th>允许空</th>
                                        <th>键</th><th>默认值</th><th>额外</th><th>备注</th>
                                    </tr>
                                    <?php foreach ($tableMeta as $col): ?>
                                        <tr>
                                            <td><?= h((string) ($col['Field'] ?? '')) ?></td>
                                            <td><?= h((string) ($col['Type'] ?? '')) ?></td>
                                            <td><?= h((string) ($col['Collation'] ?? '')) ?></td>
                                            <td><?= h((string) ($col['Null'] ?? '')) ?></td>
                                            <td><?= h((string) ($col['Key'] ?? '')) ?></td>
                                            <td><?= h(isset($col['Default']) ? (string) $col['Default'] : '') ?></td>
                                            <td><?= h((string) ($col['Extra'] ?? '')) ?></td>
                                            <td><?= h((string) ($col['Comment'] ?? '')) ?></td>
                                        </tr>
                                    <?php endforeach; ?>
                                </table>
                            </div>
                        </div>
                    <?php endif; ?>
                    <?php if ($createSql): ?>
                        <div class="card">
                            <h3 class="sec-title">建表语句</h3>
                            <pre class="create"><?= h($createSql) ?></pre>
                        </div>
                    <?php endif; ?>
                <?php endif; ?>
            <?php endif; ?>

            <?php if ($showSqlPanel):
                $sqlSnippetTable = ($activeTable !== '' && preg_match('/^[a-zA-Z0-9_]+$/', $activeTable))
                    ? $activeTable
                    : 'mac_vod';
                ?>
                <div class="card">
                    <h3 class="sec-title">执行 SQL</h3>
                    <form method="post" id="sql-exec-form">
                        <?php csrfField(); ?>
                        <input type="hidden" name="action" value="run_sql">
                        <?php if ($activeTable !== ''): ?>
                            <input type="hidden" name="ctx_table" value="<?= h($activeTable) ?>">
                        <?php else: ?>
                            <input type="hidden" name="ctx_tab" value="sql">
                        <?php endif; ?>
                        <textarea id="sql-textarea" name="sql" placeholder="支持多条语句,用英文分号 ; 分隔。也可点下方常用命令一键填入。"><?= h($sqlDefault) ?></textarea>
                        <p style="margin-top:10px;">
                            <button type="submit">执行</button>
                        </p>
                    </form>
                    <?php renderSqlSnippets($sqlSnippetTable); ?>
                    <?php if ($tableView === 'sql' || $mainTab === 'sql'): ?>
                        <?php if ($sqlBatchResults !== []): ?>
                            <?php renderSqlBatchResults($sqlBatchResults, $columnComments); ?>
                        <?php else: ?>
                            <?php renderResultTable($resultRows, $resultInfo, true, $columnComments); ?>
                        <?php endif; ?>
                    <?php endif; ?>
                </div>
            <?php endif; ?>

            <p class="hint" style="text-align:left;">不用时请在宝塔关闭本站。</p>
        </main>
    </div>
<?php endif; ?>
<script>
(function () {
    var userEl = document.getElementById('connect-user');
    var dbEl = document.getElementById('connect-dbname');
    if (!userEl || !dbEl) return;
    var dbManual = dbEl.value.trim() !== '' && dbEl.value !== userEl.value;
    userEl.addEventListener('input', function () {
        if (!dbManual) dbEl.value = userEl.value;
    });
    dbEl.addEventListener('input', function () {
        dbManual = dbEl.value !== userEl.value;
    });
    if (!dbManual && userEl.value.trim() !== '' && dbEl.value.trim() === '') {
        dbEl.value = userEl.value;
    }
})();
(function () {
    var ta = document.getElementById('sql-textarea');
    if (!ta) return;
    document.querySelectorAll('.sql-snippet-btn').forEach(function (btn) {
        btn.addEventListener('click', function () {
            var sql = btn.getAttribute('data-sql');
            if (sql != null) ta.value = sql;
            ta.focus();
        });
    });
})();
</script>
</body>
</html>

使用流程;

新建立个网站新建个index.php把代码放进去即可,打开网站看见输入密钥页面后输入代码里“const ACCESS_KEY”的值即可。下面的流程自行登陆就行。用完了可以直接关闭网站,不让他在公网一直暴露,注意使用ssl

类似phpMyAdmin的数据库管理页面
版权声明:图图 发表于 2026-11-08 22:01:02。
转载请注明:类似phpMyAdmin的数据库管理页面 | 站长资源站Pro

暂无评论

暂无评论...