feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -1,5 +1,5 @@
---
import { API_BASE_URL, apiClient } from '../lib/api/client';
import { apiClient, resolvePublicApiBaseUrl } from '../lib/api/client';
import { getI18n } from '../lib/i18n';
import type { Comment } from '../lib/api/client';
@@ -10,6 +10,7 @@ interface Props {
const { postSlug, class: className = '' } = Astro.props;
const { locale, t } = getI18n(Astro);
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
let comments: Comment[] = [];
let error: string | null = null;
@@ -35,7 +36,7 @@ function formatCommentDate(dateStr: string): string {
}
---
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={publicApiBaseUrl}>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-3">
<span class="terminal-kicker">
@@ -104,6 +105,35 @@ function formatCommentDate(dateStr: string): string {
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">{t('comments.maxChars')}</p>
</div>
<div class="hidden" aria-hidden="true">
<label>
Website
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</label>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
验证码
</p>
<button type="button" id="refresh-captcha" class="terminal-action-button px-3 py-2 text-xs">
<i class="fas fa-rotate-right"></i>
<span>刷新</span>
</button>
</div>
<p id="captcha-question" class="mt-2 text-sm text-[var(--text-secondary)]">加载中...</p>
<input type="hidden" name="captchaToken" />
<input
type="text"
name="captchaAnswer"
required
inputmode="numeric"
placeholder="请输入上方答案"
class="mt-3 terminal-form-input"
/>
</div>
<div id="replying-to" class="terminal-panel-muted hidden items-center justify-between gap-3 py-3">
<span class="text-sm text-[var(--text-secondary)]">
{t('common.reply')} -> <span id="reply-target" class="font-medium text-[var(--primary)]"></span>
@@ -209,8 +239,12 @@ function formatCommentDate(dateStr: string): string {
const cancelReply = document.getElementById('cancel-reply');
const replyBtns = document.querySelectorAll('.reply-btn');
const messageBox = document.getElementById('comment-message');
const captchaQuestion = document.getElementById('captcha-question');
const refreshCaptchaBtn = document.getElementById('refresh-captcha');
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
const captchaTokenInput = form?.querySelector('input[name=\"captchaToken\"]') as HTMLInputElement | null;
const captchaAnswerInput = form?.querySelector('input[name=\"captchaAnswer\"]') as HTMLInputElement | null;
function showMessage(message: string, type: 'success' | 'error' | 'info') {
if (!messageBox) return;
@@ -251,6 +285,37 @@ function formatCommentDate(dateStr: string): string {
replyingTo?.removeAttribute('data-reply-to');
}
async function loadCaptcha(showError = true) {
if (!captchaQuestion || !captchaTokenInput) {
return;
}
captchaQuestion.textContent = '加载中...';
captchaTokenInput.value = '';
if (captchaAnswerInput) {
captchaAnswerInput.value = '';
}
try {
const response = await fetch(`${apiBase}/comments/captcha`);
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json() as { token?: string; question?: string };
captchaTokenInput.value = payload.token || '';
captchaQuestion.textContent = payload.question || '请刷新验证码';
} catch (error) {
captchaQuestion.textContent = '验证码加载失败,请刷新重试';
if (showError) {
showMessage(
t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }),
'error'
);
}
}
}
toggleBtn?.addEventListener('click', () => {
formContainer?.classList.toggle('hidden');
if (!formContainer?.classList.contains('hidden')) {
@@ -285,6 +350,10 @@ function formatCommentDate(dateStr: string): string {
resetReply();
});
refreshCaptchaBtn?.addEventListener('click', () => {
void loadCaptcha(false);
});
form?.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -306,6 +375,9 @@ function formatCommentDate(dateStr: string): string {
content: formData.get('content'),
scope: 'article',
replyToCommentId: replyToId ? Number(replyToId) : null,
captchaToken: formData.get('captchaToken'),
captchaAnswer: formData.get('captchaAnswer'),
website: formData.get('website'),
}),
});
@@ -318,8 +390,10 @@ function formatCommentDate(dateStr: string): string {
resetReply();
formContainer?.classList.add('hidden');
showMessage(t('comments.submitSuccess'), 'success');
void loadCaptcha(false);
} catch (error) {
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
void loadCaptcha(false);
}
});
@@ -335,4 +409,6 @@ function formatCommentDate(dateStr: string): string {
}
});
});
void loadCaptcha(false);
</script>