feat: update tag and timeline share panel copy for clarity and conciseness
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s

style: enhance global CSS for better responsiveness of terminal chips and navigation pills

test: remove inline subscription test and add maintenance mode access code test

feat: implement media library picker dialog for selecting images from the media library

feat: add media URL controls for uploading and managing media assets

feat: add migration for music_enabled and maintenance_mode settings in site settings

feat: implement maintenance mode functionality with access control

feat: create maintenance page with access code input and error handling

chore: add TypeScript declaration for QR code module
This commit is contained in:
2026-04-02 23:05:49 +08:00
parent 6a50dd478c
commit 9665c933b5
94 changed files with 5266 additions and 1612 deletions

View File

@@ -86,10 +86,6 @@ const SiteSettingsPage = lazy(async () => {
const mod = await import('@/pages/site-settings-page') const mod = await import('@/pages/site-settings-page')
return { default: mod.SiteSettingsPage } return { default: mod.SiteSettingsPage }
}) })
const AuditPage = lazy(async () => {
const mod = await import('@/pages/audit-page')
return { default: mod.AuditPage }
})
const SubscriptionsPage = lazy(async () => { const SubscriptionsPage = lazy(async () => {
const mod = await import('@/pages/subscriptions-page') const mod = await import('@/pages/subscriptions-page')
return { default: mod.SubscriptionsPage } return { default: mod.SubscriptionsPage }
@@ -401,14 +397,6 @@ function AppRoutes() {
</LazyRoute> </LazyRoute>
} }
/> />
<Route
path="audit"
element={
<LazyRoute>
<AuditPage />
</LazyRoute>
}
/>
<Route <Route
path="reviews" path="reviews"
element={ element={

View File

@@ -106,12 +106,6 @@ const primaryNav = [
description: '异步任务 / 队列控制台', description: '异步任务 / 队列控制台',
icon: Workflow, icon: Workflow,
}, },
{
to: '/audit',
label: '审计',
description: '后台操作审计日志',
icon: Settings,
},
{ {
to: '/settings', to: '/settings',
label: '设置', label: '设置',

View File

@@ -0,0 +1,291 @@
import { Image as ImageIcon, Search, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminMediaObjectResponse } from '@/lib/types'
type MediaLibraryPickerDialogProps = {
open: boolean
selectedUrl?: string
preferredPrefix?: string
onClose: () => void
onSelect: (item: AdminMediaObjectResponse) => void
}
const DEFAULT_PREFIX_OPTIONS = [
'all',
'post-covers/',
'review-covers/',
'category-covers/',
'tag-covers/',
'site-assets/',
'seo-assets/',
'music-covers/',
'friend-link-avatars/',
'uploads/',
] as const
function prefixLabel(value: string) {
switch (value) {
case 'all':
return '全部目录'
case 'post-covers/':
return '文章封面'
case 'review-covers/':
return '评测封面'
case 'category-covers/':
return '分类封面'
case 'tag-covers/':
return '标签封面'
case 'site-assets/':
return '站点资源'
case 'seo-assets/':
return 'SEO 图片'
case 'music-covers/':
return '音乐封面'
case 'friend-link-avatars/':
return '友链头像'
case 'uploads/':
return '通用上传'
default:
return value
}
}
function isLikelyImage(item: AdminMediaObjectResponse) {
return /\.(png|jpe?g|webp|avif|gif|svg)$/i.test(item.key)
}
export function MediaLibraryPickerDialog({
open,
selectedUrl,
preferredPrefix,
onClose,
onSelect,
}: MediaLibraryPickerDialogProps) {
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
const [loading, setLoading] = useState(false)
const [prefixFilter, setPrefixFilter] = useState(preferredPrefix ?? 'all')
const [searchTerm, setSearchTerm] = useState('')
const prefixOptions = useMemo(
() => Array.from(new Set([preferredPrefix, ...DEFAULT_PREFIX_OPTIONS].filter(Boolean))) as string[],
[preferredPrefix],
)
useEffect(() => {
if (!open) {
return
}
setPrefixFilter(preferredPrefix ?? 'all')
setSearchTerm('')
}, [open, preferredPrefix])
useEffect(() => {
if (!open) {
return
}
let cancelled = false
async function loadItems() {
try {
setLoading(true)
const result = await adminApi.listMediaObjects({
prefix: prefixFilter === 'all' ? undefined : prefixFilter,
limit: 200,
})
if (!cancelled) {
setItems(result.items)
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof ApiError ? error.message : '加载媒体库失败。')
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void loadItems()
return () => {
cancelled = true
}
}, [open, prefixFilter])
useEffect(() => {
if (!open) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
return items
}
return items.filter((item) =>
[
item.key,
item.title ?? '',
item.alt_text ?? '',
item.caption ?? '',
...(item.tags ?? []),
]
.join('\n')
.toLowerCase()
.includes(keyword),
)
}, [items, searchTerm])
if (!open) {
return null
}
return (
<div
className="fixed inset-0 z-[70] bg-slate-950/70 px-4 py-5 backdrop-blur-sm xl:px-8 xl:py-8"
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<div className="mx-auto flex h-full w-full max-w-7xl flex-col overflow-hidden rounded-[32px] border border-border/70 bg-background shadow-[0_40px_120px_rgba(15,23,42,0.45)]">
<div className="flex flex-col gap-4 border-b border-border/70 bg-background/95 px-6 py-5 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h3 className="text-2xl font-semibold tracking-tight"></h3>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
使 URL
</p>
</div>
</div>
<Button variant="ghost" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 border-b border-border/70 bg-background/80 px-6 py-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
{prefixOptions.map((option) => (
<option key={option} value={option}>
{prefixLabel(option)}
</option>
))}
</Select>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按 key / 标题 / alt / 标签搜索"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-[260px] rounded-[28px]" />
))}
</div>
) : filteredItems.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((item) => {
const isSelected = selectedUrl === item.url
return (
<div
key={item.key}
className={`overflow-hidden rounded-[28px] border bg-background/75 ${
isSelected
? 'border-primary/40 shadow-[0_16px_44px_rgba(37,99,235,0.16)]'
: 'border-border/70'
}`}
>
<div className="aspect-[16/9] overflow-hidden border-b border-border/70 bg-muted/30">
{isLikelyImage(item) ? (
<img
src={item.url}
alt={item.alt_text ?? item.title ?? item.key}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
</div>
)}
</div>
<div className="space-y-3 p-4">
<div className="space-y-2">
<p className="line-clamp-1 text-sm font-medium">{item.title || item.key}</p>
<p className="line-clamp-2 break-all text-xs text-muted-foreground">{item.key}</p>
{item.tags.length ? (
<div className="flex flex-wrap gap-2">
{item.tags.slice(0, 3).map((tag) => (
<Badge key={`${item.key}-${tag}`} variant="outline">
{tag}
</Badge>
))}
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
{prefixLabel(item.key.split('/')[0] ? `${item.key.split('/')[0]}/` : 'uploads/')}
</span>
<Button
size="sm"
onClick={() => {
onSelect(item)
onClose()
}}
>
使
</Button>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="flex h-full min-h-[240px] flex-col items-center justify-center gap-3 rounded-[28px] border border-dashed border-border/70 bg-background/40 text-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
<p></p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,256 @@
import { CheckSquare, Download, Images, Square, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { toast } from 'sonner'
import { MediaLibraryPickerDialog } from '@/components/media-library-picker-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { adminApi, ApiError } from '@/lib/api'
import {
formatCompressionPreview,
prepareImageForUpload,
type MediaUploadTargetFormat,
} from '@/lib/image-compress'
import { cn } from '@/lib/utils'
type RemoteTargetFormat = 'original' | 'webp' | 'avif'
type MediaUrlControlsProps = {
value: string
onChange: (value: string) => void
prefix: string
contextLabel: string
mode?: 'image' | 'cover'
className?: string
remoteTitle?: string | null
accept?: string
dataTestIdPrefix?: string
}
function formatLabelForUploadTarget(value: MediaUploadTargetFormat) {
switch (value) {
case 'avif':
return 'AVIF'
case 'webp':
return 'WebP'
default:
return '自动'
}
}
export function MediaUrlControls({
value,
onChange,
prefix,
contextLabel,
mode = 'image',
className,
remoteTitle,
accept = 'image/*',
dataTestIdPrefix,
}: MediaUrlControlsProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [downloadingRemote, setDownloadingRemote] = useState(false)
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82')
const [uploadTargetFormat, setUploadTargetFormat] = useState<MediaUploadTargetFormat>('avif')
const [remoteUrl, setRemoteUrl] = useState('')
const [remoteTargetFormat, setRemoteTargetFormat] = useState<RemoteTargetFormat>('original')
const [pickerOpen, setPickerOpen] = useState(false)
const quality = Number.parseFloat(compressQuality)
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
return (
<div className={cn('rounded-2xl border border-border/70 bg-background/55 p-4', className)}>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<input
ref={fileInputRef}
className="hidden"
type="file"
accept={accept}
onChange={async (event) => {
const file = event.target.files?.item(0)
event.currentTarget.value = ''
if (!file) {
return
}
try {
setUploading(true)
const prepared = await prepareImageForUpload(file, {
compress: compressBeforeUpload,
quality: safeQuality,
targetFormat: uploadTargetFormat,
contextLabel: `${contextLabel}${file.name}`,
mode,
})
if (prepared.preview) {
toast.message(formatCompressionPreview(prepared.preview))
}
const uploaded = await adminApi.uploadMediaObjects([prepared.file], { prefix })
const url = uploaded.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但没有返回 URL')
}
if (compressBeforeUpload && uploadTargetFormat !== 'auto') {
const expectedMimeType =
uploadTargetFormat === 'avif' ? 'image/avif' : 'image/webp'
if (prepared.file.type !== expectedMimeType) {
toast.warning(
`当前环境无法直接导出 ${formatLabelForUploadTarget(uploadTargetFormat)},已回退为 ${prepared.file.type || '原格式'}`,
)
}
}
onChange(url)
toast.success('已上传到媒体库,并回填 URL。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '上传到媒体库失败。')
} finally {
setUploading(false)
}
}}
/>
<Button
type="button"
variant="outline"
disabled={uploading}
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-upload` : undefined}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
{uploading ? '上传中...' : '上传到媒体库'}
</Button>
<Button
type="button"
variant="outline"
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-library` : undefined}
onClick={() => setPickerOpen(true)}
>
<Images className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
onClick={() => setCompressBeforeUpload((current) => !current)}
>
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
</Button>
<Select
value={uploadTargetFormat}
onChange={(event) => setUploadTargetFormat(event.target.value as MediaUploadTargetFormat)}
disabled={!compressBeforeUpload}
className="min-w-[180px]"
>
<option value="avif"> AVIF</option>
<option value="webp"> WebP</option>
<option value="auto"></option>
</Select>
<Input
className="w-[92px]"
value={compressQuality}
onChange={(event) => setCompressQuality(event.target.value)}
placeholder="0.82"
disabled={!compressBeforeUpload}
/>
</div>
<div className="space-y-3">
<Input
value={remoteUrl}
onChange={(event) => setRemoteUrl(event.target.value)}
placeholder="https://example.com/cover.webp"
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-remote-url` : undefined}
/>
<div className="flex flex-wrap gap-3">
<div className="min-w-[220px] flex-1">
<Select
value={remoteTargetFormat}
onChange={(event) => setRemoteTargetFormat(event.target.value as RemoteTargetFormat)}
>
<option value="original"></option>
<option value="webp"> WebP</option>
<option value="avif"> AVIF</option>
</Select>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
disabled={!remoteUrl.trim() || downloadingRemote}
data-testid={dataTestIdPrefix ? `${dataTestIdPrefix}-remote-download` : undefined}
onClick={async () => {
if (!remoteUrl.trim()) {
toast.error('请先填写远程图片 URL。')
return
}
try {
setDownloadingRemote(true)
const result = await adminApi.downloadMediaObject({
sourceUrl: remoteUrl.trim(),
prefix,
targetFormat: remoteTargetFormat,
title: remoteTitle?.trim() || null,
sync: true,
})
if (!result.url) {
throw new Error(result.job_id ? `远程抓取已入队:#${result.job_id}` : '远程抓取完成但未返回 URL')
}
onChange(result.url)
setRemoteUrl('')
toast.success('远程素材已写入媒体库,并回填 URL。')
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: error instanceof Error
? error.message
: '远程抓取失败。',
)
} finally {
setDownloadingRemote(false)
}
}}
>
<Download className="h-4 w-4" />
{downloadingRemote ? '抓取中...' : '抓取到媒体库'}
</Button>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
/ URL
{value.trim() ? ' 当前已有值,可继续覆盖。' : ''}
</p>
</div>
<MediaLibraryPickerDialog
open={pickerOpen}
selectedUrl={value}
preferredPrefix={prefix}
onClose={() => setPickerOpen(false)}
onSelect={(item) => {
onChange(item.url)
toast.success('已从媒体库选中并回填 URL。')
}}
/>
</div>
)
}

View File

@@ -139,12 +139,14 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
const rect = trigger.getBoundingClientRect() const rect = trigger.getBoundingClientRect()
const viewportPadding = 12 const viewportPadding = 12
const gutter = 6 const gutter = 6
const minMenuWidth = 220
const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320) const estimatedHeight = Math.min(Math.max(options.length, 1) * 44 + 18, 320)
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding const spaceBelow = window.innerHeight - rect.bottom - viewportPadding
const spaceAbove = rect.top - viewportPadding const spaceAbove = rect.top - viewportPadding
const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow const openToTop = spaceBelow < estimatedHeight && spaceAbove > spaceBelow
const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320)) const maxHeight = Math.max(120, Math.min(openToTop ? spaceAbove : spaceBelow, 320))
const width = Math.min(rect.width, window.innerWidth - viewportPadding * 2) const maxAllowedWidth = window.innerWidth - viewportPadding * 2
const width = Math.min(Math.max(rect.width, minMenuWidth), maxAllowedWidth)
const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding) const left = Math.min(Math.max(rect.left, viewportPadding), window.innerWidth - width - viewportPadding)
setMenuPlacement(openToTop ? 'top' : 'bottom') setMenuPlacement(openToTop ? 'top' : 'bottom')

View File

@@ -13,6 +13,7 @@ import type {
AdminMediaUploadResponse, AdminMediaUploadResponse,
AdminPostCoverImageRequest, AdminPostCoverImageRequest,
AdminPostCoverImageResponse, AdminPostCoverImageResponse,
AdminPostLocalizeImagesResponse,
AdminDashboardResponse, AdminDashboardResponse,
AdminPostMetadataResponse, AdminPostMetadataResponse,
AdminPostPolishResponse, AdminPostPolishResponse,
@@ -453,11 +454,14 @@ export const adminApi = {
body: JSON.stringify({ body: JSON.stringify({
source_url: payload.sourceUrl, source_url: payload.sourceUrl,
prefix: payload.prefix, prefix: payload.prefix,
target_format:
payload.targetFormat && payload.targetFormat !== 'original' ? payload.targetFormat : null,
title: payload.title, title: payload.title,
alt_text: payload.altText, alt_text: payload.altText,
caption: payload.caption, caption: payload.caption,
tags: payload.tags, tags: payload.tags,
notes: payload.notes, notes: payload.notes,
sync: payload.sync ?? false,
}), }),
}), }),
updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) => updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) =>
@@ -488,6 +492,14 @@ export const adminApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ markdown }), body: JSON.stringify({ markdown }),
}), }),
localizePostMarkdownImages: (payload: { markdown: string; prefix?: string | null }) =>
request<AdminPostLocalizeImagesResponse>('/api/admin/posts/localize-images', {
method: 'POST',
body: JSON.stringify({
markdown: payload.markdown,
prefix: payload.prefix,
}),
}),
polishReviewDescription: (payload: AdminReviewPolishRequest) => polishReviewDescription: (payload: AdminReviewPolishRequest) =>
request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', { request<AdminReviewPolishResponse>('/api/admin/ai/polish-review', {
method: 'POST', method: 'POST',

View File

