feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

5
admin/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.log

31
admin/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ARG VITE_API_BASE=http://localhost:5150
ARG VITE_FRONTEND_BASE_URL=http://localhost:4321
ARG VITE_ADMIN_BASENAME=
ENV VITE_API_BASE=${VITE_API_BASE}
ENV VITE_FRONTEND_BASE_URL=${VITE_FRONTEND_BASE_URL}
ENV VITE_ADMIN_BASENAME=${VITE_ADMIN_BASENAME}
RUN pnpm build
FROM nginx:1.27-alpine AS runner
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
COPY docker-entrypoint.d/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
RUNTIME_CONFIG_FILE="/usr/share/nginx/html/runtime-config.js"
escape_js_string() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
API_BASE_URL="${ADMIN_API_BASE_URL:-}"
FRONTEND_BASE_URL="${ADMIN_FRONTEND_BASE_URL:-}"
ESCAPED_API_BASE_URL="$(escape_js_string "$API_BASE_URL")"
ESCAPED_FRONTEND_BASE_URL="$(escape_js_string "$FRONTEND_BASE_URL")"
cat > "$RUNTIME_CONFIG_FILE" <<EOF
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = Object.assign(
{},
window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {},
{
apiBaseUrl: "${ESCAPED_API_BASE_URL}",
frontendBaseUrl: "${ESCAPED_FRONTEND_BASE_URL}"
},
)
EOF

View File

@@ -18,6 +18,7 @@
</head>
<body>
<div id="root"></div>
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

63
admin/nginx.conf Normal file
View File

@@ -0,0 +1,63 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
charset utf-8;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
application/manifest+json
image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
location = /healthz {
access_log off;
add_header Content-Type text/plain;
add_header Cache-Control "no-store";
return 200 'ok';
}
location = /runtime-config.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

2304
admin/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {}

View File

