From 61cb549e72754e1735ceb3bb41acc45585db5f8c Mon Sep 17 00:00:00 2001 From: kongkx Date: Tue, 7 Jul 2026 22:30:27 +0800 Subject: [PATCH] feat(studio): authors/series/articles/subtopics/briefs/style-presets + agent tokens (show-once) --- package.json | 3 +- scripts/gen-api.mjs | 14 + src/api/modules/studio.ts | 48 +++ src/api/types.gen.ts | 1 + .../categories/category-form-dialog.tsx | 4 +- src/features/studio/detail-sheet.tsx | 65 ++++ src/routeTree.gen.ts | 366 ++++++++++++++++-- src/routes/_authed/studio/agent-tokens.tsx | 126 ++++++ src/routes/_authed/studio/articles.tsx | 137 +++++++ src/routes/_authed/studio/category-briefs.tsx | 78 ++++ src/routes/_authed/studio/robot-authors.tsx | 144 +++++++ src/routes/_authed/studio/series.tsx | 76 ++++ src/routes/_authed/studio/style-presets.tsx | 100 +++++ src/routes/_authed/studio/subtopics.tsx | 54 +++ 14 files changed, 1184 insertions(+), 32 deletions(-) create mode 100644 scripts/gen-api.mjs create mode 100644 src/api/modules/studio.ts create mode 100644 src/features/studio/detail-sheet.tsx create mode 100644 src/routes/_authed/studio/agent-tokens.tsx create mode 100644 src/routes/_authed/studio/articles.tsx create mode 100644 src/routes/_authed/studio/category-briefs.tsx create mode 100644 src/routes/_authed/studio/robot-authors.tsx create mode 100644 src/routes/_authed/studio/series.tsx create mode 100644 src/routes/_authed/studio/style-presets.tsx create mode 100644 src/routes/_authed/studio/subtopics.tsx diff --git a/package.json b/package.json index be314a7..45888ca 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/gen-api.mjs b/scripts/gen-api.mjs new file mode 100644 index 0000000..f0bb10a --- /dev/null +++ b/scripts/gen-api.mjs @@ -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}`) diff --git a/src/api/modules/studio.ts b/src/api/modules/studio.ts new file mode 100644 index 0000000..94dad79 --- /dev/null +++ b/src/api/modules/studio.ts @@ -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>(`${BASE}/robot-authors`, params) + +export const fetchSeries = (params: ListParams) => + api.get>(`${BASE}/series`, params) + +export const fetchArticles = (params: ListParams) => + api.get>(`${BASE}/articles`, params) + +export const fetchSubtopics = (params: ListParams) => + api.get>(`${BASE}/subtopics`, params) + +export const fetchCategoryBriefs = (params: ListParams) => + api.get>(`${BASE}/category-briefs`, params) + +export const fetchStylePresets = (params: ListParams) => + api.get>(`${BASE}/style-presets`, params) + +export const updateRobotAuthor = (id: string, payload: Partial) => + api.put>(`${BASE}/robot-authors/${id}`, payload) + +// ---- agent tokens (show-once) ---- + +export const fetchAgentTokens = () => + api.get & { meta: { endpoint: string } }>(`${BASE}/agent/tokens`) + +export const issueAgentToken = () => + api.post>(`${BASE}/agent/tokens`) + +export const revokeAgentToken = (id: string) => + api.delete>(`${BASE}/agent/tokens/${id}`) diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 65676e5..50f2559 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -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. diff --git a/src/features/categories/category-form-dialog.tsx b/src/features/categories/category-form-dialog.tsx index 9864432..1dbbd6e 100644 --- a/src/features/categories/category-form-dialog.tsx +++ b/src/features/categories/category-form-dialog.tsx @@ -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 - + diff --git a/src/features/studio/detail-sheet.tsx b/src/features/studio/detail-sheet.tsx new file mode 100644 index 0000000..93f6d89 --- /dev/null +++ b/src/features/studio/detail-sheet.tsx @@ -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 ( + + + + {title} + {description && {description}} + +
+
+ {fields.map((field) => ( +
+
{field.label}
+
{field.value ?? '—'}
+
+ ))} +
+ {jsonBlocks + .filter((block) => block.value != null) + .map((block) => ( +
+
{block.label}
+
+                  {typeof block.value === 'string'
+                    ? block.value
+                    : JSON.stringify(block.value, null, 2)}
+                
+
+ ))} +
+
+
+ ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 421daf2..bfd873e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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) diff --git a/src/routes/_authed/studio/agent-tokens.tsx b/src/routes/_authed/studio/agent-tokens.tsx new file mode 100644 index 0000000..ff7589f --- /dev/null +++ b/src/routes/_authed/studio/agent-tokens.tsx @@ -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(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[] = [ + { + header: '令牌 ID', + cell: ({ row }) => ( + {row.original.id.slice(0, 16)}… + ), + }, + { + header: 'Scopes', + cell: ({ row }) => ( + {row.original.scopes?.join(' ') || '—'} + ), + }, + { + 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 }) => ( + + 吊销 + + } + title="吊销该 Agent 令牌?" + description="吊销后使用该令牌的 Agent 会立即收到 401,需重新签发并配置。" + confirmLabel="吊销" + destructive + onConfirm={() => revoke(row.original)} + /> + ), + }, + ] + + const endpoint = (tokens.data?.meta as { endpoint?: string } | undefined)?.endpoint + + return ( +
+ + + {busy ? '签发中…' : '签发新令牌'} + + } + /> + + !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 "` + : undefined + } + /> +
+ ) +} diff --git a/src/routes/_authed/studio/articles.tsx b/src/routes/_authed/studio/articles.tsx new file mode 100644 index 0000000..39a4ea4 --- /dev/null +++ b/src/routes/_authed/studio/articles.tsx @@ -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(null) + const list = useListPage( + 'studio-articles', + fetchArticles, + status === 'all' ? {} : { status }, + ) + + const columns: ColumnDef[] = [ + { + header: '文章', + cell: ({ row }) => ( +
+
+ {row.original.title ?? '(未命名)'} +
+ + 系列第 {row.original.article_number} 篇 + +
+ ), + }, + { header: '状态', cell: ({ row }) => }, + { + header: 'Dimzou', + cell: ({ row }) => + row.original.dimzou_publication_id ? ( + + doc #{row.original.dimzou_document_id} · pub #{row.original.dimzou_publication_id} + + ) : row.original.dimzou_document_id ? ( + 草稿 doc #{row.original.dimzou_document_id} + ) : ( + 未建稿 + ), + }, + { + header: '更新时间', + cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'), + }, + { + header: '操作', + cell: ({ row }) => ( + + ), + }, + ] + + return ( +
+ + + + + + 全部状态 + {STATUSES.map((s) => ( + + + + ))} + + + } + /> + + !open && setDetail(null)} + title={detail?.title ?? '文章详情'} + description={detail?.summary ?? undefined} + fields={[ + { label: 'ID', value: {detail?.id} }, + { label: '状态', value: detail && }, + { 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 }, + ]} + /> +
+ ) +} diff --git a/src/routes/_authed/studio/category-briefs.tsx b/src/routes/_authed/studio/category-briefs.tsx new file mode 100644 index 0000000..9502a91 --- /dev/null +++ b/src/routes/_authed/studio/category-briefs.tsx @@ -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(null) + const list = useListPage('studio-category-briefs', fetchCategoryBriefs) + + const columns: ColumnDef[] = [ + { + header: '分类简报', + cell: ({ row }) => ( +
{row.original.description ?? '(无描述)'}
+ ), + }, + { 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 }) => ( + + ), + }, + ] + + return ( +
+ + + !open && setDetail(null)} + title="分类简报" + fields={[ + { + label: '分类', + value: {detail?.category_id}, + }, + { 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 }]} + /> +
+ ) +} diff --git a/src/routes/_authed/studio/robot-authors.tsx b/src/routes/_authed/studio/robot-authors.tsx new file mode 100644 index 0000000..54f6780 --- /dev/null +++ b/src/routes/_authed/studio/robot-authors.tsx @@ -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(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[] = [ + { + header: '作者', + cell: ({ row }) => ( +
+
+ {row.original.persona?.slice(0, 40) || '(人设待补全)'} +
+ {row.original.user_uid} +
+ ), + }, + { + header: '来源', + cell: ({ row }) => ( + {row.original.source === 'user' ? '按用户' : '按分类'} + ), + }, + { header: '状态', cell: ({ row }) => }, + { + header: '更新时间', + cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'), + }, + { + header: '操作', + cell: ({ row }) => ( +
+ + {row.original.status === 'paused' ? ( + + ) : ( + + )} +
+ ), + }, + ] + + return ( +
+ + + + + + 全部状态 + {STATUSES.map((s) => ( + + + + ))} + + + } + /> + + !open && setDetail(null)} + title="机器人作者详情" + fields={[ + { label: 'ID', value: {detail?.id} }, + { label: '用户 UID', value: {detail?.user_uid} }, + { label: '状态', value: detail && }, + { 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 }, + ]} + /> +
+ ) +} diff --git a/src/routes/_authed/studio/series.tsx b/src/routes/_authed/studio/series.tsx new file mode 100644 index 0000000..165709a --- /dev/null +++ b/src/routes/_authed/studio/series.tsx @@ -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(null) + const list = useListPage('studio-series', fetchSeries) + + const columns: ColumnDef[] = [ + { + header: '系列目标', + cell: ({ row }) => ( +
{row.original.goal ?? '(未命名系列)'}
+ ), + }, + { + header: '计划篇数', + cell: ({ row }) => row.original.target_article_count ?? '—', + }, + { header: '状态', cell: ({ row }) => }, + { + header: '更新时间', + cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'), + }, + { + header: '操作', + cell: ({ row }) => ( + + ), + }, + ] + + return ( +
+ + + !open && setDetail(null)} + title={detail?.goal ?? '系列详情'} + fields={[ + { label: 'ID', value: {detail?.id} }, + { label: '状态', value: detail && }, + { label: '计划篇数', value: detail?.target_article_count }, + { label: '读者旅程', value: detail?.reader_journey }, + { + label: '作者', + value: {detail?.robot_author_id}, + }, + ]} + jsonBlocks={[{ label: '计划文章列表', value: detail?.planned_articles }]} + /> +
+ ) +} diff --git a/src/routes/_authed/studio/style-presets.tsx b/src/routes/_authed/studio/style-presets.tsx new file mode 100644 index 0000000..bbb78a7 --- /dev/null +++ b/src/routes/_authed/studio/style-presets.tsx @@ -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 = { + article: '文章风格', + image: '图片风格', + bundle: '组合', +} + +function StylePresetsPage() { + const [type, setType] = useState('all') + const [detail, setDetail] = useState(null) + const list = useListPage( + 'studio-style-presets', + fetchStylePresets, + type === 'all' ? {} : { type }, + ) + + const columns: ColumnDef[] = [ + { header: '名称', cell: ({ row }) => {row.original.name} }, + { + header: '类型', + cell: ({ row }) => ( + {TYPE_LABELS[row.original.type] ?? row.original.type} + ), + }, + { + header: '更新时间', + cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'), + }, + { + header: '操作', + cell: ({ row }) => ( + + ), + }, + ] + + return ( +
+ + + + + + 全部类型 + 文章风格 + 图片风格 + 组合 + + + } + /> + + !open && setDetail(null)} + title={detail?.name ?? '风格预设'} + fields={[ + { label: '类型', value: detail && (TYPE_LABELS[detail.type] ?? detail.type) }, + { label: 'ID', value: {detail?.id} }, + ]} + jsonBlocks={[{ label: '风格定义 payload', value: detail?.payload }]} + /> +
+ ) +} diff --git a/src/routes/_authed/studio/subtopics.tsx b/src/routes/_authed/studio/subtopics.tsx new file mode 100644 index 0000000..e1bf145 --- /dev/null +++ b/src/routes/_authed/studio/subtopics.tsx @@ -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[] = [ + { + header: '子话题', + cell: ({ row }) => ( +
+
{row.original.title}
+
+ {row.original.target_audience ?? ''} +
+
+ ), + }, + { + header: '可扩展性', + cell: ({ row }) => + row.original.expandability_score != null ? `${row.original.expandability_score}/10` : '—', + }, + { header: '状态', cell: ({ row }) => }, + { + header: '更新时间', + cell: ({ row }) => new Date(row.original.updated_at).toLocaleString('zh-CN'), + }, +] + +function SubtopicsPage() { + const list = useListPage('studio-subtopics', fetchSubtopics) + + return ( +
+ + +
+ ) +}