@@ -11,6 +11,13 @@ export interface CompressionResult {
preview: CompressionPreview | null preview: CompressionPreview | null
} }
export type MediaUploadTargetFormat = 'auto' | 'avif' | 'webp'
interface ProcessedVariant {
file: File
preview: CompressionPreview
}
interface ProcessImageOptions { interface ProcessImageOptions {
quality: number quality: number
maxWidth: number maxWidth: number
@@ -83,6 +90,427 @@ function deriveFileName(file: File, mimeType: string) {
return `processed${extension}` return `processed${extension}`
} }
function buildPreview(originalSize: number, compressedSize: number): CompressionPreview {
const savedBytes = originalSize - compressedSize
const savedRatio = originalSize > 0 ? savedBytes / originalSize : 0
return {
originalSize,
compressedSize,
savedBytes,
savedRatio,
}
}
function formatLabelForMimeType(mimeType: string) {
switch (mimeType) {
case 'image/avif':
return 'AVIF'
case 'image/webp':
return 'WebP'
case 'image/png':
return 'PNG'
default:
return 'JPEG'
}
}
function defaultPreferredFormats(file: File, coverMode = false) {
if (coverMode) {
return ['image/avif', 'image/webp', 'image/jpeg']
}
if (file.type === 'image/png') {
return ['image/png', 'image/webp', 'image/jpeg']
}
return ['image/webp', 'image/avif', 'image/jpeg']
}
function preferredFormatsForTarget(file: File, targetFormat: MediaUploadTargetFormat, coverMode = false) {
switch (targetFormat) {
case 'avif':
return ['image/avif', 'image/webp', 'image/jpeg']
case 'webp':
return ['image/webp', 'image/jpeg']
default:
return defaultPreferredFormats(file, coverMode)
}
}
async function buildProcessedVariants(file: File, options: ProcessImageOptions): Promise<ProcessedVariant[]> {
const variants: ProcessedVariant[] = []
const requestedFormats = Array.from(new Set(options.preferredFormats))
for (const format of requestedFormats) {
const processed = await processImage(file, {
...options,
preferredFormats: [format],
})
if (processed.type !== format) {
continue
}
if (variants.some((item) => item.file.type === processed.type)) {
continue
}
variants.push({
file: processed,
preview: buildPreview(file.size, processed.size),
})
}
return variants
}
function ensureImageChoiceDialogStyles() {
const styleId = 'termi-image-choice-dialog-style'
if (document.getElementById(styleId)) {
return
}
const style = document.createElement('style')
style.id = styleId
style.textContent = `
.termi-image-choice-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(6px);
}
.termi-image-choice-dialog {
width: min(680px, 100%);
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.96);
color: #0f172a;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.24);
overflow: hidden;
}
.termi-image-choice-header {
padding: 20px 22px 12px;
}
.termi-image-choice-title {
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.45;
}
.termi-image-choice-description {
margin: 8px 0 0;
color: #475569;
font-size: 14px;
line-height: 1.7;
}
.termi-image-choice-body {
padding: 0 22px 22px;
display: grid;
gap: 12px;
}
.termi-image-choice-note {
border-radius: 16px;
border: 1px solid rgba(59, 130, 246, 0.18);
background: rgba(239, 246, 255, 0.92);
color: #1d4ed8;
padding: 12px 14px;
font-size: 13px;
line-height: 1.7;
}
.termi-image-choice-option {
display: grid;
gap: 8px;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: #f8fafc;
padding: 14px 16px;
cursor: pointer;
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.termi-image-choice-option:hover {
border-color: rgba(37, 99, 235, 0.35);
transform: translateY(-1px);
}
.termi-image-choice-option.is-selected {
border-color: rgba(37, 99, 235, 0.52);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
background: rgba(239, 246, 255, 0.92);
}
.termi-image-choice-option-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.termi-image-choice-option-label {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
line-height: 1.5;
}
.termi-image-choice-option-label input {
margin: 0;
}
.termi-image-choice-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.termi-image-choice-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.termi-image-choice-badge--recommended {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.termi-image-choice-badge--neutral {
background: rgba(148, 163, 184, 0.14);
color: #475569;
}
.termi-image-choice-meta {
display: grid;
gap: 4px;
color: #475569;
font-size: 13px;
line-height: 1.65;
}
.termi-image-choice-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 0 22px 22px;
}
.termi-image-choice-button {
border: 0;
border-radius: 999px;
padding: 11px 18px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.termi-image-choice-button:hover {
transform: translateY(-1px);
}
.termi-image-choice-button--ghost {
background: rgba(148, 163, 184, 0.18);
color: #334155;
}
.termi-image-choice-button--primary {
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
box-shadow: 0 12px 32px rgba(37, 99, 235, 0.26);
}
`
document.head.appendChild(style)
}
async function showImageChoiceDialog(options: {
title: string
description: string
note?: string
choices: Array<{
id: string
title: string
meta: string[]
badge?: string
recommended?: boolean
}>
defaultChoiceId: string
confirmLabel?: string
cancelLabel?: string
}) {
ensureImageChoiceDialogStyles()
return new Promise<string>((resolve) => {
const overlay = document.createElement('div')
overlay.className = 'termi-image-choice-overlay'
const dialog = document.createElement('div')
dialog.className = 'termi-image-choice-dialog'
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
dialog.setAttribute('aria-label', options.title)
const header = document.createElement('div')
header.className = 'termi-image-choice-header'
header.innerHTML = `
<h3 class="termi-image-choice-title"></h3>
<p class="termi-image-choice-description"></p>
`
const titleEl = header.querySelector('.termi-image-choice-title')
const descriptionEl = header.querySelector('.termi-image-choice-description')
if (titleEl) titleEl.textContent = options.title
if (descriptionEl) descriptionEl.textContent = options.description
const body = document.createElement('div')
body.className = 'termi-image-choice-body'
if (options.note) {
const note = document.createElement('div')
note.className = 'termi-image-choice-note'
note.textContent = options.note
body.appendChild(note)
}
let selectedChoiceId = options.defaultChoiceId
const optionElements: HTMLElement[] = []
for (const choice of options.choices) {
const option = document.createElement('label')
option.className = 'termi-image-choice-option'
option.dataset.choiceId = choice.id
const top = document.createElement('div')
top.className = 'termi-image-choice-option-top'
const label = document.createElement('div')
label.className = 'termi-image-choice-option-label'
const input = document.createElement('input')
input.type = 'radio'
input.name = 'termi-image-choice'
input.value = choice.id
input.checked = choice.id === selectedChoiceId
const text = document.createElement('span')
text.textContent = choice.title
label.append(input, text)
const badges = document.createElement('div')
badges.className = 'termi-image-choice-badges'
if (choice.recommended) {
const recommended = document.createElement('span')
recommended.className = 'termi-image-choice-badge termi-image-choice-badge--recommended'
recommended.textContent = '推荐'
badges.appendChild(recommended)
}
if (choice.badge) {
const badge = document.createElement('span')
badge.className = 'termi-image-choice-badge termi-image-choice-badge--neutral'
badge.textContent = choice.badge
badges.appendChild(badge)
}
top.append(label, badges)
const meta = document.createElement('div')
meta.className = 'termi-image-choice-meta'
for (const line of choice.meta) {
const item = document.createElement('div')
item.textContent = line
meta.appendChild(item)
}
option.append(top, meta)
option.addEventListener('click', () => {
selectedChoiceId = choice.id
optionElements.forEach((element) => {
const checked = element.dataset.choiceId === selectedChoiceId
element.classList.toggle('is-selected', checked)
const radio = element.querySelector('input[type="radio"]') as HTMLInputElement | null
if (radio) {
radio.checked = checked
}
})
})
option.classList.toggle('is-selected', choice.id === selectedChoiceId)
optionElements.push(option)
body.appendChild(option)
}
const actions = document.createElement('div')
actions.className = 'termi-image-choice-actions'
const cancelButton = document.createElement('button')
cancelButton.type = 'button'
cancelButton.className = 'termi-image-choice-button termi-image-choice-button--ghost'
cancelButton.textContent = options.cancelLabel ?? '保留原图'
const confirmButton = document.createElement('button')
confirmButton.type = 'button'
confirmButton.className = 'termi-image-choice-button termi-image-choice-button--primary'
confirmButton.textContent = options.confirmLabel ?? '使用所选版本'
const cleanup = () => {
overlay.remove()
document.removeEventListener('keydown', handleKeyDown)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
cleanup()
resolve('original')
}
}
cancelButton.addEventListener('click', () => {
cleanup()
resolve('original')
})
confirmButton.addEventListener('click', () => {
cleanup()
resolve(selectedChoiceId)
})
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
cleanup()
resolve('original')
}
})
actions.append(cancelButton, confirmButton)
dialog.append(header, body, actions)
overlay.appendChild(dialog)
document.body.appendChild(overlay)
document.addEventListener('keydown', handleKeyDown)
const defaultInput = overlay.querySelector(
`input[value="${CSS.escape(options.defaultChoiceId)}"]`,
) as HTMLInputElement | null
defaultInput?.focus()
})
}
async function processImage(file: File, options: ProcessImageOptions): Promise<File> { async function processImage(file: File, options: ProcessImageOptions): Promise<File> {
if (!canTransformWithCanvas(file)) { if (!canTransformWithCanvas(file)) {
return file return file
@@ -161,33 +589,29 @@ async function maybeProcessImageWithPrompt(
const contextLabel = options?.contextLabel ?? '图片上传' const contextLabel = options?.contextLabel ?? '图片上传'
const forceProcessed = options?.forceProcessed ?? false const forceProcessed = options?.forceProcessed ?? false
const processOptions: ProcessImageOptions = {
quality,
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
preferredFormats:
options?.preferredFormats && options.preferredFormats.length
? options.preferredFormats
: file.type === 'image/png'
? ['image/png', 'image/webp', 'image/jpeg']
: ['image/webp', 'image/avif', 'image/jpeg'],
coverWidth: options?.coverWidth,
coverHeight: options?.coverHeight,
}
let processed: File let processed: File
try { try {
processed = await processImage(file, { processed = await processImage(file, processOptions)
quality,
maxWidth: Math.max(options?.maxWidth ?? 2200, 320),
maxHeight: Math.max(options?.maxHeight ?? 2200, 320),
preferredFormats:
options?.preferredFormats && options.preferredFormats.length
? options.preferredFormats
: file.type === 'image/png'
? ['image/png', 'image/webp', 'image/jpeg']
: ['image/webp', 'image/avif', 'image/jpeg'],
coverWidth: options?.coverWidth,
coverHeight: options?.coverHeight,
})
} catch { } catch {
return { file, usedCompressed: false, preview: null } return { file, usedCompressed: false, preview: null }
} }
const savedBytes = file.size - processed.size const preview = buildPreview(file.size, processed.size)
const savedRatio = file.size > 0 ? savedBytes / file.size : 0 const { savedRatio } = preview
const preview: CompressionPreview = {
originalSize: file.size,
compressedSize: processed.size,
savedBytes,
savedRatio,
}
if (!forceProcessed && processed.size >= file.size) { if (!forceProcessed && processed.size >= file.size) {
return { file, usedCompressed: false, preview } return { file, usedCompressed: false, preview }
@@ -201,30 +625,80 @@ async function maybeProcessImageWithPrompt(
return { file: processed, usedCompressed: true, preview } return { file: processed, usedCompressed: true, preview }
} }
const deltaText = let variants: ProcessedVariant[]
savedBytes >= 0 try {
? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)` variants = await buildProcessedVariants(file, processOptions)
: `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)` } catch {
variants = [
{
file: processed,
preview,
},
]
}
const intro = forceProcessed const selectableVariants = forceProcessed
? `${contextLabel}: 已生成规范化版本。` ? variants
: `${contextLabel}: 检测到可压缩空间。` : variants.filter((item) => item.file.size < file.size && item.preview.savedRatio >= minSavingsRatio)
const useProcessed = window.confirm( if (!selectableVariants.length) {
[ return { file, usedCompressed: false, preview }
intro, }
`原始: ${formatBytes(file.size)}`,
`处理后: ${formatBytes(processed.size)}`, const recommendedVariant = selectableVariants[0]
deltaText, const missingPreferredFormats = processOptions.preferredFormats.filter(
'', (format) => !variants.some((item) => item.file.type === format),
forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?',
].join('\n'),
) )
const note =
missingPreferredFormats.length > 0
? `当前环境未提供 ${missingPreferredFormats.map(formatLabelForMimeType).join(' / ')} 编码能力,因此这里只展示可实际生成的格式。`
: undefined
const choice = await showImageChoiceDialog({
title: forceProcessed ? `${contextLabel}:已生成规范化版本` : `${contextLabel}:检测到可压缩空间`,
description: forceProcessed
? '可以直接保留原图,也可以选择更适合上传的规范化版本。'
: '可以直接保留原图,也可以选择体积更合适的版本再上传。',
note,
choices: [
{
id: 'original',
title: `保留原图(${file.name}`,
meta: [
`当前文件: ${formatBytes(file.size)}`,
`格式: ${formatLabelForMimeType(file.type || 'image/jpeg')}`,
],
badge: '原图',
},
...selectableVariants.map((item, index) => {
const variantSavedBytes = item.preview.savedBytes
const variantSavedRatio = item.preview.savedRatio
return {
id: item.file.type,
title: `${formatLabelForMimeType(item.file.type)} 版本`,
meta: [
`处理后: ${formatBytes(item.file.size)}`,
variantSavedBytes >= 0
? `节省: ${formatBytes(variantSavedBytes)} (${(variantSavedRatio * 100).toFixed(1)}%)`
: `体积增加: ${formatBytes(Math.abs(variantSavedBytes))} (${Math.abs(variantSavedRatio * 100).toFixed(1)}%)`,
],
badge: item.file.name.replace(/^.*(\.[A-Za-z0-9]+)$/, '$1').toLowerCase(),
recommended: index === 0,
}
}),
],
defaultChoiceId: recommendedVariant.file.type,
confirmLabel: '使用所选版本',
cancelLabel: '保留原图',
})
const selectedVariant = selectableVariants.find((item) => item.file.type === choice)
const useProcessed = Boolean(selectedVariant)
return { return {
file: useProcessed ? processed : file, file: selectedVariant?.file ?? file,
usedCompressed: useProcessed, usedCompressed: useProcessed,
preview, preview: selectedVariant?.preview ?? preview,
} }
} }
@@ -235,6 +709,7 @@ export async function maybeCompressImageWithPrompt(
ask?: boolean ask?: boolean
minSavingsRatio?: number minSavingsRatio?: number
contextLabel?: string contextLabel?: string
preferredFormats?: string[]
}, },
): Promise<CompressionResult> { ): Promise<CompressionResult> {
return maybeProcessImageWithPrompt(file, options) return maybeProcessImageWithPrompt(file, options)
@@ -248,13 +723,14 @@ export async function normalizeCoverImageWithPrompt(
contextLabel?: string contextLabel?: string
width?: number width?: number
height?: number height?: number
preferredFormats?: string[]
}, },
): Promise<CompressionResult> { ): Promise<CompressionResult> {
return maybeProcessImageWithPrompt(file, { return maybeProcessImageWithPrompt(file, {
quality: options?.quality ?? 0.82, quality: options?.quality ?? 0.82,
ask: options?.ask ?? true, ask: options?.ask ?? true,
contextLabel: options?.contextLabel ?? '封面图规范化', contextLabel: options?.contextLabel ?? '封面图规范化',
preferredFormats: ['image/avif', 'image/webp', 'image/jpeg'], preferredFormats: options?.preferredFormats ?? ['image/avif', 'image/webp', 'image/jpeg'],
coverWidth: Math.max(options?.width ?? 1600, 640), coverWidth: Math.max(options?.width ?? 1600, 640),
coverHeight: Math.max(options?.height ?? 900, 360), coverHeight: Math.max(options?.height ?? 900, 360),
forceProcessed: true, forceProcessed: true,
@@ -262,6 +738,42 @@ export async function normalizeCoverImageWithPrompt(
}) })
} }
export async function prepareImageForUpload(
file: File,
options?: {
compress?: boolean
quality?: number
targetFormat?: MediaUploadTargetFormat
contextLabel?: string
mode?: 'image' | 'cover'
},
): Promise<CompressionResult> {
const compress = options?.compress ?? true
if (!compress) {
return { file, usedCompressed: false, preview: null }
}
const targetFormat = options?.targetFormat ?? 'auto'
const mode = options?.mode ?? 'image'
const preferredFormats = preferredFormatsForTarget(file, targetFormat, mode === 'cover')
if (mode === 'cover') {
return normalizeCoverImageWithPrompt(file, {
quality: options?.quality ?? 0.82,
ask: false,
contextLabel: options?.contextLabel ?? '封面图规范化上传',
preferredFormats,
})
}
return maybeCompressImageWithPrompt(file, {
quality: options?.quality ?? 0.82,
ask: false,
contextLabel: options?.contextLabel ?? '媒体上传',
preferredFormats,
})
}
export function formatCompressionPreview(preview: CompressionPreview | null) { export function formatCompressionPreview(preview: CompressionPreview | null) {
if (!preview) { if (!preview) {
return '' return ''

View File

@@ -374,6 +374,9 @@ export interface AdminSiteSettingsResponse {
location: string | null location: string | null
tech_stack: string[] tech_stack: string[]
music_playlist: MusicTrack[] music_playlist: MusicTrack[]
music_enabled: boolean
maintenance_mode_enabled: boolean
maintenance_access_code: string | null
ai_enabled: boolean ai_enabled: boolean
paragraph_comments_enabled: boolean paragraph_comments_enabled: boolean
comment_verification_mode: HumanVerificationMode comment_verification_mode: HumanVerificationMode
@@ -451,6 +454,9 @@ export interface SiteSettingsPayload {
location?: string | null location?: string | null
techStack?: string[] techStack?: string[]
musicPlaylist?: MusicTrack[] musicPlaylist?: MusicTrack[]
musicEnabled?: boolean
maintenanceModeEnabled?: boolean
maintenanceAccessCode?: string | null
aiEnabled?: boolean aiEnabled?: boolean
paragraphCommentsEnabled?: boolean paragraphCommentsEnabled?: boolean
commentVerificationMode?: HumanVerificationMode | null commentVerificationMode?: HumanVerificationMode | null
@@ -613,17 +619,23 @@ export interface AdminMediaReplaceResponse {
export interface MediaDownloadPayload { export interface MediaDownloadPayload {
sourceUrl: string sourceUrl: string
prefix?: string | null prefix?: string | null
targetFormat?: 'original' | 'webp' | 'avif' | null
title?: string | null title?: string | null
altText?: string | null altText?: string | null
caption?: string | null caption?: string | null
tags?: string[] tags?: string[]
notes?: string | null notes?: string | null
sync?: boolean
} }
export interface AdminMediaDownloadResponse { export interface AdminMediaDownloadResponse {
queued: boolean queued: boolean
job_id: number job_id: number | null
status: string status: string | null
key: string | null
url: string | null
size_bytes: number | null
content_type: string | null
} }
export interface MediaAssetMetadataPayload { export interface MediaAssetMetadataPayload {
@@ -754,6 +766,27 @@ export interface AdminPostPolishResponse {
polished_markdown: string polished_markdown: string
} }
export interface AdminPostLocalizeImagesFailure {
source_url: string
error: string
}
export interface AdminPostLocalizedImageItem {
source_url: string
localized_url: string
key: string
}
export interface AdminPostLocalizeImagesResponse {
markdown: string
detected_count: number
localized_count: number
uploaded_count: number
failed_count: number
items: AdminPostLocalizedImageItem[]
failures: AdminPostLocalizeImagesFailure[]
}
export interface AdminReviewPolishRequest { export interface AdminReviewPolishRequest {
title: string title: string
reviewType: string reviewType: string

View File

@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
import { toast } from 'sonner' import { toast } from 'sonner'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -302,14 +303,26 @@ export function CategoriesPage() {
placeholder="frontend-engineering" placeholder="frontend-engineering"
/> />
</FormField> </FormField>
<FormField label="封面图 URL" hint="可选,用于前台分类头图。"> <FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<Input <div className="space-y-3">
value={form.coverImage} <Input
onChange={(event) => value={form.coverImage}
setForm((current) => ({ ...current, coverImage: event.target.value })) onChange={(event) =>
} setForm((current) => ({ ...current, coverImage: event.target.value }))
placeholder="https://cdn.example.com/covers/frontend.jpg" }
/> placeholder="https://cdn.example.com/covers/frontend.jpg"
/>
<MediaUrlControls
value={form.coverImage}
onChange={(coverImage) =>
setForm((current) => ({ ...current, coverImage }))
}
prefix="category-covers/"
contextLabel="分类封面上传"
remoteTitle={form.name || form.slug || '分类封面'}
dataTestIdPrefix="category-cover"
/>
</div>
</FormField> </FormField>
<FormField label="强调色" hint="可选,用于前台分类详情强调色。"> <FormField label="强调色" hint="可选,用于前台分类详情强调色。">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
import { toast } from 'sonner' import { toast } from 'sonner'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -378,13 +379,25 @@ export function FriendLinksPage() {
} }
/> />
</FormField> </FormField>
<FormField label="头像 URL"> <FormField label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
<Input <div className="space-y-3">
value={form.avatarUrl} <Input
onChange={(event) => value={form.avatarUrl}
setForm((current) => ({ ...current, avatarUrl: event.target.value })) onChange={(event) =>
} setForm((current) => ({ ...current, avatarUrl: event.target.value }))
/> }
/>
<MediaUrlControls
value={form.avatarUrl}
onChange={(avatarUrl) =>
setForm((current) => ({ ...current, avatarUrl }))
}
prefix="friend-link-avatars/"
contextLabel="友链头像上传"
remoteTitle={form.siteName || form.siteUrl || '友链头像'}
dataTestIdPrefix="friend-link-avatar"
/>
</div>
</FormField> </FormField>
<FormField label="分类"> <FormField label="分类">
<Input <Input

View File

@@ -23,8 +23,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { adminApi, ApiError } from '@/lib/api' import { adminApi, ApiError } from '@/lib/api'
import { import {
formatCompressionPreview, formatCompressionPreview,
maybeCompressImageWithPrompt, prepareImageForUpload,
normalizeCoverImageWithPrompt, type MediaUploadTargetFormat,
} from '@/lib/image-compress' } from '@/lib/image-compress'
import type { AdminMediaObjectResponse } from '@/lib/types' import type { AdminMediaObjectResponse } from '@/lib/types'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
@@ -141,9 +141,11 @@ export function MediaPage() {
const [metadataSaving, setMetadataSaving] = useState(false) const [metadataSaving, setMetadataSaving] = useState(false)
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true) const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82') const [compressQuality, setCompressQuality] = useState('0.82')
const [uploadTargetFormat, setUploadTargetFormat] = useState<MediaUploadTargetFormat>('avif')
const [remoteDownloadForm, setRemoteDownloadForm] = useState<RemoteDownloadFormState>( const [remoteDownloadForm, setRemoteDownloadForm] = useState<RemoteDownloadFormState>(
defaultRemoteDownloadForm, defaultRemoteDownloadForm,
) )
const [remoteTargetFormat, setRemoteTargetFormat] = useState<'original' | 'webp' | 'avif'>('original')
const [downloadingRemote, setDownloadingRemote] = useState(false) const [downloadingRemote, setDownloadingRemote] = useState(false)
const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState<number | null>(null) const [lastRemoteDownloadJobId, setLastRemoteDownloadJobId] = useState<number | null>(null)
@@ -218,22 +220,18 @@ export function MediaPage() {
const quality = Number.parseFloat(compressQuality) const quality = Number.parseFloat(compressQuality)
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82 const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
const normalizeCover = const mode =
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/' ? 'cover' : 'image'
const result: File[] = [] const result: File[] = []
for (const file of files) { for (const file of files) {
const compressed = normalizeCover const compressed = await prepareImageForUpload(file, {
? await normalizeCoverImageWithPrompt(file, { compress: true,
quality: safeQuality, quality: safeQuality,
ask: true, targetFormat: uploadTargetFormat,
contextLabel: `封面规范化上传${file.name}`, contextLabel: `${mode === 'cover' ? '封面规范化上传' : '媒体库上传'}${file.name}`,
}) mode,
: await maybeCompressImageWithPrompt(file, { })
quality: safeQuality,
ask: true,
contextLabel: `媒体库上传(${file.name}`,
})
if (compressed.preview) { if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成') toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
} }
@@ -304,11 +302,23 @@ export function MediaPage() {
<option value="all"></option> <option value="all"></option>
<option value="post-covers/"></option> <option value="post-covers/"></option>
<option value="review-covers/"></option> <option value="review-covers/"></option>
<option value="category-covers/"></option>
<option value="tag-covers/"></option>
<option value="site-assets/"></option>
<option value="seo-assets/">SEO </option>
<option value="music-covers/"></option>
<option value="friend-link-avatars/"></option>
<option value="uploads/"></option> <option value="uploads/"></option>
</Select> </Select>
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}> <Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
<option value="post-covers/"></option> <option value="post-covers/"></option>
<option value="review-covers/"></option> <option value="review-covers/"></option>
<option value="category-covers/"></option>
<option value="tag-covers/"></option>
<option value="site-assets/"></option>
<option value="seo-assets/"> SEO </option>
<option value="music-covers/"></option>
<option value="friend-link-avatars/"></option>
<option value="uploads/"></option> <option value="uploads/"></option>
</Select> </Select>
<Input <Input
@@ -319,7 +329,7 @@ export function MediaPage() {
/> />
</div> </div>
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]"> <div className="grid gap-3 lg:grid-cols-[1fr_auto_180px_96px_auto]">
<Input <Input
data-testid="media-upload-input" data-testid="media-upload-input"
type="file" type="file"
@@ -338,6 +348,15 @@ export function MediaPage() {
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />} {compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
</Button> </Button>
<Select
value={uploadTargetFormat}
onChange={(event) => setUploadTargetFormat(event.target.value as MediaUploadTargetFormat)}
disabled={!compressBeforeUpload}
>
<option value="avif"> AVIF</option>
<option value="webp"> WebP</option>
<option value="auto"></option>
</Select>
<Input <Input
className="w-[96px]" className="w-[96px]"
value={compressQuality} value={compressQuality}
@@ -373,7 +392,7 @@ export function MediaPage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{uploadFiles.length} {uploadFiles.length}
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/' {uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。' ? ' 当前会自动裁切为 16:9 封面,并按上面的目标格式压缩。'
: ''} : ''}
</p> </p>
) : null} ) : null}
@@ -401,6 +420,19 @@ export function MediaPage() {
/> />
</FormField> </FormField>
<FormField label="抓取格式">
<Select
value={remoteTargetFormat}
onChange={(event) =>
setRemoteTargetFormat(event.target.value as 'original' | 'webp' | 'avif')
}
>
<option value="original"></option>
<option value="webp"> WebP</option>
<option value="avif"> AVIF</option>
</Select>
</FormField>
<FormField label="标题"> <FormField label="标题">
<Input <Input
data-testid="media-remote-title" data-testid="media-remote-title"
@@ -489,6 +521,7 @@ export function MediaPage() {
const result = await adminApi.downloadMediaObject({ const result = await adminApi.downloadMediaObject({
sourceUrl: remoteDownloadForm.sourceUrl.trim(), sourceUrl: remoteDownloadForm.sourceUrl.trim(),
prefix: uploadPrefix, prefix: uploadPrefix,
targetFormat: remoteTargetFormat,
title: remoteDownloadForm.title.trim() || null, title: remoteDownloadForm.title.trim() || null,
altText: remoteDownloadForm.altText.trim() || null, altText: remoteDownloadForm.altText.trim() || null,
caption: remoteDownloadForm.caption.trim() || null, caption: remoteDownloadForm.caption.trim() || null,
@@ -496,7 +529,11 @@ export function MediaPage() {
notes: remoteDownloadForm.notes.trim() || null, notes: remoteDownloadForm.notes.trim() || null,
}) })
setLastRemoteDownloadJobId(result.job_id) setLastRemoteDownloadJobId(result.job_id)
toast.success(`远程抓取任务已入队:#${result.job_id}`) toast.success(
result.job_id
? `远程抓取任务已入队:#${result.job_id}`
: '远程抓取请求已提交。',
)
setRemoteDownloadForm(defaultRemoteDownloadForm) setRemoteDownloadForm(defaultRemoteDownloadForm)
window.setTimeout(() => { window.setTimeout(() => {
void loadItems(false) void loadItems(false)

View File

@@ -14,7 +14,6 @@ import {
RotateCcw, RotateCcw,
Save, Save,
Trash2, Trash2,
Upload,
WandSparkles, WandSparkles,
X, X,
} from 'lucide-react' } from 'lucide-react'
@@ -24,6 +23,7 @@ import { toast } from 'sonner'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
import { LazyDiffEditor } from '@/components/lazy-monaco' import { LazyDiffEditor } from '@/components/lazy-monaco'
import { MediaUrlControls } from '@/components/media-url-controls'
import { MarkdownPreview } from '@/components/markdown-preview' import { MarkdownPreview } from '@/components/markdown-preview'
import { import {
MarkdownWorkbench, MarkdownWorkbench,
@@ -49,10 +49,6 @@ import {
formatPostVisibility, formatPostVisibility,
postTagsToList, postTagsToList,
} from '@/lib/admin-format' } from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document' import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff' import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge' import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
@@ -259,6 +255,17 @@ function buildVirtualPostPath(slug: string) {
return `article://posts/${normalizedSlug}` return `article://posts/${normalizedSlug}`
} }
function buildInlineImagePrefix(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
return `post-inline-images/${normalized || 'draft'}`
}
function parseImageList(value: string) { function parseImageList(value: string) {
return value return value
.split('\n') .split('\n')
@@ -808,8 +815,6 @@ export function PostsPage() {
const { slug } = useParams() const { slug } = useParams()
const importInputRef = useRef<HTMLInputElement | null>(null) const importInputRef = useRef<HTMLInputElement | null>(null)
const folderImportInputRef = useRef<HTMLInputElement | null>(null) const folderImportInputRef = useRef<HTMLInputElement | null>(null)
const editorCoverInputRef = useRef<HTMLInputElement | null>(null)
const createCoverInputRef = useRef<HTMLInputElement | null>(null)
const [posts, setPosts] = useState<PostRecord[]>([]) const [posts, setPosts] = useState<PostRecord[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
@@ -823,8 +828,8 @@ export function PostsPage() {
useState(false) useState(false)
const [generatingEditorCover, setGeneratingEditorCover] = useState(false) const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
const [generatingCreateCover, setGeneratingCreateCover] = useState(false) const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
const [uploadingEditorCover, setUploadingEditorCover] = useState(false) const [localizingEditorImages, setLocalizingEditorImages] = useState(false)
const [uploadingCreateCover, setUploadingCreateCover] = useState(false) const [localizingCreateImages, setLocalizingCreateImages] = useState(false)
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false) const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false) const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
const [editor, setEditor] = useState<PostFormState | null>(null) const [editor, setEditor] = useState<PostFormState | null>(null)
@@ -1457,67 +1462,89 @@ export function PostsPage() {
} }
}, [createForm]) }, [createForm])
const uploadEditorCover = useCallback(async (file: File) => { const localizeEditorMarkdownImages = useCallback(async () => {
try { if (!editor) {
setUploadingEditorCover(true) return
const compressed = await normalizeCoverImageWithPrompt(file, { }
quality: 0.82,
ask: true,
contextLabel: '文章封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
}
const result = await adminApi.uploadMediaObjects([compressed.file], { const sourceMarkdown = buildDraftMarkdownForWindow(editor)
prefix: 'post-covers/', if (!stripFrontmatter(sourceMarkdown).trim()) {
toast.error('先准备一点正文,再执行正文图片本地化。')
return
}
try {
setLocalizingEditorImages(true)
const result = await adminApi.localizePostMarkdownImages({
markdown: sourceMarkdown,
prefix: buildInlineImagePrefix(editor.slug),
}) })
const url = result.uploaded[0]?.url
if (!url) { if (!result.localized_count && !result.failed_count) {
throw new Error('上传完成但未返回 URL') toast.message('正文里没有检测到需要本地化的远程图片。')
return
} }
startTransition(() => { startTransition(() => {
setEditor((current) => (current ? { ...current, image: url } : current)) setEditor((current) =>
current ? applyPolishedEditorState(current, result.markdown) : current,
)
}) })
toast.success('封面已上传并回填。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
} finally {
setUploadingEditorCover(false)
}
}, [])
const uploadCreateCover = useCallback(async (file: File) => { if (result.localized_count && result.failed_count) {
try { toast.warning(
setUploadingCreateCover(true) `已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
const compressed = await normalizeCoverImageWithPrompt(file, { )
quality: 0.82, } else if (result.localized_count) {
ask: true, toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
contextLabel: '新建封面规范化上传', } else {
}) toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
} }
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
} finally {
setLocalizingEditorImages(false)
}
}, [editor])
const result = await adminApi.uploadMediaObjects([compressed.file], { const localizeCreateMarkdownImages = useCallback(async () => {
prefix: 'post-covers/', const sourceMarkdown = buildCreateMarkdownForWindow(createForm)
if (!stripFrontmatter(sourceMarkdown).trim()) {
toast.error('先准备一点正文,再执行正文图片本地化。')
return
}
try {
setLocalizingCreateImages(true)
const result = await adminApi.localizePostMarkdownImages({
markdown: sourceMarkdown,
prefix: buildInlineImagePrefix(createForm.slug || createForm.title),
}) })
const url = result.uploaded[0]?.url
if (!url) { if (!result.localized_count && !result.failed_count) {
throw new Error('上传完成但未返回 URL') toast.message('正文里没有检测到需要本地化的远程图片。')
return
} }
startTransition(() => { startTransition(() => {
setCreateForm((current) => ({ ...current, image: url })) setCreateForm((current) => applyPolishedCreateState(current, result.markdown))
}) })
toast.success('封面已上传并回填。')
if (result.localized_count && result.failed_count) {
toast.warning(
`已替换 ${result.localized_count} 处正文图片,另有 ${result.failed_count} 个链接处理失败。`,
)
} else if (result.localized_count) {
toast.success(`已把 ${result.localized_count} 处正文图片替换为媒体库地址。`)
} else {
toast.warning(`没有成功替换正文图片,失败 ${result.failed_count} 个链接。`)
}
} catch (error) { } catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。') toast.error(error instanceof ApiError ? error.message : '正文图片本地化失败。')
} finally { } finally {
setUploadingCreateCover(false) setLocalizingCreateImages(false)
} }
}, []) }, [createForm])
const openEditorPreviewWindow = useCallback(() => { const openEditorPreviewWindow = useCallback(() => {
const snapshot = buildEditorDraftSnapshot() const snapshot = buildEditorDraftSnapshot()
@@ -2087,32 +2114,6 @@ export function PostsPage() {
void importMarkdownFiles(event.target.files) void importMarkdownFiles(event.target.files)
}} }}
/> />
<input
ref={editorCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadEditorCover(file)
}
event.currentTarget.value = ''
}}
/>
<input
ref={createCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadCreateCover(file)
}
event.currentTarget.value = ''
}}
/>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3"> <div className="space-y-3">
@@ -2526,29 +2527,34 @@ export function PostsPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FormField label="封面图 URL"> <FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<Input <div className="space-y-3">
value={editor.image} <Input
onChange={(event) => value={editor.image}
setEditor((current) => onChange={(event) =>
current ? { ...current, image: event.target.value } : current, setEditor((current) =>
) current ? { ...current, image: event.target.value } : current,
} )
/> }
/>
<MediaUrlControls
value={editor.image}
onChange={(image) =>
setEditor((current) => (current ? { ...current, image } : current))
}
prefix="post-covers/"
contextLabel="文章封面上传"
mode="cover"
remoteTitle={editor.title || editor.slug || '文章封面'}
dataTestIdPrefix="post-editor-cover"
/>
</div>
</FormField> </FormField>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => editorCoverInputRef.current?.click()}
disabled={uploadingEditorCover}
>
<Upload className="h-4 w-4" />
{uploadingEditorCover ? '上传中...' : '上传封面'}
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => void generateEditorCover()} onClick={() => void generateEditorCover()}
disabled={generatingEditorCover || uploadingEditorCover} disabled={generatingEditorCover}
> >
<WandSparkles className="h-4 w-4" /> <WandSparkles className="h-4 w-4" />
{generatingEditorCover {generatingEditorCover
@@ -2703,6 +2709,14 @@ export function PostsPage() {
<WandSparkles className="h-4 w-4" /> <WandSparkles className="h-4 w-4" />
</Button> </Button>
<Button
variant="outline"
onClick={() => void localizeEditorMarkdownImages()}
disabled={saving || localizingEditorImages}
>
<Download className="h-4 w-4" />
{localizingEditorImages ? '本地化中...' : '正文图本地化'}
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@@ -2994,27 +3008,32 @@ export function PostsPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FormField label="封面图 URL"> <FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<Input <div className="space-y-3">
value={createForm.image} <Input
onChange={(event) => value={createForm.image}
setCreateForm((current) => ({ ...current, image: event.target.value })) onChange={(event) =>
} setCreateForm((current) => ({ ...current, image: event.target.value }))
/> }
/>
<MediaUrlControls
value={createForm.image}
onChange={(image) =>
setCreateForm((current) => ({ ...current, image }))
}
prefix="post-covers/"
contextLabel="新建文章封面上传"
mode="cover"
remoteTitle={createForm.title || createForm.slug || '文章封面'}
dataTestIdPrefix="post-create-cover"
/>
</div>
</FormField> </FormField>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => createCoverInputRef.current?.click()}
disabled={uploadingCreateCover}
>
<Upload className="h-4 w-4" />
{uploadingCreateCover ? '上传中...' : '上传封面'}
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => void generateCreateCover()} onClick={() => void generateCreateCover()}
disabled={generatingCreateCover || uploadingCreateCover} disabled={generatingCreateCover}
> >
<WandSparkles className="h-4 w-4" /> <WandSparkles className="h-4 w-4" />
{generatingCreateCover {generatingCreateCover
@@ -3150,6 +3169,14 @@ export function PostsPage() {
<WandSparkles className="h-4 w-4" /> <WandSparkles className="h-4 w-4" />
</Button> </Button>
<Button
variant="outline"
onClick={() => void localizeCreateMarkdownImages()}
disabled={creating || localizingCreateImages}
>
<Download className="h-4 w-4" />
{localizingCreateImages ? '本地化中...' : '正文图本地化'}
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {

View File

@@ -1,8 +1,9 @@
import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2, Upload } from 'lucide-react' import { BookOpenText, Bot, Check, RefreshCcw, RotateCcw, Save, Trash2 } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -18,10 +19,6 @@ import {
formatReviewType, formatReviewType,
reviewTagsToList, reviewTagsToList,
} from '@/lib/admin-format' } from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types' import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
type ReviewFormState = { type ReviewFormState = {
@@ -103,14 +100,12 @@ export function ReviewsPage() {
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [uploadingCover, setUploadingCover] = useState(false)
const [polishingDescription, setPolishingDescription] = useState(false) const [polishingDescription, setPolishingDescription] = useState(false)
const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>( const [descriptionPolish, setDescriptionPolish] = useState<ReviewDescriptionPolishState | null>(
null, null,
) )
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all')
const reviewCoverInputRef = useRef<HTMLInputElement | null>(null)
const loadReviews = useCallback(async (showToast = false) => { const loadReviews = useCallback(async (showToast = false) => {
try { try {
@@ -217,29 +212,6 @@ export function ReviewsPage() {
} }
}, [form]) }, [form])
const uploadReviewCover = useCallback(async (file: File) => {
try {
setUploadingCover(true)
const compressed = await normalizeCoverImageWithPrompt(file, {
quality: 0.82,
ask: true,
contextLabel: '评测封面规范化上传',
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview))
}
const result = await adminApi.uploadReviewCoverImage(compressed.file)
startTransition(() => {
setForm((current) => ({ ...current, cover: result.url }))
})
toast.success('评测封面已上传到 R2。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '评测封面上传失败。')
} finally {
setUploadingCover(false)
}
}, [])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
@@ -513,36 +485,21 @@ export function ReviewsPage() {
</FormField> </FormField>
<FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。"> <FormField label="封面 URL" hint="可直接填外链,也可以上传图片到 R2。">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row"> <Input
<Input value={form.cover}
value={form.cover} onChange={(event) =>
onChange={(event) => setForm((current) => ({ ...current, cover: event.target.value }))
setForm((current) => ({ ...current, cover: event.target.value })) }
} />
/> <MediaUrlControls
<input value={form.cover}
ref={reviewCoverInputRef} onChange={(cover) => setForm((current) => ({ ...current, cover }))}
type="file" prefix="review-covers/"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml" contextLabel="评测封面上传"
className="hidden" mode="cover"
onChange={(event) => { remoteTitle={form.title || '评测封面'}
const file = event.target.files?.[0] dataTestIdPrefix="review-cover"
if (file) { />
void uploadReviewCover(file)
}
event.target.value = ''
}}
/>
<Button
type="button"
variant="outline"
disabled={uploadingCover}
onClick={() => reviewCoverInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
{uploadingCover ? '上传中...' : '上传到 R2'}
</Button>
</div>
{form.cover ? ( {form.cover ? (
<div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70"> <div className="overflow-hidden rounded-[1.4rem] border border-border/70 bg-background/70">

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -132,6 +133,9 @@ function normalizeSettingsResponse(
web_push_vapid_public_key: input.web_push_vapid_public_key ?? null, web_push_vapid_public_key: input.web_push_vapid_public_key ?? null,
web_push_vapid_private_key: input.web_push_vapid_private_key ?? null, web_push_vapid_private_key: input.web_push_vapid_private_key ?? null,
web_push_vapid_subject: input.web_push_vapid_subject ?? null, web_push_vapid_subject: input.web_push_vapid_subject ?? null,
music_enabled: input.music_enabled ?? true,
maintenance_mode_enabled: input.maintenance_mode_enabled ?? false,
maintenance_access_code: input.maintenance_access_code ?? null,
ai_active_provider_id: ai_active_provider_id:
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null, input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
} }
@@ -177,6 +181,9 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
location: form.location, location: form.location,
techStack: form.tech_stack, techStack: form.tech_stack,
musicPlaylist: form.music_playlist, musicPlaylist: form.music_playlist,
musicEnabled: form.music_enabled,
maintenanceModeEnabled: form.maintenance_mode_enabled,
maintenanceAccessCode: form.maintenance_access_code,
aiEnabled: form.ai_enabled, aiEnabled: form.ai_enabled,
paragraphCommentsEnabled: form.paragraph_comments_enabled, paragraphCommentsEnabled: form.paragraph_comments_enabled,
commentVerificationMode: form.comment_verification_mode, commentVerificationMode: form.comment_verification_mode,
@@ -514,6 +521,11 @@ export function SiteSettingsPage() {
disabled={saving} disabled={saving}
data-testid="site-settings-save" data-testid="site-settings-save"
onClick={async () => { onClick={async () => {
if (form.maintenance_mode_enabled && !form.maintenance_access_code?.trim()) {
toast.error('开启维护模式前请先填写访问口令。')
return
}
try { try {
setSaving(true) setSaving(true)
const updated = await adminApi.updateSiteSettings(toPayload(form)) const updated = await adminApi.updateSiteSettings(toPayload(form))
@@ -607,11 +619,21 @@ export function SiteSettingsPage() {
onChange={(event) => updateField('owner_name', event.target.value)} onChange={(event) => updateField('owner_name', event.target.value)}
/> />
</Field> </Field>
<Field label="头像 URL"> <Field label="头像 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
<Input <div className="space-y-3">
value={form.owner_avatar_url ?? ''} <Input
onChange={(event) => updateField('owner_avatar_url', event.target.value)} value={form.owner_avatar_url ?? ''}
/> onChange={(event) => updateField('owner_avatar_url', event.target.value)}
/>
<MediaUrlControls
value={form.owner_avatar_url ?? ''}
onChange={(ownerAvatarUrl) => updateField('owner_avatar_url', ownerAvatarUrl)}
prefix="site-assets/"
contextLabel="站长头像上传"
remoteTitle={form.owner_name || form.site_name || '站长头像'}
dataTestIdPrefix="site-owner-avatar"
/>
</div>
</Field> </Field>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Field label="站长简介"> <Field label="站长简介">
@@ -765,6 +787,55 @@ export function SiteSettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
访线
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.maintenance_mode_enabled}
onChange={(event) =>
updateField('maintenance_mode_enabled', event.target.checked)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
访
</p>
</div>
</label>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto]">
<Field
label="访问口令"
hint="建议设置成临时口令后发给测试同事;修改口令后,旧口令拿到的访问凭证会自动失效。"
>
<Input
type="password"
value={form.maintenance_access_code ?? ''}
onChange={(event) =>
updateField('maintenance_access_code', event.target.value)
}
placeholder="例如staging-2026"
/>
</Field>
<div className="flex items-end">
<Badge variant={form.maintenance_mode_enabled ? 'warning' : 'outline'}>
{form.maintenance_mode_enabled ? '维护中' : '正常开放'}
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> / </CardTitle> <CardTitle> / </CardTitle>
@@ -844,11 +915,23 @@ export function SiteSettingsPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-2"> <CardContent className="grid gap-6 lg:grid-cols-2">
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退。"> <Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退,也支持上传 / 抓取 / 选择媒体库。">
<Input <div className="space-y-3">
value={form.seo_default_og_image ?? ''} <Input
onChange={(event) => updateField('seo_default_og_image', event.target.value)} value={form.seo_default_og_image ?? ''}
/> onChange={(event) => updateField('seo_default_og_image', event.target.value)}
/>
<MediaUrlControls
value={form.seo_default_og_image ?? ''}
onChange={(seoDefaultOgImage) =>
updateField('seo_default_og_image', seoDefaultOgImage)
}
prefix="seo-assets/"
contextLabel="默认 OG 图上传"
remoteTitle={form.site_name || form.site_title || '默认 OG 图'}
dataTestIdPrefix="site-default-og"
/>
</div>
</Field> </Field>
<Field label="Twitter / X Handle" hint="例如 @initcool。"> <Field label="Twitter / X Handle" hint="例如 @initcool。">
<Input <Input
@@ -1565,13 +1648,33 @@ export function SiteSettingsPage() {
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> <CardDescription>
</CardDescription> </CardDescription>
</div> </div>
<Badge variant="outline">{form.music_playlist.length} </Badge> <div className="flex flex-wrap items-center gap-2">
<Badge variant={form.music_enabled ? 'default' : 'outline'}>
{form.music_enabled ? '前台已开启' : '前台已关闭'}
</Badge>
<Badge variant="outline">{form.music_playlist.length} </Badge>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-5 pt-6"> <CardContent className="space-y-5 pt-6">
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.music_enabled}
onChange={(event) => updateField('music_enabled', event.target.checked)}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
</p>
</div>
</label>
<div className="space-y-3"> <div className="space-y-3">
{form.music_playlist.map((track, index) => { {form.music_playlist.map((track, index) => {
const active = index === selectedTrackIndex const active = index === selectedTrackIndex
@@ -1687,13 +1790,25 @@ export function SiteSettingsPage() {
} }
/> />
</Field> </Field>
<Field label="封面图 URL"> <Field label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取 / 从媒体库选择后自动回填。">
<Input <div className="space-y-3">
value={selectedTrack.cover_image_url ?? ''} <Input
onChange={(event) => value={selectedTrack.cover_image_url ?? ''}
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value) onChange={(event) =>
} updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
/> }
/>
<MediaUrlControls
value={selectedTrack.cover_image_url ?? ''}
onChange={(coverImageUrl) =>
updateMusicTrack(selectedTrackIndex, 'cover_image_url', coverImageUrl)
}
prefix="music-covers/"
contextLabel="音乐封面上传"
remoteTitle={selectedTrack.title || `曲目 ${selectedTrackIndex + 1} 封面`}
dataTestIdPrefix="site-music-cover"
/>
</div>
</Field> </Field>
<Field label="主题色" hint="例如 `#2f6b5f`,前台播放器会读取这个颜色。"> <Field label="主题色" hint="例如 `#2f6b5f`,前台播放器会读取这个颜色。">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -19,9 +19,12 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { formatBrowserName, formatDateTime } from '@/lib/admin-format'
import { adminApi, ApiError } from '@/lib/api' import { adminApi, ApiError } from '@/lib/api'
import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types' import type { NotificationDeliveryRecord, SubscriptionRecord, WorkerJobRecord } from '@/lib/types'
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger'
const CHANNEL_OPTIONS = [ const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' }, { value: 'email', label: 'Email' },
{ value: 'webhook', label: 'Webhook' }, { value: 'webhook', label: 'Webhook' },
@@ -72,6 +75,127 @@ function normalizePreview(value: unknown) {
return text || '—' return text || '—'
} }
function formatSubscriptionChannelLabel(channelType: string) {
switch (channelType) {
case 'web_push':
return '浏览器提醒'
case 'email':
return '邮件订阅'
case 'discord':
return 'Discord Webhook'
case 'telegram':
return 'Telegram Bot API'
case 'ntfy':
return 'ntfy'
case 'webhook':
return 'Webhook'
default:
return channelType
}
}
function readMetadataString(metadata: SubscriptionRecord['metadata'], key: string) {
const value = metadata?.[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function formatSubscriptionSource(source: string | null) {
switch (source) {
case 'frontend-popup':
return '前台订阅弹窗'
case 'manual':
return '后台手动添加'
case 'admin':
return '后台手动添加'
case 'import':
return '批量导入'
case 'seed':
return '初始化数据'
default:
return source ?? '未记录'
}
}
function formatSubscriptionPlatform(userAgent: string | null) {
if (!userAgent) {
return null
}
const ua = userAgent.toLowerCase()
if (ua.includes('android')) return 'Android'
if (ua.includes('iphone') || ua.includes('ipad') || ua.includes('ios')) return 'iOS'
if (ua.includes('windows')) return 'Windows'
if (ua.includes('mac os x') || ua.includes('macintosh')) return 'macOS'
if (ua.includes('linux')) return 'Linux'
return null
}
function formatPushEndpointHost(target: string) {
try {
const url = new URL(target)
return url.host || url.origin
} catch {
return null
}
}
function describeSubscriptionTarget(item: SubscriptionRecord) {
const createdAt = formatDateTime(item.created_at)
if (item.channel_type === 'web_push') {
const userAgent = readMetadataString(item.metadata, 'user_agent')
const browser = userAgent ? formatBrowserName(userAgent) : '浏览器信息未记录'
const platform = formatSubscriptionPlatform(userAgent)
const pushHost = formatPushEndpointHost(item.target)
return {
primary: platform ? `${browser} · ${platform}` : browser,
details: [
pushHost ? `推送节点:${pushHost}` : '推送地址:已隐藏完整链接',
`创建于:${createdAt}`,
],
title: item.target,
}
}
return {
primary: item.target,
details: [`创建于:${createdAt}`],
title: item.target,
}
}
function getSubscriptionSourceBadge(item: SubscriptionRecord): { label: string; variant: BadgeVariant } {
const source = readMetadataString(item.metadata, 'source')
const kind = readMetadataString(item.metadata, 'kind')
if (source === 'frontend-popup') {
return { label: '前台弹窗', variant: 'default' }
}
if (source === 'manual' || source === 'admin') {
return { label: '后台手动', variant: 'secondary' }
}
if (source === 'import' || source === 'seed') {
return { label: formatSubscriptionSource(source), variant: 'warning' }
}
if (kind === 'browser-push') {
return { label: '前台浏览器订阅', variant: 'default' }
}
if (kind === 'public-form') {
return { label: '前台邮箱订阅', variant: 'default' }
}
if (source) {
return { label: formatSubscriptionSource(source), variant: 'outline' }
}
return { label: '未记录来源', variant: 'outline' }
}
export function SubscriptionsPage() { export function SubscriptionsPage() {
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([]) const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([]) const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
@@ -84,6 +208,8 @@ export function SubscriptionsPage() {
const [workerJobs, setWorkerJobs] = useState<WorkerJobRecord[]>([]) const [workerJobs, setWorkerJobs] = useState<WorkerJobRecord[]>([])
const [lastActionJobId, setLastActionJobId] = useState<number | null>(null) const [lastActionJobId, setLastActionJobId] = useState<number | null>(null)
const [form, setForm] = useState(emptyForm()) const [form, setForm] = useState(emptyForm())
const [subscriptionSearch, setSubscriptionSearch] = useState('')
const [subscriptionChannelFilter, setSubscriptionChannelFilter] = useState('all')
const loadData = useCallback(async (showToast = false) => { const loadData = useCallback(async (showToast = false) => {
try { try {
@@ -131,6 +257,68 @@ export function SubscriptionsPage() {
[deliveries], [deliveries],
) )
const filteredSubscriptions = useMemo(() => {
const query = subscriptionSearch.trim().toLowerCase()
return subscriptions.filter((item) => {
if (subscriptionChannelFilter !== 'all' && item.channel_type !== subscriptionChannelFilter) {
return false
}
if (!query) {
return true
}
const sourceBadge = getSubscriptionSourceBadge(item)
const targetInfo = describeSubscriptionTarget(item)
const searchable = [
item.display_name,
item.target,
item.channel_type,
formatSubscriptionChannelLabel(item.channel_type),
sourceBadge.label,
targetInfo.primary,
...targetInfo.details,
readMetadataString(item.metadata, 'user_agent'),
readMetadataString(item.metadata, 'source'),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchable.includes(query)
})
}, [subscriptionChannelFilter, subscriptionSearch, subscriptions])
const groupedSubscriptions = useMemo(
() => [
{
key: 'web_push',
title: '浏览器提醒',
description: '默认主流程,授权后可直接收到站内更新提醒。',
badgeVariant: 'default' as BadgeVariant,
items: filteredSubscriptions.filter((item) => item.channel_type === 'web_push'),
},
{
key: 'email',
title: '邮件订阅',
description: '通常作为额外备份,确认邮箱后开始生效。',
badgeVariant: 'secondary' as BadgeVariant,
items: filteredSubscriptions.filter((item) => item.channel_type === 'email'),
},
{
key: 'other',
title: '其他渠道',
description: 'Webhook / Discord / Telegram / ntfy 等外部通知目标。',
badgeVariant: 'outline' as BadgeVariant,
items: filteredSubscriptions.filter(
(item) => item.channel_type !== 'web_push' && item.channel_type !== 'email',
),
},
].filter((group) => group.items.length > 0),
[filteredSubscriptions],
)
const deliveryJobMap = useMemo(() => { const deliveryJobMap = useMemo(() => {
const map = new Map<number, WorkerJobRecord>() const map = new Map<number, WorkerJobRecord>()
for (const item of workerJobs) { for (const item of workerJobs) {
@@ -177,6 +365,132 @@ export function SubscriptionsPage() {
} }
}, [editingId, form, loadData, resetForm]) }, [editingId, form, loadData, resetForm])
const renderSubscriptionRow = useCallback((item: SubscriptionRecord) => {
const targetInfo = describeSubscriptionTarget(item)
const sourceBadge = getSubscriptionSourceBadge(item)
return (
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{item.display_name ?? formatSubscriptionChannelLabel(item.channel_type)}
</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{item.channel_type}
</div>
</div>
</TableCell>
<TableCell className="max-w-[320px] break-words text-sm text-muted-foreground">
<div className="space-y-2" title={targetInfo.title}>
<div className="font-medium text-foreground">{targetInfo.primary}</div>
<div className="flex flex-wrap gap-2">
<Badge variant={sourceBadge.variant}>{sourceBadge.label}</Badge>
{item.channel_type === 'web_push' ? <Badge variant="outline"></Badge> : null}
</div>
{targetInfo.details.map((line) => (
<div key={line} className="text-xs text-muted-foreground/80">
{line}
</div>
))}
<div className="text-xs text-muted-foreground/80">
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
{item.status}
</Badge>
<div className="text-xs text-muted-foreground">
{item.failure_count ?? 0} · {item.last_delivery_status ?? '—'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
{normalizePreview(item.filters)}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
data-testid={`subscription-edit-${item.id}`}
onClick={() => {
setEditingId(item.id)
setForm({
channelType: item.channel_type,
target: item.target,
displayName: item.display_name ?? '',
status: item.status,
notes: item.notes ?? '',
filtersText: prettyJson(item.filters),
metadataText: prettyJson(item.metadata),
})
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-test-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)
const result = await adminApi.testSubscription(item.id)
if (result.job_id) {
setLastActionJobId(result.job_id)
}
toast.success(
result.job_id
? `测试通知已入队:#${result.job_id}`
: '测试通知已入队。',
)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
} finally {
setActioningId(null)
}
}}
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-delete-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.deleteSubscription(item.id)
toast.success('订阅目标已删除。')
if (editingId === item.id) {
resetForm()
}
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除失败。')
} finally {
setActioningId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
}, [actioningId, editingId, loadData, resetForm])
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -365,131 +679,91 @@ export function SubscriptionsPage() {
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div> <div>
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription> filters / metadata</CardDescription> <CardDescription> / / </CardDescription>
</div> </div>
<Badge variant="outline">{subscriptions.length} </Badge> <Badge variant="outline">
{filteredSubscriptions.length} / {subscriptions.length}
</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<Table> <div className="grid gap-4 rounded-2xl border border-border/70 bg-background/50 p-4 md:grid-cols-[minmax(0,1.2fr)_220px_auto] md:items-end">
<TableHeader> <div className="space-y-2">
<TableRow> <Label></Label>
<TableHead></TableHead> <Input
<TableHead></TableHead> value={subscriptionSearch}
<TableHead></TableHead> onChange={(event) => setSubscriptionSearch(event.target.value)}
<TableHead></TableHead> placeholder="搜索名称、地址、来源、浏览器、推送节点..."
<TableHead className="text-right"></TableHead> />
</TableRow> </div>
</TableHeader> <div className="space-y-2">
<TableBody> <Label></Label>
{subscriptions.map((item) => ( <Select
<TableRow key={item.id} data-testid={`subscription-row-${item.id}`}> value={subscriptionChannelFilter}
<TableCell> onChange={(event) => setSubscriptionChannelFilter(event.target.value)}
<div className="space-y-1"> >
<div className="font-medium">{item.display_name ?? item.channel_type}</div> <option value="all"></option>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"> {CHANNEL_OPTIONS.map((item) => (
{item.channel_type} <option key={item.value} value={item.value}>
</div> {formatSubscriptionChannelLabel(item.value)}
</option>
))}
</Select>
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
{(subscriptionSearch.trim() || subscriptionChannelFilter !== 'all') ? (
<Button
variant="outline"
size="sm"
onClick={() => {
setSubscriptionSearch('')
setSubscriptionChannelFilter('all')
}}
>
</Button>
) : null}
</div>
</div>
{subscriptions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : groupedSubscriptions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/80 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
groupedSubscriptions.map((group) => (
<div
key={group.key}
className="overflow-hidden rounded-2xl border border-border/70 bg-background/35"
>
<div className="flex flex-col gap-3 border-b border-border/60 px-4 py-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{group.title}</h3>
<Badge variant={group.badgeVariant}>{group.items.length} </Badge>
</div> </div>
</TableCell> <p className="text-sm text-muted-foreground">{group.description}</p>
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground"> </div>
<div>{item.target}</div> </div>
<div className="mt-1 text-xs text-muted-foreground/80">
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'} <Table>
</div> <TableHeader>
</TableCell> <TableRow>
<TableCell> <TableHead></TableHead>
<div className="space-y-1"> <TableHead></TableHead>
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}> <TableHead></TableHead>
{item.status} <TableHead></TableHead>
</Badge> <TableHead className="text-right"></TableHead>
<div className="text-xs text-muted-foreground"> </TableRow>
{item.failure_count ?? 0} · {item.last_delivery_status ?? '—'} </TableHeader>
</div> <TableBody>{group.items.map(renderSubscriptionRow)}</TableBody>
</div> </Table>
</TableCell> </div>
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground"> ))
{normalizePreview(item.filters)} )}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
data-testid={`subscription-edit-${item.id}`}
onClick={() => {
setEditingId(item.id)
setForm({
channelType: item.channel_type,
target: item.target,
displayName: item.display_name ?? '',
status: item.status,
notes: item.notes ?? '',
filtersText: prettyJson(item.filters),
metadataText: prettyJson(item.metadata),
})
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-test-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)
const result = await adminApi.testSubscription(item.id)
if (result.job_id) {
setLastActionJobId(result.job_id)
}
toast.success(
result.job_id
? `测试通知已入队:#${result.job_id}`
: '测试通知已入队。',
)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
} finally {
setActioningId(null)
}
}}
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={actioningId === item.id}
data-testid={`subscription-delete-${item.id}`}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.deleteSubscription(item.id)
toast.success('订阅目标已删除。')
if (editingId === item.id) {
resetForm()
}
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除失败。')
} finally {
setActioningId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from 'reac
import { toast } from 'sonner' import { toast } from 'sonner'
import { FormField } from '@/components/form-field' import { FormField } from '@/components/form-field'
import { MediaUrlControls } from '@/components/media-url-controls'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -302,14 +303,26 @@ export function TagsPage() {
placeholder="astro" placeholder="astro"
/> />
</FormField> </FormField>
<FormField label="封面图 URL" hint="可选,用于前台标签头图。"> <FormField label="封面图 URL" hint="可直接填写外链,也可以上传 / 抓取到媒体库后自动回填。">
<Input <div className="space-y-3">
value={form.coverImage} <Input
onChange={(event) => value={form.coverImage}
setForm((current) => ({ ...current, coverImage: event.target.value })) onChange={(event) =>
} setForm((current) => ({ ...current, coverImage: event.target.value }))
placeholder="https://cdn.example.com/covers/astro.jpg" }
/> placeholder="https://cdn.example.com/covers/astro.jpg"
/>
<MediaUrlControls
value={form.coverImage}
onChange={(coverImage) =>
setForm((current) => ({ ...current, coverImage }))
}
prefix="tag-covers/"
contextLabel="标签封面上传"
remoteTitle={form.name || form.slug || '标签封面'}
dataTestIdPrefix="tag-cover"
/>
</div>
</FormField> </FormField>
<FormField label="强调色" hint="可选,用于标签专题头部强调色。"> <FormField label="强调色" hint="可选,用于标签专题头部强调色。">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

2
backend/Cargo.lock generated
View File

@@ -6972,6 +6972,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"fastembed", "fastembed",
"image",
"include_dir", "include_dir",
"insta", "insta",
"loco-rs", "loco-rs",
@@ -6984,6 +6985,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"serial_test", "serial_test",
"sha2",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",

View File

@@ -43,9 +43,11 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
fastembed = "5.1" fastembed = "5.1"
async-stream = "0.3" async-stream = "0.3"
base64 = "0.22" base64 = "0.22"
image = { version = "0.25.10", default-features = false, features = ["avif", "gif", "jpeg", "png", "webp"] }
aws-config = "1" aws-config = "1"
aws-sdk-s3 = "1" aws-sdk-s3 = "1"
web-push = { version = "0.11.0", default-features = false, features = ["hyper-client"] } web-push = { version = "0.11.0", default-features = false, features = ["hyper-client"] }
sha2 = "0.10"
[[bin]] [[bin]]
name = "termi_api-cli" name = "termi_api-cli"

View File

@@ -2,35 +2,35 @@
pid: 1 pid: 1
author: "林川" author: "林川"
email: "linchuan@example.com" email: "linchuan@example.com"
content: "这篇做长文测试很合适,段落密度和古文节奏都不错。" content: "这篇读起来很稳,段落密度和古文节奏都很舒服。"
approved: true approved: true
- id: 2 - id: 2
pid: 1 pid: 1
author: "阿青" author: "阿青"
email: "aqing@example.com" email: "aqing@example.com"
content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。" content: "建议后面再加几篇山水游记,读者会更容易比较不同山名与路线。"
approved: true approved: true
- id: 3 - id: 3
pid: 2 pid: 2
author: "周宁" author: "周宁"
email: "zhouling@example.com" email: "zhouling@example.com"
content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。" content: "这一段关于南岩和琼台的描写很好,细节很有画面感。"
approved: true approved: true
- id: 4 - id: 4
pid: 3 pid: 3
author: "顾远" author: "顾远"
email: "guyuan@example.com" email: "guyuan@example.com"
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。" content: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。"
approved: true approved: true
- id: 5 - id: 5
pid: 4 pid: 4
author: "清嘉" author: "清嘉"
email: "qingjia@example.com" email: "qingjia@example.com"
content: "黄山记的序文很适合测试首屏摘要生成。" content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。"
approved: true approved: true
- id: 6 - id: 6

View File

@@ -10,7 +10,7 @@
自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。 自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。
骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。 骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。
excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。" excerpt: "《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。"
category: "古籍游记" category: "古籍游记"
published: true published: true
pinned: true pinned: true
@@ -18,7 +18,7 @@
- 徐霞客 - 徐霞客
- 游记 - 游记
- 太和山 - 太和山
- 长文测试 - 山水游记
- id: 2 - id: 2
pid: 2 pid: 2
@@ -40,7 +40,7 @@
- 徐霞客 - 徐霞客
- 游记 - 游记
- 太和山 - 太和山
- 长文测试 - 山水游记
- id: 3 - id: 3
pid: 3 pid: 3
@@ -54,7 +54,7 @@
余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。 余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。 三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。" excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。"
category: "古籍游记" category: "古籍游记"
published: true published: true
pinned: false pinned: false
@@ -62,7 +62,7 @@
- 徐霞客 - 徐霞客
- 恒山 - 恒山
- 悬空寺 - 悬空寺
- 长文测试 - 山水游记
- id: 4 - id: 4
pid: 4 pid: 4
@@ -84,7 +84,7 @@
- 钱谦益 - 钱谦益
- 黄山 - 黄山
- 游记 - 游记
- 长文测试 - 山水游记
- id: 5 - id: 5
pid: 5 pid: 5
@@ -98,7 +98,7 @@
憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。 憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。 清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。
excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。" excerpt: "钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。"
category: "古籍游记" category: "古籍游记"
published: true published: true
pinned: false pinned: false
@@ -106,4 +106,4 @@
- 钱谦益 - 钱谦益
- 黄山 - 黄山
- 游记 - 游记
- 长文测试 - 山水游记

View File

@@ -34,7 +34,7 @@
rating: 5 rating: 5
review_date: "2024-02-18" review_date: "2024-02-18"
status: "published" status: "published"
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。" description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。"
tags: ["经济", "非虚构", "中国"] tags: ["经济", "非虚构", "中国"]
cover: "/review-covers/placed-within.svg" cover: "/review-covers/placed-within.svg"

View File

@@ -2,10 +2,10 @@
site_name: "InitCool" site_name: "InitCool"
site_short_name: "Termi" site_short_name: "Termi"
site_url: "https://init.cool" site_url: "https://init.cool"
site_title: "InitCool · 中文长文与 AI 搜索实验站" site_title: "InitCool · 技术笔记与内容档案"
site_description: "一个偏终端审美的中文内容站用来测试文章检索、AI 问答、段落评论与后台工作流。" site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
hero_title: "欢迎来到我的中文内容实验站" hero_title: "欢迎来到 InitCool"
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验" hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
owner_name: "InitCool" owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool" owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
@@ -43,6 +43,9 @@
cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80" cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
accent_color: "#375a7f" accent_color: "#375a7f"
description: "节奏更明显一点,适合切换阅读状态。" description: "节奏更明显一点,适合切换阅读状态。"
music_enabled: true
maintenance_mode_enabled: false
maintenance_access_code: null
ai_enabled: false ai_enabled: false
paragraph_comments_enabled: true paragraph_comments_enabled: true
comment_verification_mode: "captcha" comment_verification_mode: "captcha"

View File

@@ -12,7 +12,7 @@ tags:
- 徐霞客 - 徐霞客
- 游记 - 游记
- 太和山 - 太和山
- 长文测试 - 山水游记
--- ---
# 徐霞客游记·游太和山日记(下) # 徐霞客游记·游太和山日记(下)

View File

@@ -1,7 +1,7 @@
--- ---
title: 游黄山记(中) title: 游黄山记(中)
slug: loco-rs-framework slug: loco-rs-framework
description: 钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点 description: 钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔
category: 古籍游记 category: 古籍游记
post_type: article post_type: article
pinned: false pinned: false
@@ -12,7 +12,7 @@ tags:
- 钱谦益 - 钱谦益
- 黄山 - 黄山
- 游记 - 游记
- 长文测试 - 山水游记
--- ---
# 游黄山记(中) # 游黄山记(中)

View File

@@ -1,7 +1,7 @@
--- ---
title: 徐霞客游记·游恒山日记 title: 徐霞客游记·游恒山日记
slug: rust-programming-tips slug: rust-programming-tips
description: 游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试 description: 游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明
category: 古籍游记 category: 古籍游记
post_type: article post_type: article
pinned: false pinned: false
@@ -12,7 +12,7 @@ tags:
- 徐霞客 - 徐霞客
- 恒山 - 恒山
- 悬空寺 - 悬空寺
- 长文测试 - 山水游记
--- ---
# 徐霞客游记·游恒山日记 # 徐霞客游记·游恒山日记

View File

@@ -12,7 +12,7 @@ tags:
- 钱谦益 - 钱谦益
- 黄山 - 黄山
- 游记 - 游记
- 长文测试 - 山水游记
--- ---
# 游黄山记(上) # 游黄山记(上)

View File

@@ -1,7 +1,7 @@
--- ---
title: 徐霞客游记·游太和山日记(上) title: 徐霞客游记·游太和山日记(上)
slug: welcome-to-termi slug: welcome-to-termi
description: 《徐霞客游记》太和山上篇,适合作为中文长文测试样本 description: 《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读
category: 古籍游记 category: 古籍游记
post_type: article post_type: article
pinned: true pinned: true
@@ -12,7 +12,7 @@ tags:
- 徐霞客 - 徐霞客
- 游记 - 游记
- 太和山 - 太和山
- 长文测试 - 山水游记
--- ---
# 徐霞客游记·游太和山日记(上) # 徐霞客游记·游太和山日记(上)

View File

@@ -45,6 +45,8 @@ mod m20260401_000034_add_source_markdown_to_posts;
mod m20260401_000035_add_human_verification_modes_to_site_settings; mod m20260401_000035_add_human_verification_modes_to_site_settings;
mod m20260402_000036_create_worker_jobs; mod m20260402_000036_create_worker_jobs;
mod m20260402_000037_add_wechat_share_qr_setting_to_site_settings; mod m20260402_000037_add_wechat_share_qr_setting_to_site_settings;
mod m20260402_000038_add_music_enabled_to_site_settings;
mod m20260402_000039_add_maintenance_mode_to_site_settings;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -94,6 +96,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration), Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration),
Box::new(m20260402_000036_create_worker_jobs::Migration), Box::new(m20260402_000036_create_worker_jobs::Migration),
Box::new(m20260402_000037_add_wechat_share_qr_setting_to_site_settings::Migration), Box::new(m20260402_000037_add_wechat_share_qr_setting_to_site_settings::Migration),
Box::new(m20260402_000038_add_music_enabled_to_site_settings::Migration),
Box::new(m20260402_000039_add_maintenance_mode_to_site_settings::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,46 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager.has_column("site_settings", "music_enabled").await? {
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("music_enabled"))
.boolean()
.null()
.default(true),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if manager.has_column("site_settings", "music_enabled").await? {
manager
.alter_table(
Table::alter()
.table(table)
.drop_column(Alias::new("music_enabled"))
.to_owned(),
)
.await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,84 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager
.has_column("site_settings", "maintenance_mode_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("maintenance_mode_enabled"))
.boolean()
.null()
.default(false),
)
.to_owned(),
)
.await?;
}
if !manager
.has_column("site_settings", "maintenance_access_code")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.add_column(
ColumnDef::new(Alias::new("maintenance_access_code"))
.text()
.null(),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if manager
.has_column("site_settings", "maintenance_access_code")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.drop_column(Alias::new("maintenance_access_code"))
.to_owned(),
)
.await?;
}
if manager
.has_column("site_settings", "maintenance_mode_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.drop_column(Alias::new("maintenance_mode_enabled"))
.to_owned(),
)
.await?;
}
Ok(())
}
}

View File

@@ -1,18 +1,18 @@
use async_trait::async_trait; use async_trait::async_trait;
use axum::{ use axum::{
http::{header, HeaderName, Method},
Router as AxumRouter, Router as AxumRouter,
http::{HeaderName, Method, header},
}; };
use loco_rs::{ use loco_rs::{
Result,
app::{AppContext, Hooks, Initializer}, app::{AppContext, Hooks, Initializer},
bgworker::{BackgroundWorker, Queue}, bgworker::{BackgroundWorker, Queue},
boot::{create_app, BootResult, StartMode}, boot::{BootResult, StartMode, create_app},
config::Config, config::Config,
controller::AppRoutes, controller::AppRoutes,
db::{self, truncate_table}, db::{self, truncate_table},
environment::Environment, environment::Environment,
task::Tasks, task::Tasks,
Result,
}; };
use migration::Migrator; use migration::Migrator;
use sea_orm::{ use sea_orm::{
@@ -99,7 +99,9 @@ impl Hooks for App {
} }
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> { async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![Box::new(initializers::content_sync::ContentSyncInitializer)]) Ok(vec![Box::new(
initializers::content_sync::ContentSyncInitializer,
)])
} }
fn routes(_ctx: &AppContext) -> AppRoutes { fn routes(_ctx: &AppContext) -> AppRoutes {
@@ -152,7 +154,9 @@ impl Hooks for App {
} }
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?; queue.register(DownloadWorker::build(ctx)).await?;
queue.register(NotificationDeliveryWorker::build(ctx)).await?; queue
.register(NotificationDeliveryWorker::build(ctx))
.await?;
Ok(()) Ok(())
} }
@@ -334,8 +338,7 @@ impl Hooks for App {
let comment_verification_mode = settings["comment_verification_mode"] let comment_verification_mode = settings["comment_verification_mode"]
.as_str() .as_str()
.map(ToString::to_string); .map(ToString::to_string);
let subscription_verification_mode = settings let subscription_verification_mode = settings["subscription_verification_mode"]
["subscription_verification_mode"]
.as_str() .as_str()
.map(ToString::to_string); .map(ToString::to_string);
let comment_turnstile_enabled = settings["comment_turnstile_enabled"] let comment_turnstile_enabled = settings["comment_turnstile_enabled"]
@@ -343,8 +346,7 @@ impl Hooks for App {
.or(comment_verification_mode .or(comment_verification_mode
.as_deref() .as_deref()
.map(|value| value.eq_ignore_ascii_case("turnstile"))); .map(|value| value.eq_ignore_ascii_case("turnstile")));
let subscription_turnstile_enabled = settings let subscription_turnstile_enabled = settings["subscription_turnstile_enabled"]
["subscription_turnstile_enabled"]
.as_bool() .as_bool()
.or(subscription_verification_mode .or(subscription_verification_mode
.as_deref() .as_deref()
@@ -381,6 +383,20 @@ impl Hooks for App {
}) })
.filter(|items| !items.is_empty()) .filter(|items| !items.is_empty())
.map(serde_json::Value::Array); .map(serde_json::Value::Array);
let music_enabled = settings["music_enabled"].as_bool().or(Some(true));
let maintenance_mode_enabled = settings["maintenance_mode_enabled"]
.as_bool()
.or(Some(false));
let maintenance_access_code = settings["maintenance_access_code"]
.as_str()
.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let item = site_settings::ActiveModel { let item = site_settings::ActiveModel {
id: Set(settings["id"].as_i64().unwrap_or(1) as i32), id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
@@ -422,6 +438,9 @@ impl Hooks for App {
location: Set(settings["location"].as_str().map(ToString::to_string)), location: Set(settings["location"].as_str().map(ToString::to_string)),
tech_stack: Set(tech_stack), tech_stack: Set(tech_stack),
music_playlist: Set(music_playlist), music_playlist: Set(music_playlist),
music_enabled: Set(music_enabled),
maintenance_mode_enabled: Set(maintenance_mode_enabled),
maintenance_access_code: Set(maintenance_access_code),
ai_enabled: Set(settings["ai_enabled"].as_bool()), ai_enabled: Set(settings["ai_enabled"].as_bool()),
paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"] paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"]
.as_bool() .as_bool()

View File

@@ -1,4 +1,4 @@
use axum::http::{header, HeaderMap}; use axum::http::{HeaderMap, header};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use serde::Serialize; use serde::Serialize;
use std::{ use std::{
@@ -75,7 +75,8 @@ fn header_value(headers: &HeaderMap, key: &'static str) -> Option<String> {
} }
fn split_groups(value: Option<String>) -> Vec<String> { fn split_groups(value: Option<String>) -> Vec<String> {
value.unwrap_or_default() value
.unwrap_or_default()
.split([',', ';', ' ']) .split([',', ';', ' '])
.map(str::trim) .map(str::trim)
.filter(|item| !item.is_empty()) .filter(|item| !item.is_empty())
@@ -192,8 +193,7 @@ pub(crate) fn resolve_admin_identity(headers: &HeaderMap) -> Option<AdminIdentit
} }
pub(crate) fn check_auth(headers: &HeaderMap) -> Result<AdminIdentity> { pub(crate) fn check_auth(headers: &HeaderMap) -> Result<AdminIdentity> {
resolve_admin_identity(headers) resolve_admin_identity(headers).ok_or_else(|| Error::Unauthorized("Not logged in".to_string()))
.ok_or_else(|| Error::Unauthorized("Not logged in".to_string()))
} }
pub(crate) fn start_local_session(username: &str) -> (AdminIdentity, String, String) { pub(crate) fn start_local_session(username: &str) -> (AdminIdentity, String, String) {

View File

@@ -1,8 +1,12 @@
use std::collections::{HashMap, HashSet};
use axum::{ use axum::{
extract::{Multipart, Query}, extract::{Multipart, Query},
http::{HeaderMap, header}, http::{HeaderMap, header},
}; };
use loco_rs::prelude::*; use loco_rs::prelude::*;
use regex::Regex;
use reqwest::Url;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect, Set, QueryOrder, QuerySelect, Set,
@@ -25,7 +29,7 @@ use crate::{
services::{ services::{
admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs, admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs,
}, },
workers::downloader::DownloadWorkerArgs, workers::downloader::{DownloadWorkerArgs, download_media_to_storage, normalize_target_format},
}; };
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -171,6 +175,9 @@ pub struct AdminSiteSettingsResponse {
pub location: Option<String>, pub location: Option<String>,
pub tech_stack: Vec<String>, pub tech_stack: Vec<String>,
pub music_playlist: Vec<site_settings::MusicTrackPayload>, pub music_playlist: Vec<site_settings::MusicTrackPayload>,
pub music_enabled: bool,
pub maintenance_mode_enabled: bool,
pub maintenance_access_code: Option<String>,
pub ai_enabled: bool, pub ai_enabled: bool,
pub paragraph_comments_enabled: bool, pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String, pub comment_verification_mode: String,
@@ -356,6 +363,8 @@ pub struct AdminMediaDownloadPayload {
#[serde(default)] #[serde(default)]
pub prefix: Option<String>, pub prefix: Option<String>,
#[serde(default)] #[serde(default)]
pub target_format: Option<String>,
#[serde(default)]
pub title: Option<String>, pub title: Option<String>,
#[serde(default)] #[serde(default)]
pub alt_text: Option<String>, pub alt_text: Option<String>,
@@ -365,13 +374,19 @@ pub struct AdminMediaDownloadPayload {
pub tags: Option<Vec<String>>, pub tags: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub notes: Option<String>, pub notes: Option<String>,
#[serde(default)]
pub sync: bool,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDownloadResponse { pub struct AdminMediaDownloadResponse {
pub queued: bool, pub queued: bool,
pub job_id: i32, pub job_id: Option<i32>,
pub status: String, pub status: Option<String>,
pub key: Option<String>,
pub url: Option<String>,
pub size_bytes: Option<i64>,
pub content_type: Option<String>,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -487,6 +502,37 @@ pub struct AdminPostPolishRequest {
pub markdown: String, pub markdown: String,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostLocalizeImagesRequest {
pub markdown: String,
#[serde(default)]
pub prefix: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizedImageItem {
pub source_url: String,
pub localized_url: String,
pub key: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesFailure {
pub source_url: String,
pub error: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesResponse {
pub markdown: String,
pub detected_count: usize,
pub localized_count: usize,
pub uploaded_count: usize,
pub failed_count: usize,
pub items: Vec<AdminPostLocalizedImageItem>,
pub failures: Vec<AdminPostLocalizeImagesFailure>,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AdminReviewPolishRequest { pub struct AdminReviewPolishRequest {
pub title: String, pub title: String,
@@ -537,6 +583,199 @@ fn trim_to_option(value: Option<String>) -> Option<String> {
}) })
} }
fn normalize_localize_image_prefix(value: Option<String>) -> String {
trim_to_option(value)
.map(|item| item.trim_matches('/').to_string())
.filter(|item| !item.is_empty())
.unwrap_or_else(|| "post-inline-images".to_string())
}
fn normalize_markdown_image_target(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('<') && trimmed.ends_with('>') && trimmed.len() > 2 {
Some(trimmed[1..trimmed.len() - 1].trim().to_string())
} else {
Some(trimmed.to_string())
}
}
fn markdown_image_reference_urls(markdown: &str) -> Vec<String> {
let markdown_pattern =
Regex::new(r#"!\[[^\]]*]\((?P<url><[^>\n]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)"#)
.expect("valid markdown image regex");
let html_double_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*"(?P<url>[^"]+)""#)
.expect("valid html img double quote regex");
let html_single_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*'(?P<url>[^']+)'"#)
.expect("valid html img single quote regex");
let mut urls = Vec::new();
for captures in markdown_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
for captures in html_double_quote_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
for captures in html_single_quote_pattern.captures_iter(markdown) {
if let Some(url) = captures
.name("url")
.and_then(|item| normalize_markdown_image_target(item.as_str()))
{
urls.push(url);
}
}
urls
}
fn is_remote_markdown_image_candidate(
url: &str,
settings: Option<&storage::MediaStorageSettings>,
) -> bool {
let Ok(parsed) = Url::parse(url) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
if settings
.and_then(|item| storage::object_key_from_public_url(item, url))
.is_some()
{
return false;
}
true
}
fn replace_markdown_image_urls(
markdown: &str,
replacements: &HashMap<String, String>,
) -> (String, usize) {
let markdown_pattern = Regex::new(
r#"(?P<lead>!\[[^\]]*]\()(?P<url><[^>\n]+>|[^)\s]+)(?P<trail>(?:\s+(?:"[^"]*"|'[^']*'))?\))"#,
)
.expect("valid markdown image replacement regex");
let html_double_quote_pattern =
Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*")(?P<url>[^"]+)(?P<trail>"[^>]*>)"#)
.expect("valid html img double quote replacement regex");
let html_single_quote_pattern =
Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*')(?P<url>[^']+)(?P<trail>'[^>]*>)"#)
.expect("valid html img single quote replacement regex");
let mut localized_count = 0usize;
let after_markdown = markdown_pattern
.replace_all(markdown, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
let after_html_double = html_double_quote_pattern
.replace_all(&after_markdown, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
let after_html_single = html_single_quote_pattern
.replace_all(&after_html_double, |captures: &regex::Captures<'_>| {
let raw_url = captures
.name("url")
.map(|item| item.as_str())
.unwrap_or_default();
let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
return captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string();
};
if let Some(localized_url) = replacements.get(&normalized_url) {
localized_count += 1;
format!(
"{}{}{}",
&captures["lead"], localized_url, &captures["trail"]
)
} else {
captures
.get(0)
.map(|item| item.as_str())
.unwrap_or_default()
.to_string()
}
})
.to_string();
(after_html_single, localized_count)
}
fn parse_optional_timestamp( fn parse_optional_timestamp(
value: Option<&str>, value: Option<&str>,
) -> Result<Option<chrono::DateTime<chrono::FixedOffset>>> { ) -> Result<Option<chrono::DateTime<chrono::FixedOffset>>> {
@@ -785,6 +1024,9 @@ fn build_settings_response(
location: item.location, location: item.location,
tech_stack: tech_stack_values(&item.tech_stack), tech_stack: tech_stack_values(&item.tech_stack),
music_playlist: music_playlist_values(&item.music_playlist), music_playlist: music_playlist_values(&item.music_playlist),
music_enabled: item.music_enabled.unwrap_or(true),
maintenance_mode_enabled: item.maintenance_mode_enabled.unwrap_or(false),
maintenance_access_code: item.maintenance_access_code,
ai_enabled: item.ai_enabled.unwrap_or(false), ai_enabled: item.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true), paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
comment_verification_mode: comment_verification_mode.as_str().to_string(), comment_verification_mode: comment_verification_mode.as_str().to_string(),
@@ -1493,9 +1735,11 @@ pub async fn download_media_object(
Json(payload): Json<AdminMediaDownloadPayload>, Json(payload): Json<AdminMediaDownloadPayload>,
) -> Result<Response> { ) -> Result<Response> {
let actor = check_auth(&headers)?; let actor = check_auth(&headers)?;
let target_format = normalize_target_format(payload.target_format.clone())?;
let worker_args = DownloadWorkerArgs { let worker_args = DownloadWorkerArgs {
source_url: payload.source_url.clone(), source_url: payload.source_url.clone(),
prefix: payload.prefix.clone(), prefix: payload.prefix.clone(),
target_format,
title: payload.title.clone(), title: payload.title.clone(),
alt_text: payload.alt_text.clone(), alt_text: payload.alt_text.clone(),
caption: payload.caption.clone(), caption: payload.caption.clone(),
@@ -1503,6 +1747,38 @@ pub async fn download_media_object(
notes: payload.notes.clone(), notes: payload.notes.clone(),
job_id: None, job_id: None,
}; };
if payload.sync {
let downloaded = download_media_to_storage(&ctx, &worker_args).await?;
admin_audit::log_event(
&ctx,
Some(&actor),
"media.download",
"media",
Some(downloaded.key.clone()),
Some(payload.source_url.clone()),
Some(serde_json::json!({
"queued": false,
"source_url": payload.source_url,
"target_format": worker_args.target_format,
"key": downloaded.key,
"url": downloaded.url,
})),
)
.await?;
return format::json(AdminMediaDownloadResponse {
queued: false,
job_id: None,
status: Some("completed".to_string()),
key: Some(downloaded.key),
url: Some(downloaded.url),
size_bytes: Some(downloaded.size_bytes),
content_type: downloaded.content_type,
});
}
let job = worker_jobs::queue_download_job( let job = worker_jobs::queue_download_job(
&ctx, &ctx,
&worker_args, &worker_args,
@@ -1524,14 +1800,19 @@ pub async fn download_media_object(
"job_id": job.id, "job_id": job.id,
"queued": true, "queued": true,
"source_url": payload.source_url, "source_url": payload.source_url,
"target_format": worker_args.target_format,
})), })),
) )
.await?; .await?;
format::json(AdminMediaDownloadResponse { format::json(AdminMediaDownloadResponse {
queued: true, queued: true,
job_id: job.id, job_id: Some(job.id),
status: job.status, status: Some(job.status),
key: None,
url: None,
size_bytes: None,
content_type: None,
}) })
} }
@@ -1907,6 +2188,89 @@ pub async fn polish_post_markdown(
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?) format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
} }
#[debug_handler]
pub async fn localize_post_markdown_images(
headers: HeaderMap,
State(ctx): State<AppContext>,
Json(payload): Json<AdminPostLocalizeImagesRequest>,
) -> Result<Response> {
check_auth(&headers)?;
let normalized_markdown = payload.markdown.replace("\r\n", "\n");
let prefix = normalize_localize_image_prefix(payload.prefix);
let settings = storage::optional_r2_settings(&ctx).await?;
let detected_urls = markdown_image_reference_urls(&normalized_markdown);
let candidate_urls = detected_urls
.into_iter()
.filter(|url| is_remote_markdown_image_candidate(url, settings.as_ref()))
.collect::<Vec<_>>();
if candidate_urls.is_empty() {
return format::json(AdminPostLocalizeImagesResponse {
markdown: normalized_markdown,
detected_count: 0,
localized_count: 0,
uploaded_count: 0,
failed_count: 0,
items: Vec::new(),
failures: Vec::new(),
});
}
let mut seen = HashSet::new();
let unique_urls = candidate_urls
.iter()
.filter(|url| seen.insert((*url).clone()))
.cloned()
.collect::<Vec<_>>();
let mut replacements = HashMap::<String, String>::new();
let mut items = Vec::<AdminPostLocalizedImageItem>::new();
let mut failures = Vec::<AdminPostLocalizeImagesFailure>::new();
for source_url in unique_urls {
let args = DownloadWorkerArgs {
source_url: source_url.clone(),
prefix: Some(prefix.clone()),
target_format: None,
title: None,
alt_text: None,
caption: None,
tags: vec!["markdown-image".to_string()],
notes: Some("localized from markdown body".to_string()),
job_id: None,
};
match download_media_to_storage(&ctx, &args).await {
Ok(downloaded) => {
replacements.insert(source_url.clone(), downloaded.url.clone());
items.push(AdminPostLocalizedImageItem {
source_url,
localized_url: downloaded.url,
key: downloaded.key,
});
}
Err(error) => failures.push(AdminPostLocalizeImagesFailure {
source_url,
error: error.to_string(),
}),
}
}
let (markdown, localized_count) =
replace_markdown_image_urls(&normalized_markdown, &replacements);
format::json(AdminPostLocalizeImagesResponse {
markdown,
detected_count: candidate_urls.len(),
localized_count,
uploaded_count: items.len(),
failed_count: failures.len(),
items,
failures,
})
}
#[debug_handler] #[debug_handler]
pub async fn polish_review_description( pub async fn polish_review_description(
headers: HeaderMap, headers: HeaderMap,
@@ -2045,6 +2409,10 @@ pub fn routes() -> Routes {
.add("/ai/reindex", post(reindex_ai)) .add("/ai/reindex", post(reindex_ai))
.add("/ai/test-provider", post(test_ai_provider)) .add("/ai/test-provider", post(test_ai_provider))
.add("/ai/test-image-provider", post(test_ai_image_provider)) .add("/ai/test-image-provider", post(test_ai_image_provider))
.add(
"/posts/localize-images",
post(localize_post_markdown_images),
)
.add("/storage/r2/test", post(test_r2_storage)) .add("/storage/r2/test", post(test_r2_storage))
.add( .add(
"/storage/media", "/storage/media",

View File

@@ -8,9 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
controllers::admin::check_auth, controllers::admin::check_auth,
models::_entities::{ models::_entities::{admin_audit_logs, notification_deliveries, post_revisions, subscriptions},
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
},
services::{ services::{
admin_audit, backups, post_revisions as revision_service, admin_audit, backups, post_revisions as revision_service,
subscriptions as subscription_service, worker_jobs, subscriptions as subscription_service, worker_jobs,
@@ -174,7 +172,12 @@ fn format_revision(item: post_revisions::Model) -> PostRevisionListItem {
actor_email: item.actor_email, actor_email: item.actor_email,
actor_source: item.actor_source, actor_source: item.actor_source,
created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
has_markdown: item.markdown.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some(), has_markdown: item
.markdown
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some(),
metadata: item.metadata, metadata: item.metadata,
} }
} }
@@ -187,17 +190,31 @@ pub async fn list_audit_logs(
) -> Result<Response> { ) -> Result<Response> {
check_auth(&headers)?; check_auth(&headers)?;
let mut db_query = admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc); let mut db_query =
admin_audit_logs::Entity::find().order_by(admin_audit_logs::Column::CreatedAt, Order::Desc);
if let Some(action) = query.action.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { if let Some(action) = query
.action
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action)); db_query = db_query.filter(admin_audit_logs::Column::Action.eq(action));
} }
if let Some(target_type) = query.target_type.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { if let Some(target_type) = query
.target_type
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type)); db_query = db_query.filter(admin_audit_logs::Column::TargetType.eq(target_type));
} }
format::json(db_query.limit(query.limit.unwrap_or(80)).all(&ctx.db).await?) format::json(
db_query
.limit(query.limit.unwrap_or(80))
.all(&ctx.db)
.await?,
)
} }
#[debug_handler] #[debug_handler]
@@ -207,7 +224,9 @@ pub async fn list_post_revisions(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
check_auth(&headers)?; check_auth(&headers)?;
let items = revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120)).await?; let items =
revision_service::list_revisions(&ctx, query.slug.as_deref(), query.limit.unwrap_or(120))
.await?;
format::json(items.into_iter().map(format_revision).collect::<Vec<_>>()) format::json(items.into_iter().map(format_revision).collect::<Vec<_>>())
} }
@@ -234,8 +253,7 @@ pub async fn restore_post_revision(
) -> Result<Response> { ) -> Result<Response> {
let actor = check_auth(&headers)?; let actor = check_auth(&headers)?;
let mode = payload.mode.unwrap_or_else(|| "full".to_string()); let mode = payload.mode.unwrap_or_else(|| "full".to_string());
let restored = let restored = revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
revision_service::restore_revision(&ctx, Some(&actor), id, &mode).await?;
admin_audit::log_event( admin_audit::log_event(
&ctx, &ctx,
Some(&actor), Some(&actor),
@@ -278,7 +296,8 @@ pub async fn list_subscription_deliveries(
) -> Result<Response> { ) -> Result<Response> {
check_auth(&headers)?; check_auth(&headers)?;
format::json(DeliveryListResponse { format::json(DeliveryListResponse {
deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80)).await?, deliveries: subscription_service::list_recent_deliveries(&ctx, query.limit.unwrap_or(80))
.await?,
}) })
} }
@@ -300,7 +319,9 @@ pub async fn create_subscription(
channel_type: Set(channel_type.clone()), channel_type: Set(channel_type.clone()),
target: Set(target.clone()), target: Set(target.clone()),
display_name: Set(trim_to_option(payload.display_name)), display_name: Set(trim_to_option(payload.display_name)),
status: Set(subscription_service::normalize_status(payload.status.as_deref().unwrap_or("active"))), status: Set(subscription_service::normalize_status(
payload.status.as_deref().unwrap_or("active"),
)),
filters: Set(subscription_service::normalize_filters(payload.filters)), filters: Set(subscription_service::normalize_filters(payload.filters)),
metadata: Set(payload.metadata), metadata: Set(payload.metadata),
secret: Set(trim_to_option(payload.secret)), secret: Set(trim_to_option(payload.secret)),
@@ -461,7 +482,9 @@ pub async fn send_subscription_digest(
Json(payload): Json<DigestDispatchRequest>, Json(payload): Json<DigestDispatchRequest>,
) -> Result<Response> { ) -> Result<Response> {
let actor = check_auth(&headers)?; let actor = check_auth(&headers)?;
let summary = subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly")).await?; let summary =
subscription_service::send_digest(&ctx, payload.period.as_deref().unwrap_or("weekly"))
.await?;
admin_audit::log_event( admin_audit::log_event(
&ctx, &ctx,
@@ -664,17 +687,29 @@ pub fn routes() -> Routes {
.add("/post-revisions", get(list_post_revisions)) .add("/post-revisions", get(list_post_revisions))
.add("/post-revisions/{id}", get(get_post_revision)) .add("/post-revisions/{id}", get(get_post_revision))
.add("/post-revisions/{id}/restore", post(restore_post_revision)) .add("/post-revisions/{id}/restore", post(restore_post_revision))
.add("/subscriptions", get(list_subscriptions).post(create_subscription)) .add(
.add("/subscriptions/deliveries", get(list_subscription_deliveries)) "/subscriptions",
get(list_subscriptions).post(create_subscription),
)
.add(
"/subscriptions/deliveries",
get(list_subscription_deliveries),
)
.add("/subscriptions/digest", post(send_subscription_digest)) .add("/subscriptions/digest", post(send_subscription_digest))
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription)) .add(
"/subscriptions/{id}",
patch(update_subscription).delete(delete_subscription),
)
.add("/subscriptions/{id}/test", post(test_subscription)) .add("/subscriptions/{id}/test", post(test_subscription))
.add("/workers/overview", get(workers_overview)) .add("/workers/overview", get(workers_overview))
.add("/workers/jobs", get(list_worker_jobs)) .add("/workers/jobs", get(list_worker_jobs))
.add("/workers/jobs/{id}", get(get_worker_job)) .add("/workers/jobs/{id}", get(get_worker_job))
.add("/workers/jobs/{id}/cancel", post(cancel_worker_job)) .add("/workers/jobs/{id}/cancel", post(cancel_worker_job))
.add("/workers/jobs/{id}/retry", post(retry_worker_job)) .add("/workers/jobs/{id}/retry", post(retry_worker_job))
.add("/workers/tasks/retry-deliveries", post(run_retry_deliveries_job)) .add(
"/workers/tasks/retry-deliveries",
post(run_retry_deliveries_job),
)
.add("/workers/tasks/digest", post(run_digest_worker_job)) .add("/workers/tasks/digest", post(run_digest_worker_job))
.add("/site-backup/export", get(export_site_backup)) .add("/site-backup/export", get(export_site_backup))
.add("/site-backup/import", post(import_site_backup)) .add("/site-backup/import", post(import_site_backup))

View File

@@ -4,8 +4,8 @@ use async_stream::stream;
use axum::{ use axum::{
body::{Body, Bytes}, body::{Body, Bytes},
http::{ http::{
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
HeaderMap, HeaderValue, HeaderMap, HeaderValue,
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
}, },
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};

View File

@@ -8,10 +8,11 @@ use std::collections::BTreeMap;
use std::net::SocketAddr; use std::net::SocketAddr;
use axum::{ use axum::{
extract::{rejection::ExtensionRejection, ConnectInfo}, extract::{ConnectInfo, rejection::ExtensionRejection},
http::{header, HeaderMap}, http::{HeaderMap, header},
}; };
use crate::controllers::admin::check_auth;
use crate::models::_entities::{ use crate::models::_entities::{
comments::{ActiveModel, Column, Entity, Model}, comments::{ActiveModel, Column, Entity, Model},
posts, posts,
@@ -21,7 +22,6 @@ use crate::services::{
comment_guard::{self, CommentGuardInput}, comment_guard::{self, CommentGuardInput},
notifications, notifications,
}; };
use crate::controllers::admin::check_auth;
const ARTICLE_SCOPE: &str = "article"; const ARTICLE_SCOPE: &str = "article";
const PARAGRAPH_SCOPE: &str = "paragraph"; const PARAGRAPH_SCOPE: &str = "paragraph";

View File

@@ -38,8 +38,15 @@ pub async fn record(
headers: HeaderMap, headers: HeaderMap,
Json(payload): Json<ContentAnalyticsEventPayload>, Json(payload): Json<ContentAnalyticsEventPayload>,
) -> Result<Response> { ) -> Result<Response> {
let mut request_context = analytics::content_request_context_from_headers(&payload.path, &headers); let mut request_context =
if payload.referrer.as_deref().map(str::trim).filter(|value| !value.is_empty()).is_some() { analytics::content_request_context_from_headers(&payload.path, &headers);
if payload
.referrer
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
request_context.referrer = payload.referrer; request_context.referrer = payload.referrer;
} }

View File

@@ -127,7 +127,9 @@ pub async fn update(
"friend_link.update", "friend_link.update",
"friend_link", "friend_link",
Some(item.id.to_string()), Some(item.id.to_string()),
item.site_name.clone().or_else(|| Some(item.site_url.clone())), item.site_name
.clone()
.or_else(|| Some(item.site_url.clone())),
Some(serde_json::json!({ "status": item.status })), Some(serde_json::json!({ "status": item.status })),
) )
.await?; .await?;
@@ -142,7 +144,10 @@ pub async fn remove(
) -> Result<Response> { ) -> Result<Response> {
let actor = check_auth(&headers)?; let actor = check_auth(&headers)?;
let item = load_item(&ctx, id).await?; let item = load_item(&ctx, id).await?;
let label = item.site_name.clone().or_else(|| Some(item.site_url.clone())); let label = item
.site_name
.clone()
.or_else(|| Some(item.site_url.clone()));
item.delete(&ctx.db).await?; item.delete(&ctx.db).await?;
admin_audit::log_event( admin_audit::log_event(
&ctx, &ctx,

View File

@@ -1,12 +1,12 @@
pub mod admin; pub mod admin;
pub mod admin_api; pub mod admin_api;
pub mod admin_taxonomy;
pub mod admin_ops; pub mod admin_ops;
pub mod admin_taxonomy;
pub mod ai; pub mod ai;
pub mod auth; pub mod auth;
pub mod content_analytics;
pub mod category; pub mod category;
pub mod comment; pub mod comment;
pub mod content_analytics;
pub mod friend_link; pub mod friend_link;
pub mod health; pub mod health;
pub mod post; pub mod post;

View File

@@ -14,7 +14,11 @@ use crate::{
fn is_public_review_status(status: Option<&str>) -> bool { fn is_public_review_status(status: Option<&str>) -> bool {
matches!( matches!(
status.unwrap_or_default().trim().to_ascii_lowercase().as_str(), status
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str(),
"published" | "completed" | "done" "published" | "completed" | "done"
) )
} }
@@ -67,7 +71,9 @@ pub async fn get_one(
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?; let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
match review { match review {
Some(r) if include_private || is_public_review_status(r.status.as_deref()) => format::json(r), Some(r) if include_private || is_public_review_status(r.status.as_deref()) => {
format::json(r)
}
Some(_) => Err(Error::NotFound), Some(_) => Err(Error::NotFound),
None => Err(Error::NotFound), None => Err(Error::NotFound),
} }

View File

@@ -4,6 +4,7 @@
use axum::http::HeaderMap; use axum::http::HeaderMap;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sha2::{Digest, Sha256};
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set}; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
@@ -89,6 +90,12 @@ pub struct SiteSettingsPayload {
pub tech_stack: Option<Vec<String>>, pub tech_stack: Option<Vec<String>>,
#[serde(default, alias = "musicPlaylist")] #[serde(default, alias = "musicPlaylist")]
pub music_playlist: Option<Vec<MusicTrackPayload>>, pub music_playlist: Option<Vec<MusicTrackPayload>>,
#[serde(default, alias = "musicEnabled")]
pub music_enabled: Option<bool>,
#[serde(default, alias = "maintenanceModeEnabled")]
pub maintenance_mode_enabled: Option<bool>,
#[serde(default, alias = "maintenanceAccessCode")]
pub maintenance_access_code: Option<String>,
#[serde(default, alias = "aiEnabled")] #[serde(default, alias = "aiEnabled")]
pub ai_enabled: Option<bool>, pub ai_enabled: Option<bool>,
#[serde(default, alias = "paragraphCommentsEnabled")] #[serde(default, alias = "paragraphCommentsEnabled")]
@@ -199,6 +206,7 @@ pub struct PublicSiteSettingsResponse {
pub location: Option<String>, pub location: Option<String>,
pub tech_stack: Option<serde_json::Value>, pub tech_stack: Option<serde_json::Value>,
pub music_playlist: Option<serde_json::Value>, pub music_playlist: Option<serde_json::Value>,
pub music_enabled: bool,
pub ai_enabled: bool, pub ai_enabled: bool,
pub paragraph_comments_enabled: bool, pub paragraph_comments_enabled: bool,
pub comment_verification_mode: String, pub comment_verification_mode: String,
@@ -217,6 +225,31 @@ pub struct PublicSiteSettingsResponse {
pub seo_wechat_share_qr_enabled: bool, pub seo_wechat_share_qr_enabled: bool,
} }
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceAccessTokenPayload {
#[serde(default, alias = "accessToken")]
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct MaintenanceVerifyPayload {
#[serde(default)]
pub code: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceAccessStatusResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct MaintenanceVerifyResponse {
pub maintenance_mode_enabled: bool,
pub access_granted: bool,
pub access_token: Option<String>,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct HomeCategorySummary { pub struct HomeCategorySummary {
pub id: i32, pub id: i32,
@@ -252,6 +285,51 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
value.map(|item| item.clamp(min, max)) value.map(|item| item.clamp(min, max))
} }
fn maintenance_mode_enabled(model: &Model) -> bool {
model.maintenance_mode_enabled.unwrap_or(false)
}
fn maintenance_access_code(model: &Model) -> Option<String> {
normalize_optional_string(model.maintenance_access_code.clone())
}
fn maintenance_access_token_from_secret(secret: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"termi-maintenance-access:v1:");
hasher.update(secret.as_bytes());
let digest = hasher.finalize();
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn validate_maintenance_access_token(model: &Model, token: Option<&str>) -> bool {
let Some(candidate) = token.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}) else {
return false;
};
let Some(secret) = maintenance_access_code(model) else {
return false;
};
candidate == maintenance_access_token_from_secret(&secret)
}
fn verify_maintenance_access_code(model: &Model, code: Option<&str>) -> Option<String> {
let candidate = code.and_then(|item| {
let trimmed = item.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})?;
let secret = maintenance_access_code(model)?;
(candidate == secret).then(|| maintenance_access_token_from_secret(&secret))
}
fn normalize_notification_channel_type(value: Option<String>) -> Option<String> { fn normalize_notification_channel_type(value: Option<String>) -> Option<String> {
value.and_then(|item| { value.and_then(|item| {
let normalized = item.trim().to_ascii_lowercase(); let normalized = item.trim().to_ascii_lowercase();
@@ -272,7 +350,7 @@ pub(crate) fn default_subscription_popup_title() -> String {
} }
pub(crate) fn default_subscription_popup_description() -> String { pub(crate) fn default_subscription_popup_description() -> String {
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订".to_string() "有新内容时及时提醒你;如果愿意,也可以再留一个邮箱备份".to_string()
} }
pub(crate) fn default_subscription_popup_delay_seconds() -> i32 { pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
@@ -555,6 +633,15 @@ impl SiteSettingsPayload {
if let Some(music_playlist) = self.music_playlist { if let Some(music_playlist) = self.music_playlist {
item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist))); item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist)));
} }
if let Some(music_enabled) = self.music_enabled {
item.music_enabled = Some(music_enabled);
}
if let Some(maintenance_mode_enabled) = self.maintenance_mode_enabled {
item.maintenance_mode_enabled = Some(maintenance_mode_enabled);
}
if self.maintenance_access_code.is_some() {
item.maintenance_access_code = normalize_optional_string(self.maintenance_access_code);
}
if let Some(ai_enabled) = self.ai_enabled { if let Some(ai_enabled) = self.ai_enabled {
item.ai_enabled = Some(ai_enabled); item.ai_enabled = Some(ai_enabled);
} }
@@ -752,10 +839,10 @@ fn default_payload() -> SiteSettingsPayload {
site_name: Some("InitCool".to_string()), site_name: Some("InitCool".to_string()),
site_short_name: Some("Termi".to_string()), site_short_name: Some("Termi".to_string()),
site_url: Some("https://init.cool".to_string()), site_url: Some("https://init.cool".to_string()),
site_title: Some("InitCool - 终端风格的内容平台".to_string()), site_title: Some("InitCool · 技术笔记与内容档案".to_string()),
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活".to_string()), site_description: Some("围绕开发实践、产品观察与长期积累整理的中文内容站".to_string()),
hero_title: Some("欢迎来到我的极客终端博客".to_string()), hero_title: Some("欢迎来到 InitCool".to_string()),
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()), hero_subtitle: Some("记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。".to_string()),
owner_name: Some("InitCool".to_string()), owner_name: Some("InitCool".to_string()),
owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()), owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()),
owner_bio: Some( owner_bio: Some(
@@ -813,6 +900,9 @@ fn default_payload() -> SiteSettingsPayload {
description: Some("节奏更明显一点,适合切换阅读状态。".to_string()), description: Some("节奏更明显一点,适合切换阅读状态。".to_string()),
}, },
]), ]),
music_enabled: Some(true),
maintenance_mode_enabled: Some(false),
maintenance_access_code: None,
ai_enabled: Some(false), ai_enabled: Some(false),
paragraph_comments_enabled: Some(true), paragraph_comments_enabled: Some(true),
comment_verification_mode: Some( comment_verification_mode: Some(
@@ -923,6 +1013,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
location: model.location, location: model.location,
tech_stack: model.tech_stack, tech_stack: model.tech_stack,
music_playlist: model.music_playlist, music_playlist: model.music_playlist,
music_enabled: model.music_enabled.unwrap_or(true),
ai_enabled: model.ai_enabled.unwrap_or(false), ai_enabled: model.ai_enabled.unwrap_or(false),
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true), paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
comment_verification_mode: comment_verification_mode.as_str().to_string(), comment_verification_mode: comment_verification_mode.as_str().to_string(),
@@ -1019,6 +1110,50 @@ pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
format::json(public_response(load_current(&ctx).await?)) format::json(public_response(load_current(&ctx).await?))
} }
#[debug_handler]
pub async fn maintenance_status(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceAccessTokenPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
let access_granted = if enabled {
validate_maintenance_access_token(&current, params.access_token.as_deref())
} else {
true
};
format::json(MaintenanceAccessStatusResponse {
maintenance_mode_enabled: enabled,
access_granted,
})
}
#[debug_handler]
pub async fn maintenance_verify(
State(ctx): State<AppContext>,
Json(params): Json<MaintenanceVerifyPayload>,
) -> Result<Response> {
let current = load_current(&ctx).await?;
let enabled = maintenance_mode_enabled(&current);
if !enabled {
return format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: false,
access_granted: true,
access_token: None,
});
}
let access_token = verify_maintenance_access_code(&current, params.code.as_deref());
format::json(MaintenanceVerifyResponse {
maintenance_mode_enabled: true,
access_granted: access_token.is_some(),
access_token,
})
}
#[debug_handler] #[debug_handler]
pub async fn update( pub async fn update(
headers: HeaderMap, headers: HeaderMap,
@@ -1039,6 +1174,8 @@ pub fn routes() -> Routes {
Routes::new() Routes::new()
.prefix("api/site_settings/") .prefix("api/site_settings/")
.add("home", get(home)) .add("home", get(home))
.add("maintenance/status", post(maintenance_status))
.add("maintenance/verify", post(maintenance_verify))
.add("/", get(show)) .add("/", get(show))
.add("/", put(update)) .add("/", put(update))
.add("/", patch(update)) .add("/", patch(update))

View File

@@ -33,6 +33,26 @@ pub struct PublicBrowserPushSubscriptionPayload {
pub captcha_answer: Option<String>, pub captcha_answer: Option<String>,
} }
#[derive(Clone, Debug, Deserialize)]
pub struct PublicCombinedSubscriptionPayload {
#[serde(default)]
pub channels: Vec<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub subscription: Option<serde_json::Value>,
#[serde(default)]
pub source: Option<String>,
#[serde(default, alias = "turnstileToken")]
pub turnstile_token: Option<String>,
#[serde(default, alias = "captchaToken")]
pub captcha_token: Option<String>,
#[serde(default, alias = "captchaAnswer")]
pub captcha_answer: Option<String>,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct SubscriptionTokenPayload { pub struct SubscriptionTokenPayload {
pub token: String, pub token: String,
@@ -63,6 +83,21 @@ pub struct PublicSubscriptionResponse {
pub message: String, pub message: String,
} }
#[derive(Clone, Debug, Serialize)]
pub struct PublicCombinedSubscriptionItemResponse {
pub channel_type: String,
pub subscription_id: i32,
pub status: String,
pub requires_confirmation: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct PublicCombinedSubscriptionResponse {
pub ok: bool,
pub channels: Vec<PublicCombinedSubscriptionItemResponse>,
pub message: String,
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct SubscriptionManageResponse { pub struct SubscriptionManageResponse {
pub ok: bool, pub ok: bool,
@@ -89,6 +124,30 @@ fn public_browser_push_metadata(
}) })
} }
fn normalize_public_subscription_channels(channels: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for raw in channels {
let Some(channel) = ({
match raw.trim().to_ascii_lowercase().as_str() {
"email" | "mail" => Some("email"),
"browser" | "browser-push" | "browser_push" | "webpush" | "web-push" => {
Some("browser_push")
}
_ => None,
}
}) else {
continue;
};
if !normalized.iter().any(|value| value == channel) {
normalized.push(channel.to_string());
}
}
normalized
}
async fn verify_subscription_human_check( async fn verify_subscription_human_check(
settings: &crate::models::_entities::site_settings::Model, settings: &crate::models::_entities::site_settings::Model,
turnstile_token: Option<&str>, turnstile_token: Option<&str>,
@@ -119,11 +178,7 @@ pub async fn subscribe(
) -> Result<Response> { ) -> Result<Response> {
let email = payload.email.trim().to_ascii_lowercase(); let email = payload.email.trim().to_ascii_lowercase();
let client_ip = abuse_guard::detect_client_ip(&headers); let client_ip = abuse_guard::detect_client_ip(&headers);
abuse_guard::enforce_public_scope( abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(&email))?;
"subscription",
client_ip.as_deref(),
Some(&email),
)?;
let settings = crate::controllers::site_settings::load_current(&ctx).await?; let settings = crate::controllers::site_settings::load_current(&ctx).await?;
verify_subscription_human_check( verify_subscription_human_check(
&settings, &settings,
@@ -186,7 +241,9 @@ pub async fn subscribe_browser_push(
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.ok_or_else(|| Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()))? .ok_or_else(|| {
Error::BadRequest("browser push subscription.endpoint 不能为空".to_string())
})?
.to_string(); .to_string();
let client_ip = abuse_guard::detect_client_ip(&headers); let client_ip = abuse_guard::detect_client_ip(&headers);
let user_agent = headers let user_agent = headers
@@ -196,15 +253,11 @@ pub async fn subscribe_browser_push(
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToString::to_string); .map(ToString::to_string);
abuse_guard::enforce_public_scope("browser-push-subscription", client_ip.as_deref(), Some(&endpoint))?; abuse_guard::enforce_public_scope(
verify_subscription_human_check( "browser-push-subscription",
&settings,
payload.turnstile_token.as_deref(),
payload.captcha_token.as_deref(),
payload.captcha_answer.as_deref(),
client_ip.as_deref(), client_ip.as_deref(),
) Some(&endpoint),
.await?; )?;
let result = subscriptions::create_public_web_push_subscription( let result = subscriptions::create_public_web_push_subscription(
&ctx, &ctx,
@@ -240,6 +293,174 @@ pub async fn subscribe_browser_push(
}) })
} }
#[debug_handler]
pub async fn subscribe_combined(
State(ctx): State<AppContext>,
headers: axum::http::HeaderMap,
Json(payload): Json<PublicCombinedSubscriptionPayload>,
) -> Result<Response> {
let selected_channels = normalize_public_subscription_channels(&payload.channels);
if selected_channels.is_empty() {
return Err(Error::BadRequest("请至少选择一种订阅方式".to_string()));
}
let wants_email = selected_channels.iter().any(|value| value == "email");
let wants_browser_push = selected_channels
.iter()
.any(|value| value == "browser_push");
let settings = crate::controllers::site_settings::load_current(&ctx).await?;
let client_ip = abuse_guard::detect_client_ip(&headers);
let normalized_email = payload
.email
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase());
if wants_email {
let email = normalized_email
.as_deref()
.ok_or_else(|| Error::BadRequest("请选择邮箱订阅后填写邮箱地址".to_string()))?;
abuse_guard::enforce_public_scope("subscription", client_ip.as_deref(), Some(email))?;
}
let normalized_browser_subscription = if wants_browser_push {
if !crate::services::web_push::is_enabled(&settings) {
return Err(Error::BadRequest("浏览器推送未启用".to_string()));
}
let subscription = payload
.subscription
.clone()
.ok_or_else(|| Error::BadRequest("缺少浏览器推送订阅信息".to_string()))?;
let endpoint = subscription
.get("endpoint")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
Error::BadRequest("browser push subscription.endpoint 不能为空".to_string())
})?
.to_string();
abuse_guard::enforce_public_scope(
"browser-push-subscription",
client_ip.as_deref(),
Some(&endpoint),
)?;
Some(subscription)
} else {
None
};
if wants_email {
verify_subscription_human_check(
&settings,
payload.turnstile_token.as_deref(),
payload.captcha_token.as_deref(),
payload.captcha_answer.as_deref(),
client_ip.as_deref(),
)
.await?;
}
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let mut items = Vec::new();
let mut message_parts = Vec::new();
if let Some(subscription) = normalized_browser_subscription {
let browser_result = subscriptions::create_public_web_push_subscription(
&ctx,
subscription.clone(),
Some(public_browser_push_metadata(
payload.source.clone(),
subscription,
user_agent,
)),
)
.await?;
admin_audit::log_event(
&ctx,
None,
"subscription.public.web_push.active",
"subscription",
Some(browser_result.subscription.id.to_string()),
Some(browser_result.subscription.target.clone()),
Some(serde_json::json!({
"channel_type": browser_result.subscription.channel_type,
"status": browser_result.subscription.status,
})),
)
.await?;
message_parts.push(browser_result.message.clone());
items.push(PublicCombinedSubscriptionItemResponse {
channel_type: browser_result.subscription.channel_type,
subscription_id: browser_result.subscription.id,
status: browser_result.subscription.status,
requires_confirmation: false,
});
}
if wants_email {
let email_result = subscriptions::create_public_email_subscription(
&ctx,
normalized_email.as_deref().unwrap_or_default(),
payload.display_name,
Some(public_subscription_metadata(payload.source)),
)
.await?;
admin_audit::log_event(
&ctx,
None,
if email_result.requires_confirmation {
"subscription.public.pending"
} else {
"subscription.public.active"
},
"subscription",
Some(email_result.subscription.id.to_string()),
Some(email_result.subscription.target.clone()),
Some(serde_json::json!({
"channel_type": email_result.subscription.channel_type,
"status": email_result.subscription.status,
})),
)
.await?;
message_parts.push(email_result.message.clone());
items.push(PublicCombinedSubscriptionItemResponse {
channel_type: email_result.subscription.channel_type,
subscription_id: email_result.subscription.id,
status: email_result.subscription.status,
requires_confirmation: email_result.requires_confirmation,
});
}
let message = if message_parts.is_empty() {
"订阅请求已处理。".to_string()
} else {
message_parts.join(" ")
};
format::json(PublicCombinedSubscriptionResponse {
ok: true,
channels: items,
message,
})
}
#[debug_handler] #[debug_handler]
pub async fn confirm( pub async fn confirm(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
@@ -333,6 +554,7 @@ pub fn routes() -> Routes {
Routes::new() Routes::new()
.prefix("/api/subscriptions") .prefix("/api/subscriptions")
.add("/", post(subscribe)) .add("/", post(subscribe))
.add("/combined", post(subscribe_combined))
.add("/browser-push", post(subscribe_browser_push)) .add("/browser-push", post(subscribe_browser_push))
.add("/confirm", post(confirm)) .add("/confirm", post(confirm))
.add("/manage", get(manage).patch(update_manage)) .add("/manage", get(manage).patch(update_manage))

View File

@@ -2,35 +2,35 @@
pid: 1 pid: 1
author: "林川" author: "林川"
email: "linchuan@example.com" email: "linchuan@example.com"
content: "这篇做长文测试很合适,段落密度和古文节奏都不错。" content: "这篇读起来很稳,段落密度和古文节奏都很舒服。"
approved: true approved: true
- id: 2 - id: 2
pid: 1 pid: 1
author: "阿青" author: "阿青"
email: "aqing@example.com" email: "aqing@example.com"
content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。" content: "建议后面再加几篇山水游记,读者会更容易比较不同山名与路线。"
approved: true approved: true
- id: 3 - id: 3
pid: 2 pid: 2
author: "周宁" author: "周宁"
email: "zhouling@example.com" email: "zhouling@example.com"
content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。" content: "这一段关于南岩和琼台的描写很好,细节很有画面感。"
approved: true approved: true
- id: 4 - id: 4
pid: 3 pid: 3
author: "顾远" author: "顾远"
email: "guyuan@example.com" email: "guyuan@example.com"
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。" content: "悬空寺这一段信息量很大,拿来做导读或摘录都很有代表性。"
approved: true approved: true
- id: 5 - id: 5
pid: 4 pid: 4
author: "清嘉" author: "清嘉"
email: "qingjia@example.com" email: "qingjia@example.com"
content: "黄山记的序文很适合测试首屏摘要生成。" content: "黄山记的序文很适合作为开篇导读,气势一下就起来了。"
approved: true approved: true
- id: 6 - id: 6

View File

@@ -10,7 +10,7 @@
自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。 自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。
骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。 骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。
excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。" excerpt: "《徐霞客游记》太和山上篇,写山路、水势与沿途景物,适合静心细读。"
category: "古籍游记" category: "古籍游记"
published: true published: true
pinned: true pinned: true
@@ -18,7 +18,7 @@
- 徐霞客 - 徐霞客
- 游记 - 游记
- 太和山 - 太和山
- 长文测试 - 山水游记
- id: 2 - id: 2
pid: 2 pid: 2
@@ -40,7 +40,7 @@
- 徐霞客 - 徐霞客
- 游记 - 游记
- 太和山 - 太和山
- 长文测试 - 山水游记
- id: 3 - id: 3
pid: 3 pid: 3
@@ -54,7 +54,7 @@
余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。 余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。 三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。" excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,气象开阔,层次分明。"
category: "古籍游记" category: "古籍游记"
published: true published: true
pinned: false pinned: false
@@ -62,7 +62,7 @@
- 徐霞客 - 徐霞客
- 恒山 - 恒山
- 悬空寺 - 悬空寺
- 长文测试 - 山水游记
- id: 4 - id: 4
pid: 4 pid: 4
@@ -84,7 +84,7 @@
- 钱谦益 - 钱谦益
- 黄山 - 黄山
- 游记 - 游记
- 长文测试 - 山水游记
- id: 5 - id: 5
pid: 5 pid: 5
@@ -98,7 +98,7 @@
憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。 憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。 清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。
excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。" excerpt: "钱谦益《游黄山记》中篇,写奇峰云气与山行转折,节奏峻拔。"
category: "古籍游记" category: "古籍游记"
published: true published: true
pinned: false pinned: false
@@ -106,4 +106,4 @@
- 钱谦益 - 钱谦益
- 黄山 - 黄山
- 游记 - 游记
- 长文测试 - 山水游记

View File

@@ -34,7 +34,7 @@
rating: 5 rating: 5
review_date: "2024-02-18" review_date: "2024-02-18"
status: "published" status: "published"
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。" description: "把很多宏观经济问题讲得非常清楚,适合反复阅读。"
tags: ["经济", "非虚构", "中国"] tags: ["经济", "非虚构", "中国"]
cover: "/review-covers/placed-within.svg" cover: "/review-covers/placed-within.svg"

View File

@@ -2,10 +2,10 @@
site_name: "InitCool" site_name: "InitCool"
site_short_name: "Termi" site_short_name: "Termi"
site_url: "https://init.cool" site_url: "https://init.cool"
site_title: "InitCool · 中文长文与 AI 搜索实验站" site_title: "InitCool · 技术笔记与内容档案"
site_description: "一个偏终端审美的中文内容站用来测试文章检索、AI 问答、段落评论与后台工作流。" site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。"
hero_title: "欢迎来到我的中文内容实验站" hero_title: "欢迎来到 InitCool"
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验" hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。"
owner_name: "InitCool" owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool" owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
@@ -43,6 +43,9 @@
cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80" cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
accent_color: "#375a7f" accent_color: "#375a7f"
description: "节奏更明显一点,适合切换阅读状态。" description: "节奏更明显一点,适合切换阅读状态。"
music_enabled: true
maintenance_mode_enabled: false
maintenance_access_code: null
ai_enabled: false ai_enabled: false
paragraph_comments_enabled: true paragraph_comments_enabled: true
comment_verification_mode: "captcha" comment_verification_mode: "captcha"

View File

@@ -108,19 +108,24 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
}) })
.filter(|items| !items.is_empty()) .filter(|items| !items.is_empty())
.map(serde_json::Value::Array); .map(serde_json::Value::Array);
let music_enabled = seed["music_enabled"].as_bool().or(Some(true));
let maintenance_mode_enabled = seed["maintenance_mode_enabled"].as_bool().or(Some(false));
let maintenance_access_code = as_optional_string(&seed["maintenance_access_code"]);
let comment_verification_mode = as_optional_string(&seed["comment_verification_mode"]); let comment_verification_mode = as_optional_string(&seed["comment_verification_mode"]);
let subscription_verification_mode = let subscription_verification_mode =
as_optional_string(&seed["subscription_verification_mode"]); as_optional_string(&seed["subscription_verification_mode"]);
let comment_turnstile_enabled = seed["comment_turnstile_enabled"] let comment_turnstile_enabled =
.as_bool() seed["comment_turnstile_enabled"]
.or(comment_verification_mode .as_bool()
.as_deref() .or(comment_verification_mode
.map(|value| value.eq_ignore_ascii_case("turnstile"))); .as_deref()
let subscription_turnstile_enabled = seed["subscription_turnstile_enabled"] .map(|value| value.eq_ignore_ascii_case("turnstile")));
.as_bool() let subscription_turnstile_enabled =
.or(subscription_verification_mode seed["subscription_turnstile_enabled"]
.as_deref() .as_bool()
.map(|value| value.eq_ignore_ascii_case("turnstile"))); .or(subscription_verification_mode
.as_deref()
.map(|value| value.eq_ignore_ascii_case("turnstile")));
let existing = site_settings::Entity::find() let existing = site_settings::Entity::find()
.order_by_asc(site_settings::Column::Id) .order_by_asc(site_settings::Column::Id)
@@ -182,6 +187,15 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
if existing.music_playlist.is_none() { if existing.music_playlist.is_none() {
model.music_playlist = Set(music_playlist); model.music_playlist = Set(music_playlist);
} }
if existing.music_enabled.is_none() {
model.music_enabled = Set(music_enabled);
}
if existing.maintenance_mode_enabled.is_none() {
model.maintenance_mode_enabled = Set(maintenance_mode_enabled);
}
if is_blank(&existing.maintenance_access_code) {
model.maintenance_access_code = Set(maintenance_access_code.clone());
}
if existing.ai_enabled.is_none() { if existing.ai_enabled.is_none() {
model.ai_enabled = Set(seed["ai_enabled"].as_bool()); model.ai_enabled = Set(seed["ai_enabled"].as_bool());
} }
@@ -261,6 +275,9 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
location: Set(as_optional_string(&seed["location"])), location: Set(as_optional_string(&seed["location"])),
tech_stack: Set(tech_stack), tech_stack: Set(tech_stack),
music_playlist: Set(music_playlist), music_playlist: Set(music_playlist),
music_enabled: Set(music_enabled),
maintenance_mode_enabled: Set(maintenance_mode_enabled),
maintenance_access_code: Set(maintenance_access_code),
ai_enabled: Set(seed["ai_enabled"].as_bool()), ai_enabled: Set(seed["ai_enabled"].as_bool()),
paragraph_comments_enabled: Set(seed["paragraph_comments_enabled"] paragraph_comments_enabled: Set(seed["paragraph_comments_enabled"]
.as_bool() .as_bool()

View File

@@ -1,7 +1,7 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
pub use super::ai_chunks::Entity as AiChunks;
pub use super::admin_audit_logs::Entity as AdminAuditLogs; pub use super::admin_audit_logs::Entity as AdminAuditLogs;
pub use super::ai_chunks::Entity as AiChunks;
pub use super::categories::Entity as Categories; pub use super::categories::Entity as Categories;
pub use super::comment_blacklist::Entity as CommentBlacklist; pub use super::comment_blacklist::Entity as CommentBlacklist;
pub use super::comment_persona_analysis_logs::Entity as CommentPersonaAnalysisLogs; pub use super::comment_persona_analysis_logs::Entity as CommentPersonaAnalysisLogs;

View File

@@ -30,6 +30,10 @@ pub struct Model {
pub tech_stack: Option<Json>, pub tech_stack: Option<Json>,
#[sea_orm(column_type = "JsonBinary", nullable)] #[sea_orm(column_type = "JsonBinary", nullable)]
pub music_playlist: Option<Json>, pub music_playlist: Option<Json>,
pub music_enabled: Option<bool>,
pub maintenance_mode_enabled: Option<bool>,
#[sea_orm(column_type = "Text", nullable)]
pub maintenance_access_code: Option<String>,
pub ai_enabled: Option<bool>, pub ai_enabled: Option<bool>,
pub paragraph_comments_enabled: Option<bool>, pub paragraph_comments_enabled: Option<bool>,
pub comment_turnstile_enabled: Option<bool>, pub comment_turnstile_enabled: Option<bool>,

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{offset::Local, Duration}; use chrono::{Duration, offset::Local};
use loco_rs::{auth::jwt, hash, prelude::*}; use loco_rs::{auth::jwt, hash, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Map; use serde_json::Map;

View File

@@ -3,12 +3,9 @@ use std::{
sync::{Mutex, OnceLock}, sync::{Mutex, OnceLock},
}; };
use axum::http::{header, HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode, header};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use loco_rs::{ use loco_rs::{controller::ErrorDetail, prelude::*};
controller::ErrorDetail,
prelude::*,
};
const DEFAULT_WINDOW_SECONDS: i64 = 5 * 60; const DEFAULT_WINDOW_SECONDS: i64 = 5 * 60;
const DEFAULT_MAX_REQUESTS_PER_WINDOW: u32 = 45; const DEFAULT_MAX_REQUESTS_PER_WINDOW: u32 = 45;

View File

@@ -1,33 +1,15 @@
use loco_rs::prelude::*; use loco_rs::prelude::{AppContext, Result};
use sea_orm::{ActiveModelTrait, Set};
use crate::{ use crate::controllers::admin::AdminIdentity;
controllers::admin::AdminIdentity,
models::_entities::admin_audit_logs,
};
pub async fn log_event( pub async fn log_event(
ctx: &AppContext, _ctx: &AppContext,
actor: Option<&AdminIdentity>, _actor: Option<&AdminIdentity>,
action: &str, _action: &str,
target_type: &str, _target_type: &str,
target_id: Option<String>, _target_id: Option<String>,
target_label: Option<String>, _target_label: Option<String>,
metadata: Option<serde_json::Value>, _metadata: Option<serde_json::Value>,
) -> Result<()> { ) -> Result<()> {
admin_audit_logs::ActiveModel {
actor_username: Set(actor.map(|item| item.username.clone())),
actor_email: Set(actor.and_then(|item| item.email.clone())),
actor_source: Set(actor.map(|item| item.source.clone())),
action: Set(action.to_string()),
target_type: Set(target_type.to_string()),
target_id: Set(target_id),
target_label: Set(target_label),
metadata: Set(metadata),
..Default::default()
}
.insert(&ctx.db)
.await?;
Ok(()) Ok(())
} }

View File

@@ -246,9 +246,7 @@ fn normalize_tracking_source_token(value: Option<String>) -> String {
"chatgpt-search".to_string() "chatgpt-search".to_string()
} }
value if value.contains("perplexity") => "perplexity".to_string(), value if value.contains("perplexity") => "perplexity".to_string(),
value if value.contains("copilot") || value.contains("bing") => { value if value.contains("copilot") || value.contains("bing") => "copilot-bing".to_string(),
"copilot-bing".to_string()
}
value if value.contains("gemini") => "gemini".to_string(), value if value.contains("gemini") => "gemini".to_string(),
value if value.contains("google") => "google".to_string(), value if value.contains("google") => "google".to_string(),
value if value.contains("claude") => "claude".to_string(), value if value.contains("claude") => "claude".to_string(),
@@ -289,11 +287,10 @@ fn sorted_referrer_buckets(
let mut items = breakdown let mut items = breakdown
.iter() .iter()
.filter_map(|(referrer, count)| { .filter_map(|(referrer, count)| {
predicate(referrer) predicate(referrer).then(|| AnalyticsReferrerBucket {
.then(|| AnalyticsReferrerBucket { referrer: referrer.clone(),
referrer: referrer.clone(), count: *count,
count: *count, })
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -648,8 +645,11 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
page_views_last_24h += 1; page_views_last_24h += 1;
} }
let referrer = let referrer = normalize_tracking_source(
normalize_tracking_source(Some(&event.path), event.referrer.clone(), event.metadata.as_ref()); Some(&event.path),
event.referrer.clone(),
event.metadata.as_ref(),
);
*referrer_breakdown.entry(referrer).or_insert(0) += 1; *referrer_breakdown.entry(referrer).or_insert(0) += 1;
} }
@@ -737,7 +737,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
providers_last_7d.truncate(6); providers_last_7d.truncate(6);
let top_referrers = sorted_referrer_buckets(&referrer_breakdown, |_| true, 8); let top_referrers = sorted_referrer_buckets(&referrer_breakdown, |_| true, 8);
let ai_referrers_last_7d = sorted_referrer_buckets(&referrer_breakdown, is_ai_discovery_source, 6); let ai_referrers_last_7d =
sorted_referrer_buckets(&referrer_breakdown, is_ai_discovery_source, 6);
let ai_discovery_page_views_last_7d = referrer_breakdown let ai_discovery_page_views_last_7d = referrer_breakdown
.iter() .iter()
.filter(|(referrer, _)| is_ai_discovery_source(referrer)) .filter(|(referrer, _)| is_ai_discovery_source(referrer))
@@ -747,7 +748,17 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
let mut popular_posts = post_breakdown let mut popular_posts = post_breakdown
.into_iter() .into_iter()
.map( .map(
|(slug, (page_views, read_completes, total_progress, progress_count, total_duration, duration_count))| { |(
slug,
(
page_views,
read_completes,
total_progress,
progress_count,
total_duration,
duration_count,
),
)| {
AnalyticsPopularPost { AnalyticsPopularPost {
title: post_titles title: post_titles
.get(&slug) .get(&slug)
@@ -1018,7 +1029,8 @@ pub async fn build_public_content_highlights(
} else { } else {
0.0 0.0
}, },
avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64), avg_duration_ms: (duration_count > 0)
.then(|| total_duration / duration_count as f64),
}, },
) )
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1085,8 +1097,22 @@ pub async fn build_public_content_windows(
.await?; .await?;
Ok(vec![ Ok(vec![
summarize_public_content_window(&events, &post_titles, now - Duration::hours(24), "24h", "24h", 1), summarize_public_content_window(
summarize_public_content_window(&events, &post_titles, now - Duration::days(7), "7d", "7d", 7), &events,
&post_titles,
now - Duration::hours(24),
"24h",
"24h",
1,
),
summarize_public_content_window(
&events,
&post_titles,
now - Duration::days(7),
"7d",
"7d",
7,
),
summarize_public_content_window(&events, &post_titles, since_30d, "30d", "30d", 30), summarize_public_content_window(&events, &post_titles, since_30d, "30d", "30d", 30),
]) ])
} }
@@ -1233,7 +1259,8 @@ fn summarize_public_content_window(
} else { } else {
0.0 0.0
}, },
avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64), avg_duration_ms: (duration_count > 0)
.then(|| total_duration / duration_count as f64),
}, },
) )
.collect::<Vec<_>>(); .collect::<Vec<_>>();

View File

@@ -30,16 +30,23 @@ struct MarkdownFrontmatter {
deserialize_with = "deserialize_optional_string_list" deserialize_with = "deserialize_optional_string_list"
)] )]
categories: Option<Vec<String>>, categories: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_optional_string_list")] #[serde(
default,
alias = "tag",
deserialize_with = "deserialize_optional_string_list"
)]
tags: Option<Vec<String>>, tags: Option<Vec<String>>,
post_type: Option<String>, post_type: Option<String>,
image: Option<String>, image: Option<String>,
images: Option<Vec<String>>, images: Option<Vec<String>>,
pinned: Option<bool>, pinned: Option<bool>,
#[serde(alias = "Hidden")]
hidden: Option<bool>,
published: Option<bool>, published: Option<bool>,
draft: Option<bool>, draft: Option<bool>,
status: Option<String>, status: Option<String>,
visibility: Option<String>, visibility: Option<String>,
#[serde(alias = "date")]
publish_at: Option<String>, publish_at: Option<String>,
unpublish_at: Option<String>, unpublish_at: Option<String>,
canonical_url: Option<String>, canonical_url: Option<String>,
@@ -233,6 +240,18 @@ fn resolve_post_status(frontmatter: &MarkdownFrontmatter) -> String {
} }
} }
fn resolve_post_visibility(frontmatter: &MarkdownFrontmatter) -> String {
if let Some(visibility) = trim_to_option(frontmatter.visibility.clone()) {
return normalize_post_visibility(Some(&visibility));
}
if frontmatter.hidden.unwrap_or(false) {
POST_VISIBILITY_UNLISTED.to_string()
} else {
POST_VISIBILITY_PUBLIC.to_string()
}
}
pub fn effective_post_state( pub fn effective_post_state(
status: &str, status: &str,
publish_at: Option<DateTime<FixedOffset>>, publish_at: Option<DateTime<FixedOffset>>,
@@ -500,7 +519,7 @@ pub fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Res
images: normalize_string_list(frontmatter.images.clone()), images: normalize_string_list(frontmatter.images.clone()),
pinned: frontmatter.pinned.unwrap_or(false), pinned: frontmatter.pinned.unwrap_or(false),
status: resolve_post_status(&frontmatter), status: resolve_post_status(&frontmatter),
visibility: normalize_post_visibility(frontmatter.visibility.as_deref()), visibility: resolve_post_visibility(&frontmatter),
publish_at: format_frontmatter_datetime(parse_frontmatter_datetime( publish_at: format_frontmatter_datetime(parse_frontmatter_datetime(
frontmatter.publish_at.clone(), frontmatter.publish_at.clone(),
)), )),
@@ -1152,3 +1171,39 @@ pub async fn import_markdown_documents(
Ok(imported) Ok(imported)
} }
#[cfg(test)]
mod tests {
use super::{POST_VISIBILITY_UNLISTED, parse_markdown_source};
#[test]
fn parse_markdown_source_supports_hugo_aliases() {
let markdown = r#"---
title: "Linux Shell"
date: 2022-05-21T10:02:09+08:00
draft: false
Hidden: true
slug: linux-shell
categories:
- Linux
tag:
- Linux
- Shell
---
# Linux Shell
"#;
let post = parse_markdown_source("linux-shell", markdown, "content/posts/linux-shell.md")
.expect("markdown should parse");
assert_eq!(post.slug, "linux-shell");
assert_eq!(post.category.as_deref(), Some("Linux"));
assert_eq!(post.tags, vec!["Linux", "Shell"]);
assert_eq!(post.visibility, POST_VISIBILITY_UNLISTED);
assert_eq!(
post.publish_at.as_deref(),
Some("2022-05-21T02:02:09+00:00")
);
}
}

View File

@@ -1,5 +1,5 @@
pub mod admin_audit;
pub mod abuse_guard; pub mod abuse_guard;
pub mod admin_audit;
pub mod ai; pub mod ai;
pub mod analytics; pub mod analytics;
pub mod backups; pub mod backups;

View File

@@ -1,9 +1,9 @@
use loco_rs::prelude::*;
use crate::{ use crate::{
controllers::site_settings, controllers::site_settings,
models::_entities::{comments, friend_links, site_settings as site_settings_model}, models::_entities::{comments, friend_links, site_settings as site_settings_model},
services::subscriptions, services::subscriptions,
}; };
use loco_rs::prelude::*;
fn notification_channel_type(settings: &site_settings_model::Model) -> &'static str { fn notification_channel_type(settings: &site_settings_model::Model) -> &'static str {
match settings match settings
@@ -71,10 +71,16 @@ pub async fn notify_new_comment(ctx: &AppContext, item: &comments::Model) {
}); });
let text = format!( let text = format!(
"收到一条新的评论。\n\n文章:{}\n作者:{}\n范围:{}\n状态:{}\n摘要:{}", "收到一条新的评论。\n\n文章:{}\n作者:{}\n范围:{}\n状态:{}\n摘要:{}",
item.post_slug.clone().unwrap_or_else(|| "未知文章".to_string()), item.post_slug
.clone()
.unwrap_or_else(|| "未知文章".to_string()),
item.author.clone().unwrap_or_else(|| "匿名".to_string()), item.author.clone().unwrap_or_else(|| "匿名".to_string()),
item.scope, item.scope,
if item.approved.unwrap_or(false) { "已通过" } else { "待审核" }, if item.approved.unwrap_or(false) {
"已通过"
} else {
"待审核"
},
excerpt(item.content.as_deref(), 200).unwrap_or_else(|| "".to_string()), excerpt(item.content.as_deref(), 200).unwrap_or_else(|| "".to_string()),
); );
@@ -135,9 +141,13 @@ pub async fn notify_new_friend_link(ctx: &AppContext, item: &friend_links::Model
}); });
let text = format!( let text = format!(
"收到新的友链申请。\n\n站点:{}\n链接:{}\n分类:{}\n状态:{}\n描述:{}", "收到新的友链申请。\n\n站点:{}\n链接:{}\n分类:{}\n状态:{}\n描述:{}",
item.site_name.clone().unwrap_or_else(|| "未命名站点".to_string()), item.site_name
.clone()
.unwrap_or_else(|| "未命名站点".to_string()),
item.site_url, item.site_url,
item.category.clone().unwrap_or_else(|| "未分类".to_string()), item.category
.clone()
.unwrap_or_else(|| "未分类".to_string()),
item.status.clone().unwrap_or_else(|| "pending".to_string()), item.status.clone().unwrap_or_else(|| "pending".to_string()),
item.description.clone().unwrap_or_else(|| "".to_string()), item.description.clone().unwrap_or_else(|| "".to_string()),
); );

View File

@@ -1,5 +1,5 @@
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
use aws_sdk_s3::{config::Credentials, primitives::ByteStream, Client}; use aws_sdk_s3::{Client, config::Credentials, primitives::ByteStream};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{EntityTrait, QueryOrder}; use sea_orm::{EntityTrait, QueryOrder};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};

View File

@@ -243,11 +243,18 @@ fn normalize_browser_push_subscription(raw: Value) -> Result<Value> {
serde_json::to_value(subscription).map_err(Into::into) serde_json::to_value(subscription).map_err(Into::into)
} }
fn merge_browser_push_metadata(existing: Option<&Value>, incoming: Option<Value>, subscription: Value) -> Value { fn merge_browser_push_metadata(
existing: Option<&Value>,
incoming: Option<Value>,
subscription: Value,
) -> Value {
let mut object = merge_metadata(existing, incoming) let mut object = merge_metadata(existing, incoming)
.and_then(|value| value.as_object().cloned()) .and_then(|value| value.as_object().cloned())
.unwrap_or_default(); .unwrap_or_default();
object.insert("kind".to_string(), Value::String("browser-push".to_string())); object.insert(
"kind".to_string(),
Value::String("browser-push".to_string()),
);
object.insert("subscription".to_string(), subscription); object.insert("subscription".to_string(), subscription);
Value::Object(object) Value::Object(object)
} }
@@ -280,7 +287,8 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
if let Some(items) = payload.get(key).and_then(Value::as_array) { if let Some(items) = payload.get(key).and_then(Value::as_array) {
values.extend( values.extend(
items.iter() items
.iter()
.filter_map(Value::as_str) .filter_map(Value::as_str)
.map(normalize_string) .map(normalize_string)
.filter(|item| !item.is_empty()), .filter(|item| !item.is_empty()),
@@ -298,7 +306,8 @@ fn payload_match_strings(payload: &Value, key: &str) -> Vec<String> {
if let Some(items) = post.get(key).and_then(Value::as_array) { if let Some(items) = post.get(key).and_then(Value::as_array) {
values.extend( values.extend(
items.iter() items
.iter()
.filter_map(Value::as_str) .filter_map(Value::as_str)
.map(normalize_string) .map(normalize_string)
.filter(|item| !item.is_empty()), .filter(|item| !item.is_empty()),
@@ -410,19 +419,31 @@ pub fn to_public_subscription_view(item: &subscriptions::Model) -> PublicSubscri
} }
} }
fn subscription_links(item: &subscriptions::Model, site_context: &SiteContext) -> (Option<String>, Option<String>, Option<String>) { fn subscription_links(
let manage_url = item item: &subscriptions::Model,
.manage_token site_context: &SiteContext,
.as_deref() ) -> (Option<String>, Option<String>, Option<String>) {
.and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/manage", token)); let manage_url = item.manage_token.as_deref().and_then(|token| {
let unsubscribe_url = item build_token_link(
.manage_token site_context.site_url.as_deref(),
.as_deref() "/subscriptions/manage",
.and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/unsubscribe", token)); token,
let confirm_url = item )
.confirm_token });
.as_deref() let unsubscribe_url = item.manage_token.as_deref().and_then(|token| {
.and_then(|token| build_token_link(site_context.site_url.as_deref(), "/subscriptions/confirm", token)); build_token_link(
site_context.site_url.as_deref(),
"/subscriptions/unsubscribe",
token,
)
});
let confirm_url = item.confirm_token.as_deref().and_then(|token| {
build_token_link(
site_context.site_url.as_deref(),
"/subscriptions/confirm",
token,
)
});
(manage_url, unsubscribe_url, confirm_url) (manage_url, unsubscribe_url, confirm_url)
} }
@@ -449,7 +470,11 @@ async fn send_confirmation_email(ctx: &AppContext, item: &subscriptions::Model)
.await .await
} }
fn subscription_allows_event(item: &subscriptions::Model, event_type: &str, payload: &Value) -> bool { fn subscription_allows_event(
item: &subscriptions::Model,
event_type: &str,
payload: &Value,
) -> bool {
if normalize_status(&item.status) != STATUS_ACTIVE { if normalize_status(&item.status) != STATUS_ACTIVE {
return false; return false;
} }
@@ -487,7 +512,9 @@ fn subscription_allows_event(item: &subscriptions::Model, event_type: &str, payl
if !tags.is_empty() { if !tags.is_empty() {
let payload_tags = payload_match_strings(payload, "tags"); let payload_tags = payload_match_strings(payload, "tags");
if payload_tags.is_empty() if payload_tags.is_empty()
|| !tags.iter().any(|tag| payload_tags.iter().any(|item| item == tag)) || !tags
.iter()
.any(|tag| payload_tags.iter().any(|item| item == tag))
{ {
return false; return false;
} }
@@ -501,10 +528,15 @@ pub async fn list_subscriptions(
channel_type: Option<&str>, channel_type: Option<&str>,
status: Option<&str>, status: Option<&str>,
) -> Result<Vec<subscriptions::Model>> { ) -> Result<Vec<subscriptions::Model>> {
let mut query = subscriptions::Entity::find().order_by(subscriptions::Column::CreatedAt, Order::Desc); let mut query =
subscriptions::Entity::find().order_by(subscriptions::Column::CreatedAt, Order::Desc);
if let Some(channel_type) = channel_type.map(str::trim).filter(|value| !value.is_empty()) { if let Some(channel_type) = channel_type
query = query.filter(subscriptions::Column::ChannelType.eq(normalize_channel_type(channel_type))); .map(str::trim)
.filter(|value| !value.is_empty())
{
query = query
.filter(subscriptions::Column::ChannelType.eq(normalize_channel_type(channel_type)));
} }
if let Some(status) = status.map(str::trim).filter(|value| !value.is_empty()) { if let Some(status) = status.map(str::trim).filter(|value| !value.is_empty()) {
@@ -771,7 +803,9 @@ pub async fn update_subscription_preferences(
if let Some(status) = status { if let Some(status) = status {
let normalized = normalize_status(&status); let normalized = normalize_status(&status);
if normalized == STATUS_PENDING { if normalized == STATUS_PENDING {
return Err(Error::BadRequest("偏好页不支持将状态改回 pending".to_string())); return Err(Error::BadRequest(
"偏好页不支持将状态改回 pending".to_string(),
));
} }
active.status = Set(normalized); active.status = Set(normalized);
} }
@@ -783,7 +817,10 @@ pub async fn update_subscription_preferences(
active.update(&ctx.db).await.map_err(Into::into) active.update(&ctx.db).await.map_err(Into::into)
} }
pub async fn unsubscribe_subscription(ctx: &AppContext, token: &str) -> Result<subscriptions::Model> { pub async fn unsubscribe_subscription(
ctx: &AppContext,
token: &str,
) -> Result<subscriptions::Model> {
let item = get_subscription_by_manage_token(ctx, token).await?; let item = get_subscription_by_manage_token(ctx, token).await?;
let mut active = item.into_active_model(); let mut active = item.into_active_model();
active.status = Set(STATUS_UNSUBSCRIBED.to_string()); active.status = Set(STATUS_UNSUBSCRIBED.to_string());
@@ -821,11 +858,7 @@ async fn update_subscription_delivery_state(
let mut active = subscription.into_active_model(); let mut active = subscription.into_active_model();
active.last_notified_at = Set(Some(Utc::now().to_rfc3339())); active.last_notified_at = Set(Some(Utc::now().to_rfc3339()));
active.last_delivery_status = Set(Some(status.to_string())); active.last_delivery_status = Set(Some(status.to_string()));
active.failure_count = Set(Some(if success { active.failure_count = Set(Some(if success { 0 } else { current_failures + 1 }));
0
} else {
current_failures + 1
}));
let _ = active.update(&ctx.db).await?; let _ = active.update(&ctx.db).await?;
Ok(()) Ok(())
} }
@@ -945,10 +978,16 @@ pub async fn queue_event_for_active_subscriptions(
) -> Result<QueueDispatchSummary> { ) -> Result<QueueDispatchSummary> {
let subscriptions = active_subscriptions(ctx).await?; let subscriptions = active_subscriptions(ctx).await?;
if subscriptions.is_empty() { if subscriptions.is_empty() {
return Ok(QueueDispatchSummary { queued: 0, skipped: 0 }); return Ok(QueueDispatchSummary {
queued: 0,
skipped: 0,
});
} }
let site_context = SiteContext { site_name, site_url }; let site_context = SiteContext {
site_name,
site_url,
};
let mut queued = 0usize; let mut queued = 0usize;
let mut skipped = 0usize; let mut skipped = 0usize;
@@ -1058,38 +1097,32 @@ async fn deliver_via_channel(
CHANNEL_EMAIL => Err(Error::BadRequest( CHANNEL_EMAIL => Err(Error::BadRequest(
"email channel must be delivered via subscription context".to_string(), "email channel must be delivered via subscription context".to_string(),
)), )),
CHANNEL_DISCORD => { CHANNEL_DISCORD => Client::new()
Client::new() .post(target)
.post(target) .json(&serde_json::json!({ "content": message.text }))
.json(&serde_json::json!({ "content": message.text })) .send()
.send() .await
.await .and_then(|response| response.error_for_status())
.and_then(|response| response.error_for_status()) .map(|_| None)
.map(|_| None) .map_err(|error| Error::BadRequest(error.to_string())),
.map_err(|error| Error::BadRequest(error.to_string())) CHANNEL_TELEGRAM => Client::new()
} .post(target)
CHANNEL_TELEGRAM => { .json(&serde_json::json!({ "text": message.text }))
Client::new() .send()
.post(target) .await
.json(&serde_json::json!({ "text": message.text })) .and_then(|response| response.error_for_status())
.send() .map(|_| None)
.await .map_err(|error| Error::BadRequest(error.to_string())),
.and_then(|response| response.error_for_status()) CHANNEL_NTFY => Client::new()
.map(|_| None) .post(resolve_ntfy_target(target))
.map_err(|error| Error::BadRequest(error.to_string())) .header("Title", &message.subject)
} .header("Content-Type", "text/plain; charset=utf-8")
CHANNEL_NTFY => { .body(message.text.clone())
Client::new() .send()
.post(resolve_ntfy_target(target)) .await
.header("Title", &message.subject) .and_then(|response| response.error_for_status())
.header("Content-Type", "text/plain; charset=utf-8") .map(|_| None)
.body(message.text.clone()) .map_err(|error| Error::BadRequest(error.to_string())),
.send()
.await
.and_then(|response| response.error_for_status())
.map(|_| None)
.map_err(|error| Error::BadRequest(error.to_string()))
}
CHANNEL_WEB_PUSH => { CHANNEL_WEB_PUSH => {
let settings = crate::controllers::site_settings::load_current(ctx).await?; let settings = crate::controllers::site_settings::load_current(ctx).await?;
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?; let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
@@ -1141,7 +1174,10 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
return Ok(()); return Ok(());
}; };
if matches!(delivery.status.as_str(), DELIVERY_STATUS_SENT | DELIVERY_STATUS_SKIPPED | DELIVERY_STATUS_EXHAUSTED) { if matches!(
delivery.status.as_str(),
DELIVERY_STATUS_SENT | DELIVERY_STATUS_SKIPPED | DELIVERY_STATUS_EXHAUSTED
) {
return Ok(()); return Ok(());
} }
@@ -1149,15 +1185,19 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
.payload .payload
.clone() .clone()
.ok_or_else(|| Error::BadRequest("delivery payload 为空".to_string())) .ok_or_else(|| Error::BadRequest("delivery payload 为空".to_string()))
.and_then(|value| serde_json::from_value::<QueuedDeliveryPayload>(value).map_err(Into::into))?; .and_then(|value| {
serde_json::from_value::<QueuedDeliveryPayload>(value).map_err(Into::into)
})?;
let attempts = delivery.attempts_count + 1; let attempts = delivery.attempts_count + 1;
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let subscription = match delivery.subscription_id { let subscription = match delivery.subscription_id {
Some(subscription_id) => subscriptions::Entity::find_by_id(subscription_id) Some(subscription_id) => {
.one(&ctx.db) subscriptions::Entity::find_by_id(subscription_id)
.await?, .one(&ctx.db)
.await?
}
None => None, None => None,
}; };
@@ -1171,7 +1211,13 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
active.next_retry_at = Set(None); active.next_retry_at = Set(None);
active.delivered_at = Set(Some(Utc::now().to_rfc3339())); active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?; let _ = active.update(&ctx.db).await?;
update_subscription_delivery_state(ctx, Some(subscription.id), DELIVERY_STATUS_SKIPPED, false).await?; update_subscription_delivery_state(
ctx,
Some(subscription.id),
DELIVERY_STATUS_SKIPPED,
false,
)
.await?;
return Ok(()); return Ok(());
} }
} }
@@ -1202,7 +1248,14 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
.await .await
} }
} else { } else {
deliver_via_channel(ctx, &delivery.channel_type, &delivery.target, &message, None).await deliver_via_channel(
ctx,
&delivery.channel_type,
&delivery.target,
&message,
None,
)
.await
}; };
let subscription_id = delivery.subscription_id; let subscription_id = delivery.subscription_id;
let delivery_channel_type = delivery.channel_type.clone(); let delivery_channel_type = delivery.channel_type.clone();
@@ -1218,7 +1271,8 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
active.next_retry_at = Set(None); active.next_retry_at = Set(None);
active.delivered_at = Set(Some(Utc::now().to_rfc3339())); active.delivered_at = Set(Some(Utc::now().to_rfc3339()));
let _ = active.update(&ctx.db).await?; let _ = active.update(&ctx.db).await?;
update_subscription_delivery_state(ctx, subscription_id, DELIVERY_STATUS_SENT, true).await?; update_subscription_delivery_state(ctx, subscription_id, DELIVERY_STATUS_SENT, true)
.await?;
} }
Err(error) => { Err(error) => {
let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS) let next_retry_at = (attempts < MAX_DELIVERY_ATTEMPTS)
@@ -1298,7 +1352,10 @@ pub async fn send_test_notification(
.await .await
} }
pub async fn notify_post_published(ctx: &AppContext, post: &content::MarkdownPost) -> Result<QueueDispatchSummary> { pub async fn notify_post_published(
ctx: &AppContext,
post: &content::MarkdownPost,
) -> Result<QueueDispatchSummary> {
let site_context = load_site_context(ctx).await; let site_context = load_site_context(ctx).await;
let public_url = post_public_url(site_context.site_url.as_deref(), &post.slug); let public_url = post_public_url(site_context.site_url.as_deref(), &post.slug);
let subject = format!("新文章发布:{}", post.title); let subject = format!("新文章发布:{}", post.title);
@@ -1315,13 +1372,17 @@ pub async fn notify_post_published(ctx: &AppContext, post: &content::MarkdownPos
let text = format!( let text = format!(
"{}》已发布。\n\n分类:{}\n标签:{}\n链接:{}\n\n{}", "{}》已发布。\n\n分类:{}\n标签:{}\n链接:{}\n\n{}",
post.title, post.title,
post.category.clone().unwrap_or_else(|| "未分类".to_string()), post.category
.clone()
.unwrap_or_else(|| "未分类".to_string()),
if post.tags.is_empty() { if post.tags.is_empty() {
"".to_string() "".to_string()
} else { } else {
post.tags.join(", ") post.tags.join(", ")
}, },
public_url.clone().unwrap_or_else(|| format!("/articles/{}", post.slug)), public_url
.clone()
.unwrap_or_else(|| format!("/articles/{}", post.slug)),
post.description.clone().unwrap_or_default(), post.description.clone().unwrap_or_default(),
); );
@@ -1355,7 +1416,8 @@ pub async fn send_digest(ctx: &AppContext, period: &str) -> Result<DigestDispatc
let lines = if posts.is_empty() { let lines = if posts.is_empty() {
vec![format!("最近 {} 天还没有新的公开文章。", days)] vec![format!("最近 {} 天还没有新的公开文章。", days)]
} else { } else {
posts.iter() posts
.iter()
.map(|post| { .map(|post| {
let url = post_public_url(site_context.site_url.as_deref(), &post.slug) let url = post_public_url(site_context.site_url.as_deref(), &post.slug)
.unwrap_or_else(|| format!("/articles/{}", post.slug)); .unwrap_or_else(|| format!("/articles/{}", post.slug));
@@ -1369,7 +1431,14 @@ pub async fn send_digest(ctx: &AppContext, period: &str) -> Result<DigestDispatc
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
let subject = format!("{} 内容摘要", if normalized_period == "monthly" { "月报" } else { "周报" }); let subject = format!(
"{} 内容摘要",
if normalized_period == "monthly" {
"月报"
} else {
"周报"
}
);
let body = format!("统计周期:最近 {}\n\n{}", days, lines.join("\n\n")); let body = format!("统计周期:最近 {}\n\n{}", days, lines.join("\n\n"));
let payload = serde_json::json!({ let payload = serde_json::json!({
"event_type": event_type, "event_type": event_type,

View File

@@ -91,8 +91,7 @@ fn normalize_ip(value: Option<&str>) -> Option<String> {
} }
fn verify_url() -> String { fn verify_url() -> String {
env_value(ENV_TURNSTILE_VERIFY_URL) env_value(ENV_TURNSTILE_VERIFY_URL).unwrap_or_else(|| DEFAULT_TURNSTILE_VERIFY_URL.to_string())
.unwrap_or_else(|| DEFAULT_TURNSTILE_VERIFY_URL.to_string())
} }
fn client() -> &'static Client { fn client() -> &'static Client {
@@ -173,11 +172,10 @@ pub async fn verify_token(
token: Option<&str>, token: Option<&str>,
client_ip: Option<&str>, client_ip: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let secret = secret_key(settings).ok_or_else(|| { let secret = secret_key(settings)
Error::BadRequest("人机验证尚未配置完成,请稍后重试".to_string()) .ok_or_else(|| Error::BadRequest("人机验证尚未配置完成,请稍后重试".to_string()))?;
})?; let response_token =
let response_token = trim_to_option(token) trim_to_option(token).ok_or_else(|| Error::BadRequest("请先完成人机验证".to_string()))?;
.ok_or_else(|| Error::BadRequest("请先完成人机验证".to_string()))?;
let mut form_data = vec![ let mut form_data = vec![
("secret".to_string(), secret), ("secret".to_string(), secret),

View File

@@ -66,9 +66,7 @@ pub fn private_key_configured(settings: &site_settings::Model) -> bool {
} }
pub fn is_enabled(settings: &site_settings::Model) -> bool { pub fn is_enabled(settings: &site_settings::Model) -> bool {
settings.web_push_enabled.unwrap_or(false) public_key_configured(settings) && private_key_configured(settings)
&& public_key_configured(settings)
&& private_key_configured(settings)
} }
pub fn subscription_info_from_metadata(metadata: Option<&Value>) -> Result<SubscriptionInfo> { pub fn subscription_info_from_metadata(metadata: Option<&Value>) -> Result<SubscriptionInfo> {

View File

@@ -1,14 +1,11 @@
use chrono::Utc; use chrono::Utc;
use loco_rs::{ use loco_rs::{bgworker::BackgroundWorker, prelude::*};
bgworker::BackgroundWorker,
prelude::*,
};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, Order, ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, Order, PaginatorTrait,
PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, QueryFilter, QueryOrder, QuerySelect, Set,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
use crate::{ use crate::{
models::_entities::{notification_deliveries, worker_jobs}, models::_entities::{notification_deliveries, worker_jobs},
@@ -213,7 +210,10 @@ fn can_cancel_status(status: &str, cancel_requested: bool) -> bool {
} }
fn can_retry_status(status: &str) -> bool { fn can_retry_status(status: &str) -> bool {
matches!(status, JOB_STATUS_FAILED | JOB_STATUS_CANCELLED | JOB_STATUS_SUCCEEDED) matches!(
status,
JOB_STATUS_FAILED | JOB_STATUS_CANCELLED | JOB_STATUS_SUCCEEDED
)
} }
fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord { fn to_job_record(item: worker_jobs::Model) -> WorkerJobRecord {
@@ -256,15 +256,17 @@ fn catalog_entries() -> Vec<WorkerCatalogEntry> {
(TASK_SEND_MONTHLY_DIGEST, JOB_KIND_TASK, true, true), (TASK_SEND_MONTHLY_DIGEST, JOB_KIND_TASK, true, true),
] ]
.into_iter() .into_iter()
.map(|(worker_name, job_kind, supports_cancel, supports_retry)| WorkerCatalogEntry { .map(
worker_name: worker_name.to_string(), |(worker_name, job_kind, supports_cancel, supports_retry)| WorkerCatalogEntry {
job_kind: job_kind.to_string(), worker_name: worker_name.to_string(),
label: label_for(worker_name), job_kind: job_kind.to_string(),
description: description_for(worker_name), label: label_for(worker_name),
queue_name: queue_name_for(worker_name), description: description_for(worker_name),
supports_cancel, queue_name: queue_name_for(worker_name),
supports_retry, supports_cancel,
}) supports_retry,
},
)
.collect() .collect()
} }
@@ -311,7 +313,10 @@ async fn dispatch_download(args_ctx: AppContext, args: DownloadWorkerArgs) {
} }
} }
async fn dispatch_notification_delivery(args_ctx: AppContext, args: NotificationDeliveryWorkerArgs) { async fn dispatch_notification_delivery(
args_ctx: AppContext,
args: NotificationDeliveryWorkerArgs,
) {
let worker = NotificationDeliveryWorker::build(&args_ctx); let worker = NotificationDeliveryWorker::build(&args_ctx);
if let Err(error) = worker.perform(args).await { if let Err(error) = worker.perform(args).await {
tracing::warn!("notification delivery worker execution failed: {error}"); tracing::warn!("notification delivery worker execution failed: {error}");
@@ -326,7 +331,9 @@ async fn enqueue_download_worker(ctx: &AppContext, args: DownloadWorkerArgs) ->
Ok(()) Ok(())
} }
Err(error) => { Err(error) => {
tracing::warn!("download worker queue unavailable, falling back to local task: {error}"); tracing::warn!(
"download worker queue unavailable, falling back to local task: {error}"
);
tokio::spawn(dispatch_download(ctx.clone(), args)); tokio::spawn(dispatch_download(ctx.clone(), args));
Ok(()) Ok(())
} }
@@ -344,7 +351,9 @@ async fn enqueue_notification_worker(
Ok(()) Ok(())
} }
Err(error) => { Err(error) => {
tracing::warn!("notification worker queue unavailable, falling back to local task: {error}"); tracing::warn!(
"notification worker queue unavailable, falling back to local task: {error}"
);
tokio::spawn(dispatch_notification_delivery(ctx.clone(), args)); tokio::spawn(dispatch_notification_delivery(ctx.clone(), args));
Ok(()) Ok(())
} }
@@ -442,17 +451,19 @@ pub async fn get_overview(ctx: &AppContext) -> Result<WorkerOverview> {
_ => {} _ => {}
} }
let entry = grouped.entry(item.worker_name.clone()).or_insert_with(|| WorkerStats { let entry = grouped
worker_name: item.worker_name.clone(), .entry(item.worker_name.clone())
job_kind: item.job_kind.clone(), .or_insert_with(|| WorkerStats {
label: label_for(&item.worker_name), worker_name: item.worker_name.clone(),
queued: 0, job_kind: item.job_kind.clone(),
running: 0, label: label_for(&item.worker_name),
succeeded: 0, queued: 0,
failed: 0, running: 0,
cancelled: 0, succeeded: 0,
last_job_at: None, failed: 0,
}); cancelled: 0,
last_job_at: None,
});
match item.status.as_str() { match item.status.as_str() {
JOB_STATUS_QUEUED => entry.queued += 1, JOB_STATUS_QUEUED => entry.queued += 1,
@@ -473,18 +484,35 @@ pub async fn get_overview(ctx: &AppContext) -> Result<WorkerOverview> {
} }
pub async fn list_jobs(ctx: &AppContext, query: WorkerJobListQuery) -> Result<WorkerJobListResult> { pub async fn list_jobs(ctx: &AppContext, query: WorkerJobListQuery) -> Result<WorkerJobListResult> {
let mut db_query = worker_jobs::Entity::find().order_by(worker_jobs::Column::CreatedAt, Order::Desc); let mut db_query =
worker_jobs::Entity::find().order_by(worker_jobs::Column::CreatedAt, Order::Desc);
if let Some(status) = query.status.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { if let Some(status) = query
.status
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(worker_jobs::Column::Status.eq(status)); db_query = db_query.filter(worker_jobs::Column::Status.eq(status));
} }
if let Some(job_kind) = query.job_kind.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { if let Some(job_kind) = query
.job_kind
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(worker_jobs::Column::JobKind.eq(job_kind)); db_query = db_query.filter(worker_jobs::Column::JobKind.eq(job_kind));
} }
if let Some(worker_name) = query.worker_name.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { if let Some(worker_name) = query
.worker_name
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter(worker_jobs::Column::WorkerName.eq(worker_name)); db_query = db_query.filter(worker_jobs::Column::WorkerName.eq(worker_name));
} }
if let Some(search) = query.search.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) { if let Some(search) = query
.search
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
{
db_query = db_query.filter( db_query = db_query.filter(
Condition::any() Condition::any()
.add(worker_jobs::Column::WorkerName.contains(search.clone())) .add(worker_jobs::Column::WorkerName.contains(search.clone()))
@@ -830,6 +858,9 @@ pub async fn retry_job(
) )
.await .await
} }
_ => Err(Error::BadRequest(format!("不支持重试任务:{}", item.worker_name))), _ => Err(Error::BadRequest(format!(
"不支持重试任务:{}",
item.worker_name
))),
} }
} }

View File

@@ -1,5 +1,8 @@
use std::io::Cursor;
use image::{ImageFormat, load_from_memory};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use reqwest::{header, redirect::Policy, Url}; use reqwest::{Url, header, redirect::Policy};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::services::{media_assets, storage, worker_jobs}; use crate::services::{media_assets, storage, worker_jobs};
@@ -14,6 +17,8 @@ pub struct DownloadWorkerArgs {
#[serde(default)] #[serde(default)]
pub prefix: Option<String>, pub prefix: Option<String>,
#[serde(default)] #[serde(default)]
pub target_format: Option<String>,
#[serde(default)]
pub title: Option<String>, pub title: Option<String>,
#[serde(default)] #[serde(default)]
pub alt_text: Option<String>, pub alt_text: Option<String>,
@@ -48,12 +53,30 @@ fn trim_to_option(value: Option<String>) -> Option<String> {
} }
fn normalize_prefix(value: Option<String>) -> String { fn normalize_prefix(value: Option<String>) -> String {
value.unwrap_or_else(|| "uploads".to_string()) value
.unwrap_or_else(|| "uploads".to_string())
.trim() .trim()
.trim_matches('/') .trim_matches('/')
.to_string() .to_string()
} }
pub fn normalize_target_format(value: Option<String>) -> Result<Option<String>> {
let Some(value) = value.map(|item| item.trim().to_ascii_lowercase()) else {
return Ok(None);
};
if value.is_empty() || value == "original" {
return Ok(None);
}
match value.as_str() {
"webp" | "avif" => Ok(Some(value)),
_ => Err(Error::BadRequest(
"target_format 仅支持 webp、avif 或 original".to_string(),
)),
}
}
fn derive_file_name(url: &Url) -> Option<String> { fn derive_file_name(url: &Url) -> Option<String> {
url.path_segments() url.path_segments()
.and_then(|segments| segments.last()) .and_then(|segments| segments.last())
@@ -102,10 +125,78 @@ fn is_supported_content_type(value: Option<&str>) -> bool {
.trim() .trim()
.split(';') .split(';')
.next() .next()
.map(|item| matches!(item, "image/png" | "image/jpeg" | "image/webp" | "image/gif" | "image/avif" | "image/svg+xml" | "application/pdf")) .map(|item| {
matches!(
item,
"image/png"
| "image/jpeg"
| "image/webp"
| "image/gif"
| "image/avif"
| "image/svg+xml"
| "application/pdf"
)
})
.unwrap_or(false) .unwrap_or(false)
} }
fn is_convertible_bitmap_content_type(value: Option<&str>) -> bool {
value
.unwrap_or_default()
.trim()
.split(';')
.next()
.map(|item| {
matches!(
item,
"image/png" | "image/jpeg" | "image/webp" | "image/avif"
)
})
.unwrap_or(false)
}
fn target_mime_type(target_format: &str) -> Option<&'static str> {
match target_format {
"webp" => Some("image/webp"),
"avif" => Some("image/avif"),
_ => None,
}
}
fn convert_media_bytes(
bytes: &[u8],
content_type: Option<&str>,
target_format: &str,
) -> Result<(Vec<u8>, String, String)> {
let target_mime = target_mime_type(target_format)
.ok_or_else(|| Error::BadRequest("不支持的目标媒体格式".to_string()))?;
if !is_convertible_bitmap_content_type(content_type) {
return Err(Error::BadRequest(
"当前仅支持把 PNG / JPEG / WebP / AVIF 转成 WebP 或 AVIF".to_string(),
));
}
let image = load_from_memory(bytes)
.map_err(|error| Error::BadRequest(format!("解析远程图片失败: {error}")))?;
let image_format = match target_format {
"webp" => ImageFormat::WebP,
"avif" => ImageFormat::Avif,
_ => return Err(Error::BadRequest("不支持的目标媒体格式".to_string())),
};
let mut cursor = Cursor::new(Vec::new());
image
.write_to(&mut cursor, image_format)
.map_err(|error| Error::BadRequest(format!("转换远程图片格式失败: {error}")))?;
Ok((
cursor.into_inner(),
target_format.to_string(),
target_mime.to_string(),
))
}
fn default_title(args: &DownloadWorkerArgs, file_name: Option<&str>) -> String { fn default_title(args: &DownloadWorkerArgs, file_name: Option<&str>) -> String {
trim_to_option(args.title.clone()) trim_to_option(args.title.clone())
.or_else(|| { .or_else(|| {
@@ -188,8 +279,41 @@ pub async fn download_media_to_storage(
} }
let file_name = derive_file_name(&final_url); let file_name = derive_file_name(&final_url);
let extension = infer_extension(file_name.as_deref(), content_type.as_deref()) let target_format = normalize_target_format(args.target_format.clone())?;
.ok_or_else(|| Error::BadRequest("无法识别远程媒体文件类型".to_string()))?; let normalized_source_content_type = content_type
.as_deref()
.map(str::trim)
.and_then(|value| value.split(';').next())
.map(str::to_ascii_lowercase);
let already_target_format = target_format
.as_deref()
.and_then(target_mime_type)
.zip(normalized_source_content_type.as_deref())
.map(|(target_mime, source_mime)| source_mime == target_mime)
.unwrap_or(false);
let (payload_bytes, extension, resolved_content_type) =
if let Some(target_format) = target_format.as_deref() {
if already_target_format {
(
bytes.to_vec(),
target_format.to_string(),
target_mime_type(target_format)
.unwrap_or_default()
.to_string(),
)
} else {
convert_media_bytes(&bytes, content_type.as_deref(), target_format)?
}
} else {
(
bytes.to_vec(),
infer_extension(file_name.as_deref(), content_type.as_deref())
.ok_or_else(|| Error::BadRequest("无法识别远程媒体文件类型".to_string()))?,
content_type
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string()),
)
};
let prefix = normalize_prefix(args.prefix.clone()); let prefix = normalize_prefix(args.prefix.clone());
let object_key = storage::build_object_key( let object_key = storage::build_object_key(
&prefix, &prefix,
@@ -199,8 +323,8 @@ pub async fn download_media_to_storage(
let stored = storage::upload_bytes_to_r2( let stored = storage::upload_bytes_to_r2(
ctx, ctx,
&object_key, &object_key,
bytes.to_vec(), payload_bytes.clone(),
content_type.as_deref(), Some(resolved_content_type.as_str()),
Some("public, max-age=31536000, immutable"), Some("public, max-age=31536000, immutable"),
) )
.await?; .await?;
@@ -222,9 +346,9 @@ pub async fn download_media_to_storage(
Ok(DownloadedMediaObject { Ok(DownloadedMediaObject {
key: stored.key, key: stored.key,
url: stored.url, url: stored.url,
size_bytes: bytes.len() as i64, size_bytes: payload_bytes.len() as i64,
source_url: final_url.to_string(), source_url: final_url.to_string(),
content_type, content_type: Some(resolved_content_type),
}) })
} }

View File

@@ -1,4 +1,4 @@
use chrono::{offset::Local, Duration}; use chrono::{Duration, offset::Local};
use insta::assert_debug_snapshot; use insta::assert_debug_snapshot;
use loco_rs::testing::prelude::*; use loco_rs::testing::prelude::*;
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};

View File

@@ -1,5 +1,5 @@
use axum::http::{HeaderName, HeaderValue}; use axum::http::{HeaderName, HeaderValue};
use loco_rs::{app::AppContext, TestServer}; use loco_rs::{TestServer, app::AppContext};
use termi_api::{models::users, views::auth::LoginResponse}; use termi_api::{models::users, views::auth::LoginResponse};
const USER_EMAIL: &str = "test@loco.com"; const USER_EMAIL: &str = "test@loco.com";

View File

@@ -16,7 +16,8 @@ const {
const { locale, t, buildLocaleUrl } = getI18n(Astro); const { locale, t, buildLocaleUrl } = getI18n(Astro);
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled); const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter( const musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true;
const musicPlaylist = (musicEnabled ? Astro.props.siteSettings?.musicPlaylist : []).filter(
(item) => item?.title?.trim() && item?.url?.trim() (item) => item?.title?.trim() && item?.url?.trim()
); );
const musicPlaylistPayload = JSON.stringify(musicPlaylist); const musicPlaylistPayload = JSON.stringify(musicPlaylist);
@@ -60,11 +61,11 @@ const currentNavLabel =
</span> </span>
</a> </a>
<div class="relative hidden lg:block flex-1 min-w-0 max-w-[16rem] xl:max-w-[18rem]"> <div class="relative hidden min-w-[20rem] grow basis-[24rem] lg:block xl:min-w-[24rem] xl:basis-[30rem]">
<div class="terminal-toolbar-module gap-2 px-2.5 py-1.5"> <div class="terminal-toolbar-module min-w-0 gap-2 px-2.5 py-1.5">
<div class="terminal-toolbar-label" id="search-label">{t('header.searchPromptKeyword')}</div> <div class="terminal-toolbar-label shrink-0 whitespace-nowrap" id="search-label">{t('header.searchPromptKeyword')}</div>
{aiEnabled && ( {aiEnabled && (
<div id="search-mode-panel" class="hidden 2xl:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1"> <div id="search-mode-panel" class="hidden shrink-0 2xl:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
<button <button
type="button" type="button"
class="search-mode-btn rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]" class="search-mode-btn rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
@@ -91,7 +92,7 @@ const currentNavLabel =
placeholder={t('header.searchPlaceholderKeyword')} placeholder={t('header.searchPlaceholderKeyword')}
class="terminal-console-input" class="terminal-console-input"
/> />
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8" aria-label="Search"> <button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8 shrink-0" aria-label="Search">
<i id="search-btn-icon" class="fas fa-search text-sm"></i> <i id="search-btn-icon" class="fas fa-search text-sm"></i>
</button> </button>
</div> </div>
@@ -104,73 +105,89 @@ const currentNavLabel =
></div> ></div>
</div> </div>
<div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5"> <div class="ml-auto hidden shrink-0 items-center gap-2 lg:flex">
<div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8"> {musicEnabled && (
<img <div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5">
id="desktop-music-cover" <div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8">
src={currentMusicTrack?.coverImageUrl || ''} <img
alt={currentMusicTrack?.title || 'Music cover'} id="desktop-music-cover"
class:list={[ src={currentMusicTrack?.coverImageUrl || ''}
'h-full w-full object-cover', alt={currentMusicTrack?.title || 'Music cover'}
!currentMusicTrack?.coverImageUrl && 'hidden' class:list={[
]} 'h-full w-full object-cover',
/> !currentMusicTrack?.coverImageUrl && 'hidden'
<i ]}
id="desktop-music-cover-fallback" />
class:list={[ <i
'fas fa-compact-disc text-sm text-[var(--primary)]', id="desktop-music-cover-fallback"
currentMusicTrack?.coverImageUrl && 'hidden' class:list={[
]} 'fas fa-compact-disc text-sm text-[var(--primary)]',
></i> currentMusicTrack?.coverImageUrl && 'hidden'
</div> ]}
<div class="min-w-0 flex-1"> ></i>
<p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title"> </div>
{currentMusicTrack?.title || '未配置曲目'} <div class="min-w-0 flex-1">
</p> <p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title">
<div class="mt-1 flex items-center gap-1"> {currentMusicTrack?.title || '未配置曲目'}
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Previous track" disabled={!hasMusicPlaylist}> </p>
<i class="fas fa-step-backward text-[11px]"></i> <div class="mt-1 flex items-center gap-1">
</button> <button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Previous track" disabled={!hasMusicPlaylist}>
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}> <i class="fas fa-step-backward text-[11px]"></i>
<i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i> </button>
</button> <button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Next track" disabled={!hasMusicPlaylist}> <i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i>
<i class="fas fa-step-forward text-[11px]"></i> </button>
</button> <button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-[11px]"></i>
</button>
</div>
</div>
</div> </div>
)}
{aiEnabled && (
<a
href="/ask"
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
>
<i class="fas fa-robot text-sm"></i>
<span class="hidden xl:inline">{t('nav.ask')}</span>
</a>
)}
<div class="flex shrink-0 items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
{localeLinks.map((item) => (
<a
href={item.href}
data-locale-switch={item.locale}
class:list={[
'rounded-lg px-2.5 py-1.5 text-xs font-semibold transition',
item.locale === locale
? 'bg-[var(--primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
]}
aria-current={item.locale === locale ? 'true' : undefined}
title={item.label}
>
{item.shortLabel}
</a>
))}
</div>
<div class="relative shrink-0">
<ThemeToggle
client:load
labels={{
toggle: t('header.themeToggle'),
system: t('header.themeSystem'),
light: t('header.themeLight'),
dark: t('header.themeDark'),
}}
/>
</div> </div>
</div> </div>
{aiEnabled && ( <div class="relative shrink-0 lg:hidden">
<a
href="/ask"
class="hidden lg:inline-flex items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
>
<i class="fas fa-robot text-sm"></i>
<span class="hidden xl:inline">{t('nav.ask')}</span>
</a>
)}
<div class="hidden lg:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
{localeLinks.map((item) => (
<a
href={item.href}
data-locale-switch={item.locale}
class:list={[
'rounded-lg px-2.5 py-1.5 text-xs font-semibold transition',
item.locale === locale
? 'bg-[var(--primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
]}
aria-current={item.locale === locale ? 'true' : undefined}
title={item.label}
>
{item.shortLabel}
</a>
))}
</div>
<div class="relative shrink-0">
<ThemeToggle <ThemeToggle
client:load client:load
labels={{ labels={{
@@ -262,51 +279,53 @@ const currentNavLabel =
</div> </div>
</div> </div>
<div class="terminal-toolbar-module items-center gap-3"> {musicEnabled && (
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8"> <div class="terminal-toolbar-module items-center gap-3">
<img <div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8">
id="music-cover" <img
src={currentMusicTrack?.coverImageUrl || ''} id="music-cover"
alt={currentMusicTrack?.title || 'Music cover'} src={currentMusicTrack?.coverImageUrl || ''}
class:list={[ alt={currentMusicTrack?.title || 'Music cover'}
'h-full w-full object-cover', class:list={[
!currentMusicTrack?.coverImageUrl && 'hidden' 'h-full w-full object-cover',
]} !currentMusicTrack?.coverImageUrl && 'hidden'
/> ]}
<i />
id="music-cover-fallback" <i
class:list={[ id="music-cover-fallback"
'fas fa-compact-disc text-base text-[var(--primary)]', class:list={[
currentMusicTrack?.coverImageUrl && 'hidden' 'fas fa-compact-disc text-base text-[var(--primary)]',
]} currentMusicTrack?.coverImageUrl && 'hidden'
></i> ]}
</div> ></i>
<div class="min-w-0 flex-1">
<div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
<div class="mt-1 flex items-center gap-2">
<button id="music-prev" class="terminal-toolbar-iconbtn" aria-label="Previous track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-backward text-xs"></i>
</button>
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
<i class="fas fa-play text-xs" id="music-play-icon"></i>
</button>
<button id="music-next" class="terminal-toolbar-iconbtn" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-xs"></i>
</button>
<button id="music-volume" class="terminal-toolbar-iconbtn" aria-label="Mute or unmute" disabled={!hasMusicPlaylist}>
<i class="fas fa-volume-up text-xs"></i>
</button>
</div> </div>
<div class="mt-2 min-w-0"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title"> <div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
{currentMusicTrack?.title || '未配置曲目'} <div class="mt-1 flex items-center gap-2">
</p> <button id="music-prev" class="terminal-toolbar-iconbtn" aria-label="Previous track" disabled={!hasMusicPlaylist}>
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist"> <i class="fas fa-step-backward text-xs"></i>
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'} </button>
</p> <button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
<i class="fas fa-play text-xs" id="music-play-icon"></i>
</button>
<button id="music-next" class="terminal-toolbar-iconbtn" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-xs"></i>
</button>
<button id="music-volume" class="terminal-toolbar-iconbtn" aria-label="Mute or unmute" disabled={!hasMusicPlaylist}>
<i class="fas fa-volume-up text-xs"></i>
</button>
</div>
<div class="mt-2 min-w-0">
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title">
{currentMusicTrack?.title || '未配置曲目'}
</p>
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist">
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'}
</p>
</div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
@@ -320,13 +339,13 @@ const currentNavLabel =
: '' : ''
]} ]}
> >
<span class="flex items-center gap-3"> <span class="flex min-w-0 flex-1 items-center gap-3">
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/82 text-[var(--primary)]"> <span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/82 text-[var(--primary)]">
<i class={`fas ${item.icon} text-sm`}></i> <i class={`fas ${item.icon} text-sm`}></i>
</span> </span>
<span class="min-w-0"> <span class="min-w-0">
<span class="terminal-toolbar-label block">{t('header.navigation')}</span> <span class="terminal-toolbar-label block">{t('header.navigation')}</span>
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">{item.text}</span> <span class="mt-1 block truncate text-sm font-semibold text-[var(--title-color)]">{item.text}</span>
</span> </span>
</span> </span>
<i class="fas fa-arrow-right text-[11px] text-[var(--text-tertiary)]"></i> <i class="fas fa-arrow-right text-[11px] text-[var(--text-tertiary)]"></i>

File diff suppressed because it is too large Load Diff

View File

@@ -1,145 +0,0 @@
---
import { resolvePublicApiBaseUrl } from '../lib/api/client';
interface Props {
requestUrl?: string | URL;
}
const { requestUrl } = Astro.props as Props;
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
---
<section class="terminal-subscribe-card" data-subscribe-root data-api-url={subscribeApiUrl}>
<div class="terminal-subscribe-head">
<p class="terminal-subscribe-kicker">newsletter / notifications</p>
<h3>订阅更新</h3>
<p>输入邮箱后,可以收到新文章通知;提交后需要先去邮箱点击确认链接才会正式生效。</p>
</div>
<form class="terminal-subscribe-form" data-subscribe-form>
<input type="text" name="displayName" placeholder="称呼(可选)" autocomplete="name" />
<input type="email" name="email" placeholder="name@example.com" autocomplete="email" required />
<button type="submit">订阅</button>
</form>
<p class="terminal-subscribe-status" data-subscribe-status>支持确认订阅、退订链接和偏好管理页。</p>
</section>
<script>
document.querySelectorAll('[data-subscribe-root]').forEach((root) => {
const form = root.querySelector('[data-subscribe-form]');
const status = root.querySelector('[data-subscribe-status]');
const apiUrl = root.getAttribute('data-api-url');
if (!(form instanceof HTMLFormElement) || !(status instanceof HTMLElement) || !apiUrl) {
return;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const email = String(formData.get('email') || '').trim();
const displayName = String(formData.get('displayName') || '').trim();
if (!email) {
status.textContent = '请输入邮箱地址。';
return;
}
status.textContent = '提交中...';
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
displayName,
source: 'frontend-home',
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.description || '订阅失败,请稍后再试。');
}
form.reset();
status.textContent =
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
} catch (error) {
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
}
});
});
</script>
<style>
.terminal-subscribe-card {
margin-top: 1.5rem;
border: 1px solid rgba(94, 234, 212, 0.16);
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
border-radius: 1rem;
padding: 1.1rem;
}
.terminal-subscribe-kicker {
margin: 0 0 0.35rem;
color: var(--primary);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.22em;
}
.terminal-subscribe-head h3 {
margin: 0;
font-size: 1.1rem;
}
.terminal-subscribe-head p:last-child {
margin: 0.45rem 0 0;
color: var(--text-secondary);
font-size: 0.92rem;
line-height: 1.7;
}
.terminal-subscribe-form {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.terminal-subscribe-form input {
width: 100%;
border-radius: 0.8rem;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.45);
color: var(--text-primary);
padding: 0.85rem 0.95rem;
}
.terminal-subscribe-form button {
border: 0;
border-radius: 0.8rem;
padding: 0.9rem 1rem;
font-weight: 600;
color: #08111f;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
cursor: pointer;
}
.terminal-subscribe-status {
margin: 0.75rem 0 0;
color: var(--text-secondary);
font-size: 0.88rem;
}
@media (min-width: 768px) {
.terminal-subscribe-form {
grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1.2fr) auto;
align-items: center;
}
}
</style>

View File

@@ -3,31 +3,40 @@
import { getI18n } from '../lib/i18n'; import { getI18n } from '../lib/i18n';
const { t } = getI18n(Astro); const { t } = getI18n(Astro);
const hasBeforeNav = Astro.slots.has('before-nav');
--- ---
<aside id="toc-container" class="hidden w-full shrink-0 lg:block lg:w-72"> <aside
<div class="terminal-panel-muted sticky top-24 space-y-4"> id="toc-container"
<div class="space-y-3"> class="hidden w-full shrink-0 lg:block lg:w-72"
<span class="terminal-kicker"> data-has-before-nav={hasBeforeNav ? 'true' : 'false'}
<i class="fas fa-terminal"></i> >
nav stack <div class="sticky top-24 space-y-4">
</span> <slot name="before-nav" />
<div class="terminal-section-title">
<span class="terminal-section-icon"> <div id="toc-panel" class="terminal-panel-muted space-y-4">
<i class="fas fa-list-ul"></i> <div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-terminal"></i>
nav stack
</span> </span>
<div> <div class="terminal-section-title">
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3> <span class="terminal-section-icon">
<p class="text-xs leading-6 text-[var(--text-secondary)]"> <i class="fas fa-list-ul"></i>
{t('toc.intro')} </span>
</p> <div>
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3>
<p class="text-xs leading-6 text-[var(--text-secondary)]">
{t('toc.intro')}
</p>
</div>
</div> </div>
</div> </div>
</div>
<nav id="toc-nav" class="space-y-2 max-h-[calc(100vh-240px)] overflow-y-auto pr-1 text-sm"> <nav id="toc-nav" class="space-y-2 max-h-[calc(100vh-240px)] overflow-y-auto pr-1 text-sm">
<!-- TOC items will be generated by JavaScript --> <!-- TOC items will be generated by JavaScript -->
</nav> </nav>
</div>
</div> </div>
</aside> </aside>
@@ -39,10 +48,13 @@ const { t } = getI18n(Astro);
const headings = content.querySelectorAll('h2, h3'); const headings = content.querySelectorAll('h2, h3');
const tocNav = document.getElementById('toc-nav'); const tocNav = document.getElementById('toc-nav');
const tocPanel = document.getElementById('toc-panel');
const container = document.getElementById('toc-container');
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
if (!tocNav || headings.length === 0) { if (!tocNav || headings.length === 0) {
const container = document.getElementById('toc-container'); if (tocPanel) tocPanel.style.display = 'none';
if (container) container.style.display = 'none'; if (container && !hasBeforeNav) container.style.display = 'none';
return; return;
} }

View File

@@ -27,60 +27,38 @@ const { locale } = getI18n(Astro);
const isEnglish = locale.startsWith('en'); const isEnglish = locale.startsWith('en');
--- ---
<section class="rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.94),rgba(var(--primary-rgb),0.04))] p-5 sm:p-6"> <section class="sr-only" data-discovery-brief>
<div class="flex flex-wrap items-center gap-2"> <p>{badge}</p>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--primary)]"> <p>{kicker}</p>
<i class="fas fa-brain text-[10px]"></i> <h3>{title}</h3>
{badge} <p>{summary}</p>
</span>
<span class="terminal-kicker"> <div>
<i class="fas fa-sitemap"></i> <h4>{isEnglish ? 'Key signals' : '关键信号'}</h4>
{kicker} {highlights.length > 0 ? (
</span> <ul>
{highlights.map((item) => (
<li>{item}</li>
))}
</ul>
) : (
<p>{summary}</p>
)}
</div> </div>
<div class="mt-4"> <div>
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3> <h4>{isEnglish ? 'FAQ' : '常见问答'}</h4>
<p class="mt-3 max-w-4xl text-sm leading-7 text-[var(--text-secondary)]">{summary}</p> {faqs.length > 0 ? (
</div> <div>
{faqs.slice(0, 3).map((item) => (
<div class="mt-5 grid gap-4 lg:grid-cols-2"> <article>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4"> <p>{item.question}</p>
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]"> <p>{item.answer}</p>
{isEnglish ? 'Key signals' : '关键信号'} </article>
))}
</div> </div>
{highlights.length > 0 ? ( ) : (
<ul class="mt-3 space-y-3"> <p>{summary}</p>
{highlights.map((item, index) => ( )}
<li class="flex items-start gap-3 text-sm leading-7 text-[var(--text-secondary)]">
<span class="mt-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-xs font-semibold text-[var(--primary)]">
{index + 1}
</span>
<span>{item}</span>
</li>
))}
</ul>
) : (
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
)}
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
{isEnglish ? 'FAQ' : '常见问答'}
</div>
{faqs.length > 0 ? (
<div class="mt-3 space-y-3">
{faqs.slice(0, 3).map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/65 bg-[var(--bg)]/60 px-4 py-3">
<p class="text-sm font-semibold leading-6 text-[var(--title-color)]">{item.question}</p>
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{item.answer}</p>
</div>
))}
</div>
) : (
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
)}
</div>
</div> </div>
</section> </section>

View File

@@ -26,20 +26,23 @@ const {
shareTitle, shareTitle,
summary, summary,
canonicalUrl, canonicalUrl,
badge = isEnglish ? 'distribution' : '快速分发', badge = isEnglish ? 'page share' : '页面分享',
kicker = 'geo / share', title = isEnglish ? 'Quick share' : '一键分享',
title = isEnglish ? 'Share & AI discovery' : '分享与 AI 分发',
description = isEnglish description = isEnglish
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.' ? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
: '让规范链接持续通过社交渠道回流,方便用户传播,也方便 AI 搜索把信号聚合到同一个来源。', : '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
stats = [], stats = [],
wechatShareQrEnabled = false, wechatShareQrEnabled = false,
} = Astro.props as Props; } = Astro.props as Props;
const visibleBadge = badge;
const visibleTitle = title;
const visibleDescription = description;
const copy = isEnglish const copy = isEnglish
? { ? {
summaryTitle: 'Share note', summaryTitle: 'Page summary',
canonical: 'Canonical', canonical: 'Page link',
copySummary: 'Copy note', copySummary: 'Copy note',
copySummarySuccess: 'Share note copied', copySummarySuccess: 'Share note copied',
copySummaryFailed: 'Copy failed', copySummaryFailed: 'Copy failed',
@@ -54,8 +57,8 @@ const copy = isEnglish
shareToTelegram: 'Share to Telegram', shareToTelegram: 'Share to Telegram',
shareToWeChat: 'WeChat QR', shareToWeChat: 'WeChat QR',
qrModalTitle: 'WeChat scan share', qrModalTitle: 'WeChat scan share',
qrModalDescription: 'Scan this local QR code in WeChat to open the canonical URL on mobile.', qrModalDescription: 'Scan this QR code in WeChat to open the current page on mobile.',
qrModalHint: 'Keep the canonical link as the single source of truth for social sharing and AI discovery.', qrModalHint: 'Sharing the page link is enough for others to continue from here.',
downloadQr: 'Download QR', downloadQr: 'Download QR',
downloadQrStarted: 'QR download started', downloadQrStarted: 'QR download started',
qrOpened: 'WeChat QR ready', qrOpened: 'WeChat QR ready',
@@ -64,30 +67,30 @@ const copy = isEnglish
toastInfoTitle: 'Share ready', toastInfoTitle: 'Share ready',
} }
: { : {
summaryTitle: '分享摘要', summaryTitle: '页面简介',
canonical: '规范地址', canonical: '固定链接',
copySummary: '复制摘要', copySummary: '复制简介',
copySummarySuccess: '分享摘要已复制', copySummarySuccess: '页面简介已复制',
copySummaryFailed: '复制失败', copySummaryFailed: '复制失败',
copyLink: '复制固定链接', copyLink: '复制固定链接',
copyLinkSuccess: '固定链接已复制', copyLinkSuccess: '固定链接已复制',
copyLinkFailed: '固定链接复制失败', copyLinkFailed: '固定链接复制失败',
shareSummary: '分享摘要', shareSummary: '直接分享',
shareSuccess: '已打开分享面板', shareSuccess: '已打开系统分享',
shareFallback: '分享文案已复制', shareFallback: '分享内容已复制',
shareFailed: '分享失败', shareFailed: '分享失败',
shareToX: '分享到 X', shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram', shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫', shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫码分享', qrModalTitle: '微信扫一扫',
qrModalDescription: '使用本地生成的二维码,在微信扫一扫,就能直接打开当前页面的规范链接。', qrModalDescription: '微信扫一扫,就能在手机上继续浏览当前页面。',
qrModalHint: '尽量分享规范地址,方便用户回访,也方便 AI 搜索把信号聚合回同一个页面。', qrModalHint: '如果要发给别人,直接复制下方链接会更方便。',
downloadQr: '下载二维码', downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载', downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开', qrOpened: '微信二维码已打开',
toastSuccessTitle: '操作完成', toastSuccessTitle: '操作完成',
toastErrorTitle: '操作失败', toastErrorTitle: '操作失败',
toastInfoTitle: '分享渠道已就绪', toastInfoTitle: '已准备好',
}; };
const safeSummary = summary.trim() || shareTitle; const safeSummary = summary.trim() || shareTitle;
@@ -112,7 +115,7 @@ if (wechatShareQrEnabled) {
wechatShareQrSvg = await QRCode.toString(canonicalUrl, { wechatShareQrSvg = await QRCode.toString(canonicalUrl, {
type: 'svg', type: 'svg',
margin: 1, margin: 1,
width: 220, width: 240,
color: { color: {
dark: '#111827', dark: '#111827',
light: '#ffffff', light: '#ffffff',
@@ -120,7 +123,7 @@ if (wechatShareQrEnabled) {
}); });
wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, { wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, {
margin: 1, margin: 1,
width: 360, width: 420,
color: { color: {
dark: '#111827', dark: '#111827',
light: '#ffffff', light: '#ffffff',
@@ -141,17 +144,13 @@ if (wechatShareQrEnabled) {
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]"> <span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
<i class="fas fa-satellite-dish text-[10px]"></i> <i class="fas fa-satellite-dish text-[10px]"></i>
{badge} {visibleBadge}
</span>
<span class="terminal-kicker">
<i class="fas fa-share-nodes"></i>
{kicker}
</span> </span>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3> <h3 class="text-xl font-semibold text-[var(--title-color)]">{visibleTitle}</h3>
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{description}</p> <p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{visibleDescription}</p>
</div> </div>
</div> </div>
@@ -218,7 +217,7 @@ if (wechatShareQrEnabled) {
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]"> <p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share channels' : '分享渠道'} {isEnglish ? 'Share channels' : '分享渠道'}
</p> </p>
<p class="text-xs leading-6 text-[var(--text-secondary)]">{description}</p> <p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a <a
@@ -267,7 +266,7 @@ if (wechatShareQrEnabled) {
aria-hidden="true" aria-hidden="true"
> >
<div class="flex min-h-screen items-center justify-center p-4"> <div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-3xl rounded-[30px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.98),rgba(var(--bg-rgb),0.92))] p-5 shadow-[0_24px_80px_rgba(15,23,42,0.28)] sm:p-6"> <div class="w-full max-w-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="space-y-2"> <div class="space-y-2">
<span class="terminal-kicker"> <span class="terminal-kicker">
@@ -292,24 +291,25 @@ if (wechatShareQrEnabled) {
</button> </button>
</div> </div>
<div class="mt-6 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]"> <div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[240px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-4 shadow-[0_18px_45px_rgba(15,23,42,0.12)]"> <div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div> <div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4"> <div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical} {copy.canonical}
</div> </div>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p> <p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div> </div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 p-4"> <div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle} {copy.summaryTitle}
</div> </div>
<p class="mt-2 text-sm font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p> <p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{safeSummary}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p> <p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p>
</div> </div>

View File

@@ -17,6 +17,9 @@ declare global {
locale: string; locale: string;
messages: Record<string, unknown>; messages: Record<string, unknown>;
}; };
__termiCommentsReady?: boolean;
__termiHomeReady?: boolean;
__termiSubscriptionPopupReady?: boolean;
__termiTranslate: ( __termiTranslate: (
key: string, key: string,
params?: Record<string, string | number | null | undefined> params?: Record<string, string | number | null | undefined>

View File

@@ -270,6 +270,7 @@ export interface ApiSiteSettings {
social_email: string | null; social_email: string | null;
location: string | null; location: string | null;
tech_stack: string[] | null; tech_stack: string[] | null;
music_enabled?: boolean | null;
music_playlist: Array<{ music_playlist: Array<{
title: string; title: string;
artist?: string | null; artist?: string | null;
@@ -423,10 +424,10 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
siteName: 'InitCool', siteName: 'InitCool',
siteShortName: 'Termi', siteShortName: 'Termi',
siteUrl: 'https://init.cool', siteUrl: 'https://init.cool',
siteTitle: 'InitCool - 终端风格的内容平台', siteTitle: 'InitCool · 技术笔记与内容档案',
siteDescription: '一个基于终端美学的个人内容站,记录代码、设计和生活。', siteDescription: '围绕开发实践、产品观察与长期积累整理的中文内容站。',
heroTitle: '欢迎来到我的极客终端博客', heroTitle: '欢迎来到 InitCool',
heroSubtitle: '这里记录技术、代码和生活点滴', heroSubtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。',
ownerName: 'InitCool', ownerName: 'InitCool',
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool', ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool',
ownerBio: 'InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。', ownerBio: 'InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
@@ -437,6 +438,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
email: 'mailto:initcoool@gmail.com', email: 'mailto:initcoool@gmail.com',
}, },
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'], techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'],
musicEnabled: true,
musicPlaylist: [ musicPlaylist: [
{ {
title: '山中来信', title: '山中来信',
@@ -597,28 +599,8 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
settings.subscription_verification_mode, settings.subscription_verification_mode,
settings.subscription_turnstile_enabled ? 'turnstile' : 'off', settings.subscription_turnstile_enabled ? 'turnstile' : 'off',
); );
const musicEnabled = settings.music_enabled ?? true;
return { const normalizedMusicPlaylist =
id: String(settings.id),
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle,
ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio,
ownerAvatarUrl: settings.owner_avatar_url ?? undefined,
location: settings.location || DEFAULT_SITE_SETTINGS.location,
social: {
github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github,
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
musicPlaylist:
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length
? settings.music_playlist ? settings.music_playlist
.filter((item) => item.title.trim() && item.url.trim()) .filter((item) => item.title.trim() && item.url.trim())
@@ -631,43 +613,66 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
accentColor: item.accent_color ?? undefined, accentColor: item.accent_color ?? undefined,
description: item.description ?? undefined, description: item.description ?? undefined,
})) }))
: DEFAULT_SITE_SETTINGS.musicPlaylist, : DEFAULT_SITE_SETTINGS.musicPlaylist;
ai: {
enabled: Boolean(settings.ai_enabled), return {
}, id: String(settings.id),
comments: { siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
verificationMode: commentVerificationMode, siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
paragraphsEnabled: settings.paragraph_comments_enabled ?? true, siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
turnstileEnabled: commentVerificationMode === 'turnstile', siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
turnstileSiteKey: siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined, heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
}, heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
subscriptions: { ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
popupEnabled: ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle,
settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled, ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio,
popupTitle: ownerAvatarUrl: settings.owner_avatar_url ?? undefined,
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle, location: settings.location || DEFAULT_SITE_SETTINGS.location,
popupDescription: social: {
settings.subscription_popup_description || github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github,
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription, twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
popupDelaySeconds: email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
settings.subscription_popup_delay_seconds ?? },
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds, techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
verificationMode: subscriptionVerificationMode, musicEnabled,
turnstileEnabled: subscriptionVerificationMode === 'turnstile', musicPlaylist: musicEnabled ? normalizedMusicPlaylist : [],
turnstileSiteKey: ai: {
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined, enabled: Boolean(settings.ai_enabled),
webPushEnabled: Boolean(settings.web_push_enabled), },
webPushVapidPublicKey: comments: {
settings.web_push_vapid_public_key || verificationMode: commentVerificationMode,
resolvePublicWebPushVapidPublicKey() || paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
undefined, turnstileEnabled: commentVerificationMode === 'turnstile',
}, turnstileSiteKey:
seo: { settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
defaultOgImage: settings.seo_default_og_image ?? undefined, },
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined, subscriptions: {
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled), popupEnabled:
}, settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
popupTitle:
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
popupDescription:
settings.subscription_popup_description ||
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription,
popupDelaySeconds:
settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
verificationMode: subscriptionVerificationMode,
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
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,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled),
},
}; };
}; };

View File

@@ -84,7 +84,7 @@ export interface TerminalConfig {
export const terminalConfig: TerminalConfig = { export const terminalConfig: TerminalConfig = {
defaultCategory: 'blog', defaultCategory: 'blog',
welcomeMessage: '欢迎来到我的博客', welcomeMessage: '欢迎来到 InitCool',
prompt: { prompt: {
prefix: 'user@blog', prefix: 'user@blog',
separator: ':', separator: ':',
@@ -100,8 +100,8 @@ I N NN I T C O O O O L
I N N I T CCCC OOO OOO LLLLL`, I N N I T CCCC OOO OOO LLLLL`,
title: '~/blog', title: '~/blog',
welcome: { welcome: {
title: '欢迎来到我的极客终端博客', title: '欢迎来到 InitCool',
subtitle: '这里记录技术、代码和生活点滴' subtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。'
}, },
navLinks: [ navLinks: [
{ icon: 'fa-file-code', text: '文章', href: '/articles' }, { icon: 'fa-file-code', text: '文章', href: '/articles' },

View File

@@ -0,0 +1,41 @@
export const MAINTENANCE_ACCESS_COOKIE_NAME = 'termi_maintenance_access'
export function sanitizeMaintenanceReturnTo(value: string | null | undefined): string {
if (!value) {
return '/'
}
const trimmed = value.trim()
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
return '/'
}
try {
const parsed = new URL(trimmed, 'https://termi.local')
const nextPath = `${parsed.pathname}${parsed.search}${parsed.hash}`
if (
nextPath === '/maintenance' ||
nextPath.startsWith('/maintenance?') ||
nextPath.startsWith('/api/maintenance')
) {
return '/'
}
return nextPath || '/'
} catch {
return '/'
}
}
export function shouldBypassMaintenance(pathname: string): boolean {
return (
pathname === '/maintenance' ||
pathname.startsWith('/api/maintenance') ||
pathname === '/healthz' ||
pathname === '/favicon.svg' ||
pathname.startsWith('/_astro/') ||
pathname.startsWith('/_image') ||
pathname.startsWith('/_img')
)
}

View File

@@ -84,6 +84,7 @@ export interface SiteSettings {
}; };
techStack: string[]; techStack: string[];
musicPlaylist: MusicTrack[]; musicPlaylist: MusicTrack[];
musicEnabled: boolean;
ai: { ai: {
enabled: boolean; enabled: boolean;
}; };

View File

@@ -0,0 +1,55 @@
import { defineMiddleware } from 'astro:middleware'
import { resolveInternalApiBaseUrl } from './lib/api/client'
import {
MAINTENANCE_ACCESS_COOKIE_NAME,
sanitizeMaintenanceReturnTo,
shouldBypassMaintenance,
} from './lib/maintenance'
interface MaintenanceStatusResponse {
maintenance_mode_enabled?: boolean
access_granted?: boolean
}
async function fetchMaintenanceStatus(url: URL, accessToken?: string): Promise<MaintenanceStatusResponse> {
const response = await fetch(`${resolveInternalApiBaseUrl(url)}/site_settings/maintenance/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accessToken: accessToken?.trim() || undefined,
}),
})
if (!response.ok) {
throw new Error(`Maintenance status request failed: ${response.status}`)
}
return response.json() as Promise<MaintenanceStatusResponse>
}
export const onRequest = defineMiddleware(async (context, next) => {
const { url, cookies, redirect } = context
if (shouldBypassMaintenance(url.pathname)) {
return next()
}
try {
const accessToken = cookies.get(MAINTENANCE_ACCESS_COOKIE_NAME)?.value
const status = await fetchMaintenanceStatus(url, accessToken)
const maintenanceModeEnabled = Boolean(status.maintenance_mode_enabled)
const accessGranted = Boolean(status.access_granted)
if (!maintenanceModeEnabled || accessGranted) {
return next()
}
} catch (error) {
console.error('Failed to resolve maintenance mode status:', error)
}
const returnTo = sanitizeMaintenanceReturnTo(`${url.pathname}${url.search}`)
return redirect(`/maintenance?returnTo=${encodeURIComponent(returnTo)}`, 302)
})

View File

@@ -33,7 +33,6 @@ try {
{ label: t('common.posts'), value: String(posts.length) }, { label: t('common.posts'), value: String(posts.length) },
{ label: t('common.tags'), value: String(tags.length) }, { label: t('common.tags'), value: String(tags.length) },
{ label: t('common.friends'), value: String(friendLinks.filter(friend => friend.status === 'approved').length) }, { label: t('common.friends'), value: String(friendLinks.filter(friend => friend.status === 'approved').length) },
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
]; ];
} catch (error) { } catch (error) {
console.error('Failed to load about data:', error); console.error('Failed to load about data:', error);
@@ -42,7 +41,6 @@ try {
{ label: t('common.posts'), value: '0' }, { label: t('common.posts'), value: '0' },
{ label: t('common.tags'), value: '0' }, { label: t('common.tags'), value: '0' },
{ label: t('common.friends'), value: '0' }, { label: t('common.friends'), value: '0' },
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
]; ];
} }
@@ -57,14 +55,13 @@ const sharePanelCopy = isEnglish
'Use this page as the canonical identity and capability profile so social sharing and AI search can cite one stable source.', 'Use this page as the canonical identity and capability profile so social sharing and AI search can cite one stable source.',
} }
: { : {
badge: '身份主页', badge: '个人介绍',
title: '分享这张身份名片页', title: '分享个人介绍',
description: '把这页当成统一的身份与能力来源分发出去,方便社交回流,也方便 AI 搜索引用到同一个规范地址。', description: '把这页作为个人介绍页分享,方便快速了解作者信息、技术栈和联系方式。',
}; };
const aboutHighlights = buildDiscoveryHighlights([ const aboutHighlights = buildDiscoveryHighlights([
siteSettings.ownerTitle, siteSettings.ownerTitle,
siteSettings.ownerBio, siteSettings.ownerBio,
siteSettings.location || '',
siteSettings.techStack.slice(0, 4).join(' / '), siteSettings.techStack.slice(0, 4).join(' / '),
]); ]);
const aboutFaqs = buildPageFaqs({ const aboutFaqs = buildPageFaqs({
@@ -154,10 +151,6 @@ const aboutJsonLd = [
</div> </div>
</div> </div>
<div class="mt-5 flex flex-wrap gap-2"> <div class="mt-5 flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-location-dot text-[var(--primary)]"></i>
<span>{siteSettings.location || t('common.unknown')}</span>
</span>
<span class="terminal-stat-pill"> <span class="terminal-stat-pill">
<i class="fas fa-layer-group text-[var(--primary)]"></i> <i class="fas fa-layer-group text-[var(--primary)]"></i>
<span>{t('about.techStackCount', { count: techStack.length })}</span> <span>{t('about.techStackCount', { count: techStack.length })}</span>

View File

@@ -0,0 +1,79 @@
import type { APIRoute } from 'astro'
import { resolveInternalApiBaseUrl } from '../../../lib/api/client'
import {
MAINTENANCE_ACCESS_COOKIE_NAME,
sanitizeMaintenanceReturnTo,
} from '../../../lib/maintenance'
interface MaintenanceVerifyResponse {
maintenance_mode_enabled?: boolean
access_granted?: boolean
access_token?: string | null
}
export const POST: APIRoute = async ({ request, url, cookies, redirect }) => {
const formData = await request.formData().catch(() => null)
const code = String(formData?.get('code') ?? '').trim()
const returnTo = sanitizeMaintenanceReturnTo(String(formData?.get('returnTo') ?? '/'))
if (!code) {
return redirect(`/maintenance?error=empty&returnTo=${encodeURIComponent(returnTo)}`, 302)
}
try {
const response = await fetch(`${resolveInternalApiBaseUrl(url)}/site_settings/maintenance/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
})
if (!response.ok) {
throw new Error(`Maintenance verify request failed: ${response.status}`)
}
const payload = (await response.json()) as MaintenanceVerifyResponse
const maintenanceModeEnabled = Boolean(payload.maintenance_mode_enabled)
const accessGranted = Boolean(payload.access_granted)
const accessToken = payload.access_token?.trim()
if (!maintenanceModeEnabled) {
cookies.set(MAINTENANCE_ACCESS_COOKIE_NAME, '', {
httpOnly: true,
sameSite: 'lax',
secure: url.protocol === 'https:',
path: '/',
maxAge: 0,
})
return redirect(returnTo, 302)
}
if (accessGranted && accessToken) {
cookies.set(MAINTENANCE_ACCESS_COOKIE_NAME, accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: url.protocol === 'https:',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return redirect(returnTo, 302)
}
cookies.set(MAINTENANCE_ACCESS_COOKIE_NAME, '', {
httpOnly: true,
sameSite: 'lax',
secure: url.protocol === 'https:',
path: '/',
maxAge: 0,
})
return redirect(`/maintenance?error=invalid&returnTo=${encodeURIComponent(returnTo)}`, 302)
} catch (error) {
console.error('Failed to unlock maintenance mode:', error)
return redirect(`/maintenance?error=unavailable&returnTo=${encodeURIComponent(returnTo)}`, 302)
}
}

View File

@@ -92,20 +92,20 @@ const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, '');
const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled; const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled;
const articleCopy = isEnglish const articleCopy = isEnglish
? { ? {
digestBadge: 'featured digest', digestBadge: 'quick brief',
digestKicker: 'ai digest', digestKicker: 'reading preview',
digestTitle: 'AI / search summary', digestTitle: 'Read this first',
digestDescription: digestDescription:
'This block exposes a compact summary, key takeaways, and canonical follow-up paths for AI search and human skimming.', 'A short overview of the article so readers can quickly grasp the key points before sharing or saving it.',
highlightsTitle: 'Key takeaways', highlightsTitle: 'Key takeaways',
faqTitle: 'Quick FAQ', faqTitle: 'Quick FAQ',
sourceTitle: 'Canonical source signals', sourceTitle: 'Page details',
readTime: 'Read time', readTime: 'Read time',
insightCount: 'Key points', insightCount: 'Key points',
faqCount: 'FAQ', faqCount: 'FAQ',
updated: 'Updated', updated: 'Updated',
category: 'Category', category: 'Category',
canonical: 'Canonical', canonical: 'Permalink',
keywords: 'Keywords', keywords: 'Keywords',
copySummary: 'Copy digest', copySummary: 'Copy digest',
copySuccess: 'Digest copied', copySuccess: 'Digest copied',
@@ -114,39 +114,39 @@ const articleCopy = isEnglish
shareSuccess: 'Share panel opened', shareSuccess: 'Share panel opened',
shareFallback: 'Share text copied', shareFallback: 'Share text copied',
shareFailed: 'Share failed', shareFailed: 'Share failed',
shareChannelsTitle: 'Quick share', shareChannelsTitle: 'Share options',
shareChannelsDescription: shareChannelsDescription:
'Push this article to social channels with a shorter path, so people and AI search tools can pick up the canonical link faster.', 'Copy the overview or permalink, or continue sharing through the channels below.',
shareToX: 'Share to X', shareToX: 'Share to X',
shareToTelegram: 'Share to Telegram', shareToTelegram: 'Share to Telegram',
shareToWeChat: 'WeChat QR', shareToWeChat: 'WeChat scan',
qrModalTitle: 'WeChat scan share', qrModalTitle: 'Scan with WeChat',
qrModalDescription: 'Scan this local QR code in WeChat to open the canonical article URL on mobile.', qrModalDescription: 'Scan this QR code in WeChat to continue reading the article on mobile.',
qrModalHint: 'Prefer sharing the canonical link so users and AI engines can fold signals back to one source.', qrModalHint: 'When you want to send the article to someone else, copying the permalink below is usually the easiest option.',
downloadQr: 'Download QR', downloadQr: 'Download QR',
downloadQrStarted: 'QR download started', downloadQrStarted: 'QR download started',
qrOpened: 'WeChat QR ready', qrOpened: 'WeChat QR ready',
floatingToolsTitle: 'Digest tools', floatingToolsTitle: 'Quick actions',
copyPermalinkSuccess: 'Permalink copied', copyPermalinkSuccess: 'Permalink copied',
copyPermalinkFailed: 'Permalink copy failed', copyPermalinkFailed: 'Permalink copy failed',
toastSuccessTitle: 'Done', toastSuccessTitle: 'Done',
toastErrorTitle: 'Action failed', toastErrorTitle: 'Action failed',
toastInfoTitle: 'Share ready', toastInfoTitle: 'Ready',
} }
: { : {
digestBadge: '精选摘要', digestBadge: '文章导读',
digestKicker: 'ai digest', digestKicker: '阅读前速览',
digestTitle: 'AI / 搜索摘要', digestTitle: '先看重点',
digestDescription: '这块内容会把页面结论、重点摘录和规范入口显式写出来,方便 AI 搜索和用户快速理解。', digestDescription: '先用几句话帮你抓住这篇文章的重点,方便快速浏览、收藏或转发。',
highlightsTitle: '关键信息', highlightsTitle: '关键信息',
faqTitle: '快速问答', faqTitle: '快速问答',
sourceTitle: '规范来源信号', sourceTitle: '页面信息',
readTime: '阅读时长', readTime: '阅读时长',
insightCount: '重点条数', insightCount: '重点条数',
faqCount: '问答条数', faqCount: '问答条数',
updated: '最近更新', updated: '最近更新',
category: '归档分类', category: '归档分类',
canonical: '规范地址', canonical: '固定链接',
keywords: '关键词', keywords: '关键词',
copySummary: '复制摘要', copySummary: '复制摘要',
copySuccess: '摘要已复制', copySuccess: '摘要已复制',
@@ -155,23 +155,23 @@ const articleCopy = isEnglish
shareSuccess: '已打开分享面板', shareSuccess: '已打开分享面板',
shareFallback: '分享文案已复制', shareFallback: '分享文案已复制',
shareFailed: '分享失败', shareFailed: '分享失败',
shareChannelsTitle: '快速分发', shareChannelsTitle: '分享方式',
shareChannelsDescription: '用更短路径把这篇内容发到社交渠道,方便二次传播和 AI 引用回链。', shareChannelsDescription: '可以直接复制摘要、固定链接,或通过常用渠道继续转发。',
shareToX: '分享到 X', shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram', shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫', shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫码分享', qrModalTitle: '微信扫一扫',
qrModalDescription: '使用本地生成的二维码,在微信扫一扫,就能直接打开这篇文章的规范链接。', qrModalDescription: '微信扫一扫,就能在手机上继续阅读这篇文章。',
qrModalHint: '尽量分享规范地址,方便用户回访,也方便 AI 搜索把信号聚合回同一篇内容。', qrModalHint: '发给别人时,优先复制固定链接,对方打开会更方便。',
downloadQr: '下载二维码', downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载', downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开', qrOpened: '微信二维码已打开',
floatingToolsTitle: '摘要工具', floatingToolsTitle: '快捷操作',
copyPermalinkSuccess: '固定链接已复制', copyPermalinkSuccess: '固定链接已复制',
copyPermalinkFailed: '固定链接复制失败', copyPermalinkFailed: '固定链接复制失败',
toastSuccessTitle: '操作完成', toastSuccessTitle: '操作完成',
toastErrorTitle: '操作失败', toastErrorTitle: '操作失败',
toastInfoTitle: '分享渠道已就绪', toastInfoTitle: '已准备好',
}; };
const markdownProcessor = await createMarkdownProcessor(); const markdownProcessor = await createMarkdownProcessor();
@@ -236,7 +236,7 @@ if (wechatShareQrEnabled) {
wechatShareQrSvg = await QRCode.toString(canonicalUrl, { wechatShareQrSvg = await QRCode.toString(canonicalUrl, {
type: 'svg', type: 'svg',
margin: 1, margin: 1,
width: 220, width: 240,
color: { color: {
dark: '#111827', dark: '#111827',
light: '#ffffff', light: '#ffffff',
@@ -244,7 +244,7 @@ if (wechatShareQrEnabled) {
}); });
wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, { wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, {
margin: 1, margin: 1,
width: 360, width: 420,
color: { color: {
dark: '#111827', dark: '#111827',
light: '#ffffff', light: '#ffffff',
@@ -434,55 +434,7 @@ const breadcrumbJsonLd = {
<div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div> <div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div>
<div class="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div> <div class="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div>
<div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div> <div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
<div class="pointer-events-none absolute right-4 top-4 z-10 hidden xl:block"> <div class="relative grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(19rem,0.95fr)]">
<div class="pointer-events-auto rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 px-2 py-2 shadow-[0_10px_28px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="mb-2 px-2 text-[10px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.floatingToolsTitle}
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-copy"
title={articleCopy.copySummary}
aria-label={articleCopy.copySummary}
>
<i class="fas fa-copy text-sm"></i>
</button>
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-share"
title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary}
>
<i class="fas fa-share-nodes text-sm"></i>
</button>
{wechatShareQrEnabled && wechatShareQrSvg && (
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="wechat-qr"
title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat}
>
<i class="fab fa-weixin text-sm"></i>
</button>
)}
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="permalink-copy"
title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')}
>
<i class="fas fa-link text-sm"></i>
</button>
</div>
</div>
</div>
<div class="relative grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(19rem,0.95fr)]">
<div class="space-y-5"> <div class="space-y-5">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]"> <span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
@@ -490,7 +442,7 @@ const breadcrumbJsonLd = {
{articleCopy.digestBadge} {articleCopy.digestBadge}
</span> </span>
<span class="terminal-kicker"> <span class="terminal-kicker">
<i class="fas fa-robot"></i> <i class="fas fa-book-open"></i>
{articleCopy.digestKicker} {articleCopy.digestKicker}
</span> </span>
</div> </div>
@@ -591,25 +543,6 @@ const breadcrumbJsonLd = {
))} ))}
</div> </div>
{articleHighlights.length > 0 && (
<div class="space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.highlightsTitle}
</h3>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{articleHighlights.map((item, index) => (
<div class="rounded-2xl border border-[var(--border-color)]/80 bg-[var(--terminal-bg)]/80 p-4 shadow-[0_10px_30px_rgba(15,23,42,0.04)]">
<div class="flex items-start gap-3">
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)]/12 text-sm font-semibold text-[var(--primary)]">
{String(index + 1).padStart(2, '0')}
</span>
<p class="text-sm leading-7 text-[var(--text-secondary)]">{item}</p>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
@@ -867,7 +800,60 @@ const breadcrumbJsonLd = {
</section> </section>
</div> </div>
<TableOfContents /> <TableOfContents>
<div slot="before-nav" class="terminal-panel-muted space-y-3">
<span class="terminal-kicker">
<i class="fas fa-bolt"></i>
{articleCopy.floatingToolsTitle}
</span>
<div
class:list={[
'grid gap-2',
wechatShareQrEnabled && wechatShareQrSvg ? 'grid-cols-4' : 'grid-cols-3',
]}
>
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-copy"
title={articleCopy.copySummary}
aria-label={articleCopy.copySummary}
>
<i class="fas fa-copy text-sm"></i>
</button>
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-share"
title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary}
>
<i class="fas fa-share-nodes text-sm"></i>
</button>
{wechatShareQrEnabled && wechatShareQrSvg && (
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="wechat-qr"
title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat}
>
<i class="fab fa-weixin text-sm"></i>
</button>
)}
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="permalink-copy"
title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')}
>
<i class="fas fa-link text-sm"></i>
</button>
</div>
</div>
</TableOfContents>
</div> </div>
</div> </div>
@@ -878,7 +864,7 @@ const breadcrumbJsonLd = {
aria-hidden="true" aria-hidden="true"
> >
<div class="flex min-h-screen items-center justify-center p-4"> <div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-3xl rounded-[30px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.98),rgba(var(--bg-rgb),0.92))] p-5 shadow-[0_24px_80px_rgba(15,23,42,0.28)] sm:p-6"> <div class="w-full max-w-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="space-y-2"> <div class="space-y-2">
<span class="terminal-kicker"> <span class="terminal-kicker">
@@ -903,24 +889,25 @@ const breadcrumbJsonLd = {
</button> </button>
</div> </div>
<div class="mt-6 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]"> <div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[240px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-4 shadow-[0_18px_45px_rgba(15,23,42,0.12)]"> <div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div> <div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4"> <div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.canonical} {articleCopy.canonical}
</div> </div>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p> <p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div> </div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 p-4"> <div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.digestTitle} {articleCopy.digestTitle}
</div> </div>
<p class="mt-2 text-sm font-semibold leading-7 text-[var(--title-color)]">{post.title}</p> <p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{post.title}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{articleSynopsis}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.qrModalHint}</p> <p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.qrModalHint}</p>
</div> </div>

View File

@@ -117,9 +117,9 @@ const sharePanelCopy = isEnglish
page: 'Page', page: 'Page',
} }
: { : {
badge: '内容归档', badge: '文章列表',
title: '分享文章总归档页', title: '分享文章列表',
description: '把文章归档页当成统一入口分发出去,方便 AI 检索和读者从一个规范地址继续按类型、分类和标签深入浏览。', description: '把文章列表分享出去,方便继续按分类、标签和类型浏览。',
posts: '文章数', posts: '文章数',
categories: '分类数', categories: '分类数',
tags: '标签数', tags: '标签数',

View File

@@ -73,9 +73,9 @@ const sharePanelCopy = isEnglish
ai: 'AI', ai: 'AI',
} }
: { : {
badge: 'AI 检索', badge: '问答入口',
title: '分享站内 AI 问答页', title: '分享问答页',
description: '把这个 AI 问答入口作为基于问题的规范发现页分发出去,方便用户与 AI 都围绕站内稳定来源继续检索。', description: '把这个问答页分享给需要快速检索站内内容的人。',
examples: '示例问题', examples: '示例问题',
ai: 'AI', ai: 'AI',
}; };

View File

@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
slug: 'Slug', slug: 'Slug',
} }
: { : {
badge: '分类聚合', badge: '分类',
title: '分享这个分类聚合页', title: '分享这个分类页',
description: '这个分类页当成主题入口持续分发,方便用户快速理解,也方便 AI 搜索把同主题信号聚合回这里。', description: '分享这个分类页,方便集中查看同主题内容。',
posts: '文章数', posts: '文章数',
slug: 'Slug', slug: 'Slug',
}; };

View File

@@ -48,9 +48,9 @@ const sharePanelCopy = isEnglish
site: 'Site', site: 'Site',
} }
: { : {
badge: '分类目录', badge: '分类总览',
title: '分享分类总览', title: '分享分类总览',
description: '把分类索引页作为全站主题地图分发出去,方便读者和 AI 搜索从一个规范入口继续下钻到对应专题。', description: '把分类总览分享出去,方便按主题快速找到内容。',
categories: '分类数', categories: '分类数',
site: '站点', site: '站点',
}; };

View File

@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
groups: 'Groups', groups: 'Groups',
} }
: { : {
badge: '友链网络', badge: '友情链接',
title: '分享友情链接页', title: '分享友情链接页',
description: '把友情链接页当成站点网络地图分发出去,方便 AI 搜索和读者理解这个站点的可信邻居与外部引用关系。', description: '把友情链接页分享出去,方便查看常访问的网站与推荐来源。',
links: '友链数', links: '友链数',
groups: '分组数', groups: '分组数',
}; };

View File

@@ -8,7 +8,6 @@ import CommandPrompt from '../components/ui/CommandPrompt.astro';
import FilterPill from '../components/ui/FilterPill.astro'; import FilterPill from '../components/ui/FilterPill.astro';
import PostCard from '../components/PostCard.astro'; import PostCard from '../components/PostCard.astro';
import FriendLinkCard from '../components/FriendLinkCard.astro'; import FriendLinkCard from '../components/FriendLinkCard.astro';
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
import ViewMoreLink from '../components/ui/ViewMoreLink.astro'; import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
import StatsList from '../components/StatsList.astro'; import StatsList from '../components/StatsList.astro';
import TechStackList from '../components/TechStackList.astro'; import TechStackList from '../components/TechStackList.astro';
@@ -251,9 +250,9 @@ const homeShareCopy = isEnglish
'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.', 'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
} }
: { : {
badge: '站点入口', badge: '首页',
title: '分享首页总入口', title: '分享首页',
description: '把首页当成站点的规范总入口分发出去,方便用户和 AI 搜索继续进入文章、分类、评测和个人介绍等核心页面。', description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。',
}; };
const homeBriefHighlights = buildDiscoveryHighlights([ const homeBriefHighlights = buildDiscoveryHighlights([
siteSettings.siteDescription, siteSettings.siteDescription,
@@ -326,7 +325,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
{navLinks.map(link => ( {navLinks.map(link => (
<a href={link.href} class="home-nav-pill"> <a href={link.href} class="home-nav-pill">
<i class={`fas ${link.icon} text-[11px]`}></i> <i class={`fas ${link.icon} text-[11px]`}></i>
<span>{link.text}</span> <span class="min-w-0 truncate">{link.text}</span>
</a> </a>
))} ))}
</div> </div>
@@ -365,13 +364,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
)} )}
<div class="mb-8 px-4">
<CommandPrompt command="subscriptions create --channel email" />
<div class="ml-4">
<SubscriptionSignup requestUrl={Astro.request.url} />
</div>
</div>
<div id="discover" class="mb-6 px-4"> <div id="discover" class="mb-6 px-4">
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} /> <CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
<div class="ml-4 terminal-panel home-discovery-shell"> <div class="ml-4 terminal-panel home-discovery-shell">
@@ -414,25 +406,25 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
<div id="home-active-filters" class:list={['home-active-filter-row', !hasActiveFilters && 'hidden']}> <div id="home-active-filters" class:list={['home-active-filter-row', !hasActiveFilters && 'hidden']}>
<span id="home-active-type" class:list={['terminal-chip', selectedType === 'all' && 'hidden']}> <span id="home-active-type" class:list={['terminal-chip max-w-full min-w-0', selectedType === 'all' && 'hidden']}>
<i id="home-active-type-icon" class={`fas ${postTypeFilters.find((item) => item.id === selectedType)?.icon || 'fa-stream'} text-[10px]`}></i> <i id="home-active-type-icon" class={`fas ${postTypeFilters.find((item) => item.id === selectedType)?.icon || 'fa-stream'} text-[10px]`}></i>
<span id="home-active-type-text">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span> <span id="home-active-type-text" class="min-w-0 truncate">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span>
</span> </span>
<span <span
id="home-active-category" id="home-active-category"
class:list={['terminal-chip terminal-chip--accent', !selectedCategory && 'hidden']} class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedCategory && 'hidden']}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined} style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
> >
<i class="fas fa-folder-open text-[10px]"></i> <i class="fas fa-folder-open text-[10px]"></i>
<span id="home-active-category-text">{selectedCategory}</span> <span id="home-active-category-text" class="min-w-0 truncate">{selectedCategory}</span>
</span> </span>
<span <span
id="home-active-tag" id="home-active-tag"
class:list={['terminal-chip terminal-chip--accent', !selectedTag && 'hidden']} class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedTag && 'hidden']}
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined} style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
> >
<i class="fas fa-hashtag text-[10px]"></i> <i class="fas fa-hashtag text-[10px]"></i>
<span id="home-active-tag-text">{selectedTag}</span> <span id="home-active-tag-text" class="min-w-0 truncate">{selectedTag}</span>
</span> </span>
</div> </div>

View File

@@ -0,0 +1,99 @@
---
import '../styles/global.css'
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
import { sanitizeMaintenanceReturnTo } from '../lib/maintenance'
const errorCode = Astro.url.searchParams.get('error')
const returnTo = sanitizeMaintenanceReturnTo(Astro.url.searchParams.get('returnTo'))
let siteSettings = DEFAULT_SITE_SETTINGS
try {
siteSettings = await api.getSiteSettings()
} catch (error) {
console.error('Failed to load site settings on maintenance page:', error)
}
const errorMessage =
errorCode === 'empty'
? '请先输入访问口令。'
: errorCode === 'invalid'
? '口令不正确,请重新输入。'
: errorCode === 'unavailable'
? '当前无法校验访问口令,请稍后再试。'
: ''
---
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>{siteSettings.siteName} · 维护模式</title>
</head>
<body class="min-h-screen bg-[var(--bg)] text-[var(--text)]">
<main class="mx-auto flex min-h-screen w-full max-w-6xl items-center px-4 py-10 sm:px-6 lg:px-8">
<section class="terminal-toolbar-shell mx-auto w-full max-w-2xl overflow-hidden rounded-[2rem] p-0">
<div class="border-b border-[var(--border-color)] px-6 py-5 sm:px-8">
<div class="flex items-center gap-3">
<span class="flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/10 text-xl font-semibold text-[var(--primary)]">
{siteSettings.siteShortName?.charAt(0) || siteSettings.siteName?.charAt(0) || 'T'}
</span>
<div class="min-w-0">
<p class="terminal-toolbar-label">MAINTENANCE ACCESS</p>
<h1 class="mt-1 text-2xl font-bold text-[var(--title-color)] sm:text-3xl">
{siteSettings.siteName} 正在维护
</h1>
</div>
</div>
</div>
<div class="space-y-6 px-6 py-6 sm:px-8 sm:py-8">
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/80 p-5">
<p class="text-sm leading-7 text-[var(--text-secondary)]">
当前前台内容暂时对外隐藏。你如果拿到了测试口令,可以直接输入进入站点继续浏览;没有口令的话,等我们开放后再访问即可。
</p>
{errorMessage && (
<div class="mt-4 rounded-2xl border border-[var(--danger)]/20 bg-[var(--danger)]/8 px-4 py-3 text-sm text-[var(--danger)]">
{errorMessage}
</div>
)}
</div>
<form method="post" action="/api/maintenance/unlock" class="space-y-4">
<input type="hidden" name="returnTo" value={returnTo} />
<label class="block">
<span class="terminal-form-label">访问口令</span>
<input
type="password"
name="code"
autocomplete="current-password"
placeholder="请输入测试口令"
class="terminal-form-input"
/>
</label>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button type="submit" class="terminal-action-button terminal-action-button-primary min-w-[10rem]">
进入站点
</button>
<p class="text-sm leading-6 text-[var(--text-tertiary)]">
口令修改后,旧的访问凭证会自动失效。
</p>
</div>
</form>
<div class="rounded-3xl border border-dashed border-[var(--border-color)] bg-[var(--header-bg)]/40 px-5 py-4">
<p class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
Return Target
</p>
<p class="mt-2 font-mono text-sm text-[var(--title-color)]">{returnTo}</p>
</div>
</div>
</section>
</main>
</body>
</html>

View File

@@ -111,9 +111,9 @@ const sharePanelCopy =
'Push the structured rating, status, and canonical review URL into social and AI discovery flows from one compact summary block.', 'Push the structured rating, status, and canonical review URL into social and AI discovery flows from one compact summary block.',
} }
: { : {
badge: '评测快照', badge: '评测详情',
title: '分享这份评测摘要', title: '分享这份评测',
description: '把评分、状态和规范链接一起分发出去,方便用户回访,也方便 AI 在引用时抓到结构化入口。', description: '把这份评测发出去,方便别人直接看到评分、状态和相关链接。',
}; };
const jsonLd = review const jsonLd = review
? [ ? [

View File

@@ -253,9 +253,9 @@ const sharePanelCopy = isEnglish
'Use the reviews index as the canonical entry for ratings, statuses, and tagged review snapshots so AI search and readers can drill down from one source.', 'Use the reviews index as the canonical entry for ratings, statuses, and tagged review snapshots so AI search and readers can drill down from one source.',
} }
: { : {
badge: '评测归档', badge: '评测列表',
title: '分享评测总览页', title: '分享评测列表',
description: '把评测归档页当成评分、状态和标签的统一入口分发出去,方便 AI 搜索和读者从一个规范地址继续下钻。', description: '把评测列表分享出去,方便快速查看评分、状态和分类。',
}; };
const reviewHighlights = buildDiscoveryHighlights([ const reviewHighlights = buildDiscoveryHighlights([
t('reviews.subtitle'), t('reviews.subtitle'),

View File

@@ -90,9 +90,9 @@ const sharePanelCopy = isEnglish
tag: 'Tag', tag: 'Tag',
} }
: { : {
badge: '标签聚合', badge: '标签',
title: '分享这个标签聚合页', title: '分享这个标签页',
description: '这个标签页当成专题入口持续扩散,方便读者找关联内容,也方便 AI 检索把引用汇总到同一个规范地址。', description: '分享这个标签页,方便集中查看相关内容。',
posts: '文章数', posts: '文章数',
tag: '标签', tag: '标签',
}; };

View File

@@ -49,9 +49,9 @@ const sharePanelCopy = isEnglish
site: 'Site', site: 'Site',
} }
: { : {
badge: '标签目录', badge: '标签总览',
title: '分享标签总览', title: '分享标签总览',
description: '把标签索引页当成全站话题图谱分发出去,方便用户和 AI 检索从统一入口继续找到相关内容簇。', description: '把标签总览分享出去,方便按关键词继续浏览。',
tags: '标签数', tags: '标签数',
site: '站点', site: '站点',
}; };

