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
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:
@@ -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={
|
||||||
|
|||||||
@@ -106,12 +106,6 @@ const primaryNav = [
|
|||||||
description: '异步任务 / 队列控制台',
|
description: '异步任务 / 队列控制台',
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
to: '/audit',
|
|
||||||
label: '审计',
|
|
||||||
description: '后台操作审计日志',
|
|
||||||
icon: Settings,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
to: '/settings',
|
to: '/settings',
|
||||||
label: '设置',
|
label: '设置',
|
||||||
|
|||||||
291
admin/src/components/media-library-picker-dialog.tsx
Normal file
291
admin/src/components/media-library-picker-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
256
admin/src/components/media-url-controls.tsx
Normal file
256
admin/src/components/media-url-controls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
2
backend/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
- 钱谦益
|
- 钱谦益
|
||||||
- 黄山
|
- 黄山
|
||||||
- 游记
|
- 游记
|
||||||
- 长文测试
|
- 山水游记
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
owner_bio: "InitCool,GitHub 用户名 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"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ tags:
|
|||||||
- 徐霞客
|
- 徐霞客
|
||||||
- 游记
|
- 游记
|
||||||
- 太和山
|
- 太和山
|
||||||
- 长文测试
|
- 山水游记
|
||||||
---
|
---
|
||||||
|
|
||||||
# 徐霞客游记·游太和山日记(下)
|
# 徐霞客游记·游太和山日记(下)
|
||||||
|
|||||||
@@ -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:
|
|||||||
- 钱谦益
|
- 钱谦益
|
||||||
- 黄山
|
- 黄山
|
||||||
- 游记
|
- 游记
|
||||||
- 长文测试
|
- 山水游记
|
||||||
---
|
---
|
||||||
|
|
||||||
# 游黄山记(中)
|
# 游黄山记(中)
|
||||||
|
|||||||
@@ -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:
|
|||||||
- 徐霞客
|
- 徐霞客
|
||||||
- 恒山
|
- 恒山
|
||||||
- 悬空寺
|
- 悬空寺
|
||||||
- 长文测试
|
- 山水游记
|
||||||
---
|
---
|
||||||
|
|
||||||
# 徐霞客游记·游恒山日记
|
# 徐霞客游记·游恒山日记
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ tags:
|
|||||||
- 钱谦益
|
- 钱谦益
|
||||||
- 黄山
|
- 黄山
|
||||||
- 游记
|
- 游记
|
||||||
- 长文测试
|
- 山水游记
|
||||||
---
|
---
|
||||||
|
|
||||||
# 游黄山记(上)
|
# 游黄山记(上)
|
||||||
|
|||||||
@@ -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:
|
|||||||
- 徐霞客
|
- 徐霞客
|
||||||
- 游记
|
- 游记
|
||||||
- 太和山
|
- 太和山
|
||||||
- 长文测试
|
- 山水游记
|
||||||
---
|
---
|
||||||
|
|
||||||
# 徐霞客游记·游太和山日记(上)
|
# 徐霞客游记·游太和山日记(上)
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: ®ex::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: ®ex::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: ®ex::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",
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¤t);
|
||||||
|
let access_granted = if enabled {
|
||||||
|
validate_maintenance_access_token(¤t, 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(¤t);
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
return format::json(MaintenanceVerifyResponse {
|
||||||
|
maintenance_mode_enabled: false,
|
||||||
|
access_granted: true,
|
||||||
|
access_token: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_token = verify_maintenance_access_code(¤t, 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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
- 钱谦益
|
- 钱谦益
|
||||||
- 黄山
|
- 黄山
|
||||||
- 游记
|
- 游记
|
||||||
- 长文测试
|
- 山水游记
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
owner_bio: "InitCool,GitHub 用户名 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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<_>>();
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
3
frontend/src/env.d.ts
vendored
3
frontend/src/env.d.ts
vendored
@@ -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>
|
||||||
|
|||||||
@@ -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: 'InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
|
ownerBio: 'InitCool,GitHub 用户名 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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
41
frontend/src/lib/maintenance.ts
Normal file
41
frontend/src/lib/maintenance.ts
Normal 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')
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ export interface SiteSettings {
|
|||||||
};
|
};
|
||||||
techStack: string[];
|
techStack: string[];
|
||||||
musicPlaylist: MusicTrack[];
|
musicPlaylist: MusicTrack[];
|
||||||
|
musicEnabled: boolean;
|
||||||
ai: {
|
ai: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
55
frontend/src/middleware.ts
Normal file
55
frontend/src/middleware.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
79
frontend/src/pages/api/maintenance/unlock.ts
Normal file
79
frontend/src/pages/api/maintenance/unlock.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ const sharePanelCopy = isEnglish
|
|||||||
page: 'Page',
|
page: 'Page',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '内容归档',
|
badge: '文章列表',
|
||||||
title: '分享文章总归档页',
|
title: '分享文章列表',
|
||||||
description: '把文章归档页当成统一入口分发出去,方便 AI 检索和读者从一个规范地址继续按类型、分类和标签深入浏览。',
|
description: '把文章列表分享出去,方便继续按分类、标签和类型浏览。',
|
||||||
posts: '文章数',
|
posts: '文章数',
|
||||||
categories: '分类数',
|
categories: '分类数',
|
||||||
tags: '标签数',
|
tags: '标签数',
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
|
|||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '分类聚合',
|
badge: '分类页',
|
||||||
title: '分享这个分类聚合页',
|
title: '分享这个分类页',
|
||||||
description: '把这个分类页当成主题入口持续分发,方便用户快速理解,也方便 AI 搜索把同主题信号聚合回这里。',
|
description: '分享这个分类页,方便集中查看同主题内容。',
|
||||||
posts: '文章数',
|
posts: '文章数',
|
||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ const sharePanelCopy = isEnglish
|
|||||||
site: 'Site',
|
site: 'Site',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '分类目录',
|
badge: '分类总览',
|
||||||
title: '分享分类总览页',
|
title: '分享分类总览',
|
||||||
description: '把分类索引页作为全站主题地图分发出去,方便读者和 AI 搜索从一个规范入口继续下钻到对应专题。',
|
description: '把分类总览分享出去,方便按主题快速找到内容。',
|
||||||
categories: '分类数',
|
categories: '分类数',
|
||||||
site: '站点',
|
site: '站点',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
|
|||||||
groups: 'Groups',
|
groups: 'Groups',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '友链网络',
|
badge: '友情链接',
|
||||||
title: '分享友情链接页',
|
title: '分享友情链接页',
|
||||||
description: '把友情链接页当成站点网络地图分发出去,方便 AI 搜索和读者理解这个站点的可信邻居与外部引用关系。',
|
description: '把友情链接页分享出去,方便查看常访问的网站与推荐来源。',
|
||||||
links: '友链数',
|
links: '友链数',
|
||||||
groups: '分组数',
|
groups: '分组数',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
99
frontend/src/pages/maintenance.astro
Normal file
99
frontend/src/pages/maintenance.astro
Normal 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>
|
||||||
@@ -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
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ const sharePanelCopy = isEnglish
|
|||||||
tag: 'Tag',
|
tag: 'Tag',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '标签聚合',
|
badge: '标签页',
|
||||||
title: '分享这个标签聚合页',
|
title: '分享这个标签页',
|
||||||
description: '把这个标签页当成专题入口持续扩散,方便读者找关联内容,也方便 AI 检索把引用汇总到同一个规范地址。',
|
description: '分享这个标签页,方便集中查看相关内容。',
|
||||||
posts: '文章数',
|
posts: '文章数',
|
||||||
tag: '标签',
|
tag: '标签',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ const sharePanelCopy = isEnglish
|
|||||||
site: 'Site',
|
site: 'Site',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '标签目录',
|
badge: '标签总览',
|
||||||
title: '分享标签总览页',
|
title: '分享标签总览',
|
||||||
description: '把标签索引页当成全站话题图谱分发出去,方便用户和 AI 检索从统一入口继续找到相关内容簇。',
|
description: '把标签总览分享出去,方便按关键词继续浏览。',
|
||||||
tags: '标签数',
|
tags: '标签数',
|
||||||
site: '站点',
|
site: '站点',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ const sharePanelCopy = isEnglish
|
|||||||
latest: 'Latest',
|
latest: 'Latest',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '时间线',
|
badge: '更新时间线',
|
||||||
title: '分享站点时间线',
|
title: '分享更新时间线',
|
||||||
description: '把时间线当成内容演进的规范视图分发出去,方便 AI 搜索和读者理解更新节奏与主题变化。',
|
description: '把时间线页分享出去,方便快速了解内容更新节奏。',
|
||||||
posts: '文章数',
|
posts: '文章数',
|
||||||
years: '年份数',
|
years: '年份数',
|
||||||
latest: '最近年份',
|
latest: '最近年份',
|
||||||
|
|||||||
@@ -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
4
frontend/src/types/qrcode.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'qrcode' {
|
||||||
|
const QRCode: any;
|
||||||
|
export default QRCode;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user