feat: 熱舞社報名後台

This commit is contained in:
2026-08-02 23:24:48 +08:00
parent 44c9cc6fe2
commit 1e2e935688
13 changed files with 542 additions and 17 deletions
+92 -13
View File
@@ -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 @@
<h2 class="section-title">30TH 應援募集表單</h2>
<p class="form-lead">這份表單是水晶熱舞社為 2027/04/15 30TH 預備的應援募集模組,用來整理參與者想練的歌曲、可支援的內容與想留下的祝福。</p>
<form class="summer-form" action="#">
<div class="summer-form__feedback" data-form-feedback hidden tabindex="-1" aria-live="polite"></div>
<form class="summer-form" action="dance-application-submit.php" method="post" enctype="multipart/form-data" data-dance-application-form>
<label class="summer-form__trap" aria-hidden="true">網站<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
<div class="summer-form__stage">
<h3 class="summer-form__stage-title">填寫須知</h3>
<div class="summer-form__stage-body summer-form__stage-body--notice">
<ul class="info-list">
<li>📎 檔案格式:影片或圖片皆可,建議單檔 100MB 以內</li>
<li>📎 檔案格式:影片或圖片皆可,單檔上限 8 MB</li>
<li>🔒 這份資料僅供水晶熱舞社內部整理 30TH 應援企劃使用</li>
<li>📅 送出後若確定參與,之後會另行公告練習與錄製時間</li>
</ul>
@@ -504,12 +539,12 @@
<div class="col-12 col-sm-6">
<label for="nickname" class="form-label">暱稱</label>
<input type="text" class="form-control" id="nickname" name="nickname"
placeholder="例:熱舞小黃" required>
maxlength="80" placeholder="例:熱舞小黃" required>
</div>
<div class="col-12 col-sm-6">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email"
placeholder="dance@example.com" required>
maxlength="190" placeholder="dance@example.com" required>
</div>
<div class="col-12 col-sm-6">
<label for="dance_years" class="form-label">練舞經驗年數</label>
@@ -532,7 +567,7 @@
<label class="form-label d-block">是否參加過 20TH</label>
<div class="summer-form__option-row">
<input type="radio" class="btn-check" name="attended_20th"
id="attended_20th_yes" value="attended" autocomplete="off">
id="attended_20th_yes" value="attended" autocomplete="off" required>
<label class="btn btn-outline-dance" for="attended_20th_yes">參加過 20TH</label>
<input type="radio" class="btn-check" name="attended_20th"
@@ -547,27 +582,27 @@
<div class="col-12">
<label class="form-label d-block">想參與的 30TH 內容(可複選)</label>
<div class="summer-form__option-row">
<input type="checkbox" class="btn-check" name="participate_content"
<input type="checkbox" class="btn-check" name="participate_content[]"
id="content_cover" value="cover" autocomplete="off">
<label class="btn btn-outline-dance" for="content_cover">翻跳</label>
<input type="checkbox" class="btn-check" name="participate_content"
<input type="checkbox" class="btn-check" name="participate_content[]"
id="content_medley" value="medley" autocomplete="off">
<label class="btn btn-outline-dance" for="content_medley">串燒聯舞</label>
<input type="checkbox" class="btn-check" name="participate_content"
<input type="checkbox" class="btn-check" name="participate_content[]"
id="content_blessing" value="blessing" autocomplete="off">
<label class="btn btn-outline-dance" for="content_blessing">祝福影片</label>
<input type="checkbox" class="btn-check" name="participate_content"
<input type="checkbox" class="btn-check" name="participate_content[]"
id="content_graphic" value="graphic" autocomplete="off">
<label class="btn btn-outline-dance" for="content_graphic">圖文應援</label>
<input type="checkbox" class="btn-check" name="participate_content"
<input type="checkbox" class="btn-check" name="participate_content[]"
id="content_editing" value="editing" autocomplete="off">
<label class="btn btn-outline-dance" for="content_editing">影片剪輯</label>
<input type="checkbox" class="btn-check" name="participate_content"
<input type="checkbox" class="btn-check" name="participate_content[]"
id="content_archive" value="archive" autocomplete="off">
<label class="btn btn-outline-dance" for="content_archive">資料整理</label>
</div>
@@ -599,7 +634,7 @@
</div>
<div class="col-12">
<label for="message_30th" class="form-label">想對 30TH 說的話</label>
<textarea class="form-control" id="message_30th" name="message_30th" rows="4"
<textarea class="form-control" id="message_30th" name="message_30th" rows="4" maxlength="5000"
placeholder="想對水晶熱舞社說的話、想貢獻的部分、想見到誰、想留下的祝福……"></textarea>
</div>
</div>
@@ -608,7 +643,7 @@
<div class="summer-form__actions">
<button type="reset" class="btn btn-reset-dance">重新填寫</button>
<button type="submit" class="btn btn-dance">送出應援表單 💛</button>
<button type="submit" class="btn btn-dance" data-submit-button>送出應援表單 💛</button>
</div>
</form>
</section>
@@ -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 = '送出中…';
}
});
}
})();
</script>
</body>
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
$config = require __DIR__ . '/../admin/config.php';
function return_to_dance_form(string $result): never
{
header('Location: crystal-dance-survey.html?form=' . rawurlencode($result) . '#form-30th', true, 303);
exit;
}
function post_value(string $key, int $maxLength): string
{
$value = trim((string) ($_POST[$key] ?? ''));
return mb_substr($value, 0, $maxLength, 'UTF-8');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit('Method Not Allowed');
}
$maxUploadBytes = (int) $config['dance_max_upload_bytes'];
$contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($contentLength > $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');