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,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 : '订阅失败,请稍后重试。');
}
});
});