Add 2026 calendar page and admin CMS backend, update map and member pages

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:32:51 +08:00
co-authored by Claude Sonnet 5
parent 17856bd9c3
commit 6dd5a2f0b7
26 changed files with 2965 additions and 370 deletions
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require_login();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = (string) ($_POST['action'] ?? 'create');
if ($action === 'create') {
$username = trim((string) ($_POST['username'] ?? ''));
$name = trim((string) ($_POST['display_name'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if (!preg_match('/^[A-Za-z0-9_.-]{4,50}$/', $username) || mb_strlen($name) < 2 || strlen($password) < 10) {
$error = '請確認帳號、名稱與至少 10 字元的密碼。';
} else {
try {
$stmt = db()->prepare('INSERT INTO admin_users (username, password_hash, display_name, email) VALUES (?, ?, ?, ?)');
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), $name, trim((string) ($_POST['email'] ?? '')) ?: null]);
audit('create', 'admin_users', (int) db()->lastInsertId());
flash('success', '管理者已新增。'); redirect('admins.php');
} catch (PDOException $e) { $error = '帳號已存在或資料格式不正確。'; }
}
} elseif ($action === 'toggle') {
$id = (int) ($_POST['id'] ?? 0);
if ($id === (int) $_SESSION['admin_id']) $error = '不能停用目前登入的帳號。';
else { db()->prepare('UPDATE admin_users SET is_active = 1 - is_active WHERE id = ?')->execute([$id]); audit('toggle', 'admin_users', $id); flash('success', '帳號狀態已更新。'); redirect('admins.php'); }
}
}
$users = db()->query('SELECT id, username, display_name, email, is_active, last_login_at, created_at FROM admin_users ORDER BY id')->fetchAll();
render_header('管理者帳號', 'admins');
?>
<?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
<section class="panel"><div class="panel__head"><div><h2>新增管理者</h2><p>密碼使用不可逆雜湊儲存</p></div></div><form class="inline-form" method="post"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="action" value="create"><label>登入帳號<input name="username" required></label><label>顯示名稱<input name="display_name" required></label><label>電子信箱<input name="email" type="email"></label><label>密碼<input name="password" type="password" minlength="10" required></label><button class="button button--primary" type="submit">新增帳號</button></form></section>
<section class="panel"><div class="panel__head"><div><h2>帳號清單</h2><p>停用帳號後將無法登入</p></div></div><div class="table-wrap"><table><thead><tr><th>帳號</th><th>顯示名稱</th><th>信箱</th><th>最後登入</th><th>狀態</th><th>操作</th></tr></thead><tbody><?php foreach ($users as $user): ?><tr><td><?= h($user['username']) ?></td><td><?= h($user['display_name']) ?></td><td><?= h($user['email']) ?></td><td><?= h($user['last_login_at'] ?? '尚未登入') ?></td><td><span class="status status--<?= $user['is_active'] ? 'published' : 'archived' ?>"><?= $user['is_active'] ? '啟用' : '停用' ?></span></td><td><form method="post" data-confirm="確定變更此管理者狀態?"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="action" value="toggle"><input type="hidden" name="id" value="<?= (int) $user['id'] ?>"><button class="table-button" type="submit" <?= (int) $user['id'] === (int) $_SESSION['admin_id'] ? 'disabled' : '' ?>><?= $user['is_active'] ? '停用' : '啟用' ?></button></form></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php render_footer();
+141
View File
@@ -0,0 +1,141 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;600;700;800;900&display=swap');
:root {
--color-brand: #ffcc00;
--color-brand-light: #fceea7;
--color-brand-dark: #4e492b;
--color-bg: #0a0a0a;
--color-bg-raised: #1a1a1a;
--color-bg-alt: #fcfaf2;
--color-surface: #fffdf7;
--color-surface-soft: #fff9e8;
--color-text: #333333;
--color-text-dark: #1a1a1a;
--color-text-muted: #4d4d4d;
--color-text-inverse: #f4f4f4;
}
* { box-sizing: border-box; }
html { min-height: 100%; background: var(--color-bg); }
body { margin: 0; min-height: 100vh; font-family: 'Noto Sans TC', system-ui, sans-serif; color: var(--color-text); background: var(--color-bg-alt); }
a { color: inherit; }
button, input, select, textarea { font: inherit; }
button, .button { min-height: 42px; }
.admin-topbar { position: sticky; z-index: 20; top: 0; min-height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; color: var(--color-text-inverse); background: var(--color-bg); border-bottom: 1px solid rgba(255, 204, 0, .3); }
.admin-brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 900; letter-spacing: .06em; }
.admin-brand i { color: var(--color-brand); }
.admin-brand small { padding-left: 10px; color: var(--color-brand-light); border-left: 1px solid rgba(255, 255, 255, .25); font-weight: 500; }
.admin-topbar__actions { display: flex; align-items: center; gap: 18px; font-size: .9rem; }
.admin-topbar__actions a { color: var(--color-brand-light); text-decoration: none; }
.admin-shell { display: grid; grid-template-columns: 252px minmax(0, 1fr); min-height: calc(100vh - 68px); }
.admin-sidebar { padding: 24px 16px; color: var(--color-text-inverse); background: var(--color-bg-raised); }
.admin-sidebar nav { position: sticky; top: 92px; }
.admin-sidebar p { margin: 24px 12px 8px; color: var(--color-brand-light); font-size: .72rem; font-weight: 800; letter-spacing: .16em; }
.admin-sidebar a { display: flex; align-items: center; gap: 11px; min-height: 43px; margin: 3px 0; padding: 9px 12px; border-radius: 9px; text-decoration: none; color: var(--color-text-inverse); }
.admin-sidebar a:hover { background: rgba(255, 255, 255, .08); }
.admin-sidebar a.active { color: var(--color-text-dark); background: var(--color-brand); font-weight: 800; }
.admin-sidebar i { width: 20px; text-align: center; }
.admin-main { width: 100%; max-width: 1540px; padding: clamp(24px, 4vw, 48px); overflow: hidden; }
.sidebar-toggle { display: none; border: 0; color: var(--color-text-inverse); background: var(--color-bg); border-radius: 8px; }
.page-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; }
.page-heading h1 { margin: 3px 0 0; color: var(--color-text-dark); font-size: clamp(1.65rem, 3vw, 2.35rem); line-height: 1.2; }
.eyebrow { margin: 0; color: var(--color-brand-dark); font-size: .72rem; font-weight: 900; letter-spacing: .18em; }
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin-bottom: 24px; }
.stat-card { position: relative; min-height: 148px; padding: 24px; overflow: hidden; border: 1px solid rgba(78, 73, 43, .2); border-radius: 16px; background: var(--color-surface); box-shadow: 0 12px 28px rgba(10, 10, 10, .06); }
.stat-card span { display: block; color: var(--color-text-muted); font-size: .9rem; }
.stat-card strong { display: block; margin-top: 8px; color: var(--color-text-dark); font-size: 2.35rem; }
.stat-card i { position: absolute; right: 20px; bottom: 12px; color: rgba(78, 73, 43, .16); font-size: 3.6rem; }
.panel { margin-bottom: 24px; padding: 0; border: 1px solid rgba(78, 73, 43, .2); border-radius: 16px; background: var(--color-surface); box-shadow: 0 12px 28px rgba(10, 10, 10, .05); overflow: hidden; }
.panel__head { display: flex; justify-content: space-between; padding: 22px 24px 18px; border-bottom: 1px solid rgba(78, 73, 43, .14); }
.panel__head h2 { margin: 0; color: var(--color-text-dark); font-size: 1.1rem; }
.panel__head p { margin: 4px 0 0; color: var(--color-text-muted); font-size: .86rem; }
.module-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; padding: 20px; }
.module-grid a { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; min-height: 62px; padding: 12px 15px; border: 1px solid rgba(78, 73, 43, .16); border-radius: 10px; text-decoration: none; background: var(--color-surface-soft); }
.module-grid a:hover { border-color: var(--color-brand); }
.module-grid i { color: var(--color-brand-dark); font-size: 1.2rem; }
.module-grid strong { color: var(--color-text-dark); font-size: 1.15rem; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.search { position: relative; }
.search i { position: absolute; top: 50%; left: 13px; transform: translateY(-50%); color: var(--color-text-muted); }
.search input { padding-left: 38px; }
input, select, textarea { width: 100%; padding: 10px 12px; color: var(--color-text); background: var(--color-surface); border: 1px solid rgba(78, 73, 43, .38); border-radius: 8px; }
input:focus, select:focus, textarea:focus { outline: 3px solid rgba(255, 204, 0, .3); border-color: var(--color-brand-dark); }
.filters input { width: min(320px, 70vw); }
.filters select { width: auto; min-width: 135px; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 9px 16px; border: 1px solid rgba(78, 73, 43, .35); border-radius: 8px; color: var(--color-text-dark); background: var(--color-surface); text-decoration: none; cursor: pointer; white-space: nowrap; }
.button--primary { color: var(--color-text-dark); background: var(--color-brand); border-color: var(--color-brand); font-weight: 800; }
.button:hover { filter: brightness(.97); }
.table-wrap { width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; white-space: nowrap; }
th, td { padding: 14px 16px; border-bottom: 1px solid rgba(78, 73, 43, .13); text-align: left; font-size: .88rem; }
th { color: var(--color-brand-dark); background: var(--color-surface-soft); font-size: .77rem; letter-spacing: .04em; }
tbody tr:hover { background: rgba(255, 204, 0, .06); }
.actions { display: flex; align-items: center; gap: 12px; }
.actions a, .actions button, .table-button { padding: 0; border: 0; color: var(--color-brand-dark); background: transparent; cursor: pointer; text-decoration: underline; }
.actions button { color: var(--color-brand-dark); font-weight: 700; }
.table-button:disabled { color: var(--color-text-muted); cursor: not-allowed; }
.empty { padding: 42px; text-align: center; color: var(--color-text-muted); }
.status { display: inline-flex; align-items: center; justify-content: center; min-width: 66px; padding: 4px 9px; border-radius: 999px; font-size: .75rem; font-weight: 800; }
.status--published, .status--available { color: var(--color-brand-dark); background: var(--color-brand-light); }
.status--coming_soon { color: var(--color-brand-dark); background: var(--color-brand-light); }
.status--draft { color: var(--color-text-muted); background: rgba(77, 77, 77, .12); }
.status--archived { color: var(--color-text-muted); background: rgba(77, 77, 77, .12); }
.pagination { display: flex; justify-content: center; gap: 7px; padding: 18px; }
.pagination a { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid rgba(78, 73, 43, .25); border-radius: 7px; text-decoration: none; }
.pagination a.active { color: var(--color-text-dark); background: var(--color-brand); border-color: var(--color-brand); font-weight: 800; }
.notice { margin-bottom: 20px; padding: 14px 17px; border-radius: 9px; border: 1px solid rgba(78, 73, 43, .25); background: var(--color-surface); }
.notice--success { color: var(--color-brand-dark); border-color: rgba(78, 73, 43, .3); background: var(--color-brand-light); }
.notice--error { color: var(--color-text-dark); border-color: rgba(78, 73, 43, .4); background: var(--color-surface-soft); }
.notice--warning { color: var(--color-brand-dark); background: var(--color-brand-light); }
.editor { padding: clamp(20px, 4vw, 34px); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.field { display: flex; flex-direction: column; gap: 7px; color: var(--color-text-muted); font-size: .87rem; font-weight: 700; }
.field--wide { grid-column: 1 / -1; }
.field small { font-weight: 400; overflow-wrap: anywhere; }
.check { display: flex; align-items: center; min-height: 43px; gap: 8px; }
.check input { width: auto; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 28px; padding-top: 20px; border-top: 1px solid rgba(78, 73, 43, .14); }
.inline-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; align-items: end; gap: 14px; padding: 22px; }
.inline-form label { display: flex; flex-direction: column; gap: 6px; color: var(--color-text-muted); font-size: .82rem; font-weight: 700; }
.auth-page { display: grid; place-items: center; padding: 24px; color: var(--color-text-inverse); background: var(--color-bg); background-image: radial-gradient(circle at 20% 10%, rgba(255, 204, 0, .16), transparent 32%), radial-gradient(circle at 80% 90%, rgba(252, 238, 167, .1), transparent 34%); }
.auth-card { width: min(460px, 100%); padding: clamp(28px, 6vw, 48px); border: 1px solid rgba(255, 204, 0, .35); border-radius: 20px; background: var(--color-bg-raised); box-shadow: 0 26px 80px rgba(0, 0, 0, .45); }
.auth-card h1 { margin: 7px 0 10px; color: var(--color-text-inverse); text-align: center; }
.auth-card > p { color: var(--color-brand-light); text-align: center; }
.auth-card .eyebrow { color: var(--color-brand); }
.auth-gem { color: var(--color-brand); font-size: 2.5rem; text-align: center; }
.auth-card form { display: flex; flex-direction: column; gap: 17px; margin-top: 28px; }
.auth-card label { display: flex; flex-direction: column; gap: 7px; color: var(--color-text-inverse); font-size: .9rem; }
.auth-card input { color: var(--color-text-inverse); background: var(--color-bg); border-color: rgba(255, 204, 0, .36); }
.auth-card .text-link { color: var(--color-brand-light); text-align: center; font-size: .86rem; }
@media (max-width: 1100px) {
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.module-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.inline-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.admin-topbar { padding-inline: 16px; }
.admin-brand small, .admin-topbar__actions span { display: none; }
.admin-shell { display: block; }
.admin-sidebar { position: fixed; z-index: 30; inset: 68px auto 0 0; width: min(290px, 86vw); transform: translateX(-105%); transition: transform .2s ease; overflow-y: auto; }
.admin-sidebar.is-open { transform: translateX(0); }
.admin-main { padding: 20px 16px 36px; }
.sidebar-toggle { display: inline-flex; align-items: center; justify-content: center; width: 44px; margin-bottom: 18px; }
.toolbar { align-items: stretch; flex-direction: column; }
.filters { display: grid; grid-template-columns: 1fr; }
.filters input, .filters select { width: 100%; }
.stat-grid, .module-grid, .form-grid, .inline-form { grid-template-columns: 1fr; }
.field--wide { grid-column: auto; }
}
+9
View File
@@ -0,0 +1,9 @@
document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', function () {
document.querySelector('#admin-sidebar')?.classList.toggle('is-open');
});
document.querySelectorAll('form[data-confirm]').forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!window.confirm(form.dataset.confirm || '確定執行?')) event.preventDefault();
});
});
+241
View File
@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
session_start();
$config = require __DIR__ . '/config.php';
function db(): PDO
{
static $pdo;
global $config;
if ($pdo instanceof PDO) {
return $pdo;
}
$db = $config['db'];
$dsn = "mysql:host={$db['host']};port={$db['port']};dbname={$db['name']};charset=utf8mb4";
$pdo = new PDO($dsn, $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function h(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function redirect(string $path): never
{
header('Location: ' . $path);
exit;
}
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function verify_csrf(): void
{
$token = (string) ($_POST['csrf'] ?? '');
if (!hash_equals((string) ($_SESSION['csrf'] ?? ''), $token)) {
http_response_code(419);
exit('表單已過期,請返回後重新操作。');
}
}
function require_login(): void
{
if (empty($_SESSION['admin_id'])) {
redirect('login.php');
}
}
function flash(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function take_flash(): ?array
{
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return is_array($flash) ? $flash : null;
}
function modules(): array
{
return [
'albums' => [
'label' => '專輯管理', 'icon' => 'disc', 'table' => 'albums', 'order' => 'release_date DESC, id DESC',
'fields' => [
'title' => ['label' => '專輯名稱', 'type' => 'text', 'required' => true],
'title_ko' => ['label' => '韓文/英文名稱', 'type' => 'text'],
'release_date' => ['label' => '發行日期', 'type' => 'date'],
'era' => ['label' => '時期', 'type' => 'select', 'options' => ['classic' => '輝煌全盛期', 'reunion' => '啟動新篇章']],
'cover_media_id' => ['label' => '封面媒體編號', 'type' => 'number'],
'summary' => ['label' => '專輯簡介', 'type' => 'textarea'],
'sort_order' => ['label' => '排序', 'type' => 'number', 'default' => '0'],
'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true],
],
'list' => ['title', 'release_date', 'era', 'status', 'updated_at'],
],
'songs' => [
'label' => '歌曲管理', 'icon' => 'music-note-list', 'table' => 'songs', 'order' => 'album_id, track_no, id',
'fields' => [
'album_id' => ['label' => '所屬專輯', 'type' => 'relation', 'table' => 'albums', 'required' => true],
'track_no' => ['label' => '曲序', 'type' => 'number'],
'title' => ['label' => '歌名', 'type' => 'text', 'required' => true],
'title_ko' => ['label' => '韓文歌名', 'type' => 'text'],
'lyrics_url' => ['label' => '歌詞頁連結', 'type' => 'url'],
'video_url' => ['label' => '影片連結', 'type' => 'url'],
'is_title_track' => ['label' => '主打歌', 'type' => 'checkbox'],
'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true],
],
'list' => ['title', 'album_id', 'track_no', 'is_title_track', 'status', 'updated_at'],
],
'concerts' => [
'label' => '演唱會管理', 'icon' => 'ticket-perforated', 'table' => 'concert_events', 'order' => 'event_date DESC, id DESC',
'fields' => [
'tour_name' => ['label' => '巡演/活動名稱', 'type' => 'text', 'required' => true],
'event_date' => ['label' => '演出日期', 'type' => 'date', 'required' => true],
'country' => ['label' => '國家', 'type' => 'text'],
'city' => ['label' => '城市', 'type' => 'text'],
'venue' => ['label' => '場館', 'type' => 'text'],
'member_names' => ['label' => '演出成員', 'type' => 'text'],
'poster_media_id' => ['label' => '海報媒體編號', 'type' => 'number'],
'source_url' => ['label' => '資料來源', 'type' => 'url'],
'notes' => ['label' => '備註', 'type' => 'textarea'],
'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true],
],
'list' => ['tour_name', 'event_date', 'city', 'venue', 'status', 'updated_at'],
],
'variety' => [
'label' => '綜藝節目管理', 'icon' => 'tv', 'table' => 'variety_shows', 'order' => 'air_start DESC, id DESC',
'fields' => [
'title' => ['label' => '節目名稱', 'type' => 'text', 'required' => true],
'series_name' => ['label' => '系列/單元', 'type' => 'text'],
'platform' => ['label' => '播出平台', 'type' => 'text'],
'air_start' => ['label' => '開始日期', 'type' => 'date'],
'air_end' => ['label' => '結束日期', 'type' => 'date'],
'member_names' => ['label' => '參與成員', 'type' => 'text'],
'episode_info' => ['label' => '集數', 'type' => 'text'],
'video_url' => ['label' => '影片連結', 'type' => 'url'],
'summary' => ['label' => '節目簡介', 'type' => 'textarea'],
'tags' => ['label' => '標籤', 'type' => 'text'],
'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true],
],
'list' => ['title', 'platform', 'air_start', 'member_names', 'status', 'updated_at'],
],
'locations' => [
'label' => 'Yellow Note', 'icon' => 'geo-alt', 'table' => 'locations', 'order' => 'visit_date DESC, sort_order, id',
'fields' => [
'title' => ['label' => '地點名稱', 'type' => 'text', 'required' => true],
'visit_date' => ['label' => '行程日期', 'type' => 'date'],
'day_label' => ['label' => '日次', 'type' => 'text'],
'address' => ['label' => '地址', 'type' => 'text'],
'latitude' => ['label' => '緯度', 'type' => 'number', 'step' => 'any'],
'longitude' => ['label' => '經度', 'type' => 'number', 'step' => 'any'],
'member_names' => ['label' => '參與成員', 'type' => 'text'],
'description' => ['label' => '行程說明', 'type' => 'textarea'],
'quote_text' => ['label' => '引言', 'type' => 'textarea'],
'tags' => ['label' => '標籤', 'type' => 'text'],
'media_id' => ['label' => '圖片媒體編號', 'type' => 'number'],
'source_url' => ['label' => '來源連結', 'type' => 'url'],
'sort_order' => ['label' => '排序', 'type' => 'number', 'default' => '0'],
'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true],
],
'list' => ['title', 'visit_date', 'member_names', 'tags', 'status', 'updated_at'],
],
'media' => [
'label' => '媒體庫', 'icon' => 'images', 'table' => 'media_assets', 'order' => 'id DESC',
'fields' => [
'display_name' => ['label' => '管理名稱', 'type' => 'text', 'required' => true],
'file_path' => ['label' => '檔案', 'type' => 'file'],
'media_type' => ['label' => '類型', 'type' => 'select', 'default' => 'image', 'options' => ['image' => '圖片', 'video' => '影片', 'pdf' => 'PDF', 'other' => '其他']],
'alt_text' => ['label' => '替代文字', 'type' => 'text'],
'source_note' => ['label' => '來源/授權註記', 'type' => 'textarea'],
'category' => ['label' => '分類', 'type' => 'text'],
'status' => ['label' => '使用狀態', 'type' => 'select', 'default' => 'draft', 'options' => ['available' => '可使用', 'draft' => '待整理', 'archived' => '封存']],
],
'list' => ['display_name', 'media_type', 'category', 'file_path', 'status', 'updated_at'],
],
];
}
function module_config(string $key): array
{
$all = modules();
if (!isset($all[$key])) {
http_response_code(404);
exit('找不到管理模組。');
}
return $all[$key];
}
function status_label(string $status): string
{
return ['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存', 'available' => '可使用'][$status] ?? $status;
}
function audit(string $action, string $entityType, ?int $entityId, array $details = []): void
{
$stmt = db()->prepare('INSERT INTO audit_logs (admin_user_id, action_name, entity_type, entity_id, details_json, ip_address) VALUES (?, ?, ?, ?, ?, ?)');
$stmt->execute([$_SESSION['admin_id'] ?? null, $action, $entityType, $entityId, json_encode($details, JSON_UNESCAPED_UNICODE), $_SERVER['REMOTE_ADDR'] ?? null]);
}
function render_header(string $title, string $active = ''): void
{
global $config;
$flash = take_flash();
$mods = modules();
?>
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($title) ?><?= h($config['site_name']) ?> Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="assets/admin.css">
</head>
<body>
<header class="admin-topbar">
<a class="admin-brand" href="index.php"><i class="bi bi-gem"></i><span>水晶男孩推廣部</span><small>Admin</small></a>
<div class="admin-topbar__actions"><span><?= h($_SESSION['admin_name'] ?? '') ?></span><a href="../index.html" target="_blank" rel="noopener">查看前臺</a><a href="logout.php">登出</a></div>
</header>
<div class="admin-shell">
<aside class="admin-sidebar" id="admin-sidebar">
<nav aria-label="後臺管理選單">
<a class="<?= $active === 'dashboard' ? 'active' : '' ?>" href="index.php"><i class="bi bi-speedometer2"></i>控制台</a>
<p>內容資料</p>
<?php foreach ($mods as $key => $mod): ?>
<a class="<?= $active === $key ? 'active' : '' ?>" href="records.php?module=<?= h($key) ?>"><i class="bi bi-<?= h($mod['icon']) ?>"></i><?= h($mod['label']) ?></a>
<?php endforeach; ?>
<p>系統</p>
<a class="<?= $active === 'publishing' ? 'active' : '' ?>" href="publishing.php"><i class="bi bi-eye"></i>公開狀態</a>
<a class="<?= $active === 'admins' ? 'active' : '' ?>" href="admins.php"><i class="bi bi-shield-lock"></i>管理者帳號</a>
</nav>
</aside>
<main class="admin-main">
<button class="sidebar-toggle" type="button" data-sidebar-toggle aria-label="開啟管理選單"><i class="bi bi-list"></i></button>
<?php if ($flash): ?><div class="notice notice--<?= h($flash['type']) ?>"><?= h($flash['message']) ?></div><?php endif; ?>
<div class="page-heading"><div><p class="eyebrow">CONTENT MANAGEMENT</p><h1><?= h($title) ?></h1></div></div>
<?php
}
function render_footer(): void
{
?>
</main>
</div>
<script src="assets/admin.js"></script>
</body>
</html>
<?php
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
return [
'site_name' => '水晶男孩推廣部',
'db' => [
'host' => getenv('SECHSKIES_DB_HOST') ?: '127.0.0.1',
'port' => getenv('SECHSKIES_DB_PORT') ?: '3306',
'name' => getenv('SECHSKIES_DB_NAME') ?: 'sechskies_cms',
'user' => getenv('SECHSKIES_DB_USER') ?: 'root',
'pass' => getenv('SECHSKIES_DB_PASS') ?: '',
],
'upload_dir' => dirname(__DIR__) . '/assets/uploads',
'upload_url' => '../assets/uploads',
'max_upload_bytes' => 8 * 1024 * 1024,
];
+128
View File
@@ -0,0 +1,128 @@
CREATE DATABASE IF NOT EXISTS sechskies_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE sechskies_cms;
CREATE TABLE IF NOT EXISTS admin_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(80) NOT NULL,
email VARCHAR(190) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS media_assets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
display_name VARCHAR(160) NOT NULL,
original_name VARCHAR(255) NULL,
file_path VARCHAR(500) NOT NULL,
media_type ENUM('image','video','pdf','other') NOT NULL DEFAULT 'image',
mime_type VARCHAR(100) NULL,
file_size INT UNSIGNED NULL,
alt_text VARCHAR(255) NULL,
source_note TEXT NULL,
category VARCHAR(80) NULL,
status ENUM('available','draft','archived') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS albums (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(120) NOT NULL,
title_ko VARCHAR(160) NULL,
release_date DATE NULL,
era ENUM('classic','reunion') NULL,
cover_media_id INT UNSIGNED NULL,
summary TEXT NULL,
sort_order INT NOT NULL DEFAULT 0,
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_albums_cover FOREIGN KEY (cover_media_id) REFERENCES media_assets(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS songs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
album_id INT UNSIGNED NOT NULL,
track_no SMALLINT UNSIGNED NULL,
title VARCHAR(160) NOT NULL,
title_ko VARCHAR(160) NULL,
lyrics_url VARCHAR(500) NULL,
video_url VARCHAR(500) NULL,
is_title_track TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_songs_album FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS concert_events (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tour_name VARCHAR(180) NOT NULL,
event_date DATE NOT NULL,
country VARCHAR(80) NULL,
city VARCHAR(80) NULL,
venue VARCHAR(180) NULL,
member_names VARCHAR(255) NULL,
poster_media_id INT UNSIGNED NULL,
source_url VARCHAR(500) NULL,
notes TEXT NULL,
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_concert_poster FOREIGN KEY (poster_media_id) REFERENCES media_assets(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS variety_shows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(180) NOT NULL,
series_name VARCHAR(180) NULL,
platform VARCHAR(100) NULL,
air_start DATE NULL,
air_end DATE NULL,
member_names VARCHAR(255) NULL,
episode_info VARCHAR(100) NULL,
video_url VARCHAR(500) NULL,
summary TEXT NULL,
tags VARCHAR(255) NULL,
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS locations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(180) NOT NULL,
visit_date DATE NULL,
day_label VARCHAR(40) NULL,
address VARCHAR(255) NULL,
latitude DECIMAL(10,7) NULL,
longitude DECIMAL(10,7) NULL,
member_names VARCHAR(255) NULL,
description TEXT NULL,
quote_text TEXT NULL,
tags VARCHAR(255) NULL,
media_id INT UNSIGNED NULL,
source_url VARCHAR(500) NULL,
sort_order INT NOT NULL DEFAULT 0,
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_location_media FOREIGN KEY (media_id) REFERENCES media_assets(id) ON DELETE SET NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
admin_user_id INT UNSIGNED NULL,
action_name VARCHAR(40) NOT NULL,
entity_type VARCHAR(80) NOT NULL,
entity_id INT UNSIGNED NULL,
details_json JSON NULL,
ip_address VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_audit_entity (entity_type, entity_id),
CONSTRAINT fk_audit_admin FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE SET NULL
) ENGINE=InnoDB;
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require_login();
$stats = [];
foreach (modules() as $key => $module) {
$stats[$key] = (int) db()->query('SELECT COUNT(*) FROM `' . $module['table'] . '`')->fetchColumn();
}
$published = 0;
$comingSoon = 0;
foreach (modules() as $key => $module) {
if ($key === 'media') continue;
$published += (int) db()->query("SELECT COUNT(*) FROM `{$module['table']}` WHERE status = 'published'")->fetchColumn();
$comingSoon += (int) db()->query("SELECT COUNT(*) FROM `{$module['table']}` WHERE status = 'coming_soon'")->fetchColumn();
}
$recent = db()->query('SELECT a.*, u.display_name FROM audit_logs a LEFT JOIN admin_users u ON u.id = a.admin_user_id ORDER BY a.id DESC LIMIT 8')->fetchAll();
render_header('控制台', 'dashboard');
?>
<section class="stat-grid">
<article class="stat-card"><span>已公開內容</span><strong><?= $published ?></strong><i class="bi bi-broadcast"></i></article>
<article class="stat-card"><span>籌備中內容</span><strong><?= $comingSoon ?></strong><i class="bi bi-hourglass-split"></i></article>
<article class="stat-card"><span>媒體素材</span><strong><?= $stats['media'] ?></strong><i class="bi bi-images"></i></article>
<article class="stat-card"><span>全部內容</span><strong><?= array_sum($stats) ?></strong><i class="bi bi-archive"></i></article>
</section>
<section class="panel"><div class="panel__head"><div><h2>內容概況</h2><p>依本站真正的資料分類統計</p></div></div><div class="module-grid">
<?php foreach (modules() as $key => $module): ?><a href="records.php?module=<?= h($key) ?>"><i class="bi bi-<?= h($module['icon']) ?>"></i><span><?= h($module['label']) ?></span><strong><?= $stats[$key] ?></strong></a><?php endforeach; ?>
</div></section>
<section class="panel"><div class="panel__head"><div><h2>最近管理紀錄</h2><p>保留新增、修改、刪除與登入軌跡</p></div></div>
<div class="table-wrap"><table><thead><tr><th>時間</th><th>管理者</th><th>動作</th><th>資料類型</th><th>編號</th></tr></thead><tbody>
<?php foreach ($recent as $row): ?><tr><td><?= h($row['created_at']) ?></td><td><?= h($row['display_name'] ?? '系統') ?></td><td><?= h($row['action_name']) ?></td><td><?= h($row['entity_type']) ?></td><td><?= h((string) $row['entity_id']) ?></td></tr><?php endforeach; ?>
<?php if (!$recent): ?><tr><td colspan="5" class="empty">目前尚無管理紀錄。</td></tr><?php endif; ?>
</tbody></table></div></section>
<?php render_footer();
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!empty($_SESSION['admin_id'])) redirect('index.php');
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
try {
$stmt = db()->prepare('SELECT * FROM admin_users WHERE username = ? AND is_active = 1 LIMIT 1');
$stmt->execute([trim((string) ($_POST['username'] ?? ''))]);
$user = $stmt->fetch();
if ($user && password_verify((string) ($_POST['password'] ?? ''), $user['password_hash'])) {
session_regenerate_id(true);
$_SESSION['admin_id'] = (int) $user['id'];
$_SESSION['admin_name'] = $user['display_name'];
db()->prepare('UPDATE admin_users SET last_login_at = NOW() WHERE id = ?')->execute([$user['id']]);
audit('login', 'admin_users', (int) $user['id']);
redirect('index.php');
}
$error = '帳號或密碼輸入錯誤。';
} catch (Throwable $e) {
$error = '資料庫尚未完成設定,請先執行初始化。';
}
}
?>
<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>管理登入|水晶男孩推廣部</title><link rel="stylesheet" href="assets/admin.css"></head>
<body class="auth-page"><main class="auth-card"><div class="auth-gem">◆</div><p class="eyebrow">SECHSKIES ARCHIVE</p><h1>推廣部管理登入</h1><p>把散落的記憶,好好收進同一片黃色海洋。</p><?php if (isset($_GET['setup'])): ?><div class="notice notice--success">管理者建立完成,請登入。</div><?php endif; ?><?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
<form method="post"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><label>管理者帳號<input name="username" required autocomplete="username" autofocus></label><label>密碼<input name="password" type="password" required autocomplete="current-password"></label><button class="button button--primary" type="submit">登入後臺</button><a class="text-link" href="setup.php">第一次使用?初始化系統</a></form></main></body></html>
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if (!empty($_SESSION['admin_id'])) audit('logout', 'admin_users', (int) $_SESSION['admin_id']);
$_SESSION = [];
session_destroy();
redirect('login.php');
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require_login();
$rows = [];
foreach (modules() as $key => $module) {
if ($key === 'media') continue;
$counts = ['draft' => 0, 'coming_soon' => 0, 'published' => 0, 'archived' => 0];
foreach (db()->query("SELECT status, COUNT(*) total FROM `{$module['table']}` GROUP BY status")->fetchAll() as $row) $counts[$row['status']] = (int) $row['total'];
$rows[] = ['key' => $key, 'label' => $module['label'], 'counts' => $counts];
}
render_header('公開狀態', 'publishing');
?>
<div class="notice notice--warning"><strong>公開狀態不會直接改寫 portal-nav.js。</strong> 第一階段先管理內容狀態;正式同步頂欄前仍須依本站規則逐頁確認。</div>
<section class="panel"><div class="panel__head"><div><h2>各模組發布概況</h2><p>公開前先完成資料與來源檢查</p></div></div><div class="table-wrap"><table><thead><tr><th>內容模組</th><th>草稿</th><th>籌備中</th><th>公開</th><th>封存</th><th>管理</th></tr></thead><tbody><?php foreach ($rows as $row): ?><tr><td><?= h($row['label']) ?></td><td><?= $row['counts']['draft'] ?></td><td><?= $row['counts']['coming_soon'] ?></td><td><?= $row['counts']['published'] ?></td><td><?= $row['counts']['archived'] ?></td><td><a href="records.php?module=<?= h($row['key']) ?>">查看資料</a></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php render_footer();
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
verify_csrf();
$key = (string) ($_POST['module'] ?? '');
$module = module_config($key);
$id = (int) ($_POST['id'] ?? 0);
$stmt = db()->prepare('DELETE FROM `' . $module['table'] . '` WHERE id = ?');
$stmt->execute([$id]);
audit('delete', $module['table'], $id);
flash('success', '資料已刪除。');
redirect('records.php?module=' . urlencode($key));
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require_login();
$key = (string) ($_GET['module'] ?? $_POST['module'] ?? '');
$module = module_config($key);
$id = (int) ($_GET['id'] ?? $_POST['id'] ?? 0);
$record = [];
if ($id) {
$stmt = db()->prepare('SELECT * FROM `' . $module['table'] . '` WHERE id = ?');
$stmt->execute([$id]);
$record = $stmt->fetch() ?: [];
if (!$record) { http_response_code(404); exit('資料不存在。'); }
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$data = [];
foreach ($module['fields'] as $name => $field) {
if ($field['type'] === 'file') continue;
$value = $field['type'] === 'checkbox' ? (isset($_POST[$name]) ? 1 : 0) : trim((string) ($_POST[$name] ?? ''));
if (!empty($field['required']) && $value === '') $error = '請完成所有必填欄位。';
$data[$name] = $value === '' ? null : $value;
}
if ($key === 'media' && isset($_FILES['file_path']) && $_FILES['file_path']['error'] !== UPLOAD_ERR_NO_FILE) {
$file = $_FILES['file_path'];
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', 'video/mp4' => 'mp4'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if ($file['error'] !== UPLOAD_ERR_OK || $file['size'] > $config['max_upload_bytes'] || !isset($allowed[$mime])) {
$error = '檔案格式不支援或超過 8 MB。';
} else {
if (!is_dir($config['upload_dir'])) mkdir($config['upload_dir'], 0755, true);
$safeName = bin2hex(random_bytes(12)) . '.' . $allowed[$mime];
if (!move_uploaded_file($file['tmp_name'], $config['upload_dir'] . '/' . $safeName)) {
$error = '檔案儲存失敗。';
} else {
$data['file_path'] = $config['upload_url'] . '/' . $safeName;
$data['original_name'] = $file['name'];
$data['mime_type'] = $mime;
$data['file_size'] = $file['size'];
}
}
} elseif ($key === 'media' && $id) {
$data['file_path'] = $record['file_path'];
} elseif ($key === 'media') {
$error = '新增媒體時必須選擇檔案。';
}
if ($error === '') {
if ($id) {
$sets = [];
foreach ($data as $name => $_) $sets[] = "`$name` = ?";
$stmt = db()->prepare('UPDATE `' . $module['table'] . '` SET ' . implode(', ', $sets) . ' WHERE id = ?');
$stmt->execute([...array_values($data), $id]);
audit('update', $module['table'], $id, $data);
flash('success', '資料已更新。');
} else {
$names = array_keys($data);
$stmt = db()->prepare('INSERT INTO `' . $module['table'] . '` (`' . implode('`,`', $names) . '`) VALUES (' . implode(',', array_fill(0, count($names), '?')) . ')');
$stmt->execute(array_values($data));
$id = (int) db()->lastInsertId();
audit('create', $module['table'], $id, $data);
flash('success', '資料已新增。');
}
redirect('records.php?module=' . urlencode($key));
}
$record = array_merge($record, $data);
}
render_header(($id ? '編輯' : '新增') . $module['label'], $key);
?>
<?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
<form class="panel editor" method="post" enctype="multipart/form-data"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="module" value="<?= h($key) ?>"><input type="hidden" name="id" value="<?= $id ?>"><div class="form-grid">
<?php foreach ($module['fields'] as $name => $field): $value = $record[$name] ?? ($field['default'] ?? ''); ?><label class="field <?= $field['type'] === 'textarea' ? 'field--wide' : '' ?>"><span><?= h($field['label']) ?><?= !empty($field['required']) ? ' *' : '' ?></span>
<?php if ($field['type'] === 'textarea'): ?><textarea name="<?= h($name) ?>" rows="6" <?= !empty($field['required']) ? 'required' : '' ?>><?= h((string) $value) ?></textarea>
<?php elseif ($field['type'] === 'select'): ?><select name="<?= h($name) ?>" <?= !empty($field['required']) ? 'required' : '' ?>><option value="">請選擇</option><?php foreach ($field['options'] as $optionValue => $optionLabel): ?><option value="<?= h($optionValue) ?>" <?= (string) $value === (string) $optionValue ? 'selected' : '' ?>><?= h($optionLabel) ?></option><?php endforeach; ?></select>
<?php elseif ($field['type'] === 'status'): ?><select name="<?= h($name) ?>" required><?php foreach (['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存'] as $optionValue => $optionLabel): ?><option value="<?= $optionValue ?>" <?= (string) ($value ?: 'draft') === $optionValue ? 'selected' : '' ?>><?= $optionLabel ?></option><?php endforeach; ?></select>
<?php elseif ($field['type'] === 'relation'): ?><select name="<?= h($name) ?>" required><option value="">請選擇</option><?php foreach (db()->query("SELECT id, title FROM `{$field['table']}` ORDER BY title")->fetchAll() as $option): ?><option value="<?= (int) $option['id'] ?>" <?= (string) $value === (string) $option['id'] ? 'selected' : '' ?>><?= h($option['title']) ?></option><?php endforeach; ?></select>
<?php elseif ($field['type'] === 'checkbox'): ?><span class="check"><input type="checkbox" name="<?= h($name) ?>" value="1" <?= $value ? 'checked' : '' ?>> 是</span>
<?php elseif ($field['type'] === 'file'): ?><?php if ($value): ?><small>目前檔案:<?= h((string) $value) ?></small><?php endif; ?><input type="file" name="<?= h($name) ?>" accept="image/*,video/mp4,application/pdf" <?= !$id ? 'required' : '' ?>>
<?php else: ?><input type="<?= h($field['type']) ?>" name="<?= h($name) ?>" value="<?= h((string) $value) ?>" <?= isset($field['step']) ? 'step="' . h($field['step']) . '"' : '' ?> <?= !empty($field['required']) ? 'required' : '' ?>><?php endif; ?></label><?php endforeach; ?></div>
<div class="form-actions"><a class="button" href="records.php?module=<?= h($key) ?>">取消</a><button class="button button--primary" type="submit"><?= $id ? '儲存修改' : '建立資料' ?></button></div></form>
<?php render_footer();
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require_login();
$key = (string) ($_GET['module'] ?? '');
$module = module_config($key);
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 12;
$query = trim((string) ($_GET['q'] ?? ''));
$status = trim((string) ($_GET['status'] ?? ''));
$where = [];
$params = [];
if ($query !== '') {
$searchable = [];
foreach ($module['fields'] as $name => $field) {
if (in_array($field['type'], ['text', 'textarea', 'url'], true)) $searchable[] = "`$name` LIKE ?";
}
if ($searchable) {
$where[] = '(' . implode(' OR ', $searchable) . ')';
foreach ($searchable as $_) $params[] = '%' . $query . '%';
}
}
if ($status !== '' && isset($module['fields']['status'])) {
$where[] = '`status` = ?';
$params[] = $status;
}
$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$countStmt = db()->prepare('SELECT COUNT(*) FROM `' . $module['table'] . '`' . $whereSql);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$pages = max(1, (int) ceil($total / $perPage));
$page = min($page, $pages);
$offset = ($page - 1) * $perPage;
$stmt = db()->prepare('SELECT * FROM `' . $module['table'] . '`' . $whereSql . ' ORDER BY ' . $module['order'] . " LIMIT $perPage OFFSET $offset");
$stmt->execute($params);
$rows = $stmt->fetchAll();
$relations = [];
foreach ($module['fields'] as $name => $field) {
if ($field['type'] === 'relation') {
$relations[$name] = [];
foreach (db()->query("SELECT id, title FROM `{$field['table']}`")->fetchAll() as $rel) $relations[$name][$rel['id']] = $rel['title'];
}
}
render_header($module['label'], $key);
?>
<div class="toolbar"><form class="filters" method="get"><input type="hidden" name="module" value="<?= h($key) ?>"><label class="search"><i class="bi bi-search"></i><input name="q" value="<?= h($query) ?>" placeholder="搜尋本區資料"></label><?php if (isset($module['fields']['status'])): ?><select name="status"><option value="">全部狀態</option><?php foreach (['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存', 'available' => '可使用'] as $value => $label): ?><option value="<?= $value ?>" <?= $status === $value ? 'selected' : '' ?>><?= $label ?></option><?php endforeach; ?></select><?php endif; ?><button class="button" type="submit">篩選</button></form><a class="button button--primary" href="record-form.php?module=<?= h($key) ?>"><i class="bi bi-plus-lg"></i>新增資料</a></div>
<section class="panel"><div class="panel__head"><div><h2><?= h($module['label']) ?>清單</h2><p>共 <?= $total ?> 筆;每頁 <?= $perPage ?> 筆</p></div></div><div class="table-wrap"><table><thead><tr><th>編號</th><?php foreach ($module['list'] as $field): ?><th><?= h($module['fields'][$field]['label'] ?? $field) ?></th><?php endforeach; ?><th>操作</th></tr></thead><tbody>
<?php foreach ($rows as $row): ?><tr><td>#<?= (int) $row['id'] ?></td><?php foreach ($module['list'] as $field): ?><td><?php $value = $row[$field] ?? ''; if ($field === 'status'): ?><span class="status status--<?= h($value) ?>"><?= h(status_label($value)) ?></span><?php elseif (isset($relations[$field])): ?><?= h($relations[$field][$value] ?? ('#' . $value)) ?><?php elseif (($module['fields'][$field]['type'] ?? '') === 'checkbox'): ?><?= $value ? '是' : '否' ?><?php else: ?><?= h(mb_strimwidth((string) $value, 0, 42, '…', 'UTF-8')) ?><?php endif; ?></td><?php endforeach; ?><td class="actions"><a href="record-form.php?module=<?= h($key) ?>&id=<?= (int) $row['id'] ?>">編輯</a><form method="post" action="record-delete.php" data-confirm="確定刪除這筆資料?此動作無法復原。"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="module" value="<?= h($key) ?>"><input type="hidden" name="id" value="<?= (int) $row['id'] ?>"><button type="submit">刪除</button></form></td></tr><?php endforeach; ?>
<?php if (!$rows): ?><tr><td colspan="<?= count($module['list']) + 2 ?>" class="empty">找不到符合條件的資料。</td></tr><?php endif; ?></tbody></table></div>
<?php if ($pages > 1): ?><nav class="pagination" aria-label="資料分頁"><?php for ($i = 1; $i <= $pages; $i++): ?><a class="<?= $i === $page ? 'active' : '' ?>" href="?module=<?= h($key) ?>&q=<?= urlencode($query) ?>&status=<?= urlencode($status) ?>&page=<?= $i ?>"><?= $i ?></a><?php endfor; ?></nav><?php endif; ?></section>
<?php render_footer();
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
$error = '';
try {
$count = (int) db()->query('SELECT COUNT(*) FROM admin_users')->fetchColumn();
if ($count > 0) {
redirect('login.php');
}
} catch (Throwable $e) {
$error = '尚未建立資料表。請先在 phpMyAdmin 匯入 admin/database/schema.sql。';
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $error === '') {
verify_csrf();
$username = trim((string) ($_POST['username'] ?? ''));
$displayName = trim((string) ($_POST['display_name'] ?? ''));
$email = trim((string) ($_POST['email'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if (!preg_match('/^[A-Za-z0-9_.-]{4,50}$/', $username)) {
$error = '帳號須為 450 個英數字或 ._-。';
} elseif (mb_strlen($displayName) < 2) {
$error = '請填寫管理者顯示名稱。';
} elseif (strlen($password) < 10) {
$error = '密碼至少需要 10 個字元。';
} else {
$stmt = db()->prepare('INSERT INTO admin_users (username, password_hash, display_name, email) VALUES (?, ?, ?, ?)');
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), $displayName, $email ?: null]);
redirect('login.php?setup=1');
}
}
?>
<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>初始化後臺</title><link rel="stylesheet" href="assets/admin.css"></head>
<body class="auth-page"><main class="auth-card"><p class="eyebrow">FIRST-TIME SETUP</p><h1>建立第一位管理者</h1><p>水晶男孩推廣部內容管理系統</p><?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
<form method="post"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><label>登入帳號<input name="username" required autocomplete="username"></label><label>顯示名稱<input name="display_name" required></label><label>電子信箱<input name="email" type="email"></label><label>登入密碼<input name="password" type="password" required minlength="10" autocomplete="new-password"></label><button class="button button--primary" type="submit">建立管理者</button></form></main></body></html>