feat(studio): authors/series/articles/subtopics/briefs/style-presets + agent tokens (show-once)

This commit is contained in:
2026-07-07 22:30:27 +08:00
parent 36df03baad
commit 61cb549e72
14 changed files with 1184 additions and 32 deletions

View File

@ -0,0 +1,126 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { KeyRound } from 'lucide-react'
import { toast } from 'sonner'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioAgentToken, StudioAgentTokenGrant } from '@/api/types'
import { fetchAgentTokens, issueAgentToken, revokeAgentToken } from '@/api/modules/studio'
import { handleApiError } from '@/lib/errors'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { ShowOnceTokenDialog } from '@/components/show-once-token-dialog'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/_authed/studio/agent-tokens')({
component: AgentTokensPage,
})
function AgentTokensPage() {
const queryClient = useQueryClient()
const tokens = useQuery({ queryKey: ['studio-agent-tokens'], queryFn: fetchAgentTokens })
const [grant, setGrant] = useState<StudioAgentTokenGrant | null>(null)
const [busy, setBusy] = useState(false)
const refresh = () => queryClient.invalidateQueries({ queryKey: ['studio-agent-tokens'] })
const issue = async () => {
setBusy(true)
try {
const res = await issueAgentToken()
setGrant(res.data)
void refresh()
} catch (error) {
handleApiError(error)
} finally {
setBusy(false)
}
}
const revoke = async (row: StudioAgentToken) => {
try {
await revokeAgentToken(row.id)
toast.success('令牌已吊销Agent 将立即失去访问权限')
void refresh()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<StudioAgentToken>[] = [
{
header: '令牌 ID',
cell: ({ row }) => (
<code className="font-mono text-xs">{row.original.id.slice(0, 16)}</code>
),
},
{
header: 'Scopes',
cell: ({ row }) => (
<code className="font-mono text-xs">{row.original.scopes?.join(' ') || '—'}</code>
),
},
{
header: '签发时间',
cell: ({ row }) =>
row.original.created_at ? new Date(row.original.created_at).toLocaleString('zh-CN') : '—',
},
{
header: '过期时间',
cell: ({ row }) =>
row.original.expires_at ? new Date(row.original.expires_at).toLocaleString('zh-CN') : '—',
},
{
header: '操作',
cell: ({ row }) => (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" className="text-destructive">
</Button>
}
title="吊销该 Agent 令牌?"
description="吊销后使用该令牌的 Agent 会立即收到 401需重新签发并配置。"
confirmLabel="吊销"
destructive
onConfirm={() => revoke(row.original)}
/>
),
},
]
const endpoint = (tokens.data?.meta as { endpoint?: string } | undefined)?.endpoint
return (
<div>
<PageHeader
title="Agent 接入"
description={`内容 Agent 访问 MCP 服务的专用凭证${endpoint ? ` · ${endpoint}` : ''}`}
actions={
<Button onClick={issue} disabled={busy}>
<KeyRound className="size-4" />
{busy ? '签发中…' : '签发新令牌'}
</Button>
}
/>
<DataTable
columns={columns}
data={tokens.data?.data ?? []}
loading={tokens.isPending}
emptyText="尚未签发 Agent 令牌"
/>
<ShowOnceTokenDialog
open={grant !== null}
onOpenChange={(open) => !open && setGrant(null)}
title="Agent 令牌已签发"
token={grant?.access_token ?? null}
snippet={
grant
? `claude mcp add --transport http studio ${grant.connect.endpoint} \\\n --header "Authorization: Bearer <access_token>"`
: undefined
}
/>
</div>
)
}

View File

@ -0,0 +1,137 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioArticle } from '@/api/types'
import { fetchArticles } from '@/api/modules/studio'
import { useListPage } from '@/lib/list-page'
import { DetailSheet } from '@/features/studio/detail-sheet'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { StatusPill } from '@/components/status-pill'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/studio/articles')({
component: ArticlesPage,
})
const STATUSES = ['planned', 'drafting', 'drafted', 'published', 'scheduled'] as const
function ArticlesPage() {
const [status, setStatus] = useState('all')
const [detail, setDetail] = useState<StudioArticle | null>(null)
const list = useListPage(
'studio-articles',
fetchArticles,
status === 'all' ? {} : { status },
)
const columns: ColumnDef<StudioArticle>[] = [
{
header: '文章',
cell: ({ row }) => (
<div>
<div className="max-w-80 truncate font-medium">
{row.original.title ?? '(未命名)'}
</div>
<span className="text-xs text-muted-foreground">
{row.original.article_number}
</span>
</div>
),
},
{ header: '状态', cell: ({ row }) => <StatusPill status={row.original.status} /> },
{
header: 'Dimzou',
cell: ({ row }) =>
row.original.dimzou_publication_id ? (
<code className="font-mono text-xs">
doc #{row.original.dimzou_document_id} · pub #{row.original.dimzou_publication_id}
</code>
) : row.original.dimzou_document_id ? (
<code className="font-mono text-xs">稿 doc #{row.original.dimzou_document_id}</code>
) : (
<span className="text-xs text-muted-foreground">稿</span>
),
},
{
header: '更新时间',
cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'),
},
{
header: '操作',
cell: ({ row }) => (
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
</Button>
),
},
]
return (
<div>
<PageHeader
title="文章"
description="正文存于 Dimzou 草稿(块修订即编辑历史);此处为计划元数据"
actions={
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
<StatusPill status={s} />
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title={detail?.title ?? '文章详情'}
description={detail?.summary ?? undefined}
fields={[
{ label: 'ID', value: <code className="font-mono text-xs">{detail?.id}</code> },
{ label: '状态', value: detail && <StatusPill status={detail.status} /> },
{ label: '系列内序号', value: detail?.article_number },
{ label: '标签', value: detail?.tags?.join('、') },
{ label: 'SEO 关键词', value: detail?.seo_keywords?.join('、') },
{ label: 'CTA', value: detail?.cta },
{ label: '封面提示词', value: detail?.cover_image_prompt },
{
label: 'Dimzou 文档',
value: detail?.dimzou_document_id ? `#${detail.dimzou_document_id}` : '未建稿',
},
{
label: 'Dimzou 发布',
value: detail?.dimzou_publication_id ? `#${detail.dimzou_publication_id}` : '未发布',
},
]}
jsonBlocks={[
{ label: '要点 key_takeaways', value: detail?.key_takeaways },
{ label: '大纲 outline', value: detail?.outline },
{ label: '计划 plan', value: detail?.plan },
{ label: '发布排期', value: detail?.publish_schedule },
]}
/>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioCategoryBrief } from '@/api/types'
import { fetchCategoryBriefs } from '@/api/modules/studio'
import { useListPage } from '@/lib/list-page'
import { DetailSheet } from '@/features/studio/detail-sheet'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/_authed/studio/category-briefs')({
component: CategoryBriefsPage,
})
function CategoryBriefsPage() {
const [detail, setDetail] = useState<StudioCategoryBrief | null>(null)
const list = useListPage('studio-category-briefs', fetchCategoryBriefs)
const columns: ColumnDef<StudioCategoryBrief>[] = [
{
header: '分类简报',
cell: ({ row }) => (
<div className="max-w-96 truncate">{row.original.description ?? '(无描述)'}</div>
),
},
{ header: '子话题', cell: ({ row }) => row.original.subtopic_count },
{ header: '作者', cell: ({ row }) => row.original.author_count },
{ header: '文章', cell: ({ row }) => row.original.article_count },
{
header: '更新时间',
cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'),
},
{
header: '操作',
cell: ({ row }) => (
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
</Button>
),
},
]
return (
<div>
<PageHeader title="分类简报" description="Agent 对各分类的内容定位分析(含聚合计数)" />
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title="分类简报"
fields={[
{
label: '分类',
value: <code className="font-mono text-xs">{detail?.category_id}</code>,
},
{ label: '定位描述', value: detail?.description },
{ label: '目标受众', value: detail?.target_audience },
{ label: '风险提示', value: detail?.risk_notes },
{
label: '统计',
value:
detail &&
`${detail.subtopic_count} 个子话题 · ${detail.author_count} 位作者 · ${detail.article_count} 篇文章`,
},
]}
jsonBlocks={[{ label: '内容切入角度', value: detail?.content_angles }]}
/>
</div>
)
}

