feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled

This commit is contained in:
2026-04-01 13:22:19 +08:00
parent 669b79cc95
commit 497a9d713d
75 changed files with 6985 additions and 668 deletions

View File

@@ -1,16 +1,25 @@
---
import { apiClient, resolvePublicApiBaseUrl } from '../lib/api/client';
import {
apiClient,
resolvePublicApiBaseUrl,
resolvePublicCommentTurnstileSiteKey,
} from '../lib/api/client';
import { getI18n } from '../lib/i18n';
import type { Comment } from '../lib/api/client';
import type { SiteSettings } from '../lib/types';
interface Props {
postSlug: string;
class?: string;
siteSettings: SiteSettings;
}
const { postSlug, class: className = '' } = Astro.props;
const { postSlug, class: className = '', siteSettings } = Astro.props as Props;
const { locale, t } = getI18n(Astro);
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
const turnstileSiteKey = siteSettings.comments.turnstileEnabled
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
: '';
let comments: Comment[] = [];
let error: string | null = null;
@@ -36,7 +45,12 @@ function formatCommentDate(dateStr: string): string {
}
---
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={publicApiBaseUrl}>
<div
class={`terminal-comments ${className}`}
data-post-slug={postSlug}
data-api-base={publicApiBaseUrl}
data-turnstile-site-key={turnstileSiteKey || undefined}
>
<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">
@@ -115,23 +129,38 @@ function formatCommentDate(dateStr: string): string {
<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)]">
验证码
{t('common.humanVerification')}
</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>
{turnstileSiteKey ? (
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
) : (
<button type="button" id="refresh-captcha" class="terminal-action-button px-3 py-2 text-xs">
<i class="fas fa-rotate-right"></i>
<span>{t('common.refresh')}</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"
/>
{turnstileSiteKey ? (
<>
<div class="mt-3" data-turnstile-container></div>
<input type="hidden" name="turnstileToken" />
<p class="mt-3 text-sm text-[var(--text-secondary)]">{t('common.turnstileHint')}</p>
</>
) : (
<>
<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">
@@ -228,6 +257,8 @@ function formatCommentDate(dateStr: string): string {
</div>
<script>
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
const t = window.__termiTranslate;
const wrapper = document.querySelector('.terminal-comments');
const toggleBtn = document.getElementById('toggle-comment-form');
@@ -243,8 +274,12 @@ function formatCommentDate(dateStr: string): string {
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;
const turnstileSiteKey = wrapper?.getAttribute('data-turnstile-site-key') || '';
const turnstileContainer = form?.querySelector('[data-turnstile-container]') as HTMLElement | null;
const turnstileTokenInput = form?.querySelector('input[name="turnstileToken"]') as HTMLInputElement | null;
const captchaTokenInput = form?.querySelector('input[name="captchaToken"]') as HTMLInputElement | null;
const captchaAnswerInput = form?.querySelector('input[name="captchaAnswer"]') as HTMLInputElement | null;
let turnstileWidget: MountedTurnstile | null = null;
function showMessage(message: string, type: 'success' | 'error' | 'info') {
if (!messageBox) return;
@@ -316,6 +351,54 @@ function formatCommentDate(dateStr: string): string {
}
}
async function ensureTurnstile(showError = true) {
if (!turnstileSiteKey || !turnstileContainer || !turnstileTokenInput) {
return;
}
turnstileTokenInput.value = '';
if (turnstileWidget) {
turnstileWidget.reset();
return;
}
try {
turnstileWidget = await mountTurnstile(turnstileContainer, {
siteKey: turnstileSiteKey,
onToken(token) {
turnstileTokenInput.value = token;
},
onExpire() {
turnstileTokenInput.value = '';
},
onError() {
turnstileTokenInput.value = '';
if (showError) {
showMessage(t('common.turnstileLoadFailed'), 'error');
}
},
});
} catch (error) {
if (showError) {
showMessage(
error instanceof Error ? error.message : t('common.turnstileLoadFailed'),
'error',
);
}
}
}
function resetHumanCheck() {
if (turnstileSiteKey) {
turnstileTokenInput && (turnstileTokenInput.value = '');
turnstileWidget?.reset();
return;
}
void loadCaptcha(false);
}
toggleBtn?.addEventListener('click', () => {
formContainer?.classList.toggle('hidden');
if (!formContainer?.classList.contains('hidden')) {
@@ -360,6 +443,14 @@ function formatCommentDate(dateStr: string): string {
const formData = new FormData(form);
const replyToId = replyingTo?.getAttribute('data-reply-to');
if (turnstileSiteKey) {
const token = String(formData.get('turnstileToken') || '').trim();
if (!token) {
showMessage(t('common.turnstileRequired'), 'error');
return;
}
}
try {
showMessage(t('comments.submitting'), 'info');
@@ -375,6 +466,7 @@ function formatCommentDate(dateStr: string): string {
content: formData.get('content'),
scope: 'article',
replyToCommentId: replyToId ? Number(replyToId) : null,
turnstileToken: formData.get('turnstileToken'),
captchaToken: formData.get('captchaToken'),
captchaAnswer: formData.get('captchaAnswer'),
website: formData.get('website'),
@@ -390,10 +482,10 @@ function formatCommentDate(dateStr: string): string {
resetReply();
formContainer?.classList.add('hidden');
showMessage(t('comments.submitSuccess'), 'success');
void loadCaptcha(false);
resetHumanCheck();
} catch (error) {
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
void loadCaptcha(false);
resetHumanCheck();
}
});
@@ -410,5 +502,9 @@ function formatCommentDate(dateStr: string): string {
});
});
void loadCaptcha(false);
if (turnstileSiteKey) {
void ensureTurnstile(false);
} else {
void loadCaptcha(false);
}
</script>