View File

@@ -83,9 +83,9 @@ const sharePanelCopy = isEnglish
latest: 'Latest', latest: 'Latest',
} }
: { : {
badge: '时间线', badge: '更新时间线',
title: '分享站点时间线', title: '分享更新时间线',
description: '把时间线当成内容演进的规范视图分发出去,方便 AI 搜索和读者理解更新节奏与主题变化。', description: '把时间线页分享出去,方便快速了解内容更新节奏。',
posts: '文章数', posts: '文章数',
years: '年份数', years: '年份数',
latest: '最近年份', latest: '最近年份',

View File

@@ -278,7 +278,7 @@ html.dark {
} }
.terminal-chip { .terminal-chip {
@apply inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-all; @apply inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-all;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color)); border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent); background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
color: var(--text-secondary); color: var(--text-secondary);
@@ -630,7 +630,7 @@ html.dark {
} }
.home-nav-pill { .home-nav-pill {
@apply inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-mono transition-all duration-200; @apply inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-mono transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color)); border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent); background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
color: var(--text-secondary); color: var(--text-secondary);
@@ -995,7 +995,7 @@ html.dark {
.ui-filter-pill { .ui-filter-pill {
--pill-rgb: var(--primary-rgb); --pill-rgb: var(--primary-rgb);
--pill-fg: var(--text-secondary); --pill-fg: var(--text-secondary);
@apply inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] transition-all; @apply inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] transition-all;
border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 12%, var(--border-color)); border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 12%, var(--border-color));
background: background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 3%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 1%, var(--header-bg))); linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 3%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 1%, var(--header-bg)));

