feat: ship blog platform admin and deploy stack
This commit is contained in:
5
admin/.dockerignore
Normal file
5
admin/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
31
admin/Dockerfile
Normal file
31
admin/Dockerfile
Normal 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;"]
|
||||
24
admin/docker-entrypoint.d/40-runtime-config.sh
Normal file
24
admin/docker-entrypoint.d/40-runtime-config.sh
Normal 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
|
||||
@@ -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
63
admin/nginx.conf
Normal 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
2304
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
admin/public/runtime-config.js
Normal file
1
admin/public/runtime-config.js
Normal file
@@ -0,0 +1 @@
|
||||
window.__TERMI_ADMIN_RUNTIME_CONFIG__ = window.__TERMI_ADMIN_RUNTIME_CONFIG__ || {}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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', {
|
||||
|
||||
28
admin/src/lib/frontend-url.ts
Normal file
28
admin/src/lib/frontend-url.ts
Normal 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}`
|
||||
}
|
||||
279
admin/src/lib/image-compress.ts
Normal file
279
admin/src/lib/image-compress.ts
Normal 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)}%`
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
22
admin/src/lib/runtime-config.ts
Normal file
22
admin/src/lib/runtime-config.ts
Normal 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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
166
admin/src/pages/audit-page.tsx
Normal file
166
admin/src/pages/audit-page.tsx
Normal 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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
420
admin/src/pages/revisions-page.tsx
Normal file
420
admin/src/pages/revisions-page.tsx
Normal 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">
|
||||
full:整篇恢复;markdown:只恢复正文;metadata:只恢复 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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
499
admin/src/pages/subscriptions-page.tsx
Normal file
499
admin/src/pages/subscriptions-page.tsx
Normal 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>filters(JSON)</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>metadata(JSON,可选)</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>
|
||||
)
|
||||
}
|
||||
1
admin/src/vite-env.d.ts
vendored
1
admin/src/vite-env.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string
|
||||
readonly VITE_ADMIN_BASENAME?: string
|
||||
readonly VITE_FRONTEND_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
|
||||
Reference in New Issue
Block a user