feat(studio): authors/series/articles/subtopics/briefs/style-presets + agent tokens (show-once)
This commit is contained in:
+2
-1
@@ -11,7 +11,8 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"gen:api": "openapi-typescript ../gig-platform/public/api-docs/admin/openapi.yaml -o src/api/types.gen.ts"
|
||||
"gen:api": "node scripts/gen-api.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Regenerate src/api/types.gen.ts from the backend's scribe-admin OpenAPI spec.
|
||||
// Prepends @ts-nocheck: scribe emits duplicate operationIds (OTP/SID/AgentToken…),
|
||||
// so the file is reference-only — hand-written shapes live in src/api/types.ts.
|
||||
import { execSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const SPEC = process.env.OPENAPI_SPEC ?? '../gig-platform/public/api-docs/admin/openapi.yaml'
|
||||
const OUT = 'src/api/types.gen.ts'
|
||||
|
||||
execSync(`pnpm exec openapi-typescript ${SPEC} -o ${OUT}`, { stdio: 'inherit' })
|
||||
|
||||
const banner = '// @ts-nocheck — generated from scribe-admin OpenAPI; reference only\n'
|
||||
writeFileSync(OUT, banner + readFileSync(OUT, 'utf8'))
|
||||
console.log(`prepended @ts-nocheck to ${OUT}`)
|
||||
@@ -0,0 +1,48 @@
|
||||
import { api } from '@/api/client'
|
||||
import type {
|
||||
ApiResponse,
|
||||
ListParams,
|
||||
PaginatedResponse,
|
||||
StudioAgentToken,
|
||||
StudioAgentTokenGrant,
|
||||
StudioArticle,
|
||||
StudioCategoryBrief,
|
||||
StudioRobotAuthor,
|
||||
StudioSeries,
|
||||
StudioStylePreset,
|
||||
StudioSubtopic,
|
||||
} from '@/api/types'
|
||||
|
||||
const BASE = '/api/admin/v1/studio'
|
||||
|
||||
export const fetchRobotAuthors = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioRobotAuthor>>(`${BASE}/robot-authors`, params)
|
||||
|
||||
export const fetchSeries = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioSeries>>(`${BASE}/series`, params)
|
||||
|
||||
export const fetchArticles = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioArticle>>(`${BASE}/articles`, params)
|
||||
|
||||
export const fetchSubtopics = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioSubtopic>>(`${BASE}/subtopics`, params)
|
||||
|
||||
export const fetchCategoryBriefs = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioCategoryBrief>>(`${BASE}/category-briefs`, params)
|
||||
|
||||
export const fetchStylePresets = (params: ListParams) =>
|
||||
api.get<PaginatedResponse<StudioStylePreset>>(`${BASE}/style-presets`, params)
|
||||
|
||||
export const updateRobotAuthor = (id: string, payload: Partial<StudioRobotAuthor>) =>
|
||||
api.put<ApiResponse<StudioRobotAuthor>>(`${BASE}/robot-authors/${id}`, payload)
|
||||
|
||||
// ---- agent tokens (show-once) ----
|
||||
|
||||
export const fetchAgentTokens = () =>
|
||||
api.get<ApiResponse<StudioAgentToken[]> & { meta: { endpoint: string } }>(`${BASE}/agent/tokens`)
|
||||
|
||||
export const issueAgentToken = () =>
|
||||
api.post<ApiResponse<StudioAgentTokenGrant>>(`${BASE}/agent/tokens`)
|
||||
|
||||
export const revokeAgentToken = (id: string) =>
|
||||
api.delete<ApiResponse<null>>(`${BASE}/agent/tokens/${id}`)
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck — generated from scribe-admin OpenAPI; reference only
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
const schema = z.object({
|
||||
slug: z.string().min(1, '请输入 slug').max(128),
|
||||
sort_order: z.coerce.number().int().min(0),
|
||||
sort_order: z.number().int().min(0),
|
||||
is_active: z.boolean(),
|
||||
name_zh: z.string().min(1, '请输入中文名称').max(255),
|
||||
description_zh: z.string().optional(),
|
||||
@@ -113,7 +113,7 @@ export function CategoryFormDialog({ open, onOpenChange, category, parent }: Cat
|
||||
<Input {...form.register('slug')} placeholder="home-cleaning" />
|
||||
</Field>
|
||||
<Field label="排序" error={form.formState.errors.sort_order?.message}>
|
||||
<Input type="number" {...form.register('sort_order')} />
|
||||
<Input type="number" {...form.register('sort_order', { valueAsNumber: true })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="中文名称" error={form.formState.errors.name_zh?.message}>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
|
||||
interface DetailField {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
}
|
||||
|
||||
interface DetailSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
fields: DetailField[]
|
||||
/** raw JSON blocks rendered below the fields (风格/大纲等结构化对象) */
|
||||
jsonBlocks?: Array<{ label: string; value: unknown }>
|
||||
}
|
||||
|
||||
/** Read-mostly detail view for studio entities. */
|
||||
export function DetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
fields,
|
||||
jsonBlocks = [],
|
||||
}: DetailSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="break-all">{title}</SheetTitle>
|
||||
{description && <SheetDescription>{description}</SheetDescription>}
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-4 px-4 pb-8">
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
{fields.map((field) => (
|
||||
<div key={field.label} className="contents">
|
||||
<dt className="text-muted-foreground">{field.label}</dt>
|
||||
<dd className="min-w-0 break-words">{field.value ?? '—'}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{jsonBlocks
|
||||
.filter((block) => block.value != null)
|
||||
.map((block) => (
|
||||
<div key={block.label}>
|
||||
<div className="mb-1 text-sm font-medium">{block.label}</div>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{typeof block.value === 'string'
|
||||
? block.value
|
||||
: JSON.stringify(block.value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
+337
-29
@@ -9,68 +9,376 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AuthedRouteImport } from './routes/_authed'
|
||||
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
|
||||
import { Route as AuthedSidRouteImport } from './routes/_authed/sid'
|
||||
import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots'
|
||||
import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales'
|
||||
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
|
||||
import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index'
|
||||
import { Route as AuthedStudioSubtopicsRouteImport } from './routes/_authed/studio/subtopics'
|
||||
import { Route as AuthedStudioStylePresetsRouteImport } from './routes/_authed/studio/style-presets'
|
||||
import { Route as AuthedStudioSeriesRouteImport } from './routes/_authed/studio/series'
|
||||
import { Route as AuthedStudioRobotAuthorsRouteImport } from './routes/_authed/studio/robot-authors'
|
||||
import { Route as AuthedStudioCategoryBriefsRouteImport } from './routes/_authed/studio/category-briefs'
|
||||
import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles'
|
||||
import { Route as AuthedStudioAgentTokensRouteImport } from './routes/_authed/studio/agent-tokens'
|
||||
import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending'
|
||||
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
const AuthedRoute = AuthedRouteImport.update({
|
||||
id: '/_authed',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthedIndexRoute = AuthedIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedSidRoute = AuthedSidRouteImport.update({
|
||||
id: '/sid',
|
||||
path: '/sid',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedRobotsRoute = AuthedRobotsRouteImport.update({
|
||||
id: '/robots',
|
||||
path: '/robots',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedLocalesRoute = AuthedLocalesRouteImport.update({
|
||||
id: '/locales',
|
||||
path: '/locales',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedDevicesRoute = AuthedDevicesRouteImport.update({
|
||||
id: '/devices',
|
||||
path: '/devices',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({
|
||||
id: '/categories/',
|
||||
path: '/categories/',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedStudioSubtopicsRoute = AuthedStudioSubtopicsRouteImport.update({
|
||||
id: '/studio/subtopics',
|
||||
path: '/studio/subtopics',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedStudioStylePresetsRoute =
|
||||
AuthedStudioStylePresetsRouteImport.update({
|
||||
id: '/studio/style-presets',
|
||||
path: '/studio/style-presets',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedStudioSeriesRoute = AuthedStudioSeriesRouteImport.update({
|
||||
id: '/studio/series',
|
||||
path: '/studio/series',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedStudioRobotAuthorsRoute =
|
||||
AuthedStudioRobotAuthorsRouteImport.update({
|
||||
id: '/studio/robot-authors',
|
||||
path: '/studio/robot-authors',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedStudioCategoryBriefsRoute =
|
||||
AuthedStudioCategoryBriefsRouteImport.update({
|
||||
id: '/studio/category-briefs',
|
||||
path: '/studio/category-briefs',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedStudioArticlesRoute = AuthedStudioArticlesRouteImport.update({
|
||||
id: '/studio/articles',
|
||||
path: '/studio/articles',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedStudioAgentTokensRoute = AuthedStudioAgentTokensRouteImport.update({
|
||||
id: '/studio/agent-tokens',
|
||||
path: '/studio/agent-tokens',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({
|
||||
id: '/categories/pending',
|
||||
path: '/categories/pending',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/': typeof AuthedIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/devices': typeof AuthedDevicesRoute
|
||||
'/locales': typeof AuthedLocalesRoute
|
||||
'/robots': typeof AuthedRobotsRoute
|
||||
'/sid': typeof AuthedSidRoute
|
||||
'/categories/pending': typeof AuthedCategoriesPendingRoute
|
||||
'/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute
|
||||
'/studio/articles': typeof AuthedStudioArticlesRoute
|
||||
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
||||
'/studio/robot-authors': typeof AuthedStudioRobotAuthorsRoute
|
||||
'/studio/series': typeof AuthedStudioSeriesRoute
|
||||
'/studio/style-presets': typeof AuthedStudioStylePresetsRoute
|
||||
'/studio/subtopics': typeof AuthedStudioSubtopicsRoute
|
||||
'/categories/': typeof AuthedCategoriesIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/devices': typeof AuthedDevicesRoute
|
||||
'/locales': typeof AuthedLocalesRoute
|
||||
'/robots': typeof AuthedRobotsRoute
|
||||
'/sid': typeof AuthedSidRoute
|
||||
'/': typeof AuthedIndexRoute
|
||||
'/categories/pending': typeof AuthedCategoriesPendingRoute
|
||||
'/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute
|
||||
'/studio/articles': typeof AuthedStudioArticlesRoute
|
||||
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
||||
'/studio/robot-authors': typeof AuthedStudioRobotAuthorsRoute
|
||||
'/studio/series': typeof AuthedStudioSeriesRoute
|
||||
'/studio/style-presets': typeof AuthedStudioStylePresetsRoute
|
||||
'/studio/subtopics': typeof AuthedStudioSubtopicsRoute
|
||||
'/categories': typeof AuthedCategoriesIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/_authed': typeof AuthedRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/_authed/devices': typeof AuthedDevicesRoute
|
||||
'/_authed/locales': typeof AuthedLocalesRoute
|
||||
'/_authed/robots': typeof AuthedRobotsRoute
|
||||
'/_authed/sid': typeof AuthedSidRoute
|
||||
'/_authed/': typeof AuthedIndexRoute
|
||||
'/_authed/categories/pending': typeof AuthedCategoriesPendingRoute
|
||||
'/_authed/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute
|
||||
'/_authed/studio/articles': typeof AuthedStudioArticlesRoute
|
||||
'/_authed/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
||||
'/_authed/studio/robot-authors': typeof AuthedStudioRobotAuthorsRoute
|
||||
'/_authed/studio/series': typeof AuthedStudioSeriesRoute
|
||||
'/_authed/studio/style-presets': typeof AuthedStudioStylePresetsRoute
|
||||
'/_authed/studio/subtopics': typeof AuthedStudioSubtopicsRoute
|
||||
'/_authed/categories/': typeof AuthedCategoriesIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/about'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/devices'
|
||||
| '/locales'
|
||||
| '/robots'
|
||||
| '/sid'
|
||||
| '/categories/pending'
|
||||
| '/studio/agent-tokens'
|
||||
| '/studio/articles'
|
||||
| '/studio/category-briefs'
|
||||
| '/studio/robot-authors'
|
||||
| '/studio/series'
|
||||
| '/studio/style-presets'
|
||||
| '/studio/subtopics'
|
||||
| '/categories/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/about'
|
||||
id: '__root__' | '/' | '/about'
|
||||
to:
|
||||
| '/login'
|
||||
| '/devices'
|
||||
| '/locales'
|
||||
| '/robots'
|
||||
| '/sid'
|
||||
| '/'
|
||||
| '/categories/pending'
|
||||
| '/studio/agent-tokens'
|
||||
| '/studio/articles'
|
||||
| '/studio/category-briefs'
|
||||
| '/studio/robot-authors'
|
||||
| '/studio/series'
|
||||
| '/studio/style-presets'
|
||||
| '/studio/subtopics'
|
||||
| '/categories'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authed'
|
||||
| '/login'
|
||||
| '/_authed/devices'
|
||||
| '/_authed/locales'
|
||||
| '/_authed/robots'
|
||||
| '/_authed/sid'
|
||||
| '/_authed/'
|
||||
| '/_authed/categories/pending'
|
||||
| '/_authed/studio/agent-tokens'
|
||||
| '/_authed/studio/articles'
|
||||
| '/_authed/studio/category-briefs'
|
||||
| '/_authed/studio/robot-authors'
|
||||
| '/_authed/studio/series'
|
||||
| '/_authed/studio/style-presets'
|
||||
| '/_authed/studio/subtopics'
|
||||
| '/_authed/categories/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
AuthedRoute: typeof AuthedRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
fullPath: '/about'
|
||||
preLoaderRoute: typeof AboutRouteImport
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
'/_authed': {
|
||||
id: '/_authed'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthedRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authed/': {
|
||||
id: '/_authed/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
preLoaderRoute: typeof AuthedIndexRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/sid': {
|
||||
id: '/_authed/sid'
|
||||
path: '/sid'
|
||||
fullPath: '/sid'
|
||||
preLoaderRoute: typeof AuthedSidRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/robots': {
|
||||
id: '/_authed/robots'
|
||||
path: '/robots'
|
||||
fullPath: '/robots'
|
||||
preLoaderRoute: typeof AuthedRobotsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/locales': {
|
||||
id: '/_authed/locales'
|
||||
path: '/locales'
|
||||
fullPath: '/locales'
|
||||
preLoaderRoute: typeof AuthedLocalesRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/devices': {
|
||||
id: '/_authed/devices'
|
||||
path: '/devices'
|
||||
fullPath: '/devices'
|
||||
preLoaderRoute: typeof AuthedDevicesRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/categories/': {
|
||||
id: '/_authed/categories/'
|
||||
path: '/categories'
|
||||
fullPath: '/categories/'
|
||||
preLoaderRoute: typeof AuthedCategoriesIndexRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/studio/subtopics': {
|
||||
id: '/_authed/studio/subtopics'
|
||||
path: '/studio/subtopics'
|
||||
fullPath: '/studio/subtopics'
|
||||
preLoaderRoute: typeof AuthedStudioSubtopicsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/studio/style-presets': {
|
||||
id: '/_authed/studio/style-presets'
|
||||
path: '/studio/style-presets'
|
||||
fullPath: '/studio/style-presets'
|
||||
preLoaderRoute: typeof AuthedStudioStylePresetsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/studio/series': {
|
||||
id: '/_authed/studio/series'
|
||||
path: '/studio/series'
|
||||
fullPath: '/studio/series'
|
||||
preLoaderRoute: typeof AuthedStudioSeriesRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/studio/robot-authors': {
|
||||
id: '/_authed/studio/robot-authors'
|
||||
path: '/studio/robot-authors'
|
||||
fullPath: '/studio/robot-authors'
|
||||
preLoaderRoute: typeof AuthedStudioRobotAuthorsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/studio/category-briefs': {
|
||||
id: '/_authed/studio/category-briefs'
|
||||
path: '/studio/category-briefs'
|
||||
fullPath: '/studio/category-briefs'
|
||||
preLoaderRoute: typeof AuthedStudioCategoryBriefsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/studio/articles': {
|
||||
id: '/_authed/studio/articles'
|
||||
path: '/studio/articles'
|
||||
fullPath: '/studio/articles'
|
||||
preLoaderRoute: typeof AuthedStudioArticlesRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/studio/agent-tokens': {
|
||||
id: '/_authed/studio/agent-tokens'
|
||||
path: '/studio/agent-tokens'
|
||||
fullPath: '/studio/agent-tokens'
|
||||
preLoaderRoute: typeof AuthedStudioAgentTokensRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/categories/pending': {
|
||||
id: '/_authed/categories/pending'
|
||||
path: '/categories/pending'
|
||||
fullPath: '/categories/pending'
|
||||
preLoaderRoute: typeof AuthedCategoriesPendingRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthedRouteChildren {
|
||||
AuthedDevicesRoute: typeof AuthedDevicesRoute
|
||||
AuthedLocalesRoute: typeof AuthedLocalesRoute
|
||||
AuthedRobotsRoute: typeof AuthedRobotsRoute
|
||||
AuthedSidRoute: typeof AuthedSidRoute
|
||||
AuthedIndexRoute: typeof AuthedIndexRoute
|
||||
AuthedCategoriesPendingRoute: typeof AuthedCategoriesPendingRoute
|
||||
AuthedStudioAgentTokensRoute: typeof AuthedStudioAgentTokensRoute
|
||||
AuthedStudioArticlesRoute: typeof AuthedStudioArticlesRoute
|
||||
AuthedStudioCategoryBriefsRoute: typeof AuthedStudioCategoryBriefsRoute
|
||||
AuthedStudioRobotAuthorsRoute: typeof AuthedStudioRobotAuthorsRoute
|
||||
AuthedStudioSeriesRoute: typeof AuthedStudioSeriesRoute
|
||||
AuthedStudioStylePresetsRoute: typeof AuthedStudioStylePresetsRoute
|
||||
AuthedStudioSubtopicsRoute: typeof AuthedStudioSubtopicsRoute
|
||||
AuthedCategoriesIndexRoute: typeof AuthedCategoriesIndexRoute
|
||||
}
|
||||
|
||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||
AuthedDevicesRoute: AuthedDevicesRoute,
|
||||
AuthedLocalesRoute: AuthedLocalesRoute,
|
||||
AuthedRobotsRoute: AuthedRobotsRoute,
|
||||
AuthedSidRoute: AuthedSidRoute,
|
||||
AuthedIndexRoute: AuthedIndexRoute,
|
||||
AuthedCategoriesPendingRoute: AuthedCategoriesPendingRoute,
|
||||
AuthedStudioAgentTokensRoute: AuthedStudioAgentTokensRoute,
|
||||
AuthedStudioArticlesRoute: AuthedStudioArticlesRoute,
|
||||
AuthedStudioCategoryBriefsRoute: AuthedStudioCategoryBriefsRoute,
|
||||
AuthedStudioRobotAuthorsRoute: AuthedStudioRobotAuthorsRoute,
|
||||
AuthedStudioSeriesRoute: AuthedStudioSeriesRoute,
|
||||
AuthedStudioStylePresetsRoute: AuthedStudioStylePresetsRoute,
|
||||
AuthedStudioSubtopicsRoute: AuthedStudioSubtopicsRoute,
|
||||
AuthedCategoriesIndexRoute: AuthedCategoriesIndexRoute,
|
||||
}
|
||||
|
||||
const AuthedRouteWithChildren =
|
||||
AuthedRoute._addFileChildren(AuthedRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
AuthedRoute: AuthedRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user