diff --git a/AGENTS.md b/AGENTS.md index b7c7913..9c7b827 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,10 +72,10 @@ -**最後更新:** 2026-07-28 +**最後更新:** 2026-08-02 **進行中:** -- 推廣部內容管理系統第一階段:已建立 PHP/PDO/MySQL 後臺骨架,待在 XAMPP 匯入 schema 後進行瀏覽器整合測試 +- 水晶熱舞社重啟:30TH 應援報名已接上資料庫與後臺,下一階段可再做活動/練習影片內容管理 **待確認:** - developer-rules 健檢尚未執行 @@ -87,6 +87,9 @@ - portal-nav 新增世紀級寶藏,正確放入淪陷瞬間子選單 - `extra/site-guide.html` 同步更新:全員發瘋 → 世紀級寶藏 - 新增 `admin/` 推廣部後臺第一階段:登入、控制台、管理者帳號、通用 CRUD、媒體上傳及公開狀態總覽 +- 水晶熱舞社 30TH 募集表單已接上 PHP/PDO/MySQL,可儲存報名、參與內容與參考檔案 +- 後臺新增「30TH 應援報名」清單、搜尋、狀態處理、內部備註與受登入保護的檔案下載 +- 架構決定:水晶熱舞社暫時共用推廣部後臺;偶像培訓維持獨立資料邊界,之後另拆專案與登入系統 --- diff --git a/admin/assets/admin.css b/admin/assets/admin.css index db566c9..d1b1c7f 100644 --- a/admin/assets/admin.css +++ b/admin/assets/admin.css @@ -88,6 +88,10 @@ tbody tr:hover { background: rgba(255, 204, 0, .06); } .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); } +.status--new { color: var(--color-text-dark); background: var(--color-brand); } +.status--contacted { color: var(--color-brand-dark); background: var(--color-brand-light); } +.status--confirmed { color: var(--color-brand-dark); background: var(--color-brand-light); border: 1px solid var(--color-brand); } +.status--declined { 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; } @@ -108,6 +112,15 @@ tbody tr:hover { background: rgba(255, 204, 0, .06); } .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; } +.application-detail .panel__head { align-items: center; gap: 16px; } +.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: rgba(78, 73, 43, .14); } +.detail-item { min-width: 0; padding: 18px 22px; background: var(--color-surface); } +.detail-item--wide { grid-column: 1 / -1; } +.detail-item > span { display: block; margin-bottom: 7px; color: var(--color-text-muted); font-size: .78rem; font-weight: 800; } +.detail-item strong, .detail-item a, .detail-value { color: var(--color-text-dark); overflow-wrap: anywhere; } +.detail-value { line-height: 1.75; } +.detail-download { width: fit-content; max-width: 100%; white-space: normal; text-align: left; } + .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 { position: relative; width: min(500px, 100%); padding: clamp(28px, 6vw, 48px); overflow: hidden; 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::before { content: ""; position: absolute; inset: 0 0 auto; height: 4px; background: linear-gradient(90deg, transparent, var(--color-brand), transparent); } @@ -140,6 +153,6 @@ tbody tr:hover { background: rgba(255, 204, 0, .06); } .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; } + .stat-grid, .module-grid, .form-grid, .inline-form, .detail-grid { grid-template-columns: 1fr; } + .field--wide, .detail-item--wide { grid-column: auto; } } diff --git a/admin/bootstrap.php b/admin/bootstrap.php index a68704c..e873e4a 100644 --- a/admin/bootstrap.php +++ b/admin/bootstrap.php @@ -183,6 +183,51 @@ function status_label(string $status): string return ['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存', 'available' => '可使用'][$status] ?? $status; } +function dance_application_statuses(): array +{ + return [ + 'new' => '新報名', + 'contacted' => '已聯絡', + 'confirmed' => '確認參與', + 'declined' => '暫不參與', + 'archived' => '已封存', + ]; +} + +function dance_attendance_label(?string $value): string +{ + return [ + 'attended' => '參加過 20TH', + 'watched_video' => '看過 20TH 影片', + 'first_time' => '第一次知道', + ][$value ?? ''] ?? '未填寫'; +} + +function dance_song_label(?string $value): string +{ + return [ + 'couple' => 'Couple', + 'comeback' => "Com' Back", + 'road_fighter' => 'Road Fighter', + 'pom_saeng_pom_sa' => 'Pom Saeng Pom Sa', + 'other' => '其他', + ][$value ?? ''] ?? '未選擇'; +} + +function dance_content_labels(?string $value): array +{ + $labels = [ + 'cover' => '翻跳', + 'medley' => '串燒聯舞', + 'blessing' => '祝福影片', + 'graphic' => '圖文應援', + 'editing' => '影片剪輯', + 'archive' => '資料整理', + ]; + $selected = array_filter(array_map('trim', explode(',', (string) $value))); + return array_map(static fn (string $item): string => $labels[$item] ?? $item, $selected); +} + 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 (?, ?, ?, ?, ?, ?)'); @@ -220,6 +265,8 @@ function render_header(string $title, string $active = ''): void $mod): ?> +