View File

@ -0,0 +1,144 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import type { StudioRobotAuthor } from '@/api/types'
import { fetchRobotAuthors, updateRobotAuthor } from '@/api/modules/studio'
import { handleApiError } from '@/lib/errors'
import { useListPage } from '@/lib/list-page'
import { DetailSheet } from '@/features/studio/detail-sheet'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { StatusPill } from '@/components/status-pill'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/studio/robot-authors')({
component: RobotAuthorsPage,
})
const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const
function RobotAuthorsPage() {
const queryClient = useQueryClient()
const [status, setStatus] = useState('all')
const [detail, setDetail] = useState<StudioRobotAuthor | null>(null)
const list = useListPage(
'studio-robot-authors',
fetchRobotAuthors,
status === 'all' ? {} : { status },
)
const setAuthorStatus = async (row: StudioRobotAuthor, next: 'ready' | 'paused') => {
try {
await updateRobotAuthor(row.id, { status: next })
toast.success(next === 'paused' ? '已暂停该作者' : '已恢复该作者')
void queryClient.invalidateQueries({ queryKey: ['studio-robot-authors'] })
void list.query.refetch()
} catch (error) {
handleApiError(error)
}
}
const columns: ColumnDef<StudioRobotAuthor>[] = [
{
header: '作者',
cell: ({ row }) => (
<div>
<div className="max-w-72 truncate font-medium">
{row.original.persona?.slice(0, 40) || '(人设待补全)'}
</div>
<code className="font-mono text-xs text-muted-foreground">{row.original.user_uid}</code>
</div>
),
},
{
header: '来源',
cell: ({ row }) => (
<Badge variant="secondary">{row.original.source === 'user' ? '按用户' : '按分类'}</Badge>
),
},
{ header: '状态', cell: ({ row }) => <StatusPill status={row.original.status} /> },
{
header: '更新时间',
cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'),
},
{
header: '操作',
cell: ({ row }) => (
<div className="flex gap-1.5">
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
</Button>
{row.original.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'ready')}>
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'paused')}>
</Button>
)}
</div>
),
},
]
return (
<div>
<PageHeader
title="机器人作者"
description="由 cron 播种、Agent 补全人设;可在此暂停/恢复创作"
actions={
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
<StatusPill status={s} />
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title="机器人作者详情"
fields={[
{ label: 'ID', value: <code className="font-mono text-xs">{detail?.id}</code> },
{ label: '用户 UID', value: <code className="font-mono text-xs">{detail?.user_uid}</code> },
{ label: '状态', value: detail && <StatusPill status={detail.status} /> },
{ label: '定位', value: detail?.positioning },
{ label: '目标读者', value: detail?.target_readers },
{ label: '人设', value: detail?.persona },
]}
jsonBlocks={[
{ label: '写作风格', value: detail?.article_style },
{ label: '配图风格', value: detail?.image_style },
{ label: '发布规则', value: detail?.publishing_rules },
]}
/>
</div>
)
}

