feat: Refactor service management scripts to use a unified dev script

- Added package.json to manage development scripts.
- Updated restart-services.ps1 to call the new dev script for starting services.
- Refactored start-admin.ps1, start-backend.ps1, start-frontend.ps1, and start-mcp.ps1 to utilize the dev script for starting respective services.
- Enhanced stop-services.ps1 to improve process termination logic by matching command patterns.
This commit is contained in:
2026-03-29 21:36:13 +08:00
parent 84f82c2a7e
commit 92a85eef20
137 changed files with 14181 additions and 2691 deletions

View File

@@ -17,7 +17,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import { formatDateTime } from '@/lib/admin-format'
import { formatCommentScope, formatDateTime } from '@/lib/admin-format'
import type { CommentRecord } from '@/lib/types'
function moderationBadgeVariant(approved: boolean | null) {
@@ -49,13 +49,13 @@ export function CommentsPage() {
})
if (showToast) {
toast.success('Comments refreshed.')
toast.success('评论列表已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load comments.')
toast.error(error instanceof ApiError ? error.message : '无法加载评论列表。')
} finally {
setLoading(false)
setRefreshing(false)
@@ -106,59 +106,58 @@ export function CommentsPage() {
<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">Comments</Badge>
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Moderation queue</h2>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Review article comments and paragraph-specific responses from one place, with fast
approval controls for the public discussion layer.
</p>
</div>
</div>
<Button variant="secondary" onClick={() => void loadComments(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Pending</p>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground"></p>
<div className="mt-3 text-3xl font-semibold">{pendingCount}</div>
<p className="mt-2 text-sm text-muted-foreground">Needs moderation attention.</p>
<p className="mt-2 text-sm text-muted-foreground"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Paragraph replies
</p>
<div className="mt-3 text-3xl font-semibold">{paragraphCount}</div>
<p className="mt-2 text-sm text-muted-foreground">Scoped to paragraph anchors.</p>
<p className="mt-2 text-sm text-muted-foreground"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-card via-card to-background/70">
<CardContent className="pt-6">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Total</p>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground"></p>
<div className="mt-3 text-3xl font-semibold">{comments.length}</div>
<p className="mt-2 text-sm text-muted-foreground">Everything currently stored.</p>
<p className="mt-2 text-sm text-muted-foreground"></p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Comment list</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Filter the queue, then approve, hide, or remove entries without leaving the page.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr_0.6fr]">
<Input
placeholder="Search by author, post slug, content, or paragraph key"
placeholder="按作者、文章 slug、评论内容或段落键搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
@@ -166,14 +165,14 @@ export function CommentsPage() {
value={approvalFilter}
onChange={(event) => setApprovalFilter(event.target.value)}
>
<option value="all">All approval states</option>
<option value="pending">Pending only</option>
<option value="approved">Approved only</option>
<option value="all"></option>
<option value="pending"></option>
<option value="approved"></option>
</Select>
<Select value={scopeFilter} onChange={(event) => setScopeFilter(event.target.value)}>
<option value="all">All scopes</option>
<option value="article">Article</option>
<option value="paragraph">Paragraph</option>
<option value="all"></option>
<option value="article"></option>
<option value="paragraph"></option>
</Select>
</div>
@@ -183,10 +182,10 @@ export function CommentsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Comment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Context</TableHead>
<TableHead>Actions</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -195,20 +194,20 @@ export function CommentsPage() {
<TableCell>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{comment.author ?? 'Anonymous'}</span>
<Badge variant="outline">{comment.scope}</Badge>
<span className="font-medium">{comment.author ?? '匿名用户'}</span>
<Badge variant="outline">{formatCommentScope(comment.scope)}</Badge>
<span className="text-xs text-muted-foreground">
{formatDateTime(comment.created_at)}
</span>
</div>
<p className="text-sm leading-6 text-muted-foreground">
{comment.content ?? 'No content provided.'}
{comment.content ?? '暂无评论内容。'}
</p>
{comment.scope === 'paragraph' ? (
<div className="rounded-2xl border border-border/70 bg-background/60 px-3 py-2 text-xs text-muted-foreground">
<p className="font-mono">{comment.paragraph_key ?? 'missing-key'}</p>
<p className="font-mono">{comment.paragraph_key ?? '缺少段落键'}</p>
<p className="mt-1">
{comment.paragraph_excerpt ?? 'No paragraph excerpt stored.'}
{comment.paragraph_excerpt ?? '没有保存段落摘录。'}
</p>
</div>
) : null}
@@ -216,16 +215,16 @@ export function CommentsPage() {
</TableCell>
<TableCell>
<Badge variant={moderationBadgeVariant(comment.approved)}>
{comment.approved ? 'Approved' : 'Pending'}
{comment.approved ? '已通过' : '待审核'}
</Badge>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm text-muted-foreground">
<p className="font-mono text-xs">{comment.post_slug ?? 'unknown-post'}</p>
<p className="font-mono text-xs">{comment.post_slug ?? '未知文章'}</p>
{comment.reply_to_comment_id ? (
<p>Replying to #{comment.reply_to_comment_id}</p>
<p> #{comment.reply_to_comment_id}</p>
) : (
<p>Top-level comment</p>
<p></p>
)}
</div>
</TableCell>
@@ -239,13 +238,11 @@ export function CommentsPage() {
try {
setActingId(comment.id)
await adminApi.updateComment(comment.id, { approved: true })
toast.success('Comment approved.')
toast.success('评论已通过。')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to approve comment.',
error instanceof ApiError ? error.message : '无法通过该评论。',
)
} finally {
setActingId(null)
@@ -253,7 +250,7 @@ export function CommentsPage() {
}}
>
<CheckCheck className="h-4 w-4" />
Approve
</Button>
<Button
size="sm"
@@ -263,13 +260,11 @@ export function CommentsPage() {
try {
setActingId(comment.id)
await adminApi.updateComment(comment.id, { approved: false })
toast.success('Comment moved back to pending.')
toast.success('评论已移回待审核。')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to update comment.',
error instanceof ApiError ? error.message : '无法更新评论状态。',
)
} finally {
setActingId(null)
@@ -277,27 +272,25 @@ export function CommentsPage() {
}}
>
<XCircle className="h-4 w-4" />
Hide
</Button>
<Button
size="sm"
variant="danger"
disabled={actingId === comment.id}
onClick={async () => {
if (!window.confirm('Delete this comment permanently?')) {
if (!window.confirm('确定要永久删除这条评论吗?')) {
return
}
try {
setActingId(comment.id)
await adminApi.deleteComment(comment.id)
toast.success('Comment deleted.')
toast.success('评论已删除。')
await loadComments(false)
} catch (error) {
toast.error(
error instanceof ApiError
? error.message
: 'Unable to delete comment.',
error instanceof ApiError ? error.message : '无法删除评论。',
)
} finally {
setActingId(null)
@@ -305,7 +298,7 @@ export function CommentsPage() {
}}
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</TableCell>
@@ -316,7 +309,7 @@ export function CommentsPage() {
<TableCell colSpan={4} className="py-12 text-center">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<MessageSquareText className="h-8 w-8" />
<p>No comments match the current moderation filters.</p>
<p></p>
</div>
</TableCell>
</TableRow>

View File

@@ -24,6 +24,13 @@ import {
TableRow,
} from '@/components/ui/table'
import { adminApi, ApiError } from '@/lib/api'
import {
formatCommentScope,
formatFriendLinkStatus,
formatPostType,
formatReviewStatus,
formatReviewType,
} from '@/lib/admin-format'
import type { AdminDashboardResponse } from '@/lib/types'
function StatCard({
@@ -70,13 +77,13 @@ export function DashboardPage() {
})
if (showToast) {
toast.success('Dashboard refreshed.')
toast.success('仪表盘已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load dashboard.')
toast.error(error instanceof ApiError ? error.message : '无法加载仪表盘。')
} finally {
setLoading(false)
setRefreshing(false)
@@ -102,27 +109,27 @@ export function DashboardPage() {
const statCards = [
{
label: 'Posts',
label: '文章总数',
value: data.stats.total_posts,
note: `${data.stats.total_comments} comments across the content library`,
note: `内容库中共有 ${data.stats.total_comments} 条评论`,
icon: Rss,
},
{
label: 'Pending comments',
label: '待审核评论',
value: data.stats.pending_comments,
note: 'Queued for moderation follow-up',
note: '等待审核处理',
icon: MessageSquareWarning,
},
{
label: 'Categories',
label: '分类数量',
value: data.stats.total_categories,
note: `${data.stats.total_tags} tags currently in circulation`,
note: `当前共有 ${data.stats.total_tags} 个标签`,
icon: FolderTree,
},
{
label: 'AI chunks',
label: 'AI 分块',
value: data.stats.ai_chunks,
note: data.stats.ai_enabled ? 'Knowledge base is enabled' : 'AI is currently disabled',
note: data.stats.ai_enabled ? '知识库已启用' : 'AI 功能当前关闭',
icon: BrainCircuit,
},
]
@@ -131,12 +138,11 @@ export function DashboardPage() {
<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">Dashboard</Badge>
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Operations overview</h2>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
This screen brings the most important publishing, moderation, and AI signals into the
new standalone admin so the day-to-day control loop stays in one place.
AI
</p>
</div>
</div>
@@ -145,7 +151,7 @@ export function DashboardPage() {
<Button variant="outline" asChild>
<a href="http://localhost:4321/ask" target="_blank" rel="noreferrer">
<ArrowUpRight className="h-4 w-4" />
Open Ask AI
AI
</a>
</Button>
<Button
@@ -154,7 +160,7 @@ export function DashboardPage() {
disabled={refreshing}
>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
@@ -169,21 +175,21 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>Recent posts</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Freshly imported or updated content flowing into the public site.
</CardDescription>
</div>
<Badge variant="outline">{data.recent_posts.length} rows</Badge>
<Badge variant="outline">{data.recent_posts.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Type</TableHead>
<TableHead>Category</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -193,13 +199,13 @@ export function DashboardPage() {
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{post.title}</span>
{post.pinned ? <Badge variant="success">pinned</Badge> : null}
{post.pinned ? <Badge variant="success"></Badge> : null}
</div>
<p className="font-mono text-xs text-muted-foreground">{post.slug}</p>
</div>
</TableCell>
<TableCell className="uppercase text-muted-foreground">
{post.post_type}
{formatPostType(post.post_type)}
</TableCell>
<TableCell>{post.category}</TableCell>
<TableCell className="text-muted-foreground">{post.created_at}</TableCell>
@@ -212,9 +218,9 @@ export function DashboardPage() {
<Card>
<CardHeader>
<CardTitle>Site heartbeat</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
A quick read on the public-facing site and the AI index state.
AI
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -225,7 +231,7 @@ export function DashboardPage() {
<p className="mt-1 text-sm text-muted-foreground">{data.site.site_url}</p>
</div>
<Badge variant={data.site.ai_enabled ? 'success' : 'warning'}>
{data.site.ai_enabled ? 'AI on' : 'AI off'}
{data.site.ai_enabled ? 'AI 已开启' : 'AI 已关闭'}
</Badge>
</div>
</div>
@@ -233,7 +239,7 @@ export function DashboardPage() {
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Reviews
</p>
<div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_reviews}</span>
@@ -242,7 +248,7 @@ export function DashboardPage() {
</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">
Friend links
</p>
<div className="mt-3 flex items-end gap-2">
<span className="text-3xl font-semibold">{data.stats.total_links}</span>
@@ -253,10 +259,10 @@ export function DashboardPage() {
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Last AI index
AI
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{data.site.ai_last_indexed_at ?? 'The site has not been indexed yet.'}
{data.site.ai_last_indexed_at ?? '站点还没有建立过索引。'}
</p>
</div>
</CardContent>
@@ -267,21 +273,21 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>Pending comments</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Queue visibility without opening the old moderation page.
</CardDescription>
</div>
<Badge variant="warning">{data.pending_comments.length} queued</Badge>
<Badge variant="warning">{data.pending_comments.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Author</TableHead>
<TableHead>Scope</TableHead>
<TableHead>Post</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -296,7 +302,7 @@ export function DashboardPage() {
</div>
</TableCell>
<TableCell className="uppercase text-muted-foreground">
{comment.scope}
{formatCommentScope(comment.scope)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{comment.post_slug}
@@ -313,12 +319,12 @@ export function DashboardPage() {
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>Pending friend links</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Requests waiting for review and reciprocal checks.
</CardDescription>
</div>
<Badge variant="warning">{data.pending_friend_links.length} pending</Badge>
<Badge variant="warning">{data.pending_friend_links.length} </Badge>
</CardHeader>
<CardContent className="space-y-3">
{data.pending_friend_links.map((link) => (
@@ -335,6 +341,9 @@ export function DashboardPage() {
</div>
<Badge variant="outline">{link.category}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{formatFriendLinkStatus(link.status)}
</p>
<p className="mt-3 text-xs uppercase tracking-[0.18em] text-muted-foreground">
{link.created_at}
</p>
@@ -345,9 +354,9 @@ export function DashboardPage() {
<Card>
<CardHeader>
<CardTitle>Recent reviews</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
The latest review entries flowing into the public reviews page.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -359,7 +368,7 @@ export function DashboardPage() {
<div className="min-w-0">
<p className="font-medium">{review.title}</p>
<p className="mt-1 text-sm text-muted-foreground">
{review.review_type} · {review.status}
{formatReviewType(review.review_type)} · {formatReviewStatus(review.status)}
</p>
</div>
<div className="text-right">

View File

@@ -11,7 +11,7 @@ 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 } from '@/lib/admin-format'
import { emptyToNull, formatDateTime, formatFriendLinkStatus } from '@/lib/admin-format'
import type { FriendLinkPayload, FriendLinkRecord } from '@/lib/types'
type FriendLinkFormState = {
@@ -88,13 +88,13 @@ export function FriendLinksPage() {
})
if (showToast) {
toast.success('Friend links refreshed.')
toast.success('友链列表已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load friend links.')
toast.error(error instanceof ApiError ? error.message : '无法加载友链列表。')
} finally {
setLoading(false)
setRefreshing(false)
@@ -135,12 +135,11 @@ export function FriendLinksPage() {
<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">Friend links</Badge>
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Partner site queue</h2>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Review inbound link exchanges, keep metadata accurate, and move requests through
pending, approved, or rejected states in one dedicated workspace.
</p>
</div>
</div>
@@ -153,11 +152,11 @@ export function FriendLinksPage() {
setForm(defaultFriendLinkForm)
}}
>
New link
</Button>
<Button variant="secondary" onClick={() => void loadLinks(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
@@ -165,15 +164,15 @@ export function FriendLinksPage() {
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle>Link inventory</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Pick an item to edit it, or start a new record from the right-hand form.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
<Input
placeholder="Search by site name, URL, category, or notes"
placeholder="按站点名、URL、分类或备注搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
@@ -181,10 +180,10 @@ export function FriendLinksPage() {
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">All statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="all"></option>
<option value="pending"></option>
<option value="approved"></option>
<option value="rejected"></option>
</Select>
</div>
@@ -209,18 +208,18 @@ export function FriendLinksPage() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{link.site_name ?? 'Untitled partner'}</span>
<span className="font-medium">{link.site_name ?? '未命名站点'}</span>
<Badge variant={statusBadgeVariant(link.status)}>
{link.status ?? 'pending'}
{formatFriendLinkStatus(link.status)}
</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{link.site_url}</p>
<p className="line-clamp-2 text-sm text-muted-foreground">
{link.description ?? 'No description yet.'}
{link.description ?? '暂无简介。'}
</p>
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{link.category ?? 'uncategorized'}</p>
<p>{link.category ?? '未分类'}</p>
<p className="mt-1">{formatDateTime(link.created_at)}</p>
</div>
</div>
@@ -230,7 +229,7 @@ export function FriendLinksPage() {
{!filteredLinks.length ? (
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
<Link2 className="h-8 w-8" />
<p>No friend links match the current filters.</p>
<p></p>
</div>
) : null}
</div>
@@ -241,10 +240,9 @@ export function FriendLinksPage() {
<Card>
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<CardTitle>{selectedLink ? 'Edit friend link' : 'Create friend link'}</CardTitle>
<CardTitle>{selectedLink ? '编辑友链' : '新建友链'}</CardTitle>
<CardDescription>
Capture the reciprocal URL, classification, and moderation status the public link
page depends on.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
@@ -252,14 +250,36 @@ export function FriendLinksPage() {
<Button variant="outline" asChild>
<a href={selectedLink.site_url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Visit site
访
</a>
</Button>
) : null}
{selectedLink ? (
<>
<Button
variant={form.status === 'approved' ? 'default' : 'outline'}
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
>
</Button>
<Button
variant={form.status === 'pending' ? 'secondary' : 'outline'}
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
>
</Button>
<Button
variant={form.status === 'rejected' ? 'danger' : 'outline'}
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
>
</Button>
</>
) : null}
<Button
onClick={async () => {
if (!form.siteUrl.trim()) {
toast.error('Site URL is required.')
toast.error('站点 URL 不能为空。')
return
}
@@ -272,20 +292,18 @@ export function FriendLinksPage() {
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('Friend link updated.')
toast.success('友链已更新。')
} else {
const created = await adminApi.createFriendLink(payload)
startTransition(() => {
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('Friend link created.')
toast.success('友链已创建。')
}
await loadLinks(false)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to save friend link.',
)
toast.error(error instanceof ApiError ? error.message : '无法保存友链。')
} finally {
setSaving(false)
}
@@ -293,35 +311,33 @@ export function FriendLinksPage() {
disabled={saving}
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : selectedLink ? 'Save changes' : 'Create link'}
{saving ? '保存中...' : selectedLink ? '保存修改' : '创建友链'}
</Button>
{selectedLink ? (
<Button
variant="danger"
disabled={deleting}
onClick={async () => {
if (!window.confirm('Delete this friend link?')) {
if (!window.confirm('确定删除这条友链吗?')) {
return
}
try {
setDeleting(true)
await adminApi.deleteFriendLink(selectedLink.id)
toast.success('Friend link deleted.')
toast.success('友链已删除。')
setSelectedId(null)
setForm(defaultFriendLinkForm)
await loadLinks(false)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to delete friend link.',
)
toast.error(error instanceof ApiError ? error.message : '无法删除友链。')
} finally {
setDeleting(false)
}
}}
>
<Trash2 className="h-4 w-4" />
{deleting ? 'Deleting...' : 'Delete'}
{deleting ? '删除中...' : '删除'}
</Button>
) : null}
</div>
@@ -332,21 +348,21 @@ export function FriendLinksPage() {
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Selected record
</p>
<p className="mt-2 text-sm text-muted-foreground">
Created {formatDateTime(selectedLink.created_at)}
{formatDateTime(selectedLink.created_at)}
</p>
</div>
<Badge variant={statusBadgeVariant(selectedLink.status)}>
{selectedLink.status ?? 'pending'}
{formatFriendLinkStatus(selectedLink.status)}
</Badge>
</div>
</div>
) : null}
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="Site name">
<FormField label="站点名称">
<Input
value={form.siteName}
onChange={(event) =>
@@ -354,7 +370,7 @@ export function FriendLinksPage() {
}
/>
</FormField>
<FormField label="Site URL">
<FormField label="站点 URL">
<Input
value={form.siteUrl}
onChange={(event) =>
@@ -362,7 +378,7 @@ export function FriendLinksPage() {
}
/>
</FormField>
<FormField label="Avatar URL">
<FormField label="头像 URL">
<Input
value={form.avatarUrl}
onChange={(event) =>
@@ -370,7 +386,7 @@ export function FriendLinksPage() {
}
/>
</FormField>
<FormField label="Category">
<FormField label="分类">
<Input
value={form.category}
onChange={(event) =>
@@ -379,21 +395,49 @@ export function FriendLinksPage() {
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Status">
<Select
value={form.status}
onChange={(event) =>
setForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</Select>
<FormField label="状态">
<div className="grid gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => setForm((current) => ({ ...current, status: 'pending' }))}
className={`rounded-2xl border px-4 py-3 text-left transition ${
form.status === 'pending'
? 'border-amber-500/40 bg-amber-500/10 text-amber-700'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="font-medium"></div>
<p className="mt-1 text-xs text-muted-foreground"></p>
</button>
<button
type="button"
onClick={() => setForm((current) => ({ ...current, status: 'approved' }))}
className={`rounded-2xl border px-4 py-3 text-left transition ${
form.status === 'approved'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="font-medium"></div>
<p className="mt-1 text-xs text-muted-foreground"></p>
</button>
<button
type="button"
onClick={() => setForm((current) => ({ ...current, status: 'rejected' }))}
className={`rounded-2xl border px-4 py-3 text-left transition ${
form.status === 'rejected'
? 'border-rose-500/40 bg-rose-500/10 text-rose-700'
: 'border-border/70 bg-background/60 hover:border-border'
}`}
>
<div className="font-medium"></div>
<p className="mt-1 text-xs text-muted-foreground"></p>
</button>
</div>
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="Description">
<FormField label="简介">
<Textarea
value={form.description}
onChange={(event) =>

View File

@@ -23,23 +23,22 @@ export function LoginPage({
<CardHeader className="space-y-4">
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">
<ShieldCheck className="h-3.5 w-3.5" />
Termi admin
Termi
</div>
<div className="space-y-3">
<CardTitle className="text-4xl leading-tight">
Separate the dashboard from the public site without losing momentum.
线
</CardTitle>
<CardDescription className="max-w-xl text-base leading-7">
This new workspace is where operations, moderation, and AI controls will migrate
out of the old server-rendered admin.
AI
</CardDescription>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3">
{[
['React app', 'Independent admin surface'],
['shadcn/ui', 'Consistent component foundation'],
['Loco API', 'Backend stays focused on data and rules'],
['React 应用', '独立后台界面'],
['shadcn/ui', '统一的组件基础'],
['Loco API', '后端继续专注数据与规则'],
].map(([title, description]) => (
<div
key={title}
@@ -58,11 +57,10 @@ export function LoginPage({
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
<LockKeyhole className="h-5 w-5" />
</span>
Sign in to the control room
</CardTitle>
<CardDescription>
The login bridge still uses the current backend admin credentials so we can migrate
screens incrementally without stopping delivery.
</CardDescription>
</CardHeader>
<CardContent>
@@ -74,7 +72,7 @@ export function LoginPage({
}}
>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
@@ -85,7 +83,7 @@ export function LoginPage({
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
@@ -97,7 +95,7 @@ export function LoginPage({
</div>
<Button className="w-full" size="lg" disabled={submitting}>
{submitting ? 'Signing in...' : 'Unlock admin'}
{submitting ? '登录中...' : '进入后台'}
</Button>
</form>
</CardContent>

View File

@@ -0,0 +1,166 @@
import { GitCompareArrows, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { MarkdownWorkbench } from '@/components/markdown-workbench'
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 { countLineDiff } from '@/lib/markdown-diff'
import { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
type CompareState = {
title: string
slug: string
path: string
savedMarkdown: string
draftMarkdown: string
}
function resolveSlugFromPathname() {
if (typeof window === 'undefined') {
return ''
}
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/compare\/?$/)
return match?.[1] ? decodeURIComponent(match[1]) : ''
}
function getDraftKey() {
if (typeof window === 'undefined') {
return null
}
return new URLSearchParams(window.location.search).get('draftKey')
}
export function PostComparePage({ slugOverride }: { slugOverride?: string }) {
const slug = slugOverride ?? resolveSlugFromPathname()
const [state, setState] = useState<CompareState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
async function load() {
try {
setLoading(true)
setError(null)
const draft = loadDraftWindowSnapshot(getDraftKey())
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug),
])
if (!active) {
return
}
startTransition(() => {
setState({
title: post.title ?? slug,
slug,
path: markdown.path,
savedMarkdown: draft?.savedMarkdown ?? markdown.markdown,
draftMarkdown: draft?.markdown ?? markdown.markdown,
})
})
} catch (loadError) {
if (!active) {
return
}
setError(loadError instanceof ApiError ? loadError.message : '无法加载改动对比。')
} finally {
if (active) {
setLoading(false)
}
}
}
void load()
return () => {
active = false
}
}, [slug])
const diffStats = useMemo(() => {
if (!state) {
return { additions: 0, deletions: 0 }
}
return countLineDiff(state.savedMarkdown, state.draftMarkdown)
}, [state])
return (
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
<div className="mx-auto max-w-[1480px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{state?.title || '草稿改动对比'}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
稿
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge variant="success">+{diffStats.additions} </Badge>
<Badge variant="danger">-{diffStats.deletions} </Badge>
<Button variant="outline" onClick={() => window.location.reload()}>
<RefreshCcw className="h-4 w-4" />
</Button>
</div>
</div>
{loading ? (
<Card>
<CardContent className="py-12 text-sm text-muted-foreground">...</CardContent>
</Card>
) : error ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
) : state ? (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<GitCompareArrows className="h-4 w-4" />
vs 稿
</CardTitle>
<CardDescription>{state.path}</CardDescription>
</CardHeader>
</Card>
<MarkdownWorkbench
value={state.draftMarkdown}
originalValue={state.savedMarkdown}
path={state.path}
mode="workspace"
visiblePanels={['diff']}
availablePanels={['diff']}
readOnly
preview={<></>}
originalLabel="已保存版本"
modifiedLabel="当前草稿"
onModeChange={() => {}}
onVisiblePanelsChange={() => {}}
onChange={() => {}}
/>
</>
) : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,302 @@
import { DiffEditor } from '@monaco-editor/react'
import { Bot, CheckCheck, RefreshCcw, WandSparkles } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
configureMonaco,
editorTheme,
sharedOptions,
} from '@/components/markdown-workbench'
import { MarkdownPreview } from '@/components/markdown-preview'
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 { computeDiffHunks, applySelectedDiffHunks } from '@/lib/markdown-merge'
import {
loadDraftWindowSnapshot,
savePolishWindowResult,
type DraftWindowSnapshot,
} from '@/lib/post-draft-window'
type PolishTarget = 'editor' | 'create'
function getDraftKey() {
if (typeof window === 'undefined') {
return null
}
return new URLSearchParams(window.location.search).get('draftKey')
}
function getTarget(): PolishTarget {
if (typeof window === 'undefined') {
return 'editor'
}
const value = new URLSearchParams(window.location.search).get('target')
return value === 'create' ? 'create' : 'editor'
}
function buildApplyMessage(draftKey: string, markdown: string, target: PolishTarget) {
return {
type: 'termi-admin-post-polish-apply',
draftKey,
markdown,
target,
}
}
export function PostPolishPage() {
const draftKey = getDraftKey()
const target = getTarget()
const [snapshot, setSnapshot] = useState<DraftWindowSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [polishing, setPolishing] = useState(false)
const [polishedMarkdown, setPolishedMarkdown] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
useEffect(() => {
const draft = loadDraftWindowSnapshot(draftKey)
if (!draft) {
setError('没有找到要润色的草稿快照,请从文章编辑页重新打开 AI 润色窗口。')
} else {
startTransition(() => {
setSnapshot(draft)
})
}
setLoading(false)
}, [draftKey])
const originalMarkdown = snapshot?.markdown ?? ''
const hunks = useMemo(
() => (polishedMarkdown ? computeDiffHunks(originalMarkdown, polishedMarkdown) : []),
[originalMarkdown, polishedMarkdown],
)
const mergedMarkdown = useMemo(
() => applySelectedDiffHunks(originalMarkdown, hunks, selectedIds),
[hunks, originalMarkdown, selectedIds],
)
const applyAll = () => {
setSelectedIds(new Set(hunks.map((hunk) => hunk.id)))
}
const keepOriginal = () => {
setSelectedIds(new Set())
}
const applyToParent = () => {
if (!draftKey) {
toast.error('当前窗口缺少草稿标识,无法回填。')
return
}
const result = savePolishWindowResult(draftKey, mergedMarkdown, target)
window.opener?.postMessage(buildApplyMessage(draftKey, mergedMarkdown, target), window.location.origin)
toast.success('已把 AI 润色结果回填到原编辑器。')
return result
}
return (
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
<div className="mx-auto max-w-[1560px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<Badge variant="secondary">AI </Badge>
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{snapshot?.title || 'AI 润色与选择性合并'}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
稿 AI 稿
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
disabled={!snapshot || polishing}
onClick={async () => {
if (!snapshot) {
return
}
try {
setPolishing(true)
const result = await adminApi.polishPostMarkdown(snapshot.markdown)
const nextHunks = computeDiffHunks(snapshot.markdown, result.polished_markdown)
startTransition(() => {
setPolishedMarkdown(result.polished_markdown)
setSelectedIds(new Set(nextHunks.map((hunk) => hunk.id)))
})
toast.success(`AI 已生成润色稿,共识别 ${nextHunks.length} 个改动块。`)
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : 'AI 润色失败。')
} finally {
setPolishing(false)
}
}}
>
<Bot className="h-4 w-4" />
{polishing ? '润色中...' : polishedMarkdown ? '重新生成润色稿' : '生成 AI 润色稿'}
</Button>
<Button variant="outline" disabled={!hunks.length} onClick={applyAll}>
<CheckCheck className="h-4 w-4" />
</Button>
<Button variant="outline" disabled={!hunks.length} onClick={keepOriginal}>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button disabled={!hunks.length} onClick={applyToParent}>
<WandSparkles className="h-4 w-4" />
</Button>
</div>
</div>
{loading ? (
<Card>
<CardContent className="py-12 text-sm text-muted-foreground">稿...</CardContent>
</Card>
) : error ? (
<Card>
<CardHeader>
<CardTitle>AI </CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
) : snapshot ? (
<div className="grid gap-6 xl:grid-cols-[1.14fr_0.86fr]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle> vs </CardTitle>
<CardDescription>{snapshot.path}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary"> {hunks.length}</Badge>
<Badge variant="success"> {selectedIds.size}</Badge>
<Badge variant="outline"> {target === 'create' ? '新建草稿' : '现有文章'}</Badge>
</div>
<div className="overflow-hidden rounded-[28px] border border-slate-800 bg-[#1e1e1e]">
<div className="flex items-center justify-between border-b border-slate-800 bg-[#141414] px-4 py-2 text-[11px] uppercase tracking-[0.18em] text-slate-400">
<span>稿</span>
<span></span>
</div>
<div className="h-[560px]">
<DiffEditor
height="100%"
language="markdown"
original={originalMarkdown}
modified={mergedMarkdown}
originalModelPath={`${snapshot.path}#ai-original`}
modifiedModelPath={`${snapshot.path}#ai-merged`}
keepCurrentOriginalModel
keepCurrentModifiedModel
theme={editorTheme}
beforeMount={configureMonaco}
options={{
...sharedOptions,
originalEditable: false,
readOnly: true,
renderSideBySide: true,
}}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="h-[420px] overflow-hidden rounded-[28px] border border-slate-200 bg-white">
<MarkdownPreview markdown={mergedMarkdown || originalMarkdown} />
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
diff
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!polishedMarkdown ? (
<div className="rounded-3xl border border-dashed border-border/70 px-5 py-10 text-sm text-muted-foreground">
AI 稿
</div>
) : hunks.length ? (
hunks.map((hunk, index) => {
const accepted = selectedIds.has(hunk.id)
return (
<div
key={hunk.id}
className={`rounded-3xl border p-4 transition ${
accepted
? 'border-emerald-500/30 bg-emerald-500/10'
: 'border-border/70 bg-background/60'
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium"> {index + 1}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{hunk.originalStart}-{Math.max(hunk.originalEnd, hunk.originalStart - 1)}
稿 {hunk.modifiedStart}-{Math.max(hunk.modifiedEnd, hunk.modifiedStart - 1)}
</p>
</div>
<Button
size="sm"
variant={accepted ? 'default' : 'outline'}
onClick={() => {
setSelectedIds((current) => {
const next = new Set(current)
if (next.has(hunk.id)) {
next.delete(hunk.id)
} else {
next.add(hunk.id)
}
return next
})
}}
>
{accepted ? '已采用' : '采用这块'}
</Button>
</div>
<p className="mt-3 rounded-2xl border border-border/60 bg-background/70 px-3 py-2 text-xs leading-6 text-muted-foreground">
{hunk.preview}
</p>
</div>
)
})
) : (
<div className="rounded-3xl border border-border/70 px-5 py-10 text-sm text-muted-foreground">
AI
</div>
)}
</CardContent>
</Card>
</div>
</div>
) : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,165 @@
import { ExternalLink, RefreshCcw } from 'lucide-react'
import { startTransition, useEffect, useState } from 'react'
import { MarkdownPreview } from '@/components/markdown-preview'
import { MarkdownWorkbench } from '@/components/markdown-workbench'
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 { loadDraftWindowSnapshot } from '@/lib/post-draft-window'
type PreviewState = {
title: string
slug: string
path: string
markdown: string
}
function resolveSlugFromPathname() {
if (typeof window === 'undefined') {
return ''
}
const match = window.location.pathname.match(/^\/posts\/([^/]+)\/preview\/?$/)
return match?.[1] ? decodeURIComponent(match[1]) : ''
}
function getDraftKey() {
if (typeof window === 'undefined') {
return null
}
return new URLSearchParams(window.location.search).get('draftKey')
}
export function PostPreviewPage({ slugOverride }: { slugOverride?: string }) {
const slug = slugOverride ?? resolveSlugFromPathname()
const [state, setState] = useState<PreviewState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
async function load() {
try {
setLoading(true)
setError(null)
const draft = loadDraftWindowSnapshot(getDraftKey())
if (draft && draft.slug === slug) {
if (!active) {
return
}
startTransition(() => {
setState({
title: draft.title,
slug: draft.slug,
path: draft.path,
markdown: draft.markdown,
})
})
return
}
const [post, markdown] = await Promise.all([
adminApi.getPostBySlug(slug),
adminApi.getPostMarkdown(slug),
])
if (!active) {
return
}
startTransition(() => {
setState({
title: post.title ?? slug,
slug,
path: markdown.path,
markdown: markdown.markdown,
})
})
} catch (loadError) {
if (!active) {
return
}
setError(loadError instanceof ApiError ? loadError.message : '无法加载预览内容。')
} finally {
if (active) {
setLoading(false)
}
}
}
void load()
return () => {
active = false
}
}, [slug])
return (
<div className="min-h-screen bg-background px-4 py-6 text-foreground lg:px-6">
<div className="mx-auto max-w-[1400px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h1 className="text-3xl font-semibold tracking-tight">
{state?.title || '文章预览'}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
稿
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => window.location.reload()}>
<RefreshCcw className="h-4 w-4" />
</Button>
{slug ? (
<Button variant="outline" asChild>
<a href={`http://localhost:4321/articles/${slug}`} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
) : null}
</div>
</div>
{loading ? (
<Card>
<CardContent className="py-12 text-sm text-muted-foreground">...</CardContent>
</Card>
) : error ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
</Card>
) : state ? (
<MarkdownWorkbench
value={state.markdown}
originalValue=""
path={state.path}
mode="workspace"
visiblePanels={['preview']}
availablePanels={['preview']}
readOnly
preview={<MarkdownPreview markdown={state.markdown} />}
onModeChange={() => {}}
onVisiblePanelsChange={() => {}}
onChange={() => {}}
/>
) : null}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,13 @@ 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 { csvToList, formatDateTime, reviewTagsToList } from '@/lib/admin-format'
import {
csvToList,
formatDateTime,
formatReviewStatus,
formatReviewType,
reviewTagsToList,
} from '@/lib/admin-format'
import type { CreateReviewPayload, ReviewRecord, UpdateReviewPayload } from '@/lib/types'
type ReviewFormState = {
@@ -23,6 +29,7 @@ type ReviewFormState = {
description: string
tags: string
cover: string
linkUrl: string
}
const defaultReviewForm: ReviewFormState = {
@@ -34,6 +41,7 @@ const defaultReviewForm: ReviewFormState = {
description: '',
tags: '',
cover: '',
linkUrl: '',
}
function toFormState(review: ReviewRecord): ReviewFormState {
@@ -46,6 +54,7 @@ function toFormState(review: ReviewRecord): ReviewFormState {
description: review.description ?? '',
tags: reviewTagsToList(review.tags).join(', '),
cover: review.cover ?? '',
linkUrl: review.link_url ?? '',
}
}
@@ -59,6 +68,7 @@ function toCreatePayload(form: ReviewFormState): CreateReviewPayload {
description: form.description.trim(),
tags: csvToList(form.tags),
cover: form.cover.trim(),
link_url: form.linkUrl.trim() || null,
}
}
@@ -72,6 +82,7 @@ function toUpdatePayload(form: ReviewFormState): UpdateReviewPayload {
description: form.description.trim(),
tags: csvToList(form.tags),
cover: form.cover.trim(),
link_url: form.linkUrl.trim() || null,
}
}
@@ -98,13 +109,13 @@ export function ReviewsPage() {
})
if (showToast) {
toast.success('Reviews refreshed.')
toast.success('评测列表已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load reviews.')
toast.error(error instanceof ApiError ? error.message : '无法加载评测列表。')
} finally {
setLoading(false)
setRefreshing(false)
@@ -146,12 +157,11 @@ export function ReviewsPage() {
<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">Reviews</Badge>
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">Review library</h2>
<h2 className="text-3xl font-semibold tracking-tight"></h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Manage the review catalog that powers the public review index, including score,
medium, cover art, and publication state.
</p>
</div>
</div>
@@ -164,11 +174,11 @@ export function ReviewsPage() {
setForm(defaultReviewForm)
}}
>
New review
</Button>
<Button variant="secondary" onClick={() => void loadReviews(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? 'Refreshing...' : 'Refresh'}
{refreshing ? '刷新中...' : '刷新'}
</Button>
</div>
</div>
@@ -176,15 +186,15 @@ export function ReviewsPage() {
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card>
<CardHeader>
<CardTitle>Review list</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Select an existing review to edit it, or start a new entry from the editor.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.6fr]">
<Input
placeholder="Search by title, medium, description, tags, or status"
placeholder="按标题、媒介、简介、标签或状态搜索"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
@@ -192,10 +202,10 @@ export function ReviewsPage() {
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">All statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
<option value="all"></option>
<option value="published"></option>
<option value="draft">稿</option>
<option value="archived"></option>
</Select>
</div>
@@ -220,20 +230,20 @@ export function ReviewsPage() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{review.title ?? 'Untitled review'}</span>
<Badge variant="outline">{review.review_type ?? 'unknown'}</Badge>
<span className="font-medium">{review.title ?? '未命名评测'}</span>
<Badge variant="outline">{formatReviewType(review.review_type)}</Badge>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">
{review.description ?? 'No description yet.'}
{review.description ?? '暂无简介。'}
</p>
<p className="text-xs text-muted-foreground">
{reviewTagsToList(review.tags).join(', ') || 'No tags'}
{reviewTagsToList(review.tags).join(', ') || '暂无标签'}
</p>
</div>
<div className="text-right">
<div className="text-xl font-semibold">{review.rating ?? 0}/5</div>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">
{review.status ?? 'published'}
{formatReviewStatus(review.status)}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{formatDateTime(review.created_at)}
@@ -246,7 +256,7 @@ export function ReviewsPage() {
{!filteredReviews.length ? (
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border/70 px-6 py-14 text-center text-muted-foreground">
<BookOpenText className="h-8 w-8" />
<p>No reviews match the current filters.</p>
<p></p>
</div>
) : null}
</div>
@@ -257,22 +267,21 @@ export function ReviewsPage() {
<Card>
<CardHeader className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<CardTitle>{selectedReview ? 'Edit review' : 'Create review'}</CardTitle>
<CardTitle>{selectedReview ? '编辑评测' : '新建评测'}</CardTitle>
<CardDescription>
Control the presentation fields the public reviews page reads directly from the
backend.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
onClick={async () => {
if (!form.title.trim()) {
toast.error('Title is required.')
toast.error('标题不能为空。')
return
}
if (!form.reviewDate) {
toast.error('Review date is required.')
toast.error('评测日期不能为空。')
return
}
@@ -287,18 +296,18 @@ export function ReviewsPage() {
setSelectedId(updated.id)
setForm(toFormState(updated))
})
toast.success('Review updated.')
toast.success('评测已更新。')
} else {
const created = await adminApi.createReview(toCreatePayload(form))
startTransition(() => {
setSelectedId(created.id)
setForm(toFormState(created))
})
toast.success('Review created.')
toast.success('评测已创建。')
}
await loadReviews(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'Unable to save review.')
toast.error(error instanceof ApiError ? error.message : '无法保存评测。')
} finally {
setSaving(false)
}
@@ -306,35 +315,33 @@ export function ReviewsPage() {
disabled={saving}
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : selectedReview ? 'Save changes' : 'Create review'}
{saving ? '保存中...' : selectedReview ? '保存修改' : '创建评测'}
</Button>
{selectedReview ? (
<Button
variant="danger"
disabled={deleting}
onClick={async () => {
if (!window.confirm('Delete this review?')) {
if (!window.confirm('确定删除这条评测吗?')) {
return
}
try {
setDeleting(true)
await adminApi.deleteReview(selectedReview.id)
toast.success('Review deleted.')
toast.success('评测已删除。')
setSelectedId(null)
setForm(defaultReviewForm)
await loadReviews(false)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : 'Unable to delete review.',
)
toast.error(error instanceof ApiError ? error.message : '无法删除评测。')
} finally {
setDeleting(false)
}
}}
>
<Trash2 className="h-4 w-4" />
{deleting ? 'Deleting...' : 'Delete'}
{deleting ? '删除中...' : '删除'}
</Button>
) : null}
</div>
@@ -343,16 +350,16 @@ export function ReviewsPage() {
{selectedReview ? (
<div className="rounded-3xl border border-border/70 bg-background/60 p-5">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Selected record
</p>
<p className="mt-2 text-sm text-muted-foreground">
Created {formatDateTime(selectedReview.created_at)}
{formatDateTime(selectedReview.created_at)}
</p>
</div>
) : null}
<div className="grid gap-5 lg:grid-cols-2">
<FormField label="Title">
<FormField label="标题">
<Input
value={form.title}
onChange={(event) =>
@@ -360,21 +367,21 @@ export function ReviewsPage() {
}
/>
</FormField>
<FormField label="Review type">
<FormField label="评测类型">
<Select
value={form.reviewType}
onChange={(event) =>
setForm((current) => ({ ...current, reviewType: event.target.value }))
}
>
<option value="book">Book</option>
<option value="movie">Movie</option>
<option value="game">Game</option>
<option value="anime">Anime</option>
<option value="music">Music</option>
<option value="book"></option>
<option value="movie"></option>
<option value="game"></option>
<option value="anime"></option>
<option value="music"></option>
</Select>
</FormField>
<FormField label="Rating">
<FormField label="评分">
<Input
type="number"
min="1"
@@ -386,7 +393,7 @@ export function ReviewsPage() {
}
/>
</FormField>
<FormField label="Review date">
<FormField label="评测日期">
<Input
type="date"
value={form.reviewDate}
@@ -395,19 +402,19 @@ export function ReviewsPage() {
}
/>
</FormField>
<FormField label="Status">
<FormField label="状态">
<Select
value={form.status}
onChange={(event) =>
setForm((current) => ({ ...current, status: event.target.value }))
}
>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
<option value="published"></option>
<option value="draft">稿</option>
<option value="archived"></option>
</Select>
</FormField>
<FormField label="Cover URL">
<FormField label="封面 URL">
<Input
value={form.cover}
onChange={(event) =>
@@ -415,8 +422,17 @@ export function ReviewsPage() {
}
/>
</FormField>
<FormField label="跳转链接" hint="可填写站内路径或完整 URL。">
<Input
type="url"
value={form.linkUrl}
onChange={(event) =>
setForm((current) => ({ ...current, linkUrl: event.target.value }))
}
/>
</FormField>
<div className="lg:col-span-2">
<FormField label="Tags" hint="Comma-separated tag names.">
<FormField label="标签" hint="多个标签请用英文逗号分隔。">
<Input
value={form.tags}
onChange={(event) =>
@@ -426,7 +442,7 @@ export function ReviewsPage() {
</FormField>
</div>
<div className="lg:col-span-2">
<FormField label="Description">
<FormField label="简介">
<Textarea
value={form.description}
onChange={(event) =>

View File

@@ -1,4 +1,4 @@
import { Bot, RefreshCcw, Save } from 'lucide-react'
import { Bot, Check, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
import type { ReactNode } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -11,7 +11,56 @@ import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { adminApi, ApiError } from '@/lib/api'
import type { AdminSiteSettingsResponse, SiteSettingsPayload } from '@/lib/types'
import type {
AdminSiteSettingsResponse,
AiProviderConfig,
MusicTrack,
SiteSettingsPayload,
} from '@/lib/types'
function createEmptyMusicTrack(): MusicTrack {
return {
title: '',
artist: '',
album: '',
url: '',
cover_image_url: '',
accent_color: '',
description: '',
}
}
function createAiProviderId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `provider-${crypto.randomUUID()}`
}
return `provider-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function createEmptyAiProvider(): AiProviderConfig {
return {
id: createAiProviderId(),
name: '',
provider: 'newapi',
api_base: '',
api_key: '',
chat_model: '',
}
}
function normalizeSettingsResponse(
input: AdminSiteSettingsResponse,
): AdminSiteSettingsResponse {
const aiProviders = Array.isArray(input.ai_providers) ? input.ai_providers : []
return {
...input,
ai_providers: aiProviders,
ai_active_provider_id:
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
}
}
function Field({
label,
@@ -49,11 +98,15 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
socialEmail: form.social_email,
location: form.location,
techStack: form.tech_stack,
musicPlaylist: form.music_playlist,
aiEnabled: form.ai_enabled,
paragraphCommentsEnabled: form.paragraph_comments_enabled,
aiProvider: form.ai_provider,
aiApiBase: form.ai_api_base,
aiApiKey: form.ai_api_key,
aiChatModel: form.ai_chat_model,
aiProviders: form.ai_providers,
aiActiveProviderId: form.ai_active_provider_id,
aiEmbeddingModel: form.ai_embedding_model,
aiSystemPrompt: form.ai_system_prompt,
aiTopK: form.ai_top_k,
@@ -66,22 +119,25 @@ export function SiteSettingsPage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [reindexing, setReindexing] = useState(false)
const [testingProvider, setTestingProvider] = useState(false)
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0)
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0)
const loadSettings = useCallback(async (showToast = false) => {
try {
const next = await adminApi.getSiteSettings()
const next = normalizeSettingsResponse(await adminApi.getSiteSettings())
startTransition(() => {
setForm(next)
})
if (showToast) {
toast.success('Site settings refreshed.')
toast.success('站点设置已刷新。')
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return
}
toast.error(error instanceof ApiError ? error.message : 'Unable to load site settings.')
toast.error(error instanceof ApiError ? error.message : '无法加载站点设置。')
} finally {
setLoading(false)
}
@@ -91,6 +147,34 @@ export function SiteSettingsPage() {
void loadSettings(false)
}, [loadSettings])
useEffect(() => {
if (!form?.music_playlist.length) {
setSelectedTrackIndex(0)
return
}
setSelectedTrackIndex((current) => Math.min(current, form.music_playlist.length - 1))
}, [form?.music_playlist.length])
useEffect(() => {
if (!form?.ai_providers.length) {
setSelectedProviderIndex(0)
return
}
setSelectedProviderIndex((current) => {
const activeIndex = form.ai_providers.findIndex(
(provider) => provider.id === form.ai_active_provider_id,
)
if (activeIndex >= 0) {
return activeIndex
}
return Math.min(current, form.ai_providers.length - 1)
})
}, [form?.ai_active_provider_id, form?.ai_providers])
const updateField = <K extends keyof AdminSiteSettingsResponse>(
key: K,
value: AdminSiteSettingsResponse[K],
@@ -98,10 +182,130 @@ export function SiteSettingsPage() {
setForm((current) => (current ? { ...current, [key]: value } : current))
}
const updateMusicTrack = <K extends keyof MusicTrack>(index: number, key: K, value: MusicTrack[K]) => {
setForm((current) => {
if (!current) {
return current
}
const nextPlaylist = current.music_playlist.map((track, trackIndex) =>
trackIndex === index ? { ...track, [key]: value } : track,
)
return { ...current, music_playlist: nextPlaylist }
})
}
const updateAiProvider = <K extends keyof AiProviderConfig>(
index: number,
key: K,
value: AiProviderConfig[K],
) => {
setForm((current) => {
if (!current) {
return current
}
const nextProviders = current.ai_providers.map((provider, providerIndex) =>
providerIndex === index ? { ...provider, [key]: value } : provider,
)
return { ...current, ai_providers: nextProviders }
})
}
const addMusicTrack = () => {
setForm((current) => {
if (!current) {
return current
}
const nextPlaylist = [...current.music_playlist, createEmptyMusicTrack()]
setSelectedTrackIndex(nextPlaylist.length - 1)
return { ...current, music_playlist: nextPlaylist }
})
}
const removeMusicTrack = (index: number) => {
setForm((current) => {
if (!current) {
return current
}
const nextPlaylist = current.music_playlist.filter((_, trackIndex) => trackIndex !== index)
setSelectedTrackIndex((currentIndex) =>
Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextPlaylist.length - 1)),
)
return {
...current,
music_playlist: nextPlaylist,
}
})
}
const addAiProvider = () => {
setForm((current) => {
if (!current) {
return current
}
const nextProvider = createEmptyAiProvider()
const nextProviders = [...current.ai_providers, nextProvider]
setSelectedProviderIndex(nextProviders.length - 1)
return {
...current,
ai_providers: nextProviders,
ai_active_provider_id: current.ai_active_provider_id ?? nextProvider.id,
}
})
}
const removeAiProvider = (index: number) => {
setForm((current) => {
if (!current) {
return current
}
const removed = current.ai_providers[index]
const nextProviders = current.ai_providers.filter((_, providerIndex) => providerIndex !== index)
const nextActiveProviderId =
removed?.id === current.ai_active_provider_id ? (nextProviders[0]?.id ?? null) : current.ai_active_provider_id
setSelectedProviderIndex((currentIndex) =>
Math.max(0, Math.min(currentIndex > index ? currentIndex - 1 : currentIndex, nextProviders.length - 1)),
)
return {
...current,
ai_providers: nextProviders,
ai_active_provider_id: nextActiveProviderId,
}
})
}
const setActiveAiProvider = (providerId: string) => {
updateField('ai_active_provider_id', providerId)
}
const techStackValue = useMemo(
() => (form?.tech_stack.length ? form.tech_stack.join('\n') : ''),
[form?.tech_stack],
)
const selectedTrack = useMemo(
() => form?.music_playlist[selectedTrackIndex] ?? createEmptyMusicTrack(),
[form, selectedTrackIndex],
)
const selectedProvider = useMemo(
() => form?.ai_providers[selectedProviderIndex] ?? createEmptyAiProvider(),
[form, selectedProviderIndex],
)
const activeProvider = useMemo(
() => form?.ai_providers.find((provider) => provider.id === form.ai_active_provider_id) ?? null,
[form],
)
if (loading || !form) {
return (
@@ -116,14 +320,11 @@ export function SiteSettingsPage() {
<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">Site settings</Badge>
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight">
Brand, profile, and AI controls
</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">
This page keeps the public brand, owner profile, and AI configuration aligned with
the same backend data model the site already depends on.
使 AI
</p>
</div>
</div>
@@ -131,7 +332,7 @@ export function SiteSettingsPage() {
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadSettings(true)}>
<RefreshCcw className="h-4 w-4" />
Refresh
</Button>
<Button
variant="secondary"
@@ -140,17 +341,17 @@ export function SiteSettingsPage() {
try {
setReindexing(true)
const result = await adminApi.reindexAi()
toast.success(`AI index rebuilt with ${result.indexed_chunks} chunks.`)
toast.success(`AI 索引已重建,共生成 ${result.indexed_chunks} 个分块。`)
await loadSettings(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'AI reindex failed.')
toast.error(error instanceof ApiError ? error.message : 'AI 重建索引失败。')
} finally {
setReindexing(false)
}
}}
>
<Bot className="h-4 w-4" />
{reindexing ? 'Reindexing...' : 'Rebuild AI index'}
{reindexing ? '重建中...' : '重建 AI 索引'}
</Button>
<Button
disabled={saving}
@@ -159,102 +360,102 @@ export function SiteSettingsPage() {
setSaving(true)
const updated = await adminApi.updateSiteSettings(toPayload(form))
startTransition(() => {
setForm(updated)
setForm(normalizeSettingsResponse(updated))
})
toast.success('Site settings saved.')
toast.success('站点设置已保存。')
} catch (error) {
toast.error(error instanceof ApiError ? error.message : 'Save failed.')
toast.error(error instanceof ApiError ? error.message : '保存失败。')
} finally {
setSaving(false)
}
}}
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : 'Save changes'}
{saving ? '保存中...' : '保存修改'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Card>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Public identity</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Everything the public site reads for brand, hero copy, owner profile, and social
metadata.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-2">
<Field label="Site name">
<Field label="站点名称">
<Input
value={form.site_name ?? ''}
onChange={(event) => updateField('site_name', event.target.value)}
/>
</Field>
<Field label="Short name">
<Field label="站点短名">
<Input
value={form.site_short_name ?? ''}
onChange={(event) => updateField('site_short_name', event.target.value)}
/>
</Field>
<Field label="Site URL">
<Field label="站点 URL">
<Input
value={form.site_url ?? ''}
onChange={(event) => updateField('site_url', event.target.value)}
/>
</Field>
<Field label="Location">
<Field label="所在地">
<Input
value={form.location ?? ''}
onChange={(event) => updateField('location', event.target.value)}
/>
</Field>
<Field label="Site title" hint="Used in the main document title and SEO surface.">
<Field label="站点标题" hint="用于页面标题与 SEO 展示。">
<Input
value={form.site_title ?? ''}
onChange={(event) => updateField('site_title', event.target.value)}
/>
</Field>
<Field label="Owner title">
<Field label="站长头衔">
<Input
value={form.owner_title ?? ''}
onChange={(event) => updateField('owner_title', event.target.value)}
/>
</Field>
<div className="lg:col-span-2">
<Field label="Site description">
<Field label="站点简介">
<Textarea
value={form.site_description ?? ''}
onChange={(event) => updateField('site_description', event.target.value)}
/>
</Field>
</div>
<Field label="Hero title">
<Field label="首页主标题">
<Input
value={form.hero_title ?? ''}
onChange={(event) => updateField('hero_title', event.target.value)}
/>
</Field>
<Field label="Hero subtitle">
<Field label="首页副标题">
<Input
value={form.hero_subtitle ?? ''}
onChange={(event) => updateField('hero_subtitle', event.target.value)}
/>
</Field>
<Field label="Owner name">
<Field label="站长名称">
<Input
value={form.owner_name ?? ''}
onChange={(event) => updateField('owner_name', event.target.value)}
/>
</Field>
<Field label="Avatar URL">
<Field label="头像 URL">
<Input
value={form.owner_avatar_url ?? ''}
onChange={(event) => updateField('owner_avatar_url', event.target.value)}
/>
</Field>
<div className="lg:col-span-2">
<Field label="Owner bio">
<Field label="站长简介">
<Textarea
value={form.owner_bio ?? ''}
onChange={(event) => updateField('owner_bio', event.target.value)}
@@ -274,7 +475,7 @@ export function SiteSettingsPage() {
/>
</Field>
<div className="lg:col-span-2">
<Field label="Email / mailto">
<Field label="邮箱 / mailto">
<Input
value={form.social_email ?? ''}
onChange={(event) => updateField('social_email', event.target.value)}
@@ -282,7 +483,7 @@ export function SiteSettingsPage() {
</Field>
</div>
<div className="lg:col-span-2">
<Field label="Tech stack" hint="One item per line.">
<Field label="技术栈" hint="每行填写一个项目。">
<Textarea
value={techStackValue}
onChange={(event) =>
@@ -300,12 +501,38 @@ export function SiteSettingsPage() {
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>AI module</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Provider and retrieval controls used by the on-site AI experience.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
<input
type="checkbox"
checked={form.paragraph_comments_enabled}
onChange={(event) =>
updateField('paragraph_comments_enabled', event.target.checked)
}
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium"></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
</p>
</div>
</label>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>AI </CardTitle>
<CardDescription>
AI 使
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
@@ -317,41 +544,192 @@ export function SiteSettingsPage() {
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
/>
<div>
<div className="font-medium">Enable public AI Q&A</div>
<div className="font-medium"> AI </div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
When this is off, the public Ask AI entry stays visible only as a disabled
state.
AI
</p>
</div>
</label>
<Field label="Provider">
<Input
value={form.ai_provider ?? ''}
onChange={(event) => updateField('ai_provider', event.target.value)}
/>
</Field>
<Field label="API base">
<Input
value={form.ai_api_base ?? ''}
onChange={(event) => updateField('ai_api_base', event.target.value)}
/>
</Field>
<Field label="API key">
<Input
value={form.ai_api_key ?? ''}
onChange={(event) => updateField('ai_api_key', event.target.value)}
/>
</Field>
<Field label="Chat model">
<Input
value={form.ai_chat_model ?? ''}
onChange={(event) => updateField('ai_chat_model', event.target.value)}
/>
<Field label="提供方">
<div className="rounded-[1.75rem] border border-border/70 bg-background/55 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-medium"></p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
</p>
</div>
<Button type="button" variant="outline" onClick={addAiProvider}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<div className="space-y-3">
{form.ai_providers.length ? (
form.ai_providers.map((provider, index) => {
const active = provider.id === form.ai_active_provider_id
const selected = index === selectedProviderIndex
return (
<button
key={provider.id}
type="button"
onClick={() => setSelectedProviderIndex(index)}
className={
selected
? 'w-full rounded-[1.35rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_12px_28px_rgba(37,99,235,0.12)]'
: 'w-full rounded-[1.35rem] border border-border/70 bg-background/70 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{provider.name?.trim() || `提供商 ${index + 1}`}
</p>
<p className="mt-1 truncate text-sm text-muted-foreground">
{provider.provider?.trim() || '未填写 provider'}
</p>
</div>
{active ? (
<Badge variant="secondary" className="shrink-0">
</Badge>
) : null}
</div>
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
{provider.chat_model?.trim() || '未填写模型'}
</p>
</button>
)
})
) : (
<div className="rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-4 py-6 text-sm leading-6 text-muted-foreground">
使
</div>
)}
</div>
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 p-5">
{form.ai_providers.length ? (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<p className="mt-2 text-lg font-semibold">
{selectedProvider.name?.trim() || `提供商 ${selectedProviderIndex + 1}`}
</p>
<p className="mt-1 text-sm text-muted-foreground">
使 AI
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={testingProvider}
onClick={async () => {
try {
setTestingProvider(true)
const result = await adminApi.testAiProvider(selectedProvider)
toast.success(
`连通成功:${result.provider} / ${result.chat_model} / ${result.reply_preview}`,
)
} catch (error) {
toast.error(
error instanceof ApiError ? error.message : '模型连通性测试失败。',
)
} finally {
setTestingProvider(false)
}
}}
>
<Bot className="h-4 w-4" />
{testingProvider ? '测试中...' : '测试连通性'}
</Button>
<Button
type="button"
variant={selectedProvider.id === form.ai_active_provider_id ? 'secondary' : 'outline'}
onClick={() => setActiveAiProvider(selectedProvider.id)}
>
<Check className="h-4 w-4" />
{selectedProvider.id === form.ai_active_provider_id ? '已启用' : '设为启用'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => removeAiProvider(selectedProviderIndex)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Field label="显示名称" hint="例如 OpenAI 主通道、Gemini 备用线路。">
<Input
value={selectedProvider.name ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'name', event.target.value)
}
/>
</Field>
<Field label="Provider 标识">
<Input
value={selectedProvider.provider ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'provider', event.target.value)
}
placeholder="newapi / openai-compatible / 其他兼容值"
/>
</Field>
<Field label="API 地址">
<Input
value={selectedProvider.api_base ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'api_base', event.target.value)
}
/>
</Field>
<Field label="API 密钥">
<Input
value={selectedProvider.api_key ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'api_key', event.target.value)
}
/>
</Field>
<Field label="对话模型">
<Input
value={selectedProvider.chat_model ?? ''}
onChange={(event) =>
updateAiProvider(selectedProviderIndex, 'chat_model', event.target.value)
}
/>
</Field>
</div>
) : (
<div className="flex h-full min-h-[240px] items-center justify-center rounded-[1.35rem] border border-dashed border-border/70 bg-background/60 px-6 text-center text-sm leading-6 text-muted-foreground">
provider API
</div>
)}
</div>
</div>
</div>
</Field>
<div className="rounded-2xl border border-border/70 bg-background/60 p-4 text-sm leading-6 text-muted-foreground">
{activeProvider
? `${activeProvider.name || activeProvider.provider} / ${activeProvider.chat_model || '未填写模型'}`
: '未选择提供商'}
</div>
<Field
label="Embedding model"
hint={`Local option: ${form.ai_local_embedding}`}
label="向量模型"
hint={`本地选项:${form.ai_local_embedding}`}
>
<Input
value={form.ai_embedding_model ?? ''}
@@ -371,7 +749,7 @@ export function SiteSettingsPage() {
}
/>
</Field>
<Field label="Chunk size">
<Field label="分块大小">
<Input
type="number"
value={form.ai_chunk_size ?? ''}
@@ -384,7 +762,7 @@ export function SiteSettingsPage() {
/>
</Field>
</div>
<Field label="System prompt">
<Field label="系统提示词">
<Textarea
value={form.ai_system_prompt ?? ''}
onChange={(event) => updateField('ai_system_prompt', event.target.value)}
@@ -395,29 +773,198 @@ export function SiteSettingsPage() {
<Card>
<CardHeader>
<CardTitle>Index status</CardTitle>
<CardTitle></CardTitle>
<CardDescription>
Read-only signals from the current AI knowledge base.
AI
</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">
Indexed chunks
</p>
<p className="mt-3 text-3xl font-semibold">{form.ai_chunks_count}</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">
Last indexed at
</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{form.ai_last_indexed_at ?? 'The index has not been built yet.'}
{form.ai_last_indexed_at ?? '索引尚未建立。'}
</p>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6 xl:sticky xl:top-24 xl:self-start">
<Card className="overflow-hidden">
<CardHeader className="border-b border-border/70 bg-background/45">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</div>
<Badge variant="outline">{form.music_playlist.length} </Badge>
</div>
</CardHeader>
<CardContent className="space-y-5 pt-6">
<div className="space-y-3">
{form.music_playlist.map((track, index) => {
const active = index === selectedTrackIndex
return (
<button
key={`${track.title}-${index}`}
type="button"
onClick={() => setSelectedTrackIndex(index)}
className={
active
? 'w-full rounded-[1.5rem] border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-[0_14px_32px_rgba(37,99,235,0.14)]'
: 'w-full rounded-[1.5rem] border border-border/70 bg-background/60 px-4 py-4 text-left transition hover:border-border hover:bg-accent/35'
}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{track.title?.trim() ? track.title : `曲目 ${index + 1}`}
</p>
<p className="mt-1 truncate text-sm text-muted-foreground">
{track.artist?.trim() || '未填写歌手'}
</p>
</div>
{track.accent_color ? (
<span
className="mt-1 h-4 w-4 shrink-0 rounded-full border border-white/60"
style={{ backgroundColor: track.accent_color }}
/>
) : null}
</div>
<p className="mt-3 truncate font-mono text-[11px] text-muted-foreground">
{track.url || '未填写音频 URL'}
</p>
</button>
)
})}
<Button type="button" variant="outline" onClick={addMusicTrack} className="w-full">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="rounded-[1.8rem] border border-border/70 bg-background/55 p-5">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p>
<p className="mt-2 text-lg font-semibold">
{selectedTrack.title?.trim()
? selectedTrack.title
: `曲目 ${selectedTrackIndex + 1}`}
</p>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => removeMusicTrack(selectedTrackIndex)}
disabled={form.music_playlist.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{selectedTrack.cover_image_url ? (
<div className="mb-5 overflow-hidden rounded-3xl border border-border/70 bg-black/5">
<img
src={selectedTrack.cover_image_url}
alt={selectedTrack.title || `曲目 ${selectedTrackIndex + 1}`}
className="h-44 w-full object-cover"
/>
</div>
) : null}
<div className="space-y-4">
<Field label="标题">
<Input
value={selectedTrack.title ?? ''}
onChange={(event) =>
updateMusicTrack(selectedTrackIndex, 'title', event.target.value)
}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<Field label="歌手">
<Input
value={selectedTrack.artist ?? ''}
onChange={(event) =>
updateMusicTrack(selectedTrackIndex, 'artist', event.target.value)
}
/>
</Field>
<Field label="专辑">
<Input
value={selectedTrack.album ?? ''}
onChange={(event) =>
updateMusicTrack(selectedTrackIndex, 'album', event.target.value)
}
/>
</Field>
</div>
<Field label="音频 URL">
<Input
value={selectedTrack.url ?? ''}
onChange={(event) =>
updateMusicTrack(selectedTrackIndex, 'url', event.target.value)
}
/>
</Field>
<Field label="封面图 URL">
<Input
value={selectedTrack.cover_image_url ?? ''}
onChange={(event) =>
updateMusicTrack(selectedTrackIndex, 'cover_image_url', event.target.value)
}
/>
</Field>
<Field label="主题色" hint="例如 `#2f6b5f`,前台播放器会读取这个颜色。">
<div className="flex items-center gap-3">
<Input
value={selectedTrack.accent_color ?? ''}
onChange={(event) =>
updateMusicTrack(selectedTrackIndex, 'accent_color', event.target.value)
}
placeholder="#2f6b5f"
/>
<span
className="h-11 w-11 shrink-0 rounded-2xl border border-border/70 bg-background"
style={{
backgroundColor:
selectedTrack.accent_color?.trim() || 'transparent',
}}
/>
</div>
</Field>
<Field label="备注">
<Textarea
value={selectedTrack.description ?? ''}
onChange={(event) =>
updateMusicTrack(selectedTrackIndex, 'description', event.target.value)
}
/>
</Field>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)