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',