View File

@ -0,0 +1,76 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioSeries } from '@/api/types'
import { fetchSeries } from '@/api/modules/studio'
import { useListPage } from '@/lib/list-page'
import { DetailSheet } from '@/features/studio/detail-sheet'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { StatusPill } from '@/components/status-pill'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/_authed/studio/series')({
component: SeriesPage,
})
function SeriesPage() {
const [detail, setDetail] = useState<StudioSeries | null>(null)
const list = useListPage('studio-series', fetchSeries)
const columns: ColumnDef<StudioSeries>[] = [
{
header: '系列目标',
cell: ({ row }) => (
<div className="max-w-96 truncate font-medium">{row.original.goal ?? '(未命名系列)'}</div>
),
},
{
header: '计划篇数',
cell: ({ row }) => row.original.target_article_count ?? '—',
},
{ header: '状态', cell: ({ row }) => <StatusPill status={row.original.status} /> },
{
header: '更新时间',
cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'),
},
{
header: '操作',
cell: ({ row }) => (
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
</Button>
),
},
]
return (
<div>
<PageHeader title="文章系列" description="机器人作者的系列规划(目标 / 读者旅程 / 计划列表)" />
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title={detail?.goal ?? '系列详情'}
fields={[
{ label: 'ID', value: <code className="font-mono text-xs">{detail?.id}</code> },
{ label: '状态', value: detail && <StatusPill status={detail.status} /> },
{ label: '计划篇数', value: detail?.target_article_count },
{ label: '读者旅程', value: detail?.reader_journey },
{
label: '作者',
value: <code className="font-mono text-xs">{detail?.robot_author_id}</code>,
},
]}
jsonBlocks={[{ label: '计划文章列表', value: detail?.planned_articles }]}
/>
</div>
)
}