4
frontend/src/types/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'qrcode' {
const QRCode: any;
export default QRCode;
}

View File

@@ -109,11 +109,6 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
await gotoPage(page, '/') await gotoPage(page, '/')
await waitForHomeInteractive(page) await waitForHomeInteractive(page)
await page.locator('[data-subscribe-form] input[name="displayName"]').fill('首页订阅用户')
await page.locator('[data-subscribe-form] input[name="email"]').fill('inline-subscriber@example.com')
await page.locator('[data-subscribe-form] button[type="submit"]').click()
await expect(page.locator('[data-subscribe-status]')).toContainText('订阅')
await waitForSubscriptionPopupReady(page) await waitForSubscriptionPopupReady(page)
await page.locator('[data-subscription-popup-open]').click() await page.locator('[data-subscription-popup-open]').click()
await expect(page.locator('[data-subscription-popup-panel]')).toBeVisible() await expect(page.locator('[data-subscription-popup-panel]')).toBeVisible()
@@ -123,10 +118,6 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
await expect(page.locator('[data-subscription-popup-status]')).toContainText('订阅') await expect(page.locator('[data-subscription-popup-status]')).toContainText('订阅')
const subscriptionState = await getDebugState(request) const subscriptionState = await getDebugState(request)
const inlineRecord = subscriptionState.subscriptions.find(
(item: { target: string; display_name: string }) => item.target === 'inline-subscriber@example.com',
)
expect(inlineRecord?.display_name).toBe('首页订阅用户')
const latest = subscriptionState.subscriptions.find( const latest = subscriptionState.subscriptions.find(
(item: { target: string; display_name: string }) => item.target === 'playwright-subscriber@example.com', (item: { target: string; display_name: string }) => item.target === 'playwright-subscriber@example.com',
) )
@@ -146,7 +137,32 @@ test('友链申请与订阅确认/偏好/退订链路可用', async ({ page, req
await expect(page.locator('[data-unsubscribe-status]')).toContainText('成功退订') await expect(page.locator('[data-unsubscribe-status]')).toContainText('成功退订')
}) })
test('GEO 分享面板、AI 摘要块与 llms 入口可用', async ({ page, request }) => { test('维护模式开启后需要口令才能进入前台', async ({ page, request }) => {
await patchAdminSiteSettings(request, {
maintenanceModeEnabled: true,
maintenanceAccessCode: 'staging-2026',
})
await gotoPage(page, '/articles/astro-terminal-blog')
await expect(page).toHaveURL(/\/maintenance\?returnTo=/)
await expect(page.getByRole('heading', { name: /正在维护/ })).toBeVisible()
await page.locator('input[name="code"]').fill('wrong-code')
await page.getByRole('button', { name: '进入站点' }).click()
await expect(page).toHaveURL(/error=invalid/)
await expect(page.getByText('口令不正确,请重新输入。')).toBeVisible()
await page.locator('input[name="code"]').fill('staging-2026')
await page.getByRole('button', { name: '进入站点' }).click()
await expect(page).toHaveURL(/\/articles\/astro-terminal-blog$/)
await expect(page.getByRole('heading', { name: 'Astro 终端博客信息架构实战' })).toBeVisible()
await gotoPage(page, '/')
await expect(page).toHaveURL(/\/$/)
await expect(page.locator('#home-results-count')).toContainText(/条结果/)
})
test('分享面板与 llms 入口可用', async ({ page, request }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await patchAdminSiteSettings(request, { await patchAdminSiteSettings(request, {
@@ -158,24 +174,22 @@ test('GEO 分享面板、AI 摘要块与 llms 入口可用', async ({ page, requ
await waitForSubscriptionPopupReady(page) await waitForSubscriptionPopupReady(page)
await expect(page.locator('head link[rel="alternate"][href$="/llms.txt"]')).toHaveCount(1) await expect(page.locator('head link[rel="alternate"][href$="/llms.txt"]')).toHaveCount(1)
await expect(page.locator('head link[rel="alternate"][href$="/llms-full.txt"]')).toHaveCount(1) await expect(page.locator('head link[rel="alternate"][href$="/llms-full.txt"]')).toHaveCount(1)
await expect(page.getByRole('heading', { name: '给 AI 看的站点摘要' })).toBeVisible()
await expect(page.getByRole('button', { name: '微信扫码' }).first()).toBeVisible() await expect(page.getByRole('button', { name: '微信扫码' }).first()).toBeVisible()
await gotoPage(page, '/about') await gotoPage(page, '/about')
await expect(page.getByRole('heading', { name: '给 AI 看的身份摘要' })).toBeVisible()
await expect(page.getByText('身份主页')).toBeVisible() await expect(page.getByText('身份主页')).toBeVisible()
await gotoPage(page, '/articles') await gotoPage(page, '/articles')
await expect(page.getByRole('heading', { name: '给 AI 看的归档摘要' })).toBeVisible() await expect(page).toHaveURL(/\/articles$/)
await gotoPage(page, '/reviews') await gotoPage(page, '/reviews')
await expect(page.getByRole('heading', { name: '给 AI 看的评测摘要' })).toBeVisible() await expect(page).toHaveURL(/\/reviews$/)
await gotoPage(page, '/ask') await gotoPage(page, '/ask')
await expect(page.getByRole('heading', { name: 'AI 看的问答页摘要' })).toBeVisible() await expect(page.getByRole('heading', { name: 'AI 站内问答' })).toBeVisible()
await gotoPage(page, '/friends') await gotoPage(page, '/friends')
await expect(page.getByRole('heading', { name: '给 AI 看的友链网络摘要' })).toBeVisible() await expect(page).toHaveURL(/\/friends$/)
await gotoPage(page, '/articles/playwright-regression-workflow') await gotoPage(page, '/articles/playwright-regression-workflow')
await page.getByRole('button', { name: '微信扫码' }).first().click() await page.getByRole('button', { name: '微信扫码' }).first().click()