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

@@ -16,7 +16,13 @@ function normalizeApiBaseUrl(value?: string | null) {
return value?.trim().replace(/\/$/, '') ?? '';
}
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
function getRuntimeEnv(
name:
| 'PUBLIC_API_BASE_URL'
| 'INTERNAL_API_BASE_URL'
| 'PUBLIC_COMMENT_TURNSTILE_SITE_KEY'
| 'PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY',
) {
const runtimeProcess = (globalThis as typeof globalThis & {
process?: {
env?: Record<string, string | undefined>;
@@ -31,6 +37,10 @@ function toUrlLike(value: string | URL) {
}
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
const buildTimeCommentTurnstileSiteKey =
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
const buildTimeWebPushVapidPublicKey =
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
@@ -63,6 +73,18 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
return resolvePublicApiBaseUrl(requestUrl);
}
export function resolvePublicCommentTurnstileSiteKey() {
return (
getRuntimeEnv('PUBLIC_COMMENT_TURNSTILE_SITE_KEY') || buildTimeCommentTurnstileSiteKey
);
}
export function resolvePublicWebPushVapidPublicKey() {
return (
getRuntimeEnv('PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY') || buildTimeWebPushVapidPublicKey
);
}
export const API_BASE_URL = resolvePublicApiBaseUrl();
export interface ApiPost {
@@ -121,6 +143,7 @@ export interface CreateCommentInput {
paragraphExcerpt?: string;
replyTo?: string | null;
replyToCommentId?: number | null;
turnstileToken?: string;
captchaToken?: string;
captchaAnswer?: string;
website?: string;
@@ -141,8 +164,12 @@ export interface ApiTag {
id: number;
name: string;
slug: string;
created_at: string;
updated_at: string;
count?: number;
description?: string | null;
cover_image?: string | null;
accent_color?: string | null;
seo_title?: string | null;
seo_description?: string | null;
}
export interface ApiCategory {
@@ -150,6 +177,11 @@ export interface ApiCategory {
name: string;
slug: string;
count: number;
description?: string | null;
cover_image?: string | null;
accent_color?: string | null;
seo_title?: string | null;
seo_description?: string | null;
}
export interface ApiFriendLink {
@@ -230,6 +262,11 @@ export interface ApiSiteSettings {
}> | null;
ai_enabled: boolean;
paragraph_comments_enabled: boolean;
comment_turnstile_enabled: boolean;
subscription_turnstile_enabled: boolean;
web_push_enabled: boolean;
turnstile_site_key: string | null;
web_push_vapid_public_key: string | null;
subscription_popup_enabled: boolean;
subscription_popup_title: string | null;
subscription_popup_description: string | null;
@@ -326,6 +363,20 @@ export interface ApiSearchResult {
rank: number;
}
export interface ApiPagedResponse<T> {
items: T[];
page: number;
page_size: number;
total: number;
total_pages: number;
sort_by: string;
sort_order: string;
}
export interface ApiPagedSearchResponse extends ApiPagedResponse<ApiSearchResult> {
query: string;
}
export interface Review {
id: number;
title: string;
@@ -401,12 +452,18 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
},
comments: {
paragraphsEnabled: true,
turnstileEnabled: false,
turnstileSiteKey: undefined,
},
subscriptions: {
popupEnabled: true,
popupTitle: '订阅更新',
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
popupDelaySeconds: 18,
turnstileEnabled: false,
turnstileSiteKey: undefined,
webPushEnabled: false,
webPushVapidPublicKey: undefined,
},
seo: {
defaultOgImage: undefined,
@@ -451,6 +508,12 @@ const normalizeTag = (tag: ApiTag): UiTag => ({
id: String(tag.id),
name: tag.name,
slug: tag.slug,
count: tag.count,
description: tag.description ?? undefined,
coverImage: tag.cover_image ?? undefined,
accentColor: tag.accent_color ?? undefined,
seoTitle: tag.seo_title ?? undefined,
seoDescription: tag.seo_description ?? undefined,
});
const normalizeCategory = (category: ApiCategory): UiCategory => ({
@@ -458,6 +521,11 @@ const normalizeCategory = (category: ApiCategory): UiCategory => ({
name: category.name,
slug: category.slug,
count: category.count,
description: category.description ?? undefined,
coverImage: category.cover_image ?? undefined,
accentColor: category.accent_color ?? undefined,
seoTitle: category.seo_title ?? undefined,
seoDescription: category.seo_description ?? undefined,
});
const normalizeAvatarUrl = (value: string | null | undefined) => {
@@ -532,6 +600,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
},
comments: {
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
turnstileEnabled: Boolean(settings.comment_turnstile_enabled),
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
},
subscriptions: {
popupEnabled:
@@ -544,6 +615,14 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
popupDelaySeconds:
settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
turnstileEnabled: Boolean(settings.subscription_turnstile_enabled),
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
webPushEnabled: Boolean(settings.web_push_enabled),
webPushVapidPublicKey:
settings.web_push_vapid_public_key ||
resolvePublicWebPushVapidPublicKey() ||
undefined,
},
seo: {
defaultOgImage: settings.seo_default_og_image ?? undefined,
@@ -703,6 +782,46 @@ class ApiClient {
return posts.map(normalizePost);
}
async getPostsPage(options?: {
page?: number;
pageSize?: number;
search?: string;
category?: string;
tag?: string;
postType?: string;
sortBy?: string;
sortOrder?: string;
}): Promise<{
items: UiPost[];
page: number;
pageSize: number;
total: number;
totalPages: number;
sortBy: string;
sortOrder: string;
}> {
const params = new URLSearchParams();
if (options?.page) params.set('page', String(options.page));
if (options?.pageSize) params.set('page_size', String(options.pageSize));
if (options?.search) params.set('search', options.search);
if (options?.category) params.set('category', options.category);
if (options?.tag) params.set('tag', options.tag);
if (options?.postType) params.set('type', options.postType);
if (options?.sortBy) params.set('sort_by', options.sortBy);
if (options?.sortOrder) params.set('sort_order', options.sortOrder);
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(`/posts/page?${params.toString()}`);
return {
items: payload.items.map(normalizePost),
page: payload.page,
pageSize: payload.page_size,
total: payload.total,
totalPages: payload.total_pages,
sortBy: payload.sort_by,
sortOrder: payload.sort_order,
};
}
async getPost(id: number): Promise<UiPost> {
const post = await this.fetch<ApiPost>(`/posts/${id}`);
return normalizePost(post);
@@ -782,6 +901,7 @@ class ApiClient {
paragraphExcerpt: comment.paragraphExcerpt,
replyTo: comment.replyTo,
replyToCommentId: comment.replyToCommentId,
turnstileToken: comment.turnstileToken,
captchaToken: comment.captchaToken,
captchaAnswer: comment.captchaAnswer,
website: comment.website,
@@ -955,12 +1075,87 @@ class ApiClient {
image: result.image,
images: null,
pinned: result.pinned ?? false,
status: null,
visibility: null,
publish_at: null,
unpublish_at: null,
canonical_url: null,
noindex: null,
og_image: null,
redirect_from: null,
redirect_to: null,
created_at: result.created_at,
updated_at: result.updated_at,
})
);
}
async searchPostsPage(options: {
query: string;
page?: number;
pageSize?: number;
category?: string;
tag?: string;
postType?: string;
sortBy?: string;
sortOrder?: string;
}): Promise<{
query: string;
items: UiPost[];
page: number;
pageSize: number;
total: number;
totalPages: number;
sortBy: string;
sortOrder: string;
}> {
const params = new URLSearchParams({ q: options.query });
if (options.page) params.set('page', String(options.page));
if (options.pageSize) params.set('page_size', String(options.pageSize));
if (options.category) params.set('category', options.category);
if (options.tag) params.set('tag', options.tag);
if (options.postType) params.set('type', options.postType);
if (options.sortBy) params.set('sort_by', options.sortBy);
if (options.sortOrder) params.set('sort_order', options.sortOrder);
const payload = await this.fetch<ApiPagedSearchResponse>(`/search/page?${params.toString()}`);
return {
query: payload.query,
items: payload.items.map((result) =>
normalizePost({
id: result.id,
title: result.title || 'Untitled',
slug: result.slug,
description: result.description || '',
content: result.content || '',
category: result.category || '',
tags: result.tags ?? [],
post_type: result.post_type || 'article',
image: result.image,
images: null,
pinned: result.pinned ?? false,
status: null,
visibility: null,
publish_at: null,
unpublish_at: null,
canonical_url: null,
noindex: null,
og_image: null,
redirect_from: null,
redirect_to: null,
created_at: result.created_at,
updated_at: result.updated_at,
}),
),
page: payload.page,
pageSize: payload.page_size,
total: payload.total,
totalPages: payload.total_pages,
sortBy: payload.sort_by,
sortOrder: payload.sort_order,
};
}
async askAi(question: string): Promise<AiAskResponse> {
return this.fetch<AiAskResponse>('/ai/ask', {
method: 'POST',

View File

@@ -37,6 +37,7 @@ export const messages = {
cancel: '取消',
clear: '清除',
reset: '重置',
refresh: '刷新',
reply: '回复',
like: '点赞',
visit: '访问',
@@ -63,6 +64,10 @@ export const messages = {
featureOff: '功能未开启',
emptyState: '当前还没有内容。',
apiUnavailable: 'API 暂时不可用',
humanVerification: '人机验证',
turnstileHint: '提交前请先完成 Cloudflare Turnstile 校验。',
turnstileRequired: '请先完成人机验证。',
turnstileLoadFailed: '加载人机验证失败,请刷新页面后重试。',
unknownError: '未知错误',
},
nav: {
@@ -176,6 +181,9 @@ export const messages = {
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
resultSummary: '找到 {count} 条结果',
filteredSummary: '筛选后剩余 {count} 条结果',
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
previous: '上一页',
next: '下一页',
filtersTitle: '二次筛选',
allCategories: '全部分类',
allTags: '全部标签',
@@ -478,6 +486,7 @@ export const messages = {
cancel: 'Cancel',
clear: 'Clear',
reset: 'Reset',
refresh: 'Refresh',
reply: 'Reply',
like: 'Like',
visit: 'Visit',
@@ -504,6 +513,10 @@ export const messages = {
featureOff: 'Feature off',
emptyState: 'Nothing here yet.',
apiUnavailable: 'API temporarily unavailable',
humanVerification: 'Human verification',
turnstileHint: 'Please complete the Cloudflare Turnstile check before submitting.',
turnstileRequired: 'Please complete the human verification first.',
turnstileLoadFailed: 'Failed to load human verification. Refresh the page and try again.',
unknownError: 'unknown error',
},
nav: {
@@ -617,6 +630,9 @@ export const messages = {
searchTips: 'Search uses the site index first and also applies synonyms plus lightweight typo correction automatically.',
resultSummary: 'Found {count} results',
filteredSummary: '{count} results after filters',
pageSummary: 'Page {current}/{total} · {count} results',
previous: 'Prev',
next: 'Next',
filtersTitle: 'Refine results',
allCategories: 'All categories',
allTags: 'All tags',

View File

@@ -30,6 +30,10 @@ export interface Category {
name: string;
slug: string;
description?: string;
coverImage?: string;
accentColor?: string;
seoTitle?: string;
seoDescription?: string;
icon?: string;
count?: number;
}
@@ -38,6 +42,11 @@ export interface Tag {
id: string;
name: string;
slug: string;
description?: string;
coverImage?: string;
accentColor?: string;
seoTitle?: string;
seoDescription?: string;
count?: number;
}
@@ -76,12 +85,18 @@ export interface SiteSettings {
};
comments: {
paragraphsEnabled: boolean;
turnstileEnabled: boolean;
turnstileSiteKey?: string;
};
subscriptions: {
popupEnabled: boolean;
popupTitle: string;
popupDescription: string;
popupDelaySeconds: number;
turnstileEnabled: boolean;
turnstileSiteKey?: string;
webPushEnabled: boolean;
webPushVapidPublicKey?: string;
};
seo: {
defaultOgImage?: string;

View File

@@ -0,0 +1,110 @@
const TURNSTILE_SCRIPT_ID = 'termi-turnstile-script';
const TURNSTILE_SCRIPT_SRC =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
type TurnstileApi = {
render: (
container: HTMLElement,
options: Record<string, unknown>,
) => string | number;
reset: (widgetId?: string | number) => void;
remove?: (widgetId?: string | number) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
__termiTurnstileLoader__?: Promise<TurnstileApi>;
}
}
export type MountedTurnstile = {
reset: () => void;
remove: () => void;
};
async function loadTurnstileScript(): Promise<TurnstileApi> {
if (window.turnstile) {
return window.turnstile;
}
if (!window.__termiTurnstileLoader__) {
window.__termiTurnstileLoader__ = new Promise<TurnstileApi>((resolve, reject) => {
const existing = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
const handleReady = () => {
if (window.turnstile) {
resolve(window.turnstile);
} else {
reject(new Error('Turnstile script loaded without API'));
}
};
if (existing) {
existing.addEventListener('load', handleReady, { once: true });
existing.addEventListener(
'error',
() => reject(new Error('Failed to load Turnstile script')),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.id = TURNSTILE_SCRIPT_ID;
script.src = TURNSTILE_SCRIPT_SRC;
script.async = true;
script.defer = true;
script.addEventListener('load', handleReady, { once: true });
script.addEventListener(
'error',
() => reject(new Error('Failed to load Turnstile script')),
{ once: true },
);
document.head.appendChild(script);
});
}
return window.__termiTurnstileLoader__;
}
export async function mountTurnstile(
container: HTMLElement,
options: {
siteKey: string;
onToken: (token: string) => void;
onExpire?: () => void;
onError?: () => void;
},
): Promise<MountedTurnstile> {
const turnstile = await loadTurnstileScript();
container.innerHTML = '';
const widgetId = turnstile.render(container, {
sitekey: options.siteKey,
callback: (token: string) => {
options.onToken(token);
},
'expired-callback': () => {
options.onExpire?.();
},
'error-callback': () => {
options.onError?.();
},
});
return {
reset() {
turnstile.reset(widgetId);
},
remove() {
if (typeof turnstile.remove === 'function') {
turnstile.remove(widgetId);
} else {
turnstile.reset(widgetId);
}
container.innerHTML = '';
},
};
}

View File

@@ -0,0 +1,112 @@
const SERVICE_WORKER_URL = '/termi-web-push-sw.js';
export type BrowserPushSubscriptionPayload = {
endpoint: string;
expirationTime?: number | null;
keys: {
auth: string;
p256dh: string;
};
};
function ensureBrowserSupport() {
if (
typeof window === 'undefined' ||
!('Notification' in window) ||
!('serviceWorker' in navigator) ||
!('PushManager' in window)
) {
throw new Error('当前浏览器不支持 Web Push。');
}
}
function urlBase64ToUint8Array(base64String: string) {
const normalized = base64String.trim();
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
const binary = window.atob(base64);
const output = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
output[index] = binary.charCodeAt(index);
}
return output;
}
async function getRegistration() {
ensureBrowserSupport();
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' });
return navigator.serviceWorker.ready;
}
function normalizeSubscription(
subscription: PushSubscription | PushSubscriptionJSON,
): BrowserPushSubscriptionPayload {
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription;
const endpoint = json.endpoint?.trim() || '';
const auth = json.keys?.auth?.trim() || '';
const p256dh = json.keys?.p256dh?.trim() || '';
if (!endpoint || !auth || !p256dh) {
throw new Error('浏览器返回的 PushSubscription 不完整。');
}
return {
endpoint,
expirationTime: json.expirationTime ?? null,
keys: {
auth,
p256dh,
},
};
}
export function supportsBrowserPush() {
return (
typeof window !== 'undefined' &&
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window
);
}
export async function getBrowserPushSubscription() {
if (!supportsBrowserPush()) {
return null;
}
const registration = await getRegistration();
return registration.pushManager.getSubscription();
}
export async function ensureBrowserPushSubscription(
publicKey: string,
): Promise<BrowserPushSubscriptionPayload> {
ensureBrowserSupport();
if (!publicKey.trim()) {
throw new Error('Web Push 公钥未配置。');
}
const permission =
Notification.permission === 'granted'
? 'granted'
: await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('浏览器通知权限未开启。');
}
const registration = await getRegistration();
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
}
return normalizeSubscription(subscription);
}