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

This commit is contained in:
2026-04-01 18:47:17 +08:00
parent f2c07df320
commit 7de4ddc3ee
66 changed files with 1455 additions and 2759 deletions

View File

@@ -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'),
}),
});