水晶熱舞社

+ 30TH 應援報名

系統

公開狀態 管理者帳號 diff --git a/admin/config.php b/admin/config.php index f24e1ea..4c258c7 100644 --- a/admin/config.php +++ b/admin/config.php @@ -13,4 +13,6 @@ return [ 'upload_dir' => dirname(__DIR__) . '/assets/uploads', 'upload_url' => '../assets/uploads', 'max_upload_bytes' => 8 * 1024 * 1024, + 'dance_upload_dir' => dirname(__DIR__) . '/storage/dance-applications', + 'dance_max_upload_bytes' => 8 * 1024 * 1024, ]; diff --git a/admin/dance-application-file.php b/admin/dance-application-file.php new file mode 100644 index 0000000..7f6c051 --- /dev/null +++ b/admin/dance-application-file.php @@ -0,0 +1,31 @@ +prepare('SELECT reference_file_name, reference_original_name, reference_mime_type FROM dance_applications WHERE id = ?'); +$stmt->execute([$id]); +$file = $stmt->fetch(); +if (!$file || !$file['reference_file_name']) { + http_response_code(404); + exit('找不到檔案。'); +} + +$storedName = basename((string) $file['reference_file_name']); +$path = (string) $config['dance_upload_dir'] . DIRECTORY_SEPARATOR . $storedName; +if (!is_file($path)) { + http_response_code(404); + exit('檔案不存在。'); +} + +$downloadName = basename((string) ($file['reference_original_name'] ?: $storedName)); +$downloadName = str_replace(["\r", "\n", '"'], '', $downloadName); +audit('download', 'dance_applications', $id, ['file' => $downloadName]); + +header('Content-Type: ' . ((string) $file['reference_mime_type'] ?: 'application/octet-stream')); +header('Content-Length: ' . filesize($path)); +header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($downloadName)); +header('X-Content-Type-Options: nosniff'); +readfile($path); +exit; diff --git a/admin/dance-application.php b/admin/dance-application.php new file mode 100644 index 0000000..434ec1c --- /dev/null +++ b/admin/dance-application.php @@ -0,0 +1,61 @@ +prepare('SELECT * FROM dance_applications WHERE id = ?'); +$stmt->execute([$id]); +$application = $stmt->fetch(); +if (!$application) { + http_response_code(404); + exit('找不到這筆報名資料。'); +} + +$error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $status = trim((string) ($_POST['status'] ?? '')); + $adminNotes = mb_substr(trim((string) ($_POST['admin_notes'] ?? '')), 0, 5000, 'UTF-8'); + if (!isset($statusOptions[$status])) { + $error = '請選擇有效的處理狀態。'; + } else { + $update = db()->prepare('UPDATE dance_applications SET status = ?, admin_notes = ? WHERE id = ?'); + $update->execute([$status, $adminNotes !== '' ? $adminNotes : null, $id]); + audit('update', 'dance_applications', $id, ['status' => $status, 'admin_notes' => $adminNotes]); + flash('success', '報名資料的處理狀態已更新。'); + redirect('dance-application.php?id=' . $id); + } +} + +render_header('處理熱舞社報名 #' . $id, 'dance-applications'); +?> +
+
回報名清單
+ +
+

送出於

