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
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:
@@ -1,5 +1,9 @@
|
||||
---
|
||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import {
|
||||
resolvePublicApiBaseUrl,
|
||||
resolvePublicCommentTurnstileSiteKey,
|
||||
resolvePublicWebPushVapidPublicKey,
|
||||
} from '../lib/api/client';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -9,7 +13,14 @@ interface Props {
|
||||
|
||||
const { requestUrl, siteSettings } = Astro.props as Props;
|
||||
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
|
||||
const browserPushApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions/browser-push`;
|
||||
const popupSettings = siteSettings.subscriptions;
|
||||
const turnstileSiteKey = popupSettings.turnstileEnabled
|
||||
? popupSettings.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||
: '';
|
||||
const webPushPublicKey = popupSettings.webPushEnabled
|
||||
? popupSettings.webPushVapidPublicKey || resolvePublicWebPushVapidPublicKey()
|
||||
: '';
|
||||
---
|
||||
|
||||
{popupSettings.popupEnabled && (
|
||||
@@ -17,7 +28,10 @@ const popupSettings = siteSettings.subscriptions;
|
||||
class="subscription-popup-root"
|
||||
data-subscription-popup-root
|
||||
data-api-url={subscribeApiUrl}
|
||||
data-browser-push-api-url={browserPushApiUrl}
|
||||
data-delay-ms={String(Math.max(popupSettings.popupDelaySeconds, 3) * 1000)}
|
||||
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||
data-web-push-public-key={webPushPublicKey || undefined}
|
||||
hidden
|
||||
>
|
||||
<section
|
||||
@@ -51,6 +65,7 @@ const popupSettings = siteSettings.subscriptions;
|
||||
</div>
|
||||
|
||||
<div class="subscription-popup-badges" aria-hidden="true">
|
||||
{webPushPublicKey && <span class="subscription-popup-badge">浏览器提醒</span>}
|
||||
<span class="subscription-popup-badge">新文章</span>
|
||||
<span class="subscription-popup-badge">汇总简报</span>
|
||||
<span class="subscription-popup-badge">低频提醒</span>
|
||||
@@ -58,6 +73,12 @@ const popupSettings = siteSettings.subscriptions;
|
||||
</div>
|
||||
|
||||
<div class="subscription-popup-meta">
|
||||
{webPushPublicKey && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-bell text-[var(--primary)]"></i>
|
||||
浏览器授权后生效
|
||||
</span>
|
||||
)}
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-newspaper text-[var(--primary)]"></i>
|
||||
新文章 / 汇总简报
|
||||
@@ -73,7 +94,37 @@ const popupSettings = siteSettings.subscriptions;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webPushPublicKey && (
|
||||
<div class="terminal-panel-muted flex flex-col gap-4 rounded-2xl px-4 py-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
浏览器推送
|
||||
</p>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||
直接在浏览器收到新文章 / 汇总提醒,不用再等邮箱确认。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button terminal-action-button-primary"
|
||||
data-subscription-popup-browser-push
|
||||
>
|
||||
开启浏览器提醒
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form class="subscription-popup-form" data-subscription-popup-form>
|
||||
{webPushPublicKey && (
|
||||
<div class="mb-4 flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
<span>或使用邮箱</span>
|
||||
<span class="h-px flex-1 bg-[var(--border-color)]"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label class="subscription-popup-field">
|
||||
<span class="subscription-popup-field-label">邮箱地址</span>
|
||||
<input
|
||||
@@ -86,6 +137,19 @@ const popupSettings = siteSettings.subscriptions;
|
||||
/>
|
||||
</label>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<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>
|
||||
</div>
|
||||
<div class="mt-3" data-subscription-popup-turnstile></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="subscription-popup-actions">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -112,6 +176,13 @@ const popupSettings = siteSettings.subscriptions;
|
||||
)}
|
||||
|
||||
<script>
|
||||
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||
import {
|
||||
ensureBrowserPushSubscription,
|
||||
getBrowserPushSubscription,
|
||||
supportsBrowserPush,
|
||||
} from '../lib/utils/web-push';
|
||||
|
||||
(() => {
|
||||
const DISMISS_KEY = 'termi:subscription-popup:dismiss-until';
|
||||
const SUBSCRIBED_KEY = 'termi:subscription-popup:subscribed-at';
|
||||
@@ -127,6 +198,16 @@ const popupSettings = siteSettings.subscriptions;
|
||||
const emailInput = root.querySelector('[data-subscription-popup-email]');
|
||||
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 browserPushPublicKey = root.getAttribute('data-web-push-public-key') || '';
|
||||
const browserPushButton = root.querySelector('[data-subscription-popup-browser-push]');
|
||||
const turnstileSiteKey = root.getAttribute('data-turnstile-site-key') || '';
|
||||
const turnstileContainer = root.querySelector(
|
||||
'[data-subscription-popup-turnstile]',
|
||||
) as HTMLElement | null;
|
||||
const turnstileTokenInput = form?.querySelector(
|
||||
'input[name="turnstileToken"]',
|
||||
) 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() || '' : '';
|
||||
@@ -148,6 +229,7 @@ const popupSettings = siteSettings.subscriptions;
|
||||
let autoOpened = false;
|
||||
let hideTimer = 0;
|
||||
let successTimer = 0;
|
||||
let turnstileWidget: MountedTurnstile | null = null;
|
||||
const header = document.querySelector('header');
|
||||
|
||||
const shouldFocusEmail = () =>
|
||||
@@ -197,6 +279,30 @@ const popupSettings = siteSettings.subscriptions;
|
||||
status.textContent = defaultStatus;
|
||||
};
|
||||
|
||||
const setPending = (message: string) => {
|
||||
status.dataset.state = 'pending';
|
||||
status.textContent = message;
|
||||
};
|
||||
|
||||
const setError = (message: string) => {
|
||||
status.dataset.state = 'error';
|
||||
status.textContent = message;
|
||||
};
|
||||
|
||||
const setSuccess = (message: string) => {
|
||||
status.dataset.state = 'success';
|
||||
status.textContent = message;
|
||||
};
|
||||
|
||||
const updateBrowserPushButtonLabel = (label: string, disabled = false) => {
|
||||
if (!(browserPushButton instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
browserPushButton.textContent = label;
|
||||
browserPushButton.disabled = disabled;
|
||||
};
|
||||
|
||||
const openPopup = ({ focusEmail = false } = {}) => {
|
||||
if (opened || hasSubmitted()) {
|
||||
return;
|
||||
@@ -216,6 +322,9 @@ const popupSettings = siteSettings.subscriptions;
|
||||
if (focusEmail && shouldFocusEmail()) {
|
||||
emailInput.focus({ preventScroll: true });
|
||||
}
|
||||
if (turnstileSiteKey) {
|
||||
void ensureTurnstile(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -264,7 +373,80 @@ const popupSettings = siteSettings.subscriptions;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTurnstile = async (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) {
|
||||
setError('加载人机验证失败,请刷新页面后重试。');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (showError) {
|
||||
setError(error instanceof Error ? error.message : '加载人机验证失败,请刷新页面后重试。');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetHumanCheck = () => {
|
||||
if (!turnstileSiteKey || !turnstileTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnstileTokenInput.value = '';
|
||||
turnstileWidget?.reset();
|
||||
};
|
||||
|
||||
const syncBrowserPushState = async () => {
|
||||
if (!browserPushPublicKey || !(browserPushButton instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supportsBrowserPush()) {
|
||||
updateBrowserPushButtonLabel('当前浏览器不支持 Web Push', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await getBrowserPushSubscription();
|
||||
if (subscription) {
|
||||
rememberSubmitted();
|
||||
updateBrowserPushButtonLabel('浏览器提醒已开启', true);
|
||||
if (!opened) {
|
||||
root.hidden = true;
|
||||
}
|
||||
} else if (Notification.permission === 'denied') {
|
||||
updateBrowserPushButtonLabel('通知权限已被拒绝', false);
|
||||
} else {
|
||||
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||
}
|
||||
} catch {
|
||||
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||
}
|
||||
};
|
||||
|
||||
syncPopupOffset();
|
||||
void syncBrowserPushState();
|
||||
|
||||
if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(() => syncPopupOffset());
|
||||
@@ -306,6 +488,59 @@ const popupSettings = siteSettings.subscriptions;
|
||||
}
|
||||
});
|
||||
|
||||
browserPushButton instanceof HTMLButtonElement &&
|
||||
browserPushButton.addEventListener('click', async () => {
|
||||
if (!browserPushPublicKey || !browserPushApiUrl) {
|
||||
setError('浏览器推送尚未配置完成。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
const token = turnstileTokenInput?.value.trim() || '';
|
||||
if (!token) {
|
||||
setError('请先完成人机验证。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPending('正在申请浏览器通知权限...');
|
||||
updateBrowserPushButtonLabel('处理中...', true);
|
||||
|
||||
try {
|
||||
const subscription = await ensureBrowserPushSubscription(browserPushPublicKey);
|
||||
const response = await fetch(browserPushApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription,
|
||||
source: 'frontend-popup',
|
||||
turnstileToken: turnstileTokenInput?.value || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload?.message || payload?.description || '浏览器推送开启失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
|
||||
rememberSubmitted();
|
||||
resetHumanCheck();
|
||||
setSuccess(payload?.message || '浏览器推送已开启,后续新内容会直接提醒。');
|
||||
updateBrowserPushButtonLabel('浏览器提醒已开启', true);
|
||||
successTimer = window.setTimeout(() => closePopup(false), 2200);
|
||||
} catch (error) {
|
||||
resetHumanCheck();
|
||||
setError(
|
||||
error instanceof Error ? error.message : '浏览器推送开启失败,请稍后重试。',
|
||||
);
|
||||
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -314,14 +549,20 @@ const popupSettings = siteSettings.subscriptions;
|
||||
const displayName = String(formData.get('displayName') || '').trim();
|
||||
|
||||
if (!email) {
|
||||
status.dataset.state = 'error';
|
||||
status.textContent = '请输入邮箱地址。';
|
||||
setError('请输入邮箱地址。');
|
||||
emailInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
status.dataset.state = 'pending';
|
||||
status.textContent = '正在提交订阅申请...';
|
||||
if (turnstileSiteKey) {
|
||||
const token = String(formData.get('turnstileToken') || '').trim();
|
||||
if (!token) {
|
||||
setError('请先完成人机验证。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPending('正在提交订阅申请...');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
@@ -333,6 +574,7 @@ const popupSettings = siteSettings.subscriptions;
|
||||
email,
|
||||
displayName,
|
||||
source: 'frontend-popup',
|
||||
turnstileToken: formData.get('turnstileToken'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -343,13 +585,12 @@ const popupSettings = siteSettings.subscriptions;
|
||||
|
||||
rememberSubmitted();
|
||||
form.reset();
|
||||
status.dataset.state = 'success';
|
||||
status.textContent =
|
||||
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
|
||||
resetHumanCheck();
|
||||
setSuccess(payload?.message || '订阅申请已提交,请前往邮箱确认后生效。');
|
||||
successTimer = window.setTimeout(() => closePopup(false), 2200);
|
||||
} catch (error) {
|
||||
status.dataset.state = 'error';
|
||||
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
|
||||
resetHumanCheck();
|
||||
setError(error instanceof Error ? error.message : '订阅失败,请稍后重试。');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user