feat: refresh content workflow and verification settings
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s
This commit is contained in:
@@ -17,7 +17,8 @@ interface 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
|
||||
const commentVerificationMode = siteSettings.comments.verificationMode;
|
||||
const turnstileSiteKey = commentVerificationMode === 'turnstile'
|
||||
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||
: '';
|
||||
|
||||
@@ -49,6 +50,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
class={`terminal-comments ${className}`}
|
||||
data-post-slug={postSlug}
|
||||
data-api-base={publicApiBaseUrl}
|
||||
data-verification-mode={commentVerificationMode}
|
||||
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||
>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -126,42 +128,44 @@ function formatCommentDate(dateStr: string): string {
|
||||
</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)]">
|
||||
{t('common.humanVerification')}
|
||||
</p>
|
||||
{turnstileSiteKey ? (
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
|
||||
{commentVerificationMode !== 'off' && (
|
||||
<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>
|
||||
{commentVerificationMode === 'turnstile' ? (
|
||||
<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>
|
||||
|
||||
{commentVerificationMode === 'turnstile' ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<>
|
||||
<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>
|
||||
|
||||
{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">
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
@@ -274,6 +278,9 @@ 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 verificationMode = wrapper?.getAttribute('data-verification-mode') || 'captcha';
|
||||
const useTurnstile = verificationMode === 'turnstile';
|
||||
const useCaptcha = verificationMode === 'captcha';
|
||||
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;
|
||||
@@ -390,13 +397,15 @@ function formatCommentDate(dateStr: string): string {
|
||||
}
|
||||
|
||||
function resetHumanCheck() {
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
turnstileTokenInput && (turnstileTokenInput.value = '');
|
||||
turnstileWidget?.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCaptcha(false);
|
||||
if (useCaptcha) {
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleBtn?.addEventListener('click', () => {
|
||||
@@ -443,7 +452,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
const formData = new FormData(form);
|
||||
const replyToId = replyingTo?.getAttribute('data-reply-to');
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
const token = String(formData.get('turnstileToken') || '').trim();
|
||||
if (!token) {
|
||||
showMessage(t('common.turnstileRequired'), 'error');
|
||||
@@ -502,9 +511,9 @@ function formatCommentDate(dateStr: string): string {
|
||||
});
|
||||
});
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
void ensureTurnstile(false);
|
||||
} else {
|
||||
} else if (useCaptcha) {
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,8 @@ interface Props {
|
||||
const { postSlug, class: className = '', siteSettings } = Astro.props as Props;
|
||||
const { t } = getI18n(Astro);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
const commentVerificationMode = siteSettings.comments.verificationMode;
|
||||
const turnstileSiteKey = commentVerificationMode === 'turnstile'
|
||||
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||
: '';
|
||||
---
|
||||
@@ -25,6 +26,7 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
data-post-slug={postSlug}
|
||||
data-api-base={publicApiBaseUrl}
|
||||
data-storage-key={`termi:paragraph-comments:${postSlug}`}
|
||||
data-verification-mode={commentVerificationMode}
|
||||
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||
>
|
||||
<div class="paragraph-comments-toolbar terminal-panel-muted">
|
||||
@@ -83,6 +85,9 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
const postSlug = wrapper?.dataset.postSlug || '';
|
||||
const apiBase = wrapper?.dataset.apiBase || '/api';
|
||||
const storageKey = wrapper?.dataset.storageKey || 'termi:paragraph-comments';
|
||||
const verificationMode = wrapper?.dataset.verificationMode || 'captcha';
|
||||
const useTurnstile = verificationMode === 'turnstile';
|
||||
const useCaptcha = verificationMode === 'captcha';
|
||||
const turnstileSiteKey = wrapper?.dataset.turnstileSiteKey || '';
|
||||
const articleRoot = wrapper?.closest('[data-article-slug]') as HTMLElement | null;
|
||||
const articleContent = articleRoot?.querySelector('.article-content') as HTMLElement | null;
|
||||
@@ -355,35 +360,39 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
</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)]">${escapeHtml(t('common.humanVerification'))}</p>
|
||||
${
|
||||
turnstileSiteKey
|
||||
? `<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>`
|
||||
: `<button type="button" class="terminal-action-button px-3 py-2 text-xs" data-refresh-captcha>
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>${escapeHtml(t('common.refresh'))}</span>
|
||||
</button>`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
turnstileSiteKey
|
||||
? `<div class="mt-3" data-turnstile-container></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
<p class="mt-3 text-sm text-[var(--text-secondary)]">${escapeHtml(t('common.turnstileHint'))}</p>`
|
||||
: `<p class="mt-2 text-sm text-[var(--text-secondary)]" data-captcha-question>加载中...</p>
|
||||
<input type="hidden" name="captchaToken" />
|
||||
<input
|
||||
type="text"
|
||||
name="captchaAnswer"
|
||||
required
|
||||
inputmode="numeric"
|
||||
placeholder="请输入上方答案"
|
||||
class="mt-3 terminal-form-input"
|
||||
/>`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
useTurnstile || useCaptcha
|
||||
? `<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)]">${escapeHtml(t('common.humanVerification'))}</p>
|
||||
${
|
||||
useTurnstile
|
||||
? `<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>`
|
||||
: `<button type="button" class="terminal-action-button px-3 py-2 text-xs" data-refresh-captcha>
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>${escapeHtml(t('common.refresh'))}</span>
|
||||
</button>`
|
||||
}
|
||||
</div>
|
||||
${
|
||||
useTurnstile
|
||||
? `<div class="mt-3" data-turnstile-container></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
<p class="mt-3 text-sm text-[var(--text-secondary)]">${escapeHtml(t('common.turnstileHint'))}</p>`
|
||||
: `<p class="mt-2 text-sm text-[var(--text-secondary)]" data-captcha-question>加载中...</p>
|
||||
<input type="hidden" name="captchaToken" />
|
||||
<input
|
||||
type="text"
|
||||
name="captchaAnswer"
|
||||
required
|
||||
inputmode="numeric"
|
||||
placeholder="请输入上方答案"
|
||||
class="mt-3 terminal-form-input"
|
||||
/>`
|
||||
}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
||||
@@ -496,13 +505,15 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
}
|
||||
|
||||
function resetHumanCheck() {
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
turnstileTokenInput && (turnstileTokenInput.value = '');
|
||||
turnstileWidget?.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCaptcha(false);
|
||||
if (useCaptcha) {
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetReplyState() {
|
||||
@@ -714,11 +725,11 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
descriptor.element.insertAdjacentElement('afterend', panel);
|
||||
panel.classList.remove('hidden');
|
||||
panel.dataset.paragraphKey = paragraphKey;
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
if (!turnstileTokenInput?.value) {
|
||||
await ensureTurnstile(false);
|
||||
}
|
||||
} else if (!captchaTokenInput?.value) {
|
||||
} else if (useCaptcha && !captchaTokenInput?.value) {
|
||||
await loadCaptcha(false);
|
||||
}
|
||||
|
||||
@@ -849,7 +860,7 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
clearStatus();
|
||||
setStatus(t('paragraphComments.submitting'), 'info');
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
const token = String(formData.get('turnstileToken') || '').trim();
|
||||
if (!token) {
|
||||
setStatus(t('common.turnstileRequired'), 'error');
|
||||
@@ -958,9 +969,9 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
|
||||
updateMarkerState();
|
||||
applyMarkerVisibility(markersVisible, { persist: false });
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
await ensureTurnstile(false);
|
||||
} else {
|
||||
} else if (useCaptcha) {
|
||||
await loadCaptcha(false);
|
||||
}
|
||||
await openFromHash();
|
||||
|
||||
@@ -14,8 +14,10 @@ interface Props {
|
||||
const { requestUrl, siteSettings } = Astro.props as Props;
|
||||
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
|
||||
const browserPushApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions/browser-push`;
|
||||
const captchaApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/comments/captcha`;
|
||||
const popupSettings = siteSettings.subscriptions;
|
||||
const turnstileSiteKey = popupSettings.turnstileEnabled
|
||||
const verificationMode = popupSettings.verificationMode;
|
||||
const turnstileSiteKey = verificationMode === 'turnstile'
|
||||
? popupSettings.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||
: '';
|
||||
const webPushPublicKey = popupSettings.webPushEnabled
|
||||
@@ -29,7 +31,9 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
data-subscription-popup-root
|
||||
data-api-url={subscribeApiUrl}
|
||||
data-browser-push-api-url={browserPushApiUrl}
|
||||
data-captcha-url={captchaApiUrl}
|
||||
data-delay-ms={String(Math.max(popupSettings.popupDelaySeconds, 3) * 1000)}
|
||||
data-verification-mode={verificationMode}
|
||||
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||
data-web-push-public-key={webPushPublicKey || undefined}
|
||||
hidden
|
||||
@@ -137,16 +141,48 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
/>
|
||||
</label>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
{verificationMode !== 'off' && (
|
||||
<div class="mt-4 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
人机验证
|
||||
</p>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
|
||||
{verificationMode === 'turnstile' ? (
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button px-3 py-2 text-xs"
|
||||
data-subscription-popup-refresh-captcha
|
||||
>
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div class="mt-3" data-subscription-popup-turnstile></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
{verificationMode === 'turnstile' ? (
|
||||
<>
|
||||
<div class="mt-3" data-subscription-popup-turnstile></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
class="mt-3 text-sm text-[var(--text-secondary)]"
|
||||
data-subscription-popup-captcha-question
|
||||
>
|
||||
加载中...
|
||||
</p>
|
||||
<input type="hidden" name="captchaToken" />
|
||||
<input
|
||||
type="text"
|
||||
name="captchaAnswer"
|
||||
inputmode="numeric"
|
||||
placeholder="请输入上方答案"
|
||||
class="mt-3 terminal-form-input"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -199,8 +235,12 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
const dismissButton = root.querySelector('[data-subscription-popup-dismiss]');
|
||||
const apiUrl = root.getAttribute('data-api-url');
|
||||
const browserPushApiUrl = root.getAttribute('data-browser-push-api-url');
|
||||
const captchaApiUrl = root.getAttribute('data-captcha-url') || '/api/comments/captcha';
|
||||
const browserPushPublicKey = root.getAttribute('data-web-push-public-key') || '';
|
||||
const browserPushButton = root.querySelector('[data-subscription-popup-browser-push]');
|
||||
const verificationMode = root.getAttribute('data-verification-mode') || 'off';
|
||||
const useTurnstile = verificationMode === 'turnstile';
|
||||
const useCaptcha = verificationMode === 'captcha';
|
||||
const turnstileSiteKey = root.getAttribute('data-turnstile-site-key') || '';
|
||||
const turnstileContainer = root.querySelector(
|
||||
'[data-subscription-popup-turnstile]',
|
||||
@@ -208,6 +248,18 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
const turnstileTokenInput = form?.querySelector(
|
||||
'input[name="turnstileToken"]',
|
||||
) as HTMLInputElement | null;
|
||||
const captchaQuestion = root.querySelector(
|
||||
'[data-subscription-popup-captcha-question]',
|
||||
) as HTMLElement | null;
|
||||
const refreshCaptchaButton = root.querySelector(
|
||||
'[data-subscription-popup-refresh-captcha]',
|
||||
) as HTMLButtonElement | null;
|
||||
const captchaTokenInput = form?.querySelector(
|
||||
'input[name="captchaToken"]',
|
||||
) as HTMLInputElement | null;
|
||||
const captchaAnswerInput = form?.querySelector(
|
||||
'input[name="captchaAnswer"]',
|
||||
) as HTMLInputElement | null;
|
||||
const pathname = window.location.pathname || '/';
|
||||
const delayMs = Math.max(3000, Number(root.getAttribute('data-delay-ms') || '18000'));
|
||||
const defaultStatus = status instanceof HTMLElement ? status.textContent?.trim() || '' : '';
|
||||
@@ -322,8 +374,10 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
if (focusEmail && shouldFocusEmail()) {
|
||||
emailInput.focus({ preventScroll: true });
|
||||
}
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
void ensureTurnstile(false);
|
||||
} else if (useCaptcha) {
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -408,13 +462,42 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
}
|
||||
};
|
||||
|
||||
const resetHumanCheck = () => {
|
||||
if (!turnstileSiteKey || !turnstileTokenInput) {
|
||||
const loadCaptcha = async (showError = true) => {
|
||||
if (!captchaQuestion || !captchaTokenInput || !captchaAnswerInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnstileTokenInput.value = '';
|
||||
turnstileWidget?.reset();
|
||||
captchaQuestion.textContent = '加载中...';
|
||||
captchaTokenInput.value = '';
|
||||
captchaAnswerInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(captchaApiUrl);
|
||||
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) {
|
||||
setError(error instanceof Error ? error.message : '验证码加载失败,请刷新后重试。');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetHumanCheck = () => {
|
||||
if (useTurnstile && turnstileTokenInput) {
|
||||
turnstileTokenInput.value = '';
|
||||
turnstileWidget?.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (useCaptcha) {
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncBrowserPushState = async () => {
|
||||
@@ -481,6 +564,9 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
});
|
||||
|
||||
dismissButton.addEventListener('click', () => closePopup(true));
|
||||
refreshCaptchaButton?.addEventListener('click', () => {
|
||||
void loadCaptcha(false);
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && opened) {
|
||||
@@ -495,12 +581,24 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
return;
|
||||
}
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
const token = turnstileTokenInput?.value.trim() || '';
|
||||
if (!token) {
|
||||
setError('请先完成人机验证。');
|
||||
return;
|
||||
}
|
||||
} else if (useCaptcha) {
|
||||
const captchaToken = captchaTokenInput?.value.trim() || '';
|
||||
const captchaAnswer = captchaAnswerInput?.value.trim() || '';
|
||||
if (!captchaToken) {
|
||||
setError('验证码加载失败,请刷新后重试。');
|
||||
return;
|
||||
}
|
||||
if (!captchaAnswer) {
|
||||
setError('请先填写验证码答案。');
|
||||
captchaAnswerInput?.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPending('正在申请浏览器通知权限...');
|
||||
@@ -517,6 +615,8 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
subscription,
|
||||
source: 'frontend-popup',
|
||||
turnstileToken: turnstileTokenInput?.value || undefined,
|
||||
captchaToken: captchaTokenInput?.value || undefined,
|
||||
captchaAnswer: captchaAnswerInput?.value || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -554,12 +654,24 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
return;
|
||||
}
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
if (useTurnstile) {
|
||||
const token = String(formData.get('turnstileToken') || '').trim();
|
||||
if (!token) {
|
||||
setError('请先完成人机验证。');
|
||||
return;
|
||||
}
|
||||
} else if (useCaptcha) {
|
||||
const captchaToken = String(formData.get('captchaToken') || '').trim();
|
||||
const captchaAnswer = String(formData.get('captchaAnswer') || '').trim();
|
||||
if (!captchaToken) {
|
||||
setError('验证码加载失败,请刷新后重试。');
|
||||
return;
|
||||
}
|
||||
if (!captchaAnswer) {
|
||||
setError('请先填写验证码答案。');
|
||||
captchaAnswerInput?.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPending('正在提交订阅申请...');
|
||||
@@ -575,6 +687,8 @@ const webPushPublicKey = popupSettings.webPushEnabled
|
||||
displayName,
|
||||
source: 'frontend-popup',
|
||||
turnstileToken: formData.get('turnstileToken'),
|
||||
captchaToken: formData.get('captchaToken'),
|
||||
captchaAnswer: formData.get('captchaAnswer'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ContentOverview,
|
||||
ContentWindowHighlight,
|
||||
FriendLink as UiFriendLink,
|
||||
HumanVerificationMode,
|
||||
Post as UiPost,
|
||||
PopularPostHighlight,
|
||||
SiteSettings,
|
||||
@@ -36,6 +37,24 @@ function toUrlLike(value: string | URL) {
|
||||
return value instanceof URL ? value : new URL(value);
|
||||
}
|
||||
|
||||
function normalizeVerificationMode(
|
||||
value: string | null | undefined,
|
||||
fallback: HumanVerificationMode,
|
||||
): HumanVerificationMode {
|
||||
switch ((value ?? '').trim().toLowerCase()) {
|
||||
case 'off':
|
||||
return 'off';
|
||||
case 'captcha':
|
||||
case 'normal':
|
||||
case 'simple':
|
||||
return 'captcha';
|
||||
case 'turnstile':
|
||||
return 'turnstile';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
|
||||
const buildTimeCommentTurnstileSiteKey =
|
||||
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
|
||||
@@ -262,7 +281,9 @@ export interface ApiSiteSettings {
|
||||
}> | null;
|
||||
ai_enabled: boolean;
|
||||
paragraph_comments_enabled: boolean;
|
||||
comment_verification_mode?: HumanVerificationMode | null;
|
||||
comment_turnstile_enabled: boolean;
|
||||
subscription_verification_mode?: HumanVerificationMode | null;
|
||||
subscription_turnstile_enabled: boolean;
|
||||
web_push_enabled: boolean;
|
||||
turnstile_site_key: string | null;
|
||||
@@ -452,6 +473,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: true,
|
||||
verificationMode: 'captcha',
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
},
|
||||
@@ -460,6 +482,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
popupTitle: '订阅更新',
|
||||
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||||
popupDelaySeconds: 18,
|
||||
verificationMode: 'off',
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
webPushEnabled: false,
|
||||
@@ -561,7 +584,17 @@ const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
|
||||
status: friendLink.status,
|
||||
});
|
||||
|
||||
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
const commentVerificationMode = normalizeVerificationMode(
|
||||
settings.comment_verification_mode,
|
||||
settings.comment_turnstile_enabled ? 'turnstile' : 'captcha',
|
||||
);
|
||||
const subscriptionVerificationMode = normalizeVerificationMode(
|
||||
settings.subscription_verification_mode,
|
||||
settings.subscription_turnstile_enabled ? 'turnstile' : 'off',
|
||||
);
|
||||
|
||||
return {
|
||||
id: String(settings.id),
|
||||
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
|
||||
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
|
||||
@@ -599,8 +632,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
enabled: Boolean(settings.ai_enabled),
|
||||
},
|
||||
comments: {
|
||||
verificationMode: commentVerificationMode,
|
||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||
turnstileEnabled: Boolean(settings.comment_turnstile_enabled),
|
||||
turnstileEnabled: commentVerificationMode === 'turnstile',
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
},
|
||||
@@ -615,7 +649,8 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
popupDelaySeconds:
|
||||
settings.subscription_popup_delay_seconds ??
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
|
||||
turnstileEnabled: Boolean(settings.subscription_turnstile_enabled),
|
||||
verificationMode: subscriptionVerificationMode,
|
||||
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
webPushEnabled: Boolean(settings.web_push_enabled),
|
||||
@@ -628,7 +663,8 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeContentOverview = (
|
||||
overview: ApiHomePagePayload['content_overview'] | undefined,
|
||||
@@ -937,13 +973,23 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async subscribe(input: { email: string; displayName?: string; source?: string }): Promise<PublicSubscriptionResponse> {
|
||||
async subscribe(input: {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
source?: string;
|
||||
turnstileToken?: string;
|
||||
captchaToken?: string;
|
||||
captchaAnswer?: string;
|
||||
}): Promise<PublicSubscriptionResponse> {
|
||||
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
source: input.source,
|
||||
turnstileToken: input.turnstileToken,
|
||||
captchaToken: input.captchaToken,
|
||||
captchaAnswer: input.captchaAnswer,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface FriendLink {
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export type HumanVerificationMode = 'off' | 'captcha' | 'turnstile';
|
||||
|
||||
export interface SiteSettings {
|
||||
id: string;
|
||||
siteName: string;
|
||||
@@ -85,6 +87,7 @@ export interface SiteSettings {
|
||||
};
|
||||
comments: {
|
||||
paragraphsEnabled: boolean;
|
||||
verificationMode: HumanVerificationMode;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey?: string;
|
||||
};
|
||||
@@ -93,6 +96,7 @@ export interface SiteSettings {
|
||||
popupTitle: string;
|
||||
popupDescription: string;
|
||||
popupDelaySeconds: number;
|
||||
verificationMode: HumanVerificationMode;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey?: string;
|
||||
webPushEnabled: boolean;
|
||||
|
||||
@@ -187,7 +187,7 @@ const breadcrumbJsonLd = {
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-article-slug={post.slug}>
|
||||
<div class="flex flex-col gap-8 lg:flex-row">
|
||||
<div class="min-w-0 flex-1">
|
||||
<TerminalWindow title={`~/content/posts/${post.slug}.md`} class="w-full">
|
||||
<TerminalWindow title={`~/articles/${post.slug}`} class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<div class="terminal-panel ml-4 mt-4 space-y-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -252,7 +252,7 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-2">
|
||||
<CommandPrompt command={`bat --style=plain ${post.slug}.md`} />
|
||||
<CommandPrompt command={`preview article --slug ${post.slug}`} />
|
||||
|
||||
<div class="ml-4 mt-4 space-y-6">
|
||||
{post.image && (
|
||||
@@ -298,10 +298,7 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-6">
|
||||
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
file://content/posts/{post.slug}.md
|
||||
</span>
|
||||
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/articles" class="terminal-action-button">
|
||||
<i class="fas fa-list"></i>
|
||||
|
||||
@@ -80,14 +80,14 @@ const postTypeFilters = [
|
||||
|
||||
const typePromptCommand =
|
||||
selectedType === 'all'
|
||||
? `grep -E "^type: (article|tweet)$" ./posts/*.md`
|
||||
: `grep -E "^type: ${selectedType}$" ./posts/*.md`;
|
||||
? 'posts query --type all'
|
||||
: `posts query --type ${selectedType}`;
|
||||
const categoryPromptCommand = selectedCategory
|
||||
? `grep -El "^category: ${selectedCategory}$" ./posts/*.md`
|
||||
: `cut -d: -f2 ./categories.index | sort -u`;
|
||||
? `posts query --category "${selectedCategory}"`
|
||||
: 'categories list --sort name';
|
||||
const tagPromptCommand = selectedTag
|
||||
? `grep -Ril "#${selectedTag}" ./posts`
|
||||
: `cut -d: -f2 ./tags.index | sort -u`;
|
||||
? `posts query --tag "${selectedTag}"`
|
||||
: 'tags list --sort popularity';
|
||||
const hasActiveFilters =
|
||||
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
|
||||
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
|
||||
@@ -126,7 +126,7 @@ const buildArticlesUrl = ({
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/articles/index" class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<CommandPrompt command="find ./posts -type f -name '*.md' | sort" />
|
||||
<CommandPrompt command="posts list --sort published_at --order desc" />
|
||||
|
||||
<div class="ml-4 mt-4 space-y-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{t('articlesPage.title')}</h1>
|
||||
@@ -141,7 +141,7 @@ const buildArticlesUrl = ({
|
||||
{selectedSearch && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
|
||||
grep: {selectedSearch}
|
||||
search: {selectedSearch}
|
||||
</span>
|
||||
)}
|
||||
{selectedCategory && (
|
||||
|
||||
@@ -38,11 +38,11 @@ const filteredPosts = selectedCategory
|
||||
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
|
||||
: [];
|
||||
const categoryPromptCommand = selectedCategory
|
||||
? `grep -El "^category: ${selectedCategory}$" ./posts/*.md`
|
||||
: 'cut -d: -f2 ./categories.index | sort -u';
|
||||
? `posts query --category "${selectedCategory}"`
|
||||
: 'categories list --sort name';
|
||||
const resultsPromptCommand = selectedCategory
|
||||
? `find ./posts -type f | xargs grep -il "^category: ${selectedCategory}$"`
|
||||
: 'find ./posts -type f | sort';
|
||||
? `posts list --category "${selectedCategory}"`
|
||||
: 'posts list --group-by category';
|
||||
const categoryAccentMap = Object.fromEntries(
|
||||
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
|
||||
);
|
||||
@@ -58,7 +58,7 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/categories" class="w-full">
|
||||
<div class="mb-6 px-4">
|
||||
<CommandPrompt command="find ./categories -maxdepth 1 -type d | sort" />
|
||||
<CommandPrompt command="categories list --with-counts" />
|
||||
<div class="terminal-panel ml-4 mt-4">
|
||||
<div class="terminal-kicker">content taxonomy</div>
|
||||
<div class="terminal-section-title mt-4">
|
||||
@@ -273,11 +273,11 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
|
||||
|
||||
function updatePrompts() {
|
||||
const filterCommand = state.category
|
||||
? `grep -El "^category: ${state.category}$" ./posts/*.md`
|
||||
: 'cut -d: -f2 ./categories.index | sort -u';
|
||||
? `posts query --category "${state.category}"`
|
||||
: 'categories list --sort name';
|
||||
const resultsCommand = state.category
|
||||
? `find ./posts -type f | xargs grep -il "^category: ${state.category}$"`
|
||||
: 'find ./posts -type f | sort';
|
||||
? `posts list --category "${state.category}"`
|
||||
: 'posts list --group-by category';
|
||||
|
||||
promptApi?.set?.('categories-filter-prompt', filterCommand, { typing: false });
|
||||
promptApi?.set?.('categories-results-prompt', resultsCommand, { typing: false });
|
||||
|
||||
@@ -59,7 +59,7 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
<circle cx="92" cy="77" r="10" fill="#FF5F56"/>
|
||||
<circle cx="124" cy="77" r="10" fill="#FFBD2E"/>
|
||||
<circle cx="156" cy="77" r="10" fill="#27C93F"/>
|
||||
<text x="190" y="83" fill="#9CA3AF" font-family="'JetBrains Mono', monospace" font-size="22">~/content/posts/${escapeXml(post.slug)}.md</text>
|
||||
<text x="190" y="83" fill="#9CA3AF" font-family="'JetBrains Mono', monospace" font-size="22">~/articles/${escapeXml(post.slug)}</text>
|
||||
<rect x="88" y="150" width="180" height="44" rx="22" fill="rgba(0,255,157,0.12)" stroke="url(#accent)"/>
|
||||
<text x="178" y="178" text-anchor="middle" fill="#8BFFD3" font-family="'JetBrains Mono', monospace" font-size="24">${category}</text>
|
||||
<text x="88" y="274" fill="#F8FAFC" font-family="'IBM Plex Sans', Arial, sans-serif" font-size="64" font-weight="700">${title}</text>
|
||||
|
||||
Reference in New Issue
Block a user