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): ?> = h($mod['label']) ?> +
水晶熱舞社
+ 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'); +?> +送出於 = h($application['created_at']) ?>
共 = $total ?> 筆;Email 與上傳檔案僅供企劃聯絡使用
| 編號 | 暱稱 | 想參與 | 歌曲 | 狀態 | 送出時間 | 操作 | |
|---|---|---|---|---|---|---|---|
| #= (int) $row['id'] ?> | += h($row['nickname']) ?> | += h(mb_strimwidth((string) $row['email'], 0, 34, '…', 'UTF-8')) ?> | += h(implode('、', dance_content_labels($row['participate_content']))) ?> | += h(dance_song_label($row['song'])) ?> | += h($statusOptions[$row['status']] ?? $row['status']) ?> | += h($row['created_at']) ?> | +查看/處理 | +
| 目前沒有符合條件的報名資料。 | |||||||
這份表單是水晶熱舞社為 2027/04/15 30TH 預備的應援募集模組,用來整理參與者想練的歌曲、可支援的內容與想留下的祝福。
-