+
+
暱稱
+
Email
+
練舞經驗
+
可參與日期
+
20TH 經驗
+
想練的歌曲
+
想參與的內容
+
30TH 留言
+
參考檔案 MB)未上傳
+
+
+ +
+ + +
+ + +
+
取消
+
+prepare('SELECT COUNT(*) FROM dance_applications' . $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 dance_applications' . $whereSql . " ORDER BY created_at DESC, id DESC LIMIT $perPage OFFSET $offset"); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +render_header('30TH 應援報名', 'dance-applications'); +?> +
+
+ + + +
+ 查看募集表單 +
+ +
+

報名清單

筆;Email 與上傳檔案僅供企劃聯絡使用

+
+ + + + + + + + + + + + + + + + + +
編號暱稱Email想參與歌曲狀態送出時間操作
#查看/處理
目前沒有符合條件的報名資料。
+
+ 1): ?> + + +
+ + Require all denied + + + + Deny from all + diff --git a/storage/dance-applications/.gitkeep b/storage/dance-applications/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/dance-applications/.gitkeep @@ -0,0 +1 @@ + diff --git a/storage/index.html b/storage/index.html new file mode 100644 index 0000000..ebef36e --- /dev/null +++ b/storage/index.html @@ -0,0 +1 @@ +Not Found diff --git a/yellow-note/crystal-dance-survey.html b/yellow-note/crystal-dance-survey.html index 2f06082..6affd35 100644 --- a/yellow-note/crystal-dance-survey.html +++ b/yellow-note/crystal-dance-survey.html @@ -330,6 +330,38 @@ margin-top: 2rem; } + body.crystal-dance-page .summer-form__feedback { + max-width: 860px; + margin: 0 auto 20px; + padding: 14px 18px; + border: 1px solid rgba(78, 73, 43, 0.3); + border-radius: 10px; + color: var(--color-text-dark); + background: var(--color-surface-soft); + text-align: left; + } + + body.crystal-dance-page .summer-form__feedback--success { + border-color: var(--color-brand); + background: var(--color-brand-light); + } + + body.crystal-dance-page .summer-form__feedback--error { + border-color: var(--color-brand-dark); + } + + body.crystal-dance-page .summer-form__trap { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + body.crystal-dance-page .summer-form .btn-dance { background: var(--color-brand); color: var(--color-brand-dark); @@ -483,13 +515,16 @@

30TH 應援募集表單

這份表單是水晶熱舞社為 2027/04/15 30TH 預備的應援募集模組,用來整理參與者想練的歌曲、可支援的內容與想留下的祝福。

-
+ + + +

填寫須知

    -
  • 📎 檔案格式:影片或圖片皆可,建議單檔 100MB 以內
  • +
  • 📎 檔案格式:影片或圖片皆可,單檔上限 8 MB
  • 🔒 這份資料僅供水晶熱舞社內部整理 30TH 應援企劃使用
  • 📅 送出後若確定參與,之後會另行公告練習與錄製時間
@@ -504,12 +539,12 @@
+ maxlength="80" placeholder="例:熱舞小黃" required>
+ maxlength="190" placeholder="dance@example.com" required>
@@ -532,7 +567,7 @@
+ id="attended_20th_yes" value="attended" autocomplete="off" required>
- - - - - -
@@ -599,7 +634,7 @@
-
@@ -608,7 +643,7 @@
- +
@@ -637,6 +672,50 @@ : '💛 30TH · 2027/04/15 已經到來,一起再集合!'; } })(); + + (function () { + var form = document.querySelector('[data-dance-application-form]'); + var feedback = document.querySelector('[data-form-feedback]'); + var submitButton = document.querySelector('[data-submit-button]'); + var result = new URLSearchParams(window.location.search).get('form'); + var messages = { + success: ['success', '已收到你的應援報名!水晶熱舞社整理後會依你留下的 Email 聯絡。'], + invalid: ['error', '有欄位尚未完成或格式不正確,請檢查後再送出。'], + file_too_large: ['error', '上傳檔案超過 8 MB,請縮小檔案後再送出。'], + file_type: ['error', '檔案格式不支援,請改用圖片、MP4、WebM 或 MOV。'], + too_many: ['error', '短時間內送出次數較多,請稍候兩分鐘再試。'], + unavailable: ['error', '目前暫時無法儲存報名,請稍後再試。'] + }; + + function showFeedback(type, message) { + if (!feedback) return; + feedback.hidden = false; + feedback.className = 'summer-form__feedback summer-form__feedback--' + type; + feedback.textContent = message; + feedback.focus(); + } + + if (result && messages[result]) { + showFeedback(messages[result][0], messages[result][1]); + } + + if (form) { + form.addEventListener('submit', function (event) { + var selectedContent = form.querySelectorAll('input[name="participate_content[]"]:checked'); + if (!selectedContent.length) { + event.preventDefault(); + showFeedback('error', '請至少選擇一項想參與的 30TH 內容。'); + var firstContent = form.querySelector('input[name="participate_content[]"]'); + if (firstContent) firstContent.focus(); + return; + } + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = '送出中…'; + } + }); + } + })(); diff --git a/yellow-note/dance-application-submit.php b/yellow-note/dance-application-submit.php new file mode 100644 index 0000000..5bfffd5 --- /dev/null +++ b/yellow-note/dance-application-submit.php @@ -0,0 +1,170 @@ + $maxUploadBytes + 1024 * 1024) { + return_to_dance_form('file_too_large'); +} + +// 隱藏欄位有內容時視為機器送件;不透露攔截規則。 +if (trim((string) ($_POST['website'] ?? '')) !== '') { + return_to_dance_form('success'); +} + +$nickname = post_value('nickname', 80); +$email = mb_strtolower(post_value('email', 190), 'UTF-8'); +$danceYearsRaw = trim((string) ($_POST['dance_years'] ?? '')); +$availableDate = trim((string) ($_POST['available_date'] ?? '')); +$attended20th = trim((string) ($_POST['attended_20th'] ?? '')); +$song = trim((string) ($_POST['song'] ?? '')); +$message30th = post_value('message_30th', 5000); + +$allowedAttendance = ['attended', 'watched_video', 'first_time']; +$allowedContent = ['cover', 'medley', 'blessing', 'graphic', 'editing', 'archive']; +$allowedSongs = ['', 'couple', 'comeback', 'road_fighter', 'pom_saeng_pom_sa', 'other']; + +$selectedContent = $_POST['participate_content'] ?? []; +if (!is_array($selectedContent)) { + $selectedContent = [$selectedContent]; +} +$selectedContent = array_values(array_unique(array_intersect( + $allowedContent, + array_map(static fn ($value): string => trim((string) $value), $selectedContent) +))); + +if ($nickname === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || !$selectedContent) { + return_to_dance_form('invalid'); +} +if ($attended20th !== '' && !in_array($attended20th, $allowedAttendance, true)) { + return_to_dance_form('invalid'); +} +if (!in_array($song, $allowedSongs, true)) { + return_to_dance_form('invalid'); +} + +$danceYears = null; +if ($danceYearsRaw !== '') { + $danceYearsFilter = filter_var($danceYearsRaw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 30]]); + if ($danceYearsFilter === false) { + return_to_dance_form('invalid'); + } + $danceYears = (int) $danceYearsFilter; +} + +if ($availableDate !== '') { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $availableDate); + if (!$date || $date->format('Y-m-d') !== $availableDate) { + return_to_dance_form('invalid'); + } +} + +$db = $config['db']; +$dsn = "mysql:host={$db['host']};port={$db['port']};dbname={$db['name']};charset=utf8mb4"; +try { + $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, + ]); +} catch (PDOException) { + return_to_dance_form('unavailable'); +} + +$submittedIp = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45, 'UTF-8'); +if ($submittedIp !== '') { + $rate = $pdo->prepare('SELECT COUNT(*) FROM dance_applications WHERE submitted_ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE)'); + $rate->execute([$submittedIp]); + if ((int) $rate->fetchColumn() >= 3) { + return_to_dance_form('too_many'); + } +} + +$storedFileName = null; +$originalFileName = null; +$mimeType = null; +$fileSize = null; +$file = $_FILES['reference_file'] ?? null; +if (is_array($file) && (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + if ((int) $file['error'] !== UPLOAD_ERR_OK || (int) $file['size'] > $maxUploadBytes) { + return_to_dance_form('file_too_large'); + } + + $allowedMimeTypes = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'video/mp4' => 'mp4', + 'video/webm' => 'webm', + 'video/quicktime' => 'mov', + ]; + $mimeType = (new finfo(FILEINFO_MIME_TYPE))->file((string) $file['tmp_name']) ?: ''; + if (!isset($allowedMimeTypes[$mimeType])) { + return_to_dance_form('file_type'); + } + + $uploadDir = (string) $config['dance_upload_dir']; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0750, true) && !is_dir($uploadDir)) { + return_to_dance_form('unavailable'); + } + $storedFileName = bin2hex(random_bytes(20)) . '.' . $allowedMimeTypes[$mimeType]; + if (!move_uploaded_file((string) $file['tmp_name'], $uploadDir . DIRECTORY_SEPARATOR . $storedFileName)) { + return_to_dance_form('unavailable'); + } + $originalFileName = mb_substr(basename((string) $file['name']), 0, 255, 'UTF-8'); + $fileSize = (int) $file['size']; +} + +try { + $stmt = $pdo->prepare( + 'INSERT INTO dance_applications + (nickname, email, dance_years, available_date, attended_20th, participate_content, song, + reference_file_name, reference_original_name, reference_mime_type, reference_file_size, + message_30th, submitted_ip, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $nickname, + $email, + $danceYears, + $availableDate !== '' ? $availableDate : null, + $attended20th !== '' ? $attended20th : null, + implode(',', $selectedContent), + $song !== '' ? $song : null, + $storedFileName, + $originalFileName, + $mimeType, + $fileSize, + $message30th !== '' ? $message30th : null, + $submittedIp !== '' ? $submittedIp : null, + mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255, 'UTF-8'), + ]); +} catch (PDOException) { + if ($storedFileName !== null) { + @unlink((string) $config['dance_upload_dir'] . DIRECTORY_SEPARATOR . $storedFileName); + } + return_to_dance_form('unavailable'); +} + +return_to_dance_form('success');