View File

@ -0,0 +1,100 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioStylePreset } from '@/api/types'
import { fetchStylePresets } from '@/api/modules/studio'
import { useListPage } from '@/lib/list-page'
import { DetailSheet } from '@/features/studio/detail-sheet'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/studio/style-presets')({
component: StylePresetsPage,
})
const TYPE_LABELS: Record<string, string> = {
article: '文章风格',
image: '图片风格',
bundle: '组合',
}
function StylePresetsPage() {
const [type, setType] = useState('all')
const [detail, setDetail] = useState<StudioStylePreset | null>(null)
const list = useListPage(
'studio-style-presets',
fetchStylePresets,
type === 'all' ? {} : { type },
)
const columns: ColumnDef<StudioStylePreset>[] = [
{ header: '名称', cell: ({ row }) => <span className="font-medium">{row.original.name}</span> },
{
header: '类型',
cell: ({ row }) => (
<Badge variant="secondary">{TYPE_LABELS[row.original.type] ?? row.original.type}</Badge>
),
},
{
header: '更新时间',
cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'),
},
{
header: '操作',
cell: ({ row }) => (
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
</Button>
),
},
]
return (
<div>
<PageHeader
title="风格预设"
description="Agent 的风格库preset / hybrid 模式),按 type + name 幂等"
actions={
<Select value={type} onValueChange={setType}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="article"></SelectItem>
<SelectItem value="image"></SelectItem>
<SelectItem value="bundle"></SelectItem>
</SelectContent>
</Select>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
title={detail?.name ?? '风格预设'}
fields={[
{ label: '类型', value: detail && (TYPE_LABELS[detail.type] ?? detail.type) },
{ label: 'ID', value: <code className="font-mono text-xs">{detail?.id}</code> },
]}
jsonBlocks={[{ label: '风格定义 payload', value: detail?.payload }]}
/>
</div>
)
}

View File

@ -0,0 +1,54 @@
import { createFileRoute } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioSubtopic } from '@/api/types'
import { fetchSubtopics } from '@/api/modules/studio'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { StatusPill } from '@/components/status-pill'
export const Route = createFileRoute('/_authed/studio/subtopics')({
component: SubtopicsPage,
})
const columns: ColumnDef<StudioSubtopic>[] = [
{
header: '子话题',
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.title}</div>
<div className="max-w-96 truncate text-xs text-muted-foreground">
{row.original.target_audience ?? ''}
</div>
</div>
),
},
{
header: '可扩展性',
cell: ({ row }) =>
row.original.expandability_score != null ? `${row.original.expandability_score}/10` : '—',
},
{ header: '状态', cell: ({ row }) => <StatusPill status={row.original.status} /> },
{
header: '更新时间',
cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'),
},
]
function SubtopicsPage() {
const list = useListPage('studio-subtopics', fetchSubtopics)
return (
<div>
<PageHeader title="子话题" description="分类下的细分内容专题category_id + title 幂等)" />
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
</div>
)
}