@@ -38,6 +38,10 @@ const PostsPage = lazy(async () => {
const mod = await import('@/pages/posts-page')
return { default: mod.PostsPage }
})
const RevisionsPage = lazy(async () => {
const mod = await import('@/pages/revisions-page')
return { default: mod.RevisionsPage }
})
const CommentsPage = lazy(async () => {
const mod = await import('@/pages/comments-page')
return { default: mod.CommentsPage }
@@ -58,6 +62,14 @@ const SiteSettingsPage = lazy(async () => {
const mod = await import('@/pages/site-settings-page')
return { default: mod.SiteSettingsPage }
})
const AuditPage = lazy(async () => {
const mod = await import('@/pages/audit-page')
return { default: mod.AuditPage }
})
const SubscriptionsPage = lazy(async () => {
const mod = await import('@/pages/subscriptions-page')
return { default: mod.SubscriptionsPage }
})
type SessionContextValue = {
session: AdminSessionResponse
@@ -140,6 +152,8 @@ function PublicOnly() {
return (
<LoginPage
submitting={submitting}
localLoginEnabled={session.local_login_enabled}
proxyAuthEnabled={session.proxy_auth_enabled}
onLogin={async (payload) => {
try {
setSubmitting(true)
@@ -167,7 +181,11 @@ function ProtectedLayout() {
return (
<AppShell
username={session.username}
email={session.email}
authSource={session.auth_source}
authProvider={session.auth_provider}
loggingOut={loggingOut}
canLogout={session.can_logout}
onLogout={async () => {
try {
setLoggingOut(true)
@@ -233,6 +251,14 @@ function AppRoutes() {
</LazyRoute>
}
/>
<Route
path="revisions"
element={
<LazyRoute>
<RevisionsPage />
</LazyRoute>
}
/>
<Route
path="comments"
element={
@@ -257,6 +283,22 @@ function AppRoutes() {
</LazyRoute>
}
/>
<Route
path="subscriptions"
element={
<LazyRoute>
<SubscriptionsPage />
</LazyRoute>
}
/>
<Route
path="audit"
element={
<LazyRoute>
<AuditPage />
</LazyRoute>
}
/>
<Route
path="reviews"
element={
@@ -283,6 +325,13 @@ export default function App() {
const [session, setSession] = useState<AdminSessionResponse>({
authenticated: false,
username: null,
email: null,
auth_source: null,
auth_provider: null,
groups: [],
proxy_auth_enabled: false,
local_login_enabled: true,
can_logout: false,
})
const [loading, setLoading] = useState(true)

View File

@@ -1,7 +1,9 @@
import {
BarChart3,
BellRing,
BookOpenText,
ExternalLink,
History,
Image as ImageIcon,
LayoutDashboard,
Link2,
@@ -18,6 +20,7 @@ import { NavLink } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
const primaryNav = [
@@ -39,6 +42,12 @@ const primaryNav = [
description: 'Markdown 内容管理',
icon: ScrollText,
},
{
to: '/revisions',
label: '版本',
description: '历史快照与一键回滚',
icon: History,
},
{
to: '/comments',
label: '评论',
@@ -63,6 +72,18 @@ const primaryNav = [
description: '对象存储图片管理',
icon: ImageIcon,
},
{
to: '/subscriptions',
label: '订阅',
description: '邮件 / Webhook 推送',
icon: BellRing,
},
{
to: '/audit',
label: '审计',
description: '后台操作审计日志',
icon: Settings,
},
{
to: '/settings',
label: '设置',
@@ -74,12 +95,20 @@ const primaryNav = [
export function AppShell({
children,
username,
email,
authSource,
authProvider,
loggingOut,
canLogout,
onLogout,
}: {
children: ReactNode
username: string | null
email: string | null
authSource: string | null
authProvider: string | null
loggingOut: boolean
canLogout: boolean
onLogout: () => Promise<void>
}) {
return (
@@ -155,7 +184,7 @@ export function AppShell({
</p>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<Badge variant="success"></Badge>
@@ -186,8 +215,13 @@ export function AppShell({
{username ?? 'admin'}
</p>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
React + shadcn/ui
{authProvider ?? 'React + shadcn/ui 基础架构'}
</p>
{email ? (
<p className="text-xs text-muted-foreground">{email}</p>
) : authSource ? (
<p className="text-xs text-muted-foreground">{authSource}</p>
) : null}
</div>
</div>
@@ -213,14 +247,19 @@ export function AppShell({
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321" target="_blank" rel="noreferrer">
<a href={buildFrontendUrl('/')} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button variant="ghost" onClick={() => void onLogout()} disabled={loggingOut}>
<Button
variant="ghost"
onClick={() => void onLogout()}
disabled={loggingOut || !canLogout}
title={canLogout ? undefined : '当前会话由前置 SSO / 代理控制'}
>
<LogOut className="h-4 w-4" />
{loggingOut ? '退出中...' : '退出登录'}
{canLogout ? (loggingOut ? '退出中...' : '退出登录') : 'SSO 受代理保护'}
</Button>
</div>
</div>

View File

@@ -342,7 +342,7 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
}, [highlightedIndex, open])
const triggerClasses = cn(
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/40 data-[state=open]:bg-card data-[state=open]:shadow-[0_18px_40px_rgb(15_23_42_/_0.14)]',
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-input bg-background/80 px-3 py-2 text-left text-sm text-foreground shadow-sm outline-none transition-[border-color,box-shadow,background-color,transform] focus-visible:ring-2 focus-visible:ring-ring/70 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/20 data-[state=open]:bg-card/95 data-[state=open]:shadow-[0_16px_36px_rgb(15_23_42_/_0.10)]',
className,
)
@@ -352,7 +352,7 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
ref={menuRef}
aria-orientation="vertical"
className={cn(
'custom-select-popover fixed z-[80] overflow-hidden rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_48px_rgb(15_23_42_/_0.18)] will-change-transform',
'custom-select-popover fixed z-[80] overflow-hidden rounded-[20px] border border-border/80 bg-[color:rgb(255_255_255_/_0.96)] p-2 text-popover-foreground shadow-[0_18px_46px_rgb(15_23_42_/_0.12)] backdrop-blur-xl will-change-transform dark:bg-card/96',
menuPlacement === 'top' ? 'origin-bottom' : 'origin-top',
)}
id={menuId}
@@ -374,13 +374,13 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
}}
aria-selected={selected}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors',
'relative flex min-h-10.5 w-full items-center justify-between gap-3 overflow-hidden rounded-[16px] border px-4 py-2.5 text-left text-sm transition-[background-color,border-color,color,box-shadow]',
option.disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer',
selected
? 'bg-primary text-primary-foreground shadow-[0_12px_30px_rgb(37_99_235_/_0.22)]'
? 'border-primary/15 bg-primary/[0.045] text-foreground shadow-[inset_0_1px_0_rgb(255_255_255_/_0.55)]'
: highlighted
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
? 'border-border/60 bg-muted/70 text-foreground'
: 'border-transparent text-foreground/80 hover:border-border/45 hover:bg-muted/55 hover:text-foreground',
)}
disabled={option.disabled}
onClick={() => commitValue(index)}
@@ -392,8 +392,31 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
role="option"
type="button"
>
<span className="truncate">{option.label}</span>
<Check className={cn('h-4 w-4 shrink-0', selected ? 'opacity-100' : 'opacity-0')} />
<span
aria-hidden="true"
className={cn(
'absolute left-1.5 top-1/2 h-5 w-1 -translate-y-1/2 rounded-full transition-all',
selected ? 'bg-primary/70 opacity-100' : 'bg-transparent opacity-0',
)}
/>
<span
className={cn(
'truncate pr-2',
selected
? 'font-semibold text-foreground'
: highlighted
? 'font-medium text-foreground'
: 'font-medium',
)}
>
{option.label}
</span>
<Check
className={cn(
'h-3.5 w-3.5 shrink-0 transition-[opacity,transform,color]',
selected ? 'translate-x-0 opacity-100 text-primary/90' : 'translate-x-1 opacity-0 text-transparent',
)}
/>
</button>
)
})}
@@ -459,7 +482,12 @@ const Select = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
type="button"
>
<span className="min-w-0 flex-1 truncate">{selectedOption?.label ?? '请选择'}</span>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors">
<span
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/70 text-muted-foreground transition-colors',
open && 'bg-muted text-foreground',
)}
>
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
</span>
</button>

View File

@@ -41,6 +41,76 @@ export function formatCommentScope(value: string | null | undefined) {
}
}
export function formatPostStatus(value: string | null | undefined) {
switch (value) {
case 'draft':
return '草稿'
case 'published':
return '已发布'
case 'scheduled':
return '定时发布'
case 'expired':
return '已下线'
case 'offline':
return '离线'
default:
return value || '已发布'
}
}
export function formatPostVisibility(value: string | null | undefined) {
switch (value) {
case 'unlisted':
return '不公开'
case 'private':
return '私有'
case 'public':
return '公开'
default:
return value || '公开'
}
}
function matchBrowserVersion(userAgent: string, marker: RegExp) {
const matched = userAgent.match(marker)
return matched?.[1] ?? null
}
export function formatBrowserName(userAgent: string | null | undefined) {
if (!userAgent) {
return '未知浏览器'
}
const ua = userAgent.toLowerCase()
if (ua.includes('edg/')) {
const version = matchBrowserVersion(userAgent, /edg\/([\d.]+)/i)
return version ? `Edge ${version}` : 'Edge'
}
if (ua.includes('opr/') || ua.includes('opera')) {
const version = matchBrowserVersion(userAgent, /(?:opr|opera)\/([\d.]+)/i)
return version ? `Opera ${version}` : 'Opera'
}
if (ua.includes('firefox/')) {
const version = matchBrowserVersion(userAgent, /firefox\/([\d.]+)/i)
return version ? `Firefox ${version}` : 'Firefox'
}
if (ua.includes('chrome/') && !ua.includes('chromium/')) {
const version = matchBrowserVersion(userAgent, /chrome\/([\d.]+)/i)
return version ? `Chrome ${version}` : 'Chrome'
}
if (ua.includes('safari/') && !ua.includes('chrome/')) {
const version = matchBrowserVersion(userAgent, /version\/([\d.]+)/i)
return version ? `Safari ${version}` : 'Safari'
}
return '其他浏览器'
}
export function formatFriendLinkStatus(value: string | null | undefined) {
switch (value) {
case 'approved':

View File

@@ -4,8 +4,11 @@ import type {
AdminAiReindexResponse,
AdminAiProviderTestResponse,
AdminImageUploadResponse,
AdminMediaBatchDeleteResponse,
AdminMediaDeleteResponse,
AdminMediaListResponse,
AdminMediaReplaceResponse,
AdminMediaUploadResponse,
AdminPostCoverImageRequest,
AdminPostCoverImageResponse,
AdminDashboardResponse,
@@ -16,7 +19,11 @@ import type {
AdminR2ConnectivityResponse,
AdminSessionResponse,
AdminSiteSettingsResponse,
AuditLogRecord,
CommentListQuery,
CommentBlacklistRecord,
CommentPersonaAnalysisLogRecord,
CommentPersonaAnalysisResponse,
CommentRecord,
CreatePostPayload,
CreateReviewPayload,
@@ -26,16 +33,52 @@ import type {
MarkdownDeleteResponse,
MarkdownDocumentResponse,
MarkdownImportResponse,
NotificationDeliveryRecord,
PostListQuery,
PostRevisionDetail,
PostRevisionRecord,
PostRecord,
ReviewRecord,
RestoreRevisionResponse,
SiteSettingsPayload,
SubscriptionDigestResponse,
SubscriptionListResponse,
SubscriptionPayload,
SubscriptionRecord,
SubscriptionUpdatePayload,
UpdateCommentPayload,
UpdatePostPayload,
UpdateReviewPayload,
} from '@/lib/types'
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
const API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
const envApiBase = normalizeAdminBaseUrl(import.meta.env.VITE_API_BASE)
const DEV_API_BASE = 'http://localhost:5150'
const PROD_DEFAULT_API_PORT = '5150'
function getApiBase() {
const runtimeApiBase = getRuntimeAdminBaseUrl('apiBaseUrl')
if (runtimeApiBase) {
return runtimeApiBase
}
if (envApiBase) {
return envApiBase
}
if (import.meta.env.DEV) {
return DEV_API_BASE
}
if (typeof window === 'undefined') {
return DEV_API_BASE
}
const { protocol, hostname } = window.location
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}`
}
const API_BASE = getApiBase()
export class ApiError extends Error {
status: number
@@ -95,6 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers,
})
@@ -126,6 +170,74 @@ export const adminApi = {
request<AdminSessionResponse>('/api/admin/session', {
method: 'DELETE',
}),
listAuditLogs: (query?: { action?: string; targetType?: string; limit?: number }) =>
request<AuditLogRecord[]>(
appendQueryParams('/api/admin/audit-logs', {
action: query?.action,
target_type: query?.targetType,
limit: query?.limit,
}),
),
listPostRevisions: (query?: { slug?: string; limit?: number }) =>
request<PostRevisionRecord[]>(
appendQueryParams('/api/admin/post-revisions', {
slug: query?.slug,
limit: query?.limit,
}),
),
getPostRevision: (id: number) => request<PostRevisionDetail>(`/api/admin/post-revisions/${id}`),
restorePostRevision: (id: number, mode: 'full' | 'markdown' | 'metadata' = 'full') =>
request<RestoreRevisionResponse>(`/api/admin/post-revisions/${id}/restore`, {
method: 'POST',
body: JSON.stringify({ mode }),
}),
listSubscriptions: async () =>
(await request<SubscriptionListResponse>('/api/admin/subscriptions')).subscriptions,
createSubscription: (payload: SubscriptionPayload) =>
request<SubscriptionRecord>('/api/admin/subscriptions', {
method: 'POST',
body: JSON.stringify({
channelType: payload.channelType,
target: payload.target,
displayName: payload.displayName,
status: payload.status,
filters: payload.filters,
metadata: payload.metadata,
secret: payload.secret,
notes: payload.notes,
}),
}),
updateSubscription: (id: number, payload: SubscriptionUpdatePayload) =>
request<SubscriptionRecord>(`/api/admin/subscriptions/${id}`, {
method: 'PATCH',
body: JSON.stringify({
channelType: payload.channelType,
target: payload.target,
displayName: payload.displayName,
status: payload.status,
filters: payload.filters,
metadata: payload.metadata,
secret: payload.secret,
notes: payload.notes,
}),
}),
deleteSubscription: (id: number) =>
request<void>(`/api/admin/subscriptions/${id}`, {
method: 'DELETE',
}),
testSubscription: (id: number) =>
request<{ queued: boolean; id: number; delivery_id: number }>(`/api/admin/subscriptions/${id}/test`, {
method: 'POST',
}),
listSubscriptionDeliveries: async (limit = 80) =>
(await request<{ deliveries: NotificationDeliveryRecord[] }>(
appendQueryParams('/api/admin/subscriptions/deliveries', { limit }),
)).deliveries,
sendSubscriptionDigest: (period: 'weekly' | 'monthly') =>
request<SubscriptionDigestResponse>('/api/admin/subscriptions/digest', {
method: 'POST',
body: JSON.stringify({ period }),
}),
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
@@ -192,6 +304,35 @@ export const adminApi = {
method: 'DELETE',
},
),
uploadMediaObjects: (files: File[], options?: { prefix?: string }) => {
const formData = new FormData()
if (options?.prefix) {
formData.append('prefix', options.prefix)
}
files.forEach((file) => {
formData.append('files', file, file.name)
})
return request<AdminMediaUploadResponse>('/api/admin/storage/media', {
method: 'POST',
body: formData,
})
},
batchDeleteMediaObjects: (keys: string[]) =>
request<AdminMediaBatchDeleteResponse>('/api/admin/storage/media/batch-delete', {
method: 'POST',
body: JSON.stringify({ keys }),
}),
replaceMediaObject: (key: string, file: File) => {
const formData = new FormData()
formData.append('key', key)
formData.append('file', file, file.name)
return request<AdminMediaReplaceResponse>('/api/admin/storage/media/replace', {
method: 'POST',
body: formData,
})
},
generatePostMetadata: (markdown: string) =>
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
method: 'POST',
@@ -237,9 +378,16 @@ export const adminApi = {
search: query?.search,
type: query?.postType,
pinned: query?.pinned,
status: query?.status,
visibility: query?.visibility,
listed_only: query?.listedOnly,
include_private: query?.includePrivate ?? true,
include_redirects: query?.includeRedirects ?? true,
preview: query?.preview ?? true,
}),
),
getPostBySlug: (slug: string) => request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}`),
getPostBySlug: (slug: string) =>
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
createPost: (payload: CreatePostPayload) =>
request<MarkdownDocumentResponse>('/api/posts/markdown', {
method: 'POST',
@@ -254,6 +402,15 @@ export const adminApi = {
image: payload.image,
images: payload.images,
pinned: payload.pinned,
status: payload.status,
visibility: payload.visibility,
publish_at: payload.publishAt,
unpublish_at: payload.unpublishAt,
canonical_url: payload.canonicalUrl,
noindex: payload.noindex,
og_image: payload.ogImage,
redirect_from: payload.redirectFrom,
redirect_to: payload.redirectTo,
published: payload.published,
}),
}),
@@ -271,6 +428,15 @@ export const adminApi = {
image: payload.image,
images: payload.images,
pinned: payload.pinned,
status: payload.status,
visibility: payload.visibility,
publish_at: payload.publishAt,
unpublish_at: payload.unpublishAt,
canonical_url: payload.canonicalUrl,
noindex: payload.noindex,
og_image: payload.ogImage,
redirect_from: payload.redirectFrom,
redirect_to: payload.redirectTo,
}),
}),
getPostMarkdown: (slug: string) =>
@@ -315,6 +481,59 @@ export const adminApi = {
request<void>(`/api/comments/${id}`, {
method: 'DELETE',
}),
listCommentBlacklist: () =>
request<CommentBlacklistRecord[]>('/api/admin/comments/blacklist'),
createCommentBlacklist: (payload: {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
reason?: string | null
active?: boolean
expires_at?: string | null
}) =>
request<CommentBlacklistRecord>('/api/admin/comments/blacklist', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateCommentBlacklist: (
id: number,
payload: {
reason?: string | null
active?: boolean
expires_at?: string | null
clear_expires_at?: boolean
},
) =>
request<CommentBlacklistRecord>(`/api/admin/comments/blacklist/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteCommentBlacklist: (id: number) =>
request<{ deleted: boolean; id: number }>(`/api/admin/comments/blacklist/${id}`, {
method: 'DELETE',
}),
analyzeCommentPersona: (payload: {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
from?: string | null
to?: string | null
limit?: number
}) =>
request<CommentPersonaAnalysisResponse>('/api/admin/comments/analyze', {
method: 'POST',
body: JSON.stringify(payload),
}),
listCommentPersonaAnalysisLogs: (query?: {
matcher_type?: 'ip' | 'email' | 'user_agent' | string
matcher_value?: string
limit?: number
}) =>
request<CommentPersonaAnalysisLogRecord[]>(
appendQueryParams('/api/admin/comments/analyze/logs', {
matcher_type: query?.matcher_type,
matcher_value: query?.matcher_value,
limit: query?.limit,
}),
),
listFriendLinks: (query?: FriendLinkListQuery) =>
request<FriendLinkRecord[]>(
appendQueryParams('/api/friend_links', {

View File

@@ -0,0 +1,28 @@
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
const envFrontendBaseUrl = normalizeAdminBaseUrl(import.meta.env.VITE_FRONTEND_BASE_URL)
const DEV_FRONTEND_BASE_URL = 'http://localhost:4321'
const PROD_DEFAULT_FRONTEND_PORT = '4321'
export function getFrontendBaseUrl() {
const runtimeFrontendBaseUrl = getRuntimeAdminBaseUrl('frontendBaseUrl')
if (runtimeFrontendBaseUrl) {
return runtimeFrontendBaseUrl
}
if (envFrontendBaseUrl) {
return envFrontendBaseUrl
}
if (import.meta.env.DEV || typeof window === 'undefined') {
return DEV_FRONTEND_BASE_URL
}
const { protocol, hostname } = window.location
return `${protocol}//${hostname}:${PROD_DEFAULT_FRONTEND_PORT}`
}
export function buildFrontendUrl(path = '/') {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${getFrontendBaseUrl()}${normalizedPath}`
}

View File

@@ -0,0 +1,279 @@
export interface CompressionPreview {
originalSize: number
compressedSize: number
savedBytes: number
savedRatio: number
}
export interface CompressionResult {
file: File
usedCompressed: boolean
preview: CompressionPreview | null
}
interface ProcessImageOptions {
quality: number
maxWidth: number
maxHeight: number
preferredFormats: string[]
coverWidth?: number
coverHeight?: number
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unit = 0
while (size >= 1024 && unit < units.length - 1) {
size /= 1024
unit += 1
}
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`
}
function canTransformWithCanvas(file: File) {
return file.type.startsWith('image/') && file.type !== 'image/svg+xml' && file.type !== 'image/gif'
}
async function canvasToBlob(
canvas: HTMLCanvasElement,
preferredFormats: string[],
quality: number,
) {
for (const format of preferredFormats) {
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, format, quality)
})
if (blob && blob.type === format) {
return blob
}
}
return new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
}
function extensionForMimeType(mimeType: string) {
switch (mimeType) {
case 'image/avif':
return '.avif'
case 'image/webp':
return '.webp'
case 'image/png':
return '.png'
default:
return '.jpg'
}
}
function deriveFileName(file: File, mimeType: string) {
const extension = extensionForMimeType(mimeType)
if (/\.[A-Za-z0-9]+$/.test(file.name)) {
return file.name.replace(/\.[A-Za-z0-9]+$/, extension)
}
return `processed${extension}`
}
async function processImage(file: File, options: ProcessImageOptions): Promise<File> {
if (!canTransformWithCanvas(file)) {
return file
}
const bitmap = await createImageBitmap(file)
const canvas = document.createElement('canvas')
if (options.coverWidth && options.coverHeight) {
canvas.width = options.coverWidth
canvas.height = options.coverHeight
} else {
const scale = Math.min(
options.maxWidth / bitmap.width,
options.maxHeight / bitmap.height,
1,
)
canvas.width = Math.max(1, Math.round(bitmap.width * scale))
canvas.height = Math.max(1, Math.round(bitmap.height * scale))
}
const ctx = canvas.getContext('2d')
if (!ctx) {
return file
}
if (options.coverWidth && options.coverHeight) {
const scale = Math.max(
options.coverWidth / bitmap.width,
options.coverHeight / bitmap.height,
)
const drawWidth = bitmap.width * scale
const drawHeight = bitmap.height * scale
const offsetX = (options.coverWidth - drawWidth) / 2
const offsetY = (options.coverHeight - drawHeight) / 2
ctx.drawImage(bitmap, offsetX, offsetY, drawWidth, drawHeight)
} else {
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
}
const blob = await canvasToBlob(canvas, options.preferredFormats, options.quality)
if (!blob) {
return file
}
return new File([blob], deriveFileName(file, blob.type), {
type: blob.type,
lastModified: Date.now(),
})
}
async function maybeProcessImageWithPrompt(
file: File,
options?: {
quality?: number
ask?: boolean
minSavingsRatio?: number
contextLabel?: string
maxWidth?: number
maxHeight?: number
preferredFormats?: string[]
coverWidth?: number
coverHeight?: number
forceProcessed?: boolean
},
): Promise<CompressionResult> {
if (!canTransformWithCanvas(file)) {
return { file, usedCompressed: false, preview: null }
}
const quality = Math.min(Math.max(options?.quality ?? 0.82, 0.45), 0.95)
const minSavingsRatio = Math.min(Math.max(options?.minSavingsRatio ?? 0.03, 0), 0.9)
const ask = options?.ask ?? true
const contextLabel = options?.contextLabel ?? '图片上传'
const forceProcessed = options?.forceProcessed ?? false
let processed: File
try {
processed = await processImage(file, {
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 {
return { file, usedCompressed: false, preview: null }
}
const savedBytes = file.size - processed.size
const savedRatio = file.size > 0 ? savedBytes / file.size : 0
const preview: CompressionPreview = {
originalSize: file.size,
compressedSize: processed.size,
savedBytes,
savedRatio,
}
if (!forceProcessed && processed.size >= file.size) {
return { file, usedCompressed: false, preview }
}
if (!forceProcessed && savedRatio < minSavingsRatio) {
return { file, usedCompressed: false, preview }
}
if (!ask) {
return { file: processed, usedCompressed: true, preview }
}
const deltaText =
savedBytes >= 0
? `节省: ${formatBytes(savedBytes)} (${(savedRatio * 100).toFixed(1)}%)`
: `体积增加: ${formatBytes(Math.abs(savedBytes))} (${Math.abs(savedRatio * 100).toFixed(1)}%)`
const intro = forceProcessed
? `${contextLabel}: 已生成规范化版本。`
: `${contextLabel}: 检测到可压缩空间。`
const useProcessed = window.confirm(
[
intro,
`原始: ${formatBytes(file.size)}`,
`处理后: ${formatBytes(processed.size)}`,
deltaText,
'',
forceProcessed ? '是否使用规范化版本上传?' : '是否使用压缩版本上传?',
].join('\n'),
)
return {
file: useProcessed ? processed : file,
usedCompressed: useProcessed,
preview,
}
}
export async function maybeCompressImageWithPrompt(
file: File,
options?: {
quality?: number
ask?: boolean
minSavingsRatio?: number
contextLabel?: string
},
): Promise<CompressionResult> {
return maybeProcessImageWithPrompt(file, options)
}
export async function normalizeCoverImageWithPrompt(
file: File,
options?: {
quality?: number
ask?: boolean
contextLabel?: string
width?: number
height?: number
},
): Promise<CompressionResult> {
return maybeProcessImageWithPrompt(file, {
quality: options?.quality ?? 0.82,
ask: options?.ask ?? true,
contextLabel: options?.contextLabel ?? '封面图规范化',
preferredFormats: ['image/avif', 'image/webp', 'image/jpeg'],
coverWidth: Math.max(options?.width ?? 1600, 640),
coverHeight: Math.max(options?.height ?? 900, 360),
forceProcessed: true,
minSavingsRatio: 0,
})
}
export function formatCompressionPreview(preview: CompressionPreview | null) {
if (!preview) {
return ''
}
if (preview.savedBytes >= 0) {
return `原始 ${formatBytes(preview.originalSize)} → 处理后 ${formatBytes(
preview.compressedSize,
)},节省 ${(preview.savedRatio * 100).toFixed(1)}%`
}
return `原始 ${formatBytes(preview.originalSize)} → 处理后 ${formatBytes(
preview.compressedSize,
)},体积增加 ${Math.abs(preview.savedRatio * 100).toFixed(1)}%`
}

View File

@@ -9,7 +9,15 @@ export type ParsedMarkdownMeta = {
image: string
images: string[]
pinned: boolean
published: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFrom: string[]
redirectTo: string
tags: string[]
}
@@ -28,7 +36,15 @@ const defaultMeta: ParsedMarkdownMeta = {
image: '',
images: [],
pinned: false,
published: true,
status: 'published',
visibility: 'public',
publishAt: '',
unpublishAt: '',
canonicalUrl: '',
noindex: false,
ogImage: '',
redirectFrom: [],
redirectTo: '',
tags: [],
}
@@ -102,7 +118,7 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
const frontmatter = normalized.slice(4, endIndex)
const body = normalized.slice(endIndex + 5).trimStart()
let currentListKey: 'tags' | 'images' | 'categories' | null = null
let currentListKey: 'tags' | 'images' | 'categories' | 'redirect_from' | null = null
const categories: string[] = []
frontmatter.split('\n').forEach((line) => {
@@ -118,6 +134,8 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
meta.tags.push(nextValue)
} else if (currentListKey === 'images') {
meta.images.push(nextValue)
} else if (currentListKey === 'redirect_from') {
meta.redirectFrom.push(nextValue)
} else {
categories.push(nextValue)
}
@@ -155,6 +173,16 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
return
}
if (key === 'redirect_from') {
const redirectFrom = toStringList(value)
if (redirectFrom.length) {
meta.redirectFrom = redirectFrom
} else if (!String(rawValue).trim()) {
currentListKey = 'redirect_from'
}
return
}
if (key === 'categories' || key === 'category') {
const parsedCategories = toStringList(value)
if (parsedCategories.length) {
@@ -184,12 +212,36 @@ export function parseMarkdownDocument(markdown: string): ParsedMarkdownDocument
case 'pinned':
meta.pinned = Boolean(value)
break
case 'status':
meta.status = String(value).trim() || 'published'
break
case 'visibility':
meta.visibility = String(value).trim() || 'public'
break
case 'publish_at':
meta.publishAt = String(value).trim()
break
case 'unpublish_at':
meta.unpublishAt = String(value).trim()
break
case 'canonical_url':
meta.canonicalUrl = String(value).trim()
break
case 'noindex':
meta.noindex = Boolean(value)
break
case 'og_image':
meta.ogImage = String(value).trim()
break
case 'redirect_to':
meta.redirectTo = String(value).trim()
break
case 'published':
meta.published = value !== false
meta.status = value === false ? 'draft' : 'published'
break
case 'draft':
if (value === true) {
meta.published = false
meta.status = 'draft'
}
break
default:
@@ -223,7 +275,17 @@ export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
lines.push(`post_type: ${JSON.stringify(meta.postType.trim() || 'article')}`)
lines.push(`pinned: ${meta.pinned ? 'true' : 'false'}`)
lines.push(`published: ${meta.published ? 'true' : 'false'}`)
lines.push(`status: ${JSON.stringify(meta.status.trim() || 'published')}`)
lines.push(`visibility: ${JSON.stringify(meta.visibility.trim() || 'public')}`)
lines.push(`noindex: ${meta.noindex ? 'true' : 'false'}`)
if (meta.publishAt.trim()) {
lines.push(`publish_at: ${JSON.stringify(meta.publishAt.trim())}`)
}
if (meta.unpublishAt.trim()) {
lines.push(`unpublish_at: ${JSON.stringify(meta.unpublishAt.trim())}`)
}
if (meta.image.trim()) {
lines.push(`image: ${JSON.stringify(meta.image.trim())}`)
@@ -243,5 +305,24 @@ export function buildMarkdownDocument(meta: ParsedMarkdownMeta, body: string) {
})
}
if (meta.canonicalUrl.trim()) {
lines.push(`canonical_url: ${JSON.stringify(meta.canonicalUrl.trim())}`)
}
if (meta.ogImage.trim()) {
lines.push(`og_image: ${JSON.stringify(meta.ogImage.trim())}`)
}
if (meta.redirectFrom.length) {
lines.push('redirect_from:')
meta.redirectFrom.forEach((item) => {
lines.push(` - ${JSON.stringify(item)}`)
})
}
if (meta.redirectTo.trim()) {
lines.push(`redirect_to: ${JSON.stringify(meta.redirectTo.trim())}`)
}
return `${lines.join('\n')}\n---\n\n${body.trim()}\n`
}

View File

@@ -0,0 +1,22 @@
export type TermiAdminRuntimeConfig = {
apiBaseUrl?: string
frontendBaseUrl?: string
}
declare global {
interface Window {
__TERMI_ADMIN_RUNTIME_CONFIG__?: TermiAdminRuntimeConfig
}
}
export function normalizeAdminBaseUrl(value?: string | null) {
return value?.trim().replace(/\/$/, '') ?? ''
}
export function getRuntimeAdminBaseUrl(key: keyof TermiAdminRuntimeConfig) {
if (typeof window === 'undefined') {
return ''
}
return normalizeAdminBaseUrl(window.__TERMI_ADMIN_RUNTIME_CONFIG__?.[key])
}

View File

@@ -1,12 +1,140 @@
export interface AdminSessionResponse {
authenticated: boolean
username: string | null
email: string | null
auth_source: string | null
auth_provider: string | null
groups: string[]
proxy_auth_enabled: boolean
local_login_enabled: boolean
can_logout: boolean
}
export interface AuditLogRecord {
created_at: string
updated_at: string
id: number
actor_username: string | null
actor_email: string | null
actor_source: string | null
action: string
target_type: string
target_id: string | null
target_label: string | null
metadata: Record<string, unknown> | null
}
export interface PostRevisionRecord {
id: number
post_slug: string
post_title: string | null
operation: string
revision_reason: string | null
actor_username: string | null
actor_email: string | null
actor_source: string | null
created_at: string
has_markdown: boolean
metadata: Record<string, unknown> | null
}
export interface PostRevisionDetail {
item: PostRevisionRecord
markdown: string | null
}
export interface RestoreRevisionResponse {
restored: boolean
revision_id: number
post_slug: string
mode: 'full' | 'markdown' | 'metadata' | string
}
export interface SubscriptionRecord {
created_at: string
updated_at: string
id: number
channel_type: string
target: string
display_name: string | null
status: string
filters: Record<string, unknown> | null
metadata: Record<string, unknown> | null
secret: string | null
notes: string | null
confirm_token: string | null
manage_token: string | null
verified_at: string | null
last_notified_at: string | null
failure_count: number | null
last_delivery_status: string | null
}
export interface NotificationDeliveryRecord {
created_at: string
updated_at: string
id: number
subscription_id: number | null
channel_type: string
target: string
event_type: string
status: string
provider: string | null
response_text: string | null
payload: Record<string, unknown> | null
attempts_count: number
next_retry_at: string | null
last_attempt_at: string | null
delivered_at: string | null
}
export interface SubscriptionListResponse {
subscriptions: SubscriptionRecord[]
}
export interface DeliveryListResponse {
deliveries: NotificationDeliveryRecord[]
}
export interface SubscriptionPayload {
channelType: string
target: string
displayName?: string | null
status?: string | null
filters?: Record<string, unknown> | null
metadata?: Record<string, unknown> | null
secret?: string | null
notes?: string | null
}
export interface SubscriptionUpdatePayload {
channelType?: string | null
target?: string | null
displayName?: string | null
status?: string | null
filters?: Record<string, unknown> | null
metadata?: Record<string, unknown> | null
secret?: string | null
notes?: string | null
}
export interface SubscriptionDigestResponse {
period: string
post_count: number
queued: number
skipped: number
}
export interface DashboardStats {
total_posts: number
total_comments: number
pending_comments: number
draft_posts: number
scheduled_posts: number
offline_posts: number
expired_posts: number
private_posts: number
unlisted_posts: number
total_categories: number
total_tags: number
total_reviews: number
@@ -23,6 +151,8 @@ export interface DashboardPostItem {
category: string
post_type: string
pinned: boolean
status: string
visibility: string
created_at: string
}
@@ -84,6 +214,16 @@ export interface AnalyticsOverview {
avg_ai_latency_ms_last_7d: number | null
}
export interface ContentAnalyticsOverview {
total_page_views: number
page_views_last_24h: number
page_views_last_7d: number
total_read_completes: number
read_completes_last_7d: number
avg_read_progress_last_7d: number
avg_read_duration_ms_last_7d: number | null
}
export interface AnalyticsTopQuery {
query: string
count: number
@@ -108,6 +248,20 @@ export interface AnalyticsProviderBucket {
count: number
}
export interface AnalyticsReferrerBucket {
referrer: string
count: number
}
export interface AnalyticsPopularPost {
slug: string
title: string
page_views: number
read_completes: number
avg_progress_percent: number
avg_duration_ms: number | null
}
export interface AnalyticsDailyBucket {
date: string
searches: number
@@ -116,10 +270,13 @@ export interface AnalyticsDailyBucket {
export interface AdminAnalyticsResponse {
overview: AnalyticsOverview
content_overview: ContentAnalyticsOverview
top_search_terms: AnalyticsTopQuery[]
top_ai_questions: AnalyticsTopQuery[]
recent_events: AnalyticsRecentEvent[]
providers_last_7d: AnalyticsProviderBucket[]
top_referrers: AnalyticsReferrerBucket[]
popular_posts: AnalyticsPopularPost[]
daily_activity: AnalyticsDailyBucket[]
}
@@ -167,6 +324,12 @@ export interface AdminSiteSettingsResponse {
media_r2_public_base_url: string | null
media_r2_access_key_id: string | null
media_r2_secret_access_key: string | null
seo_default_og_image: string | null
seo_default_twitter_handle: string | null
notification_webhook_url: string | null
notification_comment_enabled: boolean
notification_friend_link_enabled: boolean
search_synonyms: string[]
}
export interface AiProviderConfig {
@@ -219,6 +382,12 @@ export interface SiteSettingsPayload {
mediaR2PublicBaseUrl?: string | null
mediaR2AccessKeyId?: string | null
mediaR2SecretAccessKey?: string | null
seoDefaultOgImage?: string | null
seoDefaultTwitterHandle?: string | null
notificationWebhookUrl?: string | null
notificationCommentEnabled?: boolean
notificationFriendLinkEnabled?: boolean
searchSynonyms?: string[]
}
export interface AdminAiReindexResponse {
@@ -269,6 +438,74 @@ export interface AdminMediaDeleteResponse {
key: string
}
export interface AdminMediaUploadItem {
key: string
url: string
size_bytes: number
}
export interface AdminMediaUploadResponse {
uploaded: AdminMediaUploadItem[]
}
export interface AdminMediaBatchDeleteResponse {
deleted: string[]
failed: string[]
}
export interface AdminMediaReplaceResponse {
key: string
url: string
}
export interface CommentBlacklistRecord {
id: number
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
reason: string | null
active: boolean
expires_at: string | null
created_at: string
updated_at: string
effective: boolean
}
export interface CommentPersonaAnalysisSample {
id: number
created_at: string
post_slug: string
author: string
email: string
approved: boolean
content_preview: string
}
export interface CommentPersonaAnalysisResponse {
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
total_comments: number
pending_comments: number
first_seen_at: string | null
latest_seen_at: string | null
distinct_posts: number
analysis: string
samples: CommentPersonaAnalysisSample[]
}
export interface CommentPersonaAnalysisLogRecord {
id: number
matcher_type: 'ip' | 'email' | 'user_agent' | string
matcher_value: string
from_at: string | null
to_at: string | null
total_comments: number
pending_comments: number
distinct_posts: number
analysis: string
samples: CommentPersonaAnalysisSample[]
created_at: string
}
export interface MusicTrack {
title: string
artist?: string | null
@@ -334,6 +571,15 @@ export interface PostRecord {
image: string | null
images: string[] | null
pinned: boolean | null
status: string | null
visibility: string | null
publish_at: string | null
unpublish_at: string | null
canonical_url: string | null
noindex: boolean | null
og_image: string | null
redirect_from: string[] | null
redirect_to: string | null
}
export interface PostListQuery {
@@ -343,6 +589,12 @@ export interface PostListQuery {
search?: string
postType?: string
pinned?: boolean
status?: string
visibility?: string
listedOnly?: boolean
includePrivate?: boolean
includeRedirects?: boolean
preview?: boolean
}
export interface CreatePostPayload {
@@ -356,6 +608,15 @@ export interface CreatePostPayload {
image?: string | null
images?: string[] | null
pinned?: boolean
status?: string | null
visibility?: string | null
publishAt?: string | null
unpublishAt?: string | null
canonicalUrl?: string | null
noindex?: boolean
ogImage?: string | null
redirectFrom?: string[]
redirectTo?: string | null
published?: boolean
}
@@ -370,6 +631,15 @@ export interface UpdatePostPayload {
image?: string | null
images?: string[] | null
pinned?: boolean | null
status?: string | null
visibility?: string | null
publishAt?: string | null
unpublishAt?: string | null
canonicalUrl?: string | null
noindex?: boolean | null
ogImage?: string | null
redirectFrom?: string[]
redirectTo?: string | null
}
export interface MarkdownDocumentResponse {
@@ -397,6 +667,9 @@ export interface CommentRecord {
author: string | null
email: string | null
avatar: string | null
ip_address: string | null
user_agent: string | null
referer: string | null
content: string | null
scope: string
paragraph_key: string | null

View File

@@ -1,4 +1,4 @@
import { BarChart3, BrainCircuit, Clock3, RefreshCcw, Search } from 'lucide-react'
import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -15,6 +15,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import type { AdminAnalyticsResponse } from '@/lib/types'
function StatCard({
@@ -56,6 +57,29 @@ function formatSuccess(value: boolean | null) {
return value ? '成功' : '失败'
}
function formatPercent(value: number) {
return `${Math.round(value)}%`
}
function formatDuration(value: number | null) {
if (value === null || !Number.isFinite(value) || value <= 0) {
return '暂无'
}
if (value < 1000) {
return `${Math.round(value)} ms`
}
const seconds = value / 1000
if (seconds < 60) {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}`
}
const minutes = Math.floor(seconds / 60)
const restSeconds = Math.round(seconds % 60)
return `${minutes}${restSeconds}`
}
export function AnalyticsPage() {
const [data, setData] = useState<AdminAnalyticsResponse | null>(null)
const [loading, setLoading] = useState(true)
@@ -147,22 +171,49 @@ export function AnalyticsPage() {
},
]
const contentStatCards = [
{
label: '累计页面访问',
value: String(data.content_overview.total_page_views),
note: `近 24 小时 ${data.content_overview.page_views_last_24h} 次,近 7 天 ${data.content_overview.page_views_last_7d}`,
icon: Eye,
},
{
label: '累计完读次数',
value: String(data.content_overview.total_read_completes),
note: `近 7 天新增 ${data.content_overview.read_completes_last_7d} 次 read_complete`,
icon: BarChart3,
},
{
label: '近 7 天平均进度',
value: formatPercent(data.content_overview.avg_read_progress_last_7d),
note: '基于 read_progress / read_complete 事件估算内容消费深度',
icon: Search,
},
{
label: '近 7 天平均阅读时长',
value: formatDuration(data.content_overview.avg_read_duration_ms_last_7d),
note: '同一会话在文章页停留并产生阅读进度的平均时长',
icon: Clock3,
},
]
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> AI </h2>
<h2 className="text-3xl font-semibold tracking-tight"> AI </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
AI 便
AI 访便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
<BrainCircuit className="h-4 w-4" />
</a>
@@ -184,6 +235,12 @@ export function AnalyticsPage() {
))}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{contentStatCards.map((item) => (
<StatCard key={item.label} {...item} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
@@ -246,11 +303,69 @@ export function AnalyticsPage() {
))}
</TableBody>
</Table>
</CardContent>
</Card>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
page_view / read_complete
</CardDescription>
</div>
<Badge variant="outline">{data.popular_posts.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.popular_posts.length ? (
data.popular_posts.map((post) => (
<TableRow key={post.slug}>
<TableCell>
<div className="space-y-1">
<a
href={buildFrontendUrl(`/articles/${post.slug}`)}
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
{post.title}
</a>
<p className="font-mono text-xs text-muted-foreground">
{post.slug}
</p>
</div>
</TableCell>
<TableCell>{post.page_views}</TableCell>
<TableCell>{post.read_completes}</TableCell>
<TableCell>{formatPercent(post.avg_progress_percent)}</TableCell>
<TableCell>{formatDuration(post.avg_duration_ms)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-sm text-muted-foreground">
访
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
@@ -319,35 +434,70 @@ export function AnalyticsPage() {
<div className="space-y-6 xl:sticky xl:top-28 xl:self-start">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription>
24 7
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.searches_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
24 AI
</p>
<p className="mt-3 text-3xl font-semibold">{data.overview.ai_questions_last_24h}</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
AI
24 访
</p>
<p className="mt-3 text-3xl font-semibold">
{data.overview.avg_ai_latency_ms_last_7d !== null
? `${Math.round(data.overview.avg_ai_latency_ms_last_7d)} ms`
: '暂无'}
{data.content_overview.page_views_last_24h}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{data.content_overview.read_completes_last_7d}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{formatPercent(data.content_overview.avg_read_progress_last_7d)}
</p>
<p className="mt-2 text-sm text-muted-foreground"> 7 </p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
7
</p>
<p className="mt-3 text-3xl font-semibold">
{formatDuration(data.content_overview.avg_read_duration_ms_last_7d)}
</p>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
7 page_view referrer host
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.top_referrers.length ? (
data.top_referrers.map((item) => (
<div
key={item.referrer}
className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3"
>
<span className="line-clamp-1 font-medium">{item.referrer}</span>
<Badge variant="outline">{item.count}</Badge>
</div>
))
) : (
<p className="text-sm text-muted-foreground"> 7 </p>
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,166 @@
import { RefreshCcw } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import type { AuditLogRecord } from '@/lib/types'
export function AuditPage() {
const [logs, setLogs] = useState<AuditLogRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [keyword, setKeyword] = useState('')
const loadLogs = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listAuditLogs({ limit: 120 })
startTransition(() => {
setLogs(next)
})
if (showToast) {
toast.success('审计日志已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载审计日志。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadLogs(false)
}, [loadLogs])
const filteredLogs = useMemo(() => {
const normalized = keyword.trim().toLowerCase()
if (!normalized) {
return logs
}
return logs.filter((log) =>
[
log.action,
log.target_type,
log.target_id ?? '',
log.target_label ?? '',
log.actor_username ?? '',
log.actor_email ?? '',
]
.join(' ')
.toLowerCase()
.includes(normalized),
)
}, [keyword, logs])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 rounded-3xl" />
<Skeleton className="h-[520px] rounded-3xl" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
便
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="按动作 / 对象 / 操作者过滤"
className="w-[280px]"
/>
<Button variant="secondary" onClick={() => void loadLogs(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> 120 </CardDescription>
</div>
<Badge variant="outline">{filteredLogs.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-muted-foreground">{log.created_at}</TableCell>
<TableCell>
<Badge variant="secondary">{log.action}</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{log.target_type}</div>
<div className="font-mono text-xs text-muted-foreground">
{log.target_label ?? log.target_id ?? '—'}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{log.actor_username ?? 'system'}</div>
<div className="text-xs text-muted-foreground">
{log.actor_email ?? log.actor_source ?? '未记录'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[320px] text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-5">
{log.metadata ? JSON.stringify(log.metadata, null, 2) : '—'}
</pre>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import {
ArrowUpRight,
BrainCircuit,
Clock3,
FolderTree,
MessageSquareWarning,
RefreshCcw,
@@ -24,10 +25,13 @@ import {
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import {
formatCommentScope,
formatPostStatus,
formatFriendLinkStatus,
formatPostType,
formatPostVisibility,
formatReviewStatus,
formatReviewType,
} from '@/lib/admin-format'
@@ -120,6 +124,16 @@ export function DashboardPage() {
note: '等待审核处理',
icon: MessageSquareWarning,
},
{
label: '发布待办',
value:
data.stats.draft_posts +
data.stats.scheduled_posts +
data.stats.offline_posts +
data.stats.expired_posts,
note: `草稿 ${data.stats.draft_posts} / 定时 ${data.stats.scheduled_posts} / 下线 ${data.stats.offline_posts + data.stats.expired_posts}`,
icon: Clock3,
},
{
label: '分类数量',
value: data.stats.total_categories,
@@ -149,7 +163,7 @@ export function DashboardPage() {
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<a href={buildFrontendUrl('/ask')} target="_blank" rel="noreferrer">
<ArrowUpRight className="h-4 w-4" />
AI
</a>
@@ -188,6 +202,7 @@ export function DashboardPage() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
@@ -207,6 +222,12 @@ export function DashboardPage() {
<TableCell className="uppercase text-muted-foreground">
{formatPostType(post.post_type)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{formatPostStatus(post.status)}</Badge>
<Badge variant="secondary">{formatPostVisibility(post.visibility)}</Badge>
</div>
</TableCell>
<TableCell>{post.category}</TableCell>
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
</TableRow>
@@ -257,6 +278,34 @@ export function DashboardPage() {
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-2xl font-semibold">{data.stats.draft_posts}</p>
<p className="text-xs text-muted-foreground">稿</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.scheduled_posts}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.offline_posts}</p>
<p className="text-xs text-muted-foreground">线</p>
</div>
<div>
<p className="text-2xl font-semibold">{data.stats.expired_posts}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-muted-foreground">
<Badge variant="outline"> {data.stats.private_posts}</Badge>
<Badge variant="outline"> {data.stats.unlisted_posts}</Badge>
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
AI
@@ -275,7 +324,7 @@ export function DashboardPage() {
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
<Badge variant="warning">{data.pending_comments.length} </Badge>

View File

@@ -8,9 +8,13 @@ import { Label } from '@/components/ui/label'
export function LoginPage({
submitting,
localLoginEnabled,
proxyAuthEnabled,
onLogin,
}: {
submitting: boolean
localLoginEnabled: boolean
proxyAuthEnabled: boolean
onLogin: (payload: { username: string; password: string }) => Promise<void>
}) {
const [username, setUsername] = useState('admin')
@@ -30,7 +34,7 @@ export function LoginPage({
线
</CardTitle>
<CardDescription className="max-w-xl text-base leading-7">
AI
API
</CardDescription>
</div>
</CardHeader>
@@ -60,44 +64,58 @@ export function LoginPage({
</CardTitle>
<CardDescription>
{localLoginEnabled
? '当前登录复用后端管理员账号;如果前面接了 TinyAuth / Pocket ID也可以直接由反向代理完成 SSO。'
: proxyAuthEnabled
? '当前后台已切到代理侧 SSO 模式,请从受保护的后台域名入口进入。'
: '当前后台未开放本地账号密码登录,请检查部署配置。'}
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault()
void onLogin({ username, password })
}}
>
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</div>
{localLoginEnabled ? (
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault()
void onLogin({ username, password })
}}
>
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
<Button className="w-full" size="lg" disabled={submitting}>
{submitting ? '登录中...' : '进入后台'}
</Button>
</form>
<Button className="w-full" size="lg" disabled={submitting}>
{submitting ? '登录中...' : '进入后台'}
</Button>
</form>
) : (
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-7 text-muted-foreground">
<p> Caddy + TinyAuth + Pocket ID </p>
<p> SSO </p>
<Button className="w-full" size="lg" onClick={() => window.location.reload()}>
</Button>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,4 +1,13 @@
import { Copy, Image as ImageIcon, RefreshCcw, Trash2 } from 'lucide-react'
import {
CheckSquare,
Copy,
Image as ImageIcon,
RefreshCcw,
Replace,
Square,
Trash2,
Upload,
} from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -9,6 +18,11 @@ 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 {
formatCompressionPreview,
maybeCompressImageWithPrompt,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import type { AdminMediaObjectResponse } from '@/lib/types'
function formatBytes(value: number) {
@@ -30,10 +44,18 @@ export function MediaPage() {
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [deletingKey, setDeletingKey] = useState<string | null>(null)
const [replacingKey, setReplacingKey] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [batchDeleting, setBatchDeleting] = useState(false)
const [prefixFilter, setPrefixFilter] = useState('all')
const [uploadPrefix, setUploadPrefix] = useState('post-covers/')
const [searchTerm, setSearchTerm] = useState('')
const [provider, setProvider] = useState<string | null>(null)
const [bucket, setBucket] = useState<string | null>(null)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
const [compressQuality, setCompressQuality] = useState('0.82')
const loadItems = useCallback(async (showToast = false) => {
try {
@@ -62,6 +84,12 @@ export function MediaPage() {
void loadItems(false)
}, [loadItems])
useEffect(() => {
setSelectedKeys((current) =>
current.filter((key) => items.some((item) => item.key === key)),
)
}, [items])
const filteredItems = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase()
if (!keyword) {
@@ -70,6 +98,40 @@ export function MediaPage() {
return items.filter((item) => item.key.toLowerCase().includes(keyword))
}, [items, searchTerm])
const allFilteredSelected =
filteredItems.length > 0 && filteredItems.every((item) => selectedKeys.includes(item.key))
async function prepareFiles(files: File[], targetPrefix = uploadPrefix) {
if (!compressBeforeUpload) {
return files
}
const quality = Number.parseFloat(compressQuality)
const safeQuality = Number.isFinite(quality) ? Math.min(Math.max(quality, 0.4), 0.95) : 0.82
const normalizeCover =
targetPrefix === 'post-covers/' || targetPrefix === 'review-covers/'
const result: File[] = []
for (const file of files) {
const compressed = normalizeCover
? await normalizeCoverImageWithPrompt(file, {
quality: safeQuality,
ask: true,
contextLabel: `封面规范化上传(${file.name}`,
})
: await maybeCompressImageWithPrompt(file, {
quality: safeQuality,
ask: true,
contextLabel: `媒体库上传(${file.name}`,
})
if (compressed.preview) {
toast.message(formatCompressionPreview(compressed.preview) || '压缩评估完成')
}
result.push(compressed.file)
}
return result
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
@@ -78,7 +140,7 @@ export function MediaPage() {
<div>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
</p>
</div>
</div>
@@ -88,27 +150,119 @@ export function MediaPage() {
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="danger"
disabled={!selectedKeys.length || batchDeleting}
onClick={async () => {
if (!window.confirm(`确定批量删除 ${selectedKeys.length} 个对象吗?`)) {
return
}
try {
setBatchDeleting(true)
const result = await adminApi.batchDeleteMediaObjects(selectedKeys)
if (result.failed.length) {
toast.warning(`已删除 ${result.deleted.length} 个,失败 ${result.failed.length} 个。`)
} else {
toast.success(`已删除 ${result.deleted.length} 个对象。`)
}
await loadItems(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '批量删除失败。')
} finally {
setBatchDeleting(false)
}
}}
>
<Trash2 className="h-4 w-4" />
({selectedKeys.length})
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Provider{provider ?? '未配置'} / Bucket{bucket ?? '未配置'}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-[220px_1fr]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
<option value="all"></option>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
</Select>
<Input
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<CardContent className="space-y-3">
<div className="grid gap-3 lg:grid-cols-[220px_220px_1fr]">
<Select value={prefixFilter} onChange={(event) => setPrefixFilter(event.target.value)}>
<option value="all"></option>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
<option value="uploads/"></option>
</Select>
<Select value={uploadPrefix} onChange={(event) => setUploadPrefix(event.target.value)}>
<option value="post-covers/"></option>
<option value="review-covers/"></option>
<option value="uploads/"></option>
</Select>
<Input
placeholder="按对象 key 搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_auto_auto_auto]">
<Input
type="file"
multiple
accept="image/*"
onChange={(event) => {
const files = Array.from(event.target.files || [])
setUploadFiles(files)
}}
/>
<Button
type="button"
variant="outline"
onClick={() => setCompressBeforeUpload((current) => !current)}
>
{compressBeforeUpload ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
</Button>
<Input
className="w-[96px]"
value={compressQuality}
onChange={(event) => setCompressQuality(event.target.value)}
placeholder="0.82"
disabled={!compressBeforeUpload}
/>
<Button
disabled={!uploadFiles.length || uploading}
onClick={async () => {
try {
setUploading(true)
const files = await prepareFiles(uploadFiles)
const result = await adminApi.uploadMediaObjects(files, {
prefix: uploadPrefix,
})
toast.success(`上传完成,共 ${result.uploaded.length} 个文件。`)
setUploadFiles([])
await loadItems(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '上传失败。')
} finally {
setUploading(false)
}
}}
>
<Upload className="h-4 w-4" />
{uploading ? '上传中...' : '上传'}
</Button>
</div>
{uploadFiles.length ? (
<p className="text-xs text-muted-foreground">
{uploadFiles.length}
{uploadPrefix === 'post-covers/' || uploadPrefix === 'review-covers/'
? ' 当前会自动裁切为 16:9 封面,并优先转成 AVIF/WebP。'
: ''}
</p>
) : null}
</CardContent>
</Card>
@@ -116,64 +270,138 @@ export function MediaPage() {
<Skeleton className="h-[520px] rounded-3xl" />
) : (
<div className="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3">
{filteredItems.map((item) => (
<Card key={item.key} className="overflow-hidden">
<div className="aspect-[16/9] overflow-hidden bg-muted/30">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
</div>
<CardContent className="space-y-4 p-5">
<div className="space-y-2">
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
{filteredItems.map((item, index) => {
const selected = selectedKeys.includes(item.key)
const replaceInputId = `replace-media-${index}`
return (
<Card key={item.key} className="overflow-hidden">
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
<button
type="button"
className="absolute left-2 top-2 rounded-xl border border-border/80 bg-background/80 p-1"
onClick={() => {
setSelectedKeys((current) => {
if (current.includes(item.key)) {
return current.filter((key) => key !== item.key)
}
return [...current, item.key]
})
}}
>
{selected ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
</button>
</div>
<CardContent className="space-y-4 p-5">
<div className="space-y-2">
<p className="line-clamp-2 break-all text-sm font-medium">{item.key}</p>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{formatBytes(item.size_bytes)}</span>
{item.last_modified ? <span>{item.last_modified}</span> : null}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(item.url)
toast.success('图片链接已复制。')
} catch {
toast.error('复制失败,请手动复制。')
}
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="danger"
disabled={deletingKey === item.key}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return
}
try {
setDeletingKey(item.key)
await adminApi.deleteMediaObject(item.key)
startTransition(() => {
setItems((current) => current.filter((currentItem) => currentItem.key !== item.key))
})
toast.success('媒体对象已删除。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
} finally {
setDeletingKey(null)
}
}}
>
<Trash2 className="h-4 w-4" />
{deletingKey === item.key ? '删除中...' : '删除'}
</Button>
</div>
</CardContent>
</Card>
))}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await navigator.clipboard.writeText(item.url)
toast.success('图片链接已复制。')
} catch {
toast.error('复制失败,请手动复制。')
}
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" asChild>
<label htmlFor={replaceInputId} className="cursor-pointer">
<Replace className="h-4 w-4" />
</label>
</Button>
<input
id={replaceInputId}
className="hidden"
type="file"
accept="image/*"
onChange={async (event) => {
const file = event.target.files?.item(0)
event.currentTarget.value = ''
if (!file) {
return
}
try {
setReplacingKey(item.key)
const [prepared] = await prepareFiles(
[file],
item.key.startsWith('review-covers/')
? 'review-covers/'
: item.key.startsWith('post-covers/')
? 'post-covers/'
: 'uploads/',
)
const result = await adminApi.replaceMediaObject(item.key, prepared)
startTransition(() => {
setItems((current) =>
current.map((currentItem) =>
currentItem.key === item.key
? { ...currentItem, url: result.url }
: currentItem,
),
)
})
toast.success('已替换媒体对象。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '替换失败。')
} finally {
setReplacingKey(null)
}
}}
/>
<Button
size="sm"
variant="danger"
disabled={deletingKey === item.key || replacingKey === item.key}
onClick={async () => {
if (!window.confirm(`确定删除 ${item.key} 吗?`)) {
return
}
try {
setDeletingKey(item.key)
await adminApi.deleteMediaObject(item.key)
startTransition(() => {
setItems((current) =>
current.filter((currentItem) => currentItem.key !== item.key),
)
setSelectedKeys((current) =>
current.filter((key) => key !== item.key),
)
})
toast.success('媒体对象已删除。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除媒体对象失败。')
} finally {
setDeletingKey(null)
}
}}
>
<Trash2 className="h-4 w-4" />
{deletingKey === item.key
? '删除中...'
: replacingKey === item.key
? '替换中...'
: '删除'}
</Button>
</div>
</CardContent>
</Card>
)
})}
{!filteredItems.length ? (
<Card className="xl:col-span-2 2xl:col-span-3">
@@ -185,6 +413,37 @@ export function MediaPage() {
) : null}
</div>
)}
{filteredItems.length ? (
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6 text-sm text-muted-foreground">
<p>
{filteredItems.length} {selectedKeys.length}
</p>
<Button
variant="outline"
onClick={() => {
if (allFilteredSelected) {
setSelectedKeys((current) =>
current.filter(
(key) => !filteredItems.some((item) => item.key === key),
),
)
return
}
setSelectedKeys((current) => {
const next = new Set(current)
filteredItems.forEach((item) => next.add(item.key))
return Array.from(next)
})
}}
>
{allFilteredSelected ? <Square className="h-4 w-4" /> : <CheckSquare className="h-4 w-4" />}
{allFilteredSelected ? '取消全选' : '全选当前筛选'}
</Button>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { adminApi, ApiError } from '@/lib/api'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
type PreviewState = {
@@ -124,7 +125,7 @@ export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
</Button>
{slug ? (
<Button variant="outline" asChild>
<a href={`http://localhost:4321/articles/${slug}`} target="_blank" rel="noreferrer">
<a href={buildFrontendUrl(`/articles/${slug}`)} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>

View File

@@ -12,6 +12,7 @@ import {
RotateCcw,
Save,
Trash2,
Upload,
WandSparkles,
X,
} from 'lucide-react'
@@ -38,10 +39,22 @@ import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { emptyToNull, formatDateTime, formatPostType, postTagsToList } from '@/lib/admin-format'
import {
emptyToNull,
formatDateTime,
formatPostStatus,
formatPostType,
formatPostVisibility,
postTagsToList,
} from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import { buildMarkdownDocument, parseMarkdownDocument } from '@/lib/markdown-document'
import { countLineDiff, normalizeMarkdown } from '@/lib/markdown-diff'
import { applySelectedDiffHunks, computeDiffHunks, type DiffHunk } from '@/lib/markdown-merge'
import { buildFrontendUrl } from '@/lib/frontend-url'
import { cn } from '@/lib/utils'
import type {
AdminPostMetadataResponse,
@@ -59,6 +72,15 @@ type PostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
markdown: string
savedMarkdown: string
@@ -73,6 +95,15 @@ type PostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
}
}
@@ -86,6 +117,15 @@ type CreatePostFormState = {
image: string
imagesText: string
pinned: boolean
status: string
visibility: string
publishAt: string
unpublishAt: string
canonicalUrl: string
noindex: boolean
ogImage: string
redirectFromText: string
redirectTo: string
tags: string
markdown: string
}
@@ -141,8 +181,6 @@ const createMetadataProposalFields: MetadataProposalField[] = [
'category',
'tags',
]
const FRONTEND_DEV_ORIGIN = 'http://localhost:4321'
const defaultCreateForm: CreatePostFormState = {
title: '',
slug: '',
@@ -152,6 +190,15 @@ const defaultCreateForm: CreatePostFormState = {
image: '',
imagesText: '',
pinned: false,
status: 'draft',
visibility: 'public',
publishAt: '',
unpublishAt: '',
canonicalUrl: '',
noindex: false,
ogImage: '',
redirectFromText: '',
redirectTo: '',
tags: '',
markdown: '# 未命名文章\n',
}
@@ -219,11 +266,7 @@ function resolveCoverPreviewUrl(value: string) {
}
if (trimmed.startsWith('/')) {
if (import.meta.env.DEV) {
return new URL(trimmed, FRONTEND_DEV_ORIGIN).toString()
}
return new URL(trimmed, window.location.origin).toString()
return buildFrontendUrl(trimmed)
}
return trimmed
@@ -409,20 +452,29 @@ function stripFrontmatter(markdown: string) {
return normalized.slice(endIndex + 5).trimStart()
}
function extractPublishedFlag(markdown: string) {
function extractPostStatus(markdown: string) {
const normalized = markdown.replace(/\r\n/g, '\n')
if (!normalized.startsWith('---\n')) {
return true
return 'published'
}
const endIndex = normalized.indexOf('\n---\n', 4)
if (endIndex === -1) {
return true
return 'published'
}
const frontmatter = normalized.slice(4, endIndex)
const match = frontmatter.match(/^published:\s*(true|false)\s*$/m)
return match?.[1] !== 'false'
const statusMatch = frontmatter.match(/^status:\s*(.+)\s*$/m)
if (statusMatch?.[1]) {
return statusMatch[1].replace(/^['"]|['"]$/g, '').trim() || 'published'
}
const publishedMatch = frontmatter.match(/^published:\s*(true|false)\s*$/m)
if (publishedMatch) {
return publishedMatch[1] === 'false' ? 'draft' : 'published'
}
return 'published'
}
function buildMarkdownForSave(form: PostFormState) {
@@ -441,7 +493,17 @@ function buildMarkdownForSave(form: PostFormState) {
lines.push(`post_type: ${JSON.stringify(form.postType.trim() || 'article')}`)
lines.push(`pinned: ${form.pinned ? 'true' : 'false'}`)
lines.push(`published: ${extractPublishedFlag(form.markdown) ? 'true' : 'false'}`)
lines.push(`status: ${JSON.stringify(form.status.trim() || extractPostStatus(form.markdown))}`)
lines.push(`visibility: ${JSON.stringify(form.visibility.trim() || 'public')}`)
lines.push(`noindex: ${form.noindex ? 'true' : 'false'}`)
if (form.publishAt.trim()) {
lines.push(`publish_at: ${JSON.stringify(form.publishAt.trim())}`)
}
if (form.unpublishAt.trim()) {
lines.push(`unpublish_at: ${JSON.stringify(form.unpublishAt.trim())}`)
}
if (form.image.trim()) {
lines.push(`image: ${JSON.stringify(form.image.trim())}`)
@@ -466,6 +528,26 @@ function buildMarkdownForSave(form: PostFormState) {
})
}
if (form.canonicalUrl.trim()) {
lines.push(`canonical_url: ${JSON.stringify(form.canonicalUrl.trim())}`)
}
if (form.ogImage.trim()) {
lines.push(`og_image: ${JSON.stringify(form.ogImage.trim())}`)
}
const redirectFrom = parseImageList(form.redirectFromText)
if (redirectFrom.length) {
lines.push('redirect_from:')
redirectFrom.forEach((item) => {
lines.push(` - ${JSON.stringify(item)}`)
})
}
if (form.redirectTo.trim()) {
lines.push(`redirect_to: ${JSON.stringify(form.redirectTo.trim())}`)
}
return `${lines.join('\n')}\n---\n\n${stripFrontmatter(form.markdown).trim()}\n`
}
@@ -483,6 +565,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
image: post.image ?? '',
imagesText,
pinned: Boolean(post.pinned),
status: post.status ?? extractPostStatus(markdown),
visibility: post.visibility ?? 'public',
publishAt: post.publish_at ?? '',
unpublishAt: post.unpublish_at ?? '',
canonicalUrl: post.canonical_url ?? '',
noindex: Boolean(post.noindex),
ogImage: post.og_image ?? '',
redirectFromText: (post.redirect_from ?? []).join('\n'),
redirectTo: post.redirect_to ?? '',
tags,
markdown,
savedMarkdown: markdown,
@@ -497,6 +588,15 @@ function buildEditorState(post: PostRecord, markdown: string, path: string): Pos
image: post.image ?? '',
imagesText,
pinned: Boolean(post.pinned),
status: post.status ?? extractPostStatus(markdown),
visibility: post.visibility ?? 'public',
publishAt: post.publish_at ?? '',
unpublishAt: post.unpublish_at ?? '',
canonicalUrl: post.canonical_url ?? '',
noindex: Boolean(post.noindex),
ogImage: post.og_image ?? '',
redirectFromText: (post.redirect_from ?? []).join('\n'),
redirectTo: post.redirect_to ?? '',
tags,
},
}
@@ -511,6 +611,15 @@ function hasMetadataDraftChanges(form: PostFormState) {
form.image !== form.savedMeta.image ||
form.imagesText !== form.savedMeta.imagesText ||
form.pinned !== form.savedMeta.pinned ||
form.status !== form.savedMeta.status ||
form.visibility !== form.savedMeta.visibility ||
form.publishAt !== form.savedMeta.publishAt ||
form.unpublishAt !== form.savedMeta.unpublishAt ||
form.canonicalUrl !== form.savedMeta.canonicalUrl ||
form.noindex !== form.savedMeta.noindex ||
form.ogImage !== form.savedMeta.ogImage ||
form.redirectFromText !== form.savedMeta.redirectFromText ||
form.redirectTo !== form.savedMeta.redirectTo ||
form.tags !== form.savedMeta.tags
)
}
@@ -534,7 +643,15 @@ function buildCreatePayload(form: CreatePostFormState): CreatePostPayload {
image: emptyToNull(form.image),
images: parseImageList(form.imagesText),
pinned: form.pinned,
published: true,
status: emptyToNull(form.status) ?? 'draft',
visibility: emptyToNull(form.visibility) ?? 'public',
publishAt: emptyToNull(form.publishAt),
unpublishAt: emptyToNull(form.unpublishAt),
canonicalUrl: emptyToNull(form.canonicalUrl),
noindex: form.noindex,
ogImage: emptyToNull(form.ogImage),
redirectFrom: parseImageList(form.redirectFromText),
redirectTo: emptyToNull(form.redirectTo),
}
}
@@ -549,7 +666,15 @@ function buildCreateMarkdownForWindow(form: CreatePostFormState) {
image: form.image.trim(),
images: parseImageList(form.imagesText),
pinned: form.pinned,
published: true,
status: form.status.trim() || 'draft',
visibility: form.visibility.trim() || 'public',
publishAt: form.publishAt.trim(),
unpublishAt: form.unpublishAt.trim(),
canonicalUrl: form.canonicalUrl.trim(),
noindex: form.noindex,
ogImage: form.ogImage.trim(),
redirectFrom: parseImageList(form.redirectFromText),
redirectTo: form.redirectTo.trim(),
tags: form.tags
.split(',')
.map((item) => item.trim())
@@ -571,7 +696,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
image: parsed.meta.image || form.image,
images: parsed.meta.images.length ? parsed.meta.images : parseImageList(form.imagesText),
pinned: parsed.meta.pinned,
published: extractPublishedFlag(markdown),
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFrom: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom
: parseImageList(form.redirectFromText),
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length
? parsed.meta.tags
: form.tags
@@ -591,6 +726,17 @@ function applyPolishedEditorState(form: PostFormState, markdown: string): PostFo
image: parsed.meta.image || form.image,
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
pinned: parsed.meta.pinned,
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFromText: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom.join('\n')
: form.redirectFromText,
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
markdown: nextMarkdown,
}
@@ -609,6 +755,17 @@ function applyPolishedCreateState(form: CreatePostFormState, markdown: string):
image: parsed.meta.image || form.image,
imagesText: parsed.meta.images.length ? parsed.meta.images.join('\n') : form.imagesText,
pinned: parsed.meta.pinned,
status: parsed.meta.status || form.status,
visibility: parsed.meta.visibility || form.visibility,
publishAt: parsed.meta.publishAt || form.publishAt,
unpublishAt: parsed.meta.unpublishAt || form.unpublishAt,
canonicalUrl: parsed.meta.canonicalUrl || form.canonicalUrl,
noindex: parsed.meta.noindex,
ogImage: parsed.meta.ogImage || form.ogImage,
redirectFromText: parsed.meta.redirectFrom.length
? parsed.meta.redirectFrom.join('\n')
: form.redirectFromText,
redirectTo: parsed.meta.redirectTo || form.redirectTo,
tags: parsed.meta.tags.length ? parsed.meta.tags.join(', ') : form.tags,
markdown: parsed.body || stripFrontmatter(markdown),
}
@@ -629,6 +786,8 @@ export function PostsPage() {
const { slug } = useParams()
const importInputRef = 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 [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -642,6 +801,8 @@ export function PostsPage() {
useState(false)
const [generatingEditorCover, setGeneratingEditorCover] = useState(false)
const [generatingCreateCover, setGeneratingCreateCover] = useState(false)
const [uploadingEditorCover, setUploadingEditorCover] = useState(false)
const [uploadingCreateCover, setUploadingCreateCover] = useState(false)
const [generatingEditorPolish, setGeneratingEditorPolish] = useState(false)
const [generatingCreatePolish, setGeneratingCreatePolish] = useState(false)
const [editor, setEditor] = useState<PostFormState | null>(null)
@@ -896,6 +1057,15 @@ export function PostsPage() {
image: emptyToNull(editor.image),
images: parseImageList(editor.imagesText),
pinned: editor.pinned,
status: emptyToNull(editor.status) ?? 'draft',
visibility: emptyToNull(editor.visibility) ?? 'public',
publishAt: emptyToNull(editor.publishAt),
unpublishAt: emptyToNull(editor.unpublishAt),
canonicalUrl: emptyToNull(editor.canonicalUrl),
noindex: editor.noindex,
ogImage: emptyToNull(editor.ogImage),
redirectFrom: parseImageList(editor.redirectFromText),
redirectTo: emptyToNull(editor.redirectTo),
})
const updatedMarkdown = await adminApi.updatePostMarkdown(editor.slug, persistedMarkdown)
@@ -1082,6 +1252,68 @@ export function PostsPage() {
}
}, [createForm])
const uploadEditorCover = useCallback(async (file: File) => {
try {
setUploadingEditorCover(true)
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], {
prefix: 'post-covers/',
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
}
startTransition(() => {
setEditor((current) => (current ? { ...current, image: url } : current))
})
toast.success('封面已上传并回填。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
} finally {
setUploadingEditorCover(false)
}
}, [])
const uploadCreateCover = useCallback(async (file: File) => {
try {
setUploadingCreateCover(true)
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], {
prefix: 'post-covers/',
})
const url = result.uploaded[0]?.url
if (!url) {
throw new Error('上传完成但未返回 URL')
}
startTransition(() => {
setCreateForm((current) => ({ ...current, image: url }))
})
toast.success('封面已上传并回填。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '封面上传失败。')
} finally {
setUploadingCreateCover(false)
}
}, [])
const editorPolishHunks = useMemo(
() =>
editorPolish
@@ -1596,6 +1828,32 @@ export function PostsPage() {
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="space-y-3">
@@ -1842,7 +2100,10 @@ export function PostsPage() {
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl">{editor.title || editor.slug}</CardTitle>
<Badge variant="secondary">{formatPostType(editor.postType)}</Badge>
<Badge variant="outline">{formatPostStatus(editor.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(editor.visibility)}</Badge>
{editor.pinned ? <Badge variant="success"></Badge> : null}
{editor.noindex ? <Badge variant="warning">noindex</Badge> : null}
{markdownDirty ? <Badge variant="warning"></Badge> : null}
</div>
<CardDescription className="font-mono text-xs">{editor.slug}</CardDescription>
@@ -1912,6 +2173,60 @@ export function PostsPage() {
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={editor.status}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, status: event.target.value } : current,
)
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={editor.visibility}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, visibility: event.target.value } : current,
)
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={editor.publishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, publishAt: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={editor.unpublishAt}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, unpublishAt: event.target.value } : current,
)
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={editor.tags}
@@ -1954,10 +2269,18 @@ export function PostsPage() {
/>
</FormField>
<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
variant="outline"
onClick={() => void generateEditorCover()}
disabled={generatingEditorCover}
disabled={generatingEditorCover || uploadingEditorCover}
>
<WandSparkles className="h-4 w-4" />
{generatingEditorCover
@@ -1998,6 +2321,64 @@ export function PostsPage() {
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空则使用默认文章地址。">
<Input
value={editor.canonicalUrl}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, canonicalUrl: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则前台自动生成 SVG 分享图。">
<Input
value={editor.ogImage}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, ogImage: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug不带 /articles/ 前缀。">
<Textarea
value={editor.redirectFromText}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectFromText: event.target.value } : current,
)
}
/>
</FormField>
<FormField label="强制跳转目标" hint="适合旧文跳新文;留空表示当前 slug 为主地址。">
<Input
value={editor.redirectTo}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, redirectTo: event.target.value } : current,
)
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={editor.noindex}
onChange={(event) =>
setEditor((current) =>
current ? { ...current, noindex: event.target.checked } : current,
)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium">noindex</div>
<div className="text-sm text-muted-foreground">
访
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
@@ -2200,6 +2581,8 @@ export function PostsPage() {
<Badge variant="outline">{createForm.markdown.split(/\r?\n/).length} </Badge>
<Badge variant="secondary">AI </Badge>
<Badge variant="outline">AI </Badge>
<Badge variant="outline">{formatPostStatus(createForm.status)}</Badge>
<Badge variant="outline">{formatPostVisibility(createForm.visibility)}</Badge>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm leading-6 text-muted-foreground">
AI slug稿
@@ -2252,6 +2635,52 @@ export function PostsPage() {
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="发布状态">
<Select
value={createForm.status}
onChange={(event) =>
setCreateForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="draft">稿</option>
<option value="published"></option>
<option value="offline">线</option>
</Select>
</FormField>
<FormField label="可见性">
<Select
value={createForm.visibility}
onChange={(event) =>
setCreateForm((current) => ({ ...current, visibility: event.target.value }))
}
>
<option value="public"></option>
<option value="unlisted"></option>
<option value="private"></option>
</Select>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<FormField label="定时发布">
<Input
type="datetime-local"
value={createForm.publishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, publishAt: event.target.value }))
}
/>
</FormField>
<FormField label="下线时间">
<Input
type="datetime-local"
value={createForm.unpublishAt}
onChange={(event) =>
setCreateForm((current) => ({ ...current, unpublishAt: event.target.value }))
}
/>
</FormField>
</div>
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={createForm.tags}
@@ -2291,10 +2720,18 @@ export function PostsPage() {
/>
</FormField>
<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
variant="outline"
onClick={() => void generateCreateCover()}
disabled={generatingCreateCover}
disabled={generatingCreateCover || uploadingCreateCover}
>
<WandSparkles className="h-4 w-4" />
{generatingCreateCover
@@ -2333,6 +2770,57 @@ export function PostsPage() {
}
/>
</FormField>
<FormField label="Canonical URL" hint="留空时使用默认文章地址。">
<Input
value={createForm.canonicalUrl}
onChange={(event) =>
setCreateForm((current) => ({ ...current, canonicalUrl: event.target.value }))
}
/>
</FormField>
<FormField label="OG 图 URL" hint="留空则由前台自动生成。">
<Input
value={createForm.ogImage}
onChange={(event) =>
setCreateForm((current) => ({ ...current, ogImage: event.target.value }))
}
/>
</FormField>
<FormField label="旧地址重定向" hint="每行一个旧 slug。">
<Textarea
value={createForm.redirectFromText}
onChange={(event) =>
setCreateForm((current) => ({
...current,
redirectFromText: event.target.value,
}))
}
/>
</FormField>
<FormField label="强制跳转目标" hint="可选:创建即作为跳转占位。">
<Input
value={createForm.redirectTo}
onChange={(event) =>
setCreateForm((current) => ({ ...current, redirectTo: event.target.value }))
}
/>
</FormField>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={createForm.noindex}
onChange={(event) =>
setCreateForm((current) => ({ ...current, noindex: event.target.checked }))
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"> noindex</div>
<div className="text-sm text-muted-foreground">
sitemap / RSS
</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"

View File

@@ -18,6 +18,10 @@ import {
formatReviewType,
reviewTagsToList,
} from '@/lib/admin-format'
import {
formatCompressionPreview,
normalizeCoverImageWithPrompt,
} from '@/lib/image-compress'
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
type ReviewFormState = {
@@ -216,7 +220,15 @@ export function ReviewsPage() {
const uploadReviewCover = useCallback(async (file: File) => {
try {
setUploadingCover(true)
const result = await adminApi.uploadReviewCoverImage(file)
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 }))
})
@@ -506,7 +518,7 @@ export function ReviewsPage() {
<input
ref={reviewCoverInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
accept="image/png,image/jpeg,image/webp,image/avif,image/gif,image/svg+xml"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]

View File

@@ -0,0 +1,420 @@
import { ArrowLeftRight, History, RefreshCcw, RotateCcw } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import { countLineDiff } from '@/lib/markdown-diff'
import { parseMarkdownDocument } from '@/lib/markdown-document'
import type { PostRevisionDetail, PostRevisionRecord } from '@/lib/types'
type RestoreMode = 'full' | 'markdown' | 'metadata'
const META_LABELS: Record<string, string> = {
title: '标题',
slug: 'Slug',
description: '摘要',
category: '分类',
postType: '类型',
image: '封面',
images: '图片集',
pinned: '置顶',
status: '状态',
visibility: '可见性',
publishAt: '定时发布',
unpublishAt: '下线时间',
canonicalUrl: 'Canonical',
noindex: 'Noindex',
ogImage: 'OG 图',
redirectFrom: '旧地址',
redirectTo: '重定向',
tags: '标签',
}
function stableValue(value: unknown) {
if (Array.isArray(value) || (value && typeof value === 'object')) {
return JSON.stringify(value)
}
return String(value ?? '')
}
function summarizeMetadataChanges(leftMarkdown: string, rightMarkdown: string) {
const left = parseMarkdownDocument(leftMarkdown).meta
const right = parseMarkdownDocument(rightMarkdown).meta
return Object.entries(META_LABELS)
.filter(([key]) => stableValue(left[key as keyof typeof left]) !== stableValue(right[key as keyof typeof right]))
.map(([, label]) => label)
}
export function RevisionsPage() {
const [revisions, setRevisions] = useState<PostRevisionRecord[]>([])
const [selected, setSelected] = useState<PostRevisionDetail | null>(null)
const [detailsCache, setDetailsCache] = useState<Record<number, PostRevisionDetail>>({})
const [liveMarkdown, setLiveMarkdown] = useState('')
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [slugFilter, setSlugFilter] = useState('')
const [compareTarget, setCompareTarget] = useState('current')
const loadRevisions = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const next = await adminApi.listPostRevisions({
slug: slugFilter.trim() || undefined,
limit: 120,
})
startTransition(() => {
setRevisions(next)
})
if (showToast) {
toast.success('版本历史已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载版本历史。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [slugFilter])
useEffect(() => {
void loadRevisions(false)
}, [loadRevisions])
const openDetail = useCallback(async (id: number) => {
try {
const detail = detailsCache[id] ?? (await adminApi.getPostRevision(id))
let liveMarkdownValue = ''
try {
const live = await adminApi.getPostMarkdown(detail.item.post_slug)
liveMarkdownValue = live.markdown
} catch {
liveMarkdownValue = ''
}
startTransition(() => {
setDetailsCache((current) => ({ ...current, [id]: detail }))
setSelected(detail)
setLiveMarkdown(liveMarkdownValue)
setCompareTarget('current')
})
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '无法加载该版本详情。')
}
}, [detailsCache])
useEffect(() => {
if (!selected) {
return
}
if (compareTarget === 'current' || !compareTarget) {
return
}
const revisionId = Number(compareTarget)
if (!Number.isFinite(revisionId) || detailsCache[revisionId]) {
return
}
void adminApi
.getPostRevision(revisionId)
.then((detail) => {
startTransition(() => {
setDetailsCache((current) => ({ ...current, [revisionId]: detail }))
})
})
.catch((error) => {
toast.error(error instanceof ApiError ? error.message : '无法加载比较版本。')
})
}, [compareTarget, detailsCache, selected])
const summary = useMemo(() => {
const uniqueSlugs = new Set(revisions.map((item) => item.post_slug))
return {
count: revisions.length,
slugs: uniqueSlugs.size,
}
}, [revisions])
const compareCandidates = useMemo(
() =>
selected
? revisions.filter((item) => item.post_slug === selected.item.post_slug && item.id !== selected.item.id)
: [],
[revisions, selected],
)
const comparisonMarkdown = useMemo(() => {
if (!selected) {
return ''
}
if (compareTarget === 'current') {
return liveMarkdown
}
const revisionId = Number(compareTarget)
return Number.isFinite(revisionId) ? detailsCache[revisionId]?.markdown ?? '' : ''
}, [compareTarget, detailsCache, liveMarkdown, selected])
const comparisonLabel = useMemo(() => {
if (compareTarget === 'current') {
return '当前线上版本'
}
const revisionId = Number(compareTarget)
const detail = Number.isFinite(revisionId) ? detailsCache[revisionId] : undefined
return detail ? `版本 #${detail.item.id}` : '比较版本'
}, [compareTarget, detailsCache])
const diffStats = useMemo(() => {
if (!selected || !comparisonMarkdown) {
return { additions: 0, deletions: 0 }
}
return countLineDiff(comparisonMarkdown, selected.markdown ?? '')
}, [comparisonMarkdown, selected])
const metadataChanges = useMemo(() => {
if (!selected || !comparisonMarkdown) {
return [] as string[]
}
return summarizeMetadataChanges(comparisonMarkdown, selected.markdown ?? '')
}, [comparisonMarkdown, selected])
const runRestore = useCallback(
async (mode: RestoreMode) => {
if (!selected) {
return
}
try {
setRestoring(`${selected.item.id}:${mode}`)
await adminApi.restorePostRevision(selected.item.id, mode)
toast.success(`已按 ${mode} 模式回滚到版本 #${selected.item.id}`)
await loadRevisions(false)
await openDetail(selected.item.id)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '恢复版本失败。')
} finally {
setRestoring(null)
}
},
[loadRevisions, openDetail, selected],
)
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-32 rounded-3xl" />
<Skeleton className="h-[580px] rounded-3xl" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Diff </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Markdown 线 full / markdown / metadata
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={slugFilter}
onChange={(event) => setSlugFilter(event.target.value)}
placeholder="按 slug 过滤,例如 hello-world"
className="w-[280px]"
/>
<Button variant="secondary" onClick={() => void loadRevisions(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.12fr_0.88fr]">
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
{summary.count} {summary.slugs}
</CardDescription>
</div>
<Badge variant="outline">{summary.count}</Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revisions.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{item.post_title ?? item.post_slug}</div>
<div className="font-mono text-xs text-muted-foreground">{item.post_slug}</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant="secondary">{item.operation}</Badge>
<div className="text-xs text-muted-foreground">
{item.revision_reason ?? '自动记录'}
</div>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.actor_username ?? item.actor_email ?? 'system'}
</TableCell>
<TableCell className="text-muted-foreground">{item.created_at}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
<History className="h-4 w-4" />
/
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>线</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selected ? (
<>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{selected.item.operation}</Badge>
<Badge variant="outline">#{selected.item.id}</Badge>
</div>
<p className="mt-3 font-medium">{selected.item.post_title ?? selected.item.post_slug}</p>
<p className="mt-1 font-mono text-xs text-muted-foreground">
{selected.item.post_slug} · {selected.item.created_at}
</p>
</div>
<div className="space-y-2">
<LabelRow title="比较基线" />
<Select value={compareTarget} onChange={(event) => setCompareTarget(event.target.value)}>
<option value="current">线</option>
{compareCandidates.map((item) => (
<option key={item.id} value={String(item.id)}>
#{item.id} · {item.created_at}
</option>
))}
</Select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
<div className="flex items-center gap-2 text-foreground">
<ArrowLeftRight className="h-4 w-4" />
<span>Diff </span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="success">+{diffStats.additions}</Badge>
<Badge variant="secondary">-{diffStats.deletions}</Badge>
<Badge variant="outline">metadata {metadataChanges.length}</Badge>
</div>
<div className="mt-3 text-xs leading-6 text-muted-foreground">
线{comparisonLabel}
{metadataChanges.length ? ` · 变化字段:${metadataChanges.join('、')}` : ' · Frontmatter 无变化'}
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/50 p-4 text-sm">
<div className="font-medium text-foreground"></div>
<div className="mt-3 flex flex-wrap gap-2">
{(['full', 'markdown', 'metadata'] as RestoreMode[]).map((mode) => (
<Button
key={mode}
size="sm"
disabled={restoring !== null || !selected.item.has_markdown}
onClick={() => void runRestore(mode)}
>
<RotateCcw className="h-4 w-4" />
{restoring === `${selected.item.id}:${mode}` ? '恢复中...' : mode}
</Button>
))}
</div>
<div className="mt-3 text-xs leading-6 text-muted-foreground">
fullmarkdownmetadata frontmatter / SEO /
</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="space-y-2">
<LabelRow title={comparisonLabel} />
<Textarea
value={comparisonMarkdown}
readOnly
className="min-h-[280px] font-mono text-xs leading-6"
/>
</div>
<div className="space-y-2">
<LabelRow title={`版本 #${selected.item.id}`} />
<Textarea
value={selected.markdown ?? ''}
readOnly
className="min-h-[280px] font-mono text-xs leading-6"
/>
</div>
</div>
</>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-10 text-center text-sm text-muted-foreground">
Diff
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function LabelRow({ title }: { title: string }) {
return <div className="text-sm font-medium text-foreground">{title}</div>
}

View File

@@ -83,10 +83,12 @@ function normalizeSettingsResponse(
input: AdminSiteSettingsResponse,
): AdminSiteSettingsResponse {
const aiProviders = Array.isArray(input.ai_providers) ? input.ai_providers : []
const searchSynonyms = Array.isArray(input.search_synonyms) ? input.search_synonyms : []
return {
...input,
ai_providers: aiProviders,
search_synonyms: searchSynonyms,
ai_active_provider_id:
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
}
@@ -151,6 +153,12 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
mediaR2PublicBaseUrl: form.media_r2_public_base_url,
mediaR2AccessKeyId: form.media_r2_access_key_id,
mediaR2SecretAccessKey: form.media_r2_secret_access_key,
seoDefaultOgImage: form.seo_default_og_image,
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
notificationWebhookUrl: form.notification_webhook_url,
notificationCommentEnabled: form.notification_comment_enabled,
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
searchSynonyms: form.search_synonyms,
}
}
@@ -587,6 +595,94 @@ export function SiteSettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>SEO</CardTitle>
<CardDescription>
OG Twitter Webhook
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-2">
<Field label="默认 OG 图 URL" hint="文章未单独设置时作为分享图回退。">
<Input
value={form.seo_default_og_image ?? ''}
onChange={(event) => updateField('seo_default_og_image', event.target.value)}
/>
</Field>
<Field label="Twitter / X Handle" hint="例如 @initcool。">
<Input
value={form.seo_default_twitter_handle ?? ''}
onChange={(event) =>
updateField('seo_default_twitter_handle', event.target.value)
}
/>
</Field>
<div className="lg:col-span-2">
<Field label="Webhook URL" hint="评论和友链申请会向这个地址推送 JSON。">
<Input
value={form.notification_webhook_url ?? ''}
onChange={(event) =>
updateField('notification_webhook_url', event.target.value)
}
/>
</Field>
</div>
<div className="lg:col-span-2 grid gap-4 md:grid-cols-2">
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.notification_comment_enabled}
onChange={(event) =>
updateField('notification_comment_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">
Webhook
</p>
</div>
</label>
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.notification_friend_link_enabled}
onChange={(event) =>
updateField('notification_friend_link_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">
Webhook
</p>
</div>
</label>
</div>
<div className="lg:col-span-2">
<Field
label="搜索同义词"
hint="每行一组逗号分隔。例如ai, llm, gpt 或 rust, cargo, crates。"
>
<Textarea
value={form.search_synonyms.join('\n')}
onChange={(event) =>
updateField(
'search_synonyms',
event.target.value
.split('\n')
.map((item) => item.trim())
.filter(Boolean),
)
}
/>
</Field>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>

View File

@@ -0,0 +1,499 @@
import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types'
const CHANNEL_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'discord', label: 'Discord Webhook' },
{ value: 'telegram', label: 'Telegram Bot API' },
{ value: 'ntfy', label: 'ntfy' },
] as const
const DEFAULT_FILTERS = {
event_types: ['post.published', 'digest.weekly', 'digest.monthly'],
}
function prettyJson(value: unknown) {
if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0)) {
return ''
}
return JSON.stringify(value, null, 2)
}
function emptyForm() {
return {
channelType: 'email',
target: '',
displayName: '',
status: 'active',
notes: '',
filtersText: prettyJson(DEFAULT_FILTERS),
metadataText: '',
}
}
function parseOptionalJson(label: string, raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
try {
return JSON.parse(trimmed) as Record<string, unknown>
} catch {
throw new Error(`${label} 不是合法 JSON`)
}
}
function normalizePreview(value: unknown) {
const text = prettyJson(value)
return text || '—'
}
export function SubscriptionsPage() {
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null)
const [actioningId, setActioningId] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState(emptyForm())
const loadData = useCallback(async (showToast = false) => {
try {
if (showToast) {
setRefreshing(true)
}
const [nextSubscriptions, nextDeliveries] = await Promise.all([
adminApi.listSubscriptions(),
adminApi.listSubscriptionDeliveries(),
])
startTransition(() => {
setSubscriptions(nextSubscriptions)
setDeliveries(nextDeliveries)
})
if (showToast) {
toast.success('订阅中心已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : '无法加载订阅中心。')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
void loadData(false)
}, [loadData])
const activeCount = useMemo(
() => subscriptions.filter((item) => item.status === 'active').length,
[subscriptions],
)
const queuedOrRetryCount = useMemo(
() => deliveries.filter((item) => item.status === 'queued' || item.status === 'retry_pending').length,
[deliveries],
)
const resetForm = useCallback(() => {
setEditingId(null)
setForm(emptyForm())
}, [])
const submitForm = useCallback(async () => {
try {
setSubmitting(true)
const payload = {
channelType: form.channelType,
target: form.target,
displayName: form.displayName || null,
status: form.status,
notes: form.notes || null,
filters: parseOptionalJson('filters', form.filtersText),
metadata: parseOptionalJson('metadata', form.metadataText),
}
if (editingId) {
await adminApi.updateSubscription(editingId, payload)
toast.success('订阅目标已更新。')
} else {
await adminApi.createSubscription(payload)
toast.success('订阅目标已创建。')
}
resetForm()
await loadData(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存订阅失败。')
} finally {
setSubmitting(false)
}
}, [editingId, form, loadData, resetForm])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[640px] rounded-3xl" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> / / Digest</h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Webhook / Discord / Telegram / ntfy retry pending
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="secondary"
disabled={digesting !== null}
onClick={async () => {
try {
setDigesting('weekly')
const result = await adminApi.sendSubscriptionDigest('weekly')
toast.success(`周报已入队queued ${result.queued}skipped ${result.skipped}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
} finally {
setDigesting(null)
}
}}
>
<Send className="h-4 w-4" />
{digesting === 'weekly' ? '入队中...' : '发送周报'}
</Button>
<Button
disabled={digesting !== null}
onClick={async () => {
try {
setDigesting('monthly')
const result = await adminApi.sendSubscriptionDigest('monthly')
toast.success(`月报已入队queued ${result.queued}skipped ${result.skipped}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
} finally {
setDigesting(null)
}
}}
>
<BellRing className="h-4 w-4" />
{digesting === 'monthly' ? '入队中...' : '发送月报'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.98fr_1.02fr]">
<Card>
<CardHeader>
<CardTitle>{editingId ? `编辑订阅 #${editingId}` : '新增订阅目标'}</CardTitle>
<CardDescription>
{subscriptions.length} {activeCount} / {queuedOrRetryCount}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select
value={form.channelType}
onChange={(event) => setForm((current) => ({ ...current, channelType: event.target.value }))}
>
{CHANNEL_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.target}
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
placeholder={form.channelType === 'email' ? 'name@example.com' : 'https://...'}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.displayName}
onChange={(event) =>
setForm((current) => ({ ...current, displayName: event.target.value }))
}
placeholder="例如 站长邮箱 / Discord 运维群"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select
value={form.status}
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))}
>
<option value="active">active</option>
<option value="paused">paused</option>
<option value="pending">pending</option>
<option value="unsubscribed">unsubscribed</option>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.notes}
onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))}
placeholder="用途、机器人说明、负责人等"
/>
</div>
</div>
<div className="space-y-2">
<Label>filtersJSON</Label>
<Textarea
value={form.filtersText}
onChange={(event) => setForm((current) => ({ ...current, filtersText: event.target.value }))}
placeholder='{"event_types":["post.published","digest.weekly"]}'
className="min-h-32 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>metadataJSON</Label>
<Textarea
value={form.metadataText}
onChange={(event) => setForm((current) => ({ ...current, metadataText: event.target.value }))}
placeholder='{"owner":"ops","source":"manual"}'
className="min-h-28 font-mono text-xs"
/>
</div>
<div className="flex flex-wrap gap-3">
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
</Button>
{editingId ? (
<Button variant="outline" disabled={submitting} onClick={resetForm}>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> filters / metadata</CardDescription>
</div>
<Badge variant="outline">{subscriptions.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{item.display_name ?? 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-[280px] break-words text-sm text-muted-foreground">
<div>{item.target}</div>
<div className="mt-1 text-xs text-muted-foreground/80">
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
</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"
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}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.testSubscription(item.id)
toast.success('测试通知已入队。')
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}
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>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> attempts / next retry / response</CardDescription>
</div>
<Badge variant="outline">{deliveries.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deliveries.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
<TableCell>
<div className="font-medium">{item.event_type}</div>
<div className="text-xs text-muted-foreground">#{item.subscription_id ?? '—'}</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{item.channel_type}</div>
<div className="line-clamp-1 text-xs text-muted-foreground">{item.target}</div>
</div>
</TableCell>
<TableCell>
<Badge variant={item.status === 'sent' ? 'success' : item.status === 'retry_pending' ? 'warning' : 'secondary'}>
{item.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div>attempts: {item.attempts_count}</div>
<div>next: {item.next_retry_at ?? '—'}</div>
</TableCell>
<TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
{item.response_text ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE?: string
readonly VITE_ADMIN_BASENAME?: string
readonly VITE_FRONTEND_BASE_URL?: string
}
interface ImportMeta {

View File

@@ -15,9 +15,8 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
},
/* Linting */