feat(db-studio): add gated entry to the database console

Adds 开发工具 → 数据库管理台, which exchanges the admin bearer token for a
single-use entry link (POST /api/admin/v1/internal-surfaces/db-studio/session)
and opens it in a new tab. The link sets an HttpOnly session cookie on the
console's own hostname, so it has to be opened by the browser rather than
fetched — the nginx gate in front of the console then re-checks the permission
on every request.

The tab is opened synchronously inside the click handler, before the request
resolves, or the popup blocker kills it; `noopener` is unusable there since it
makes window.open return null, so the opener is detached manually.

Gated on auth:db-studio:access. That key is `auth:`-prefixed rather than
`admin:` on purpose — PermissionSeeder syncs the admin role to every `admin:%`
key, which would grant database-owner SQL access to every admin account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 09:39:51 +08:00
parent ae29e7b2fc
commit 6ebf21ddd6
5 changed files with 403 additions and 274 deletions

View File

@ -0,0 +1,29 @@
import { api } from '@/api/client'
import type { ApiResponse } from '@/api/types'
/**
* Operator consoles that live outside Laravel (database admin, ...) but are
* gated by our own admin permissions. The backend registry is
* config/internal_surfaces.php; the surface id must match a key there.
*/
export type InternalSurface = 'db-studio'
export interface InternalSurfaceSession {
kind: 'internal_surface_session'
surface: InternalSurface
label: string
/** single-use entry link — redeem it in the browser before it expires */
url: string
expires_in: number
}
/**
* Exchanges the admin bearer token for a single-use entry link. The link sets
* an HttpOnly session cookie on the surface's own hostname, so it must be
* opened in the browser rather than fetched.
*/
export function openInternalSurface(surface: InternalSurface) {
return api.post<ApiResponse<InternalSurfaceSession>>(
`/api/admin/v1/internal-surfaces/${surface}/session`,
)
}

View File

@ -31,6 +31,9 @@ export const PERM = {
NOTIFICATION_READ: 'admin:notification:read',
ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue',
API_DOCS_READ: 'auth:api-docs:read',
// internal operator consoles — `auth:` prefixed, so super-admin only
// (the `admin` role is synced to admin:% keys and would otherwise inherit it)
DB_STUDIO_ACCESS: 'auth:db-studio:access',
// role management — only super-admin holds these
ROLE_READ: 'auth:role:read',
ROLE_CREATE: 'auth:role:create',

View File

@ -4,6 +4,7 @@ import {
BookOpenText,
Bot,
ClipboardList,
Database,
FileText,
FileJson,
Flag,
@ -110,6 +111,12 @@ export const NAV_GROUPS: NavGroup[] = [
icon: FileJson,
permission: 'auth:api-docs:read',
},
{
label: '数据库管理台',
to: '/db-studio',
icon: Database,
permission: 'auth:db-studio:access',
},
],
},
]

View File

@ -9,38 +9,40 @@
// 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 AuthedRouteImport } from './routes/_authed'
import { Route as ApiDocsRouteImport } from './routes/api-docs'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ApiDocsRouteImport } from './routes/api-docs'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users'
import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-reports'
import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales'
import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots'
import { Route as AuthedRolesRouteImport } from './routes/_authed/roles'
import { Route as AuthedSidRouteImport } from './routes/_authed/sid'
import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index'
import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending'
import { Route as AuthedDimzouDocumentsRouteImport } from './routes/_authed/dimzou/documents'
import { Route as AuthedDimzouPublicationsRouteImport } from './routes/_authed/dimzou/publications'
import { Route as AuthedExcDemandsRouteImport } from './routes/_authed/exc/demands'
import { Route as AuthedExcDispatchesRouteImport } from './routes/_authed/exc/dispatches'
import { Route as AuthedExcOrdersRouteImport } from './routes/_authed/exc/orders'
import { Route as AuthedExcProvidersRouteImport } from './routes/_authed/exc/providers'
import { Route as AuthedStudioAgentTokensRouteImport } from './routes/_authed/studio/agent-tokens'
import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles'
import { Route as AuthedStudioCategoryBriefsRouteImport } from './routes/_authed/studio/category-briefs'
import { Route as AuthedStudioRobotAuthorsRouteImport } from './routes/_authed/studio/robot-authors'
import { Route as AuthedStudioSeriesRouteImport } from './routes/_authed/studio/series'
import { Route as AuthedStudioStylePresetsRouteImport } from './routes/_authed/studio/style-presets'
import { Route as AuthedStudioSubtopicsRouteImport } from './routes/_authed/studio/subtopics'
import { Route as AuthedRolesRouteImport } from './routes/_authed/roles'
import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots'
import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales'
import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-reports'
import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
import { Route as AuthedDbStudioRouteImport } from './routes/_authed/db-studio'
import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users'
import { Route as AuthedUsersIndexRouteImport } from './routes/_authed/users/index'
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 AuthedExcProvidersRouteImport } from './routes/_authed/exc/providers'
import { Route as AuthedExcOrdersRouteImport } from './routes/_authed/exc/orders'
import { Route as AuthedExcDispatchesRouteImport } from './routes/_authed/exc/dispatches'
import { Route as AuthedExcDemandsRouteImport } from './routes/_authed/exc/demands'
import { Route as AuthedDimzouPublicationsRouteImport } from './routes/_authed/dimzou/publications'
import { Route as AuthedDimzouDocumentsRouteImport } from './routes/_authed/dimzou/documents'
import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending'
const AuthedRoute = AuthedRouteImport.update({
id: '/_authed',
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const ApiDocsRoute = ApiDocsRouteImport.update({
@ -48,9 +50,8 @@ const ApiDocsRoute = ApiDocsRouteImport.update({
path: '/api-docs',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
const AuthedRoute = AuthedRouteImport.update({
id: '/_authed',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedIndexRoute = AuthedIndexRouteImport.update({
@ -58,19 +59,29 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminUsersRoute = AuthedAdminUsersRouteImport.update({
id: '/admin-users',
path: '/admin-users',
const AuthedSidRoute = AuthedSidRouteImport.update({
id: '/sid',
path: '/sid',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({
id: '/comment-reports',
path: '/comment-reports',
const AuthedRolesRoute = AuthedRolesRouteImport.update({
id: '/roles',
path: '/roles',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDevicesRoute = AuthedDevicesRouteImport.update({
id: '/devices',
path: '/devices',
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 AuthedEventReportsRoute = AuthedEventReportsRouteImport.update({
id: '/event-reports',
path: '/event-reports',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouTranslationsRoute =
@ -79,29 +90,29 @@ const AuthedDimzouTranslationsRoute =
path: '/dimzou-translations',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedEventReportsRoute = AuthedEventReportsRouteImport.update({
id: '/event-reports',
path: '/event-reports',
const AuthedDevicesRoute = AuthedDevicesRouteImport.update({
id: '/devices',
path: '/devices',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedLocalesRoute = AuthedLocalesRouteImport.update({
id: '/locales',
path: '/locales',
const AuthedDbStudioRoute = AuthedDbStudioRouteImport.update({
id: '/db-studio',
path: '/db-studio',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedRobotsRoute = AuthedRobotsRouteImport.update({
id: '/robots',
path: '/robots',
const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({
id: '/comment-reports',
path: '/comment-reports',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedRolesRoute = AuthedRolesRouteImport.update({
id: '/roles',
path: '/roles',
const AuthedAdminUsersRoute = AuthedAdminUsersRouteImport.update({
id: '/admin-users',
path: '/admin-users',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedSidRoute = AuthedSidRouteImport.update({
id: '/sid',
path: '/sid',
const AuthedUsersIndexRoute = AuthedUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({
@ -109,67 +120,9 @@ const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({
path: '/categories/',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({
id: '/categories/pending',
path: '/categories/pending',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouDocumentsRoute = AuthedDimzouDocumentsRouteImport.update({
id: '/dimzou/documents',
path: '/dimzou/documents',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouPublicationsRoute =
AuthedDimzouPublicationsRouteImport.update({
id: '/dimzou/publications',
path: '/dimzou/publications',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDemandsRoute = AuthedExcDemandsRouteImport.update({
id: '/exc/demands',
path: '/exc/demands',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDispatchesRoute = AuthedExcDispatchesRouteImport.update({
id: '/exc/dispatches',
path: '/exc/dispatches',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcOrdersRoute = AuthedExcOrdersRouteImport.update({
id: '/exc/orders',
path: '/exc/orders',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcProvidersRoute = AuthedExcProvidersRouteImport.update({
id: '/exc/providers',
path: '/exc/providers',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioAgentTokensRoute = AuthedStudioAgentTokensRouteImport.update({
id: '/studio/agent-tokens',
path: '/studio/agent-tokens',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioArticlesRoute = AuthedStudioArticlesRouteImport.update({
id: '/studio/articles',
path: '/studio/articles',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioCategoryBriefsRoute =
AuthedStudioCategoryBriefsRouteImport.update({
id: '/studio/category-briefs',
path: '/studio/category-briefs',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioRobotAuthorsRoute =
AuthedStudioRobotAuthorsRouteImport.update({
id: '/studio/robot-authors',
path: '/studio/robot-authors',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioSeriesRoute = AuthedStudioSeriesRouteImport.update({
id: '/studio/series',
path: '/studio/series',
const AuthedStudioSubtopicsRoute = AuthedStudioSubtopicsRouteImport.update({
id: '/studio/subtopics',
path: '/studio/subtopics',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioStylePresetsRoute =
@ -178,14 +131,67 @@ const AuthedStudioStylePresetsRoute =
path: '/studio/style-presets',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioSubtopicsRoute = AuthedStudioSubtopicsRouteImport.update({
id: '/studio/subtopics',
path: '/studio/subtopics',
const AuthedStudioSeriesRoute = AuthedStudioSeriesRouteImport.update({
id: '/studio/series',
path: '/studio/series',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedUsersIndexRoute = AuthedUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
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 AuthedExcProvidersRoute = AuthedExcProvidersRouteImport.update({
id: '/exc/providers',
path: '/exc/providers',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcOrdersRoute = AuthedExcOrdersRouteImport.update({
id: '/exc/orders',
path: '/exc/orders',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDispatchesRoute = AuthedExcDispatchesRouteImport.update({
id: '/exc/dispatches',
path: '/exc/dispatches',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDemandsRoute = AuthedExcDemandsRouteImport.update({
id: '/exc/demands',
path: '/exc/demands',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouPublicationsRoute =
AuthedDimzouPublicationsRouteImport.update({
id: '/dimzou/publications',
path: '/dimzou/publications',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouDocumentsRoute = AuthedDimzouDocumentsRouteImport.update({
id: '/dimzou/documents',
path: '/dimzou/documents',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({
id: '/categories/pending',
path: '/categories/pending',
getParentRoute: () => AuthedRoute,
} as any)
@ -195,6 +201,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/admin-users': typeof AuthedAdminUsersRoute
'/comment-reports': typeof AuthedCommentReportsRoute
'/db-studio': typeof AuthedDbStudioRoute
'/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/event-reports': typeof AuthedEventReportsRoute
@ -224,6 +231,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/admin-users': typeof AuthedAdminUsersRoute
'/comment-reports': typeof AuthedCommentReportsRoute
'/db-studio': typeof AuthedDbStudioRoute
'/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/event-reports': typeof AuthedEventReportsRoute
@ -256,6 +264,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute
'/_authed/admin-users': typeof AuthedAdminUsersRoute
'/_authed/comment-reports': typeof AuthedCommentReportsRoute
'/_authed/db-studio': typeof AuthedDbStudioRoute
'/_authed/devices': typeof AuthedDevicesRoute
'/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/_authed/event-reports': typeof AuthedEventReportsRoute
@ -289,6 +298,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin-users'
| '/comment-reports'
| '/db-studio'
| '/devices'
| '/dimzou-translations'
| '/event-reports'
@ -318,6 +328,7 @@ export interface FileRouteTypes {
| '/login'
| '/admin-users'
| '/comment-reports'
| '/db-studio'
| '/devices'
| '/dimzou-translations'
| '/event-reports'
@ -349,6 +360,7 @@ export interface FileRouteTypes {
| '/login'
| '/_authed/admin-users'
| '/_authed/comment-reports'
| '/_authed/db-studio'
| '/_authed/devices'
| '/_authed/dimzou-translations'
| '/_authed/event-reports'
@ -383,11 +395,11 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_authed': {
id: '/_authed'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthedRouteImport
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/api-docs': {
@ -397,11 +409,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiDocsRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
'/_authed': {
id: '/_authed'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthedRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/': {
@ -411,53 +423,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedIndexRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin-users': {
id: '/_authed/admin-users'
path: '/admin-users'
fullPath: '/admin-users'
preLoaderRoute: typeof AuthedAdminUsersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/comment-reports': {
id: '/_authed/comment-reports'
path: '/comment-reports'
fullPath: '/comment-reports'
preLoaderRoute: typeof AuthedCommentReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/devices': {
id: '/_authed/devices'
path: '/devices'
fullPath: '/devices'
preLoaderRoute: typeof AuthedDevicesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou-translations': {
id: '/_authed/dimzou-translations'
path: '/dimzou-translations'
fullPath: '/dimzou-translations'
preLoaderRoute: typeof AuthedDimzouTranslationsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/event-reports': {
id: '/_authed/event-reports'
path: '/event-reports'
fullPath: '/event-reports'
preLoaderRoute: typeof AuthedEventReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/locales': {
id: '/_authed/locales'
path: '/locales'
fullPath: '/locales'
preLoaderRoute: typeof AuthedLocalesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/robots': {
id: '/_authed/robots'
path: '/robots'
fullPath: '/robots'
preLoaderRoute: typeof AuthedRobotsRouteImport
'/_authed/sid': {
id: '/_authed/sid'
path: '/sid'
fullPath: '/sid'
preLoaderRoute: typeof AuthedSidRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/roles': {
@ -467,116 +437,60 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedRolesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/sid': {
id: '/_authed/sid'
path: '/sid'
fullPath: '/sid'
preLoaderRoute: typeof AuthedSidRouteImport
'/_authed/robots': {
id: '/_authed/robots'
path: '/robots'
fullPath: '/robots'
preLoaderRoute: typeof AuthedRobotsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/categories/': {
id: '/_authed/categories/'
path: '/categories'
fullPath: '/categories/'
preLoaderRoute: typeof AuthedCategoriesIndexRouteImport
'/_authed/locales': {
id: '/_authed/locales'
path: '/locales'
fullPath: '/locales'
preLoaderRoute: typeof AuthedLocalesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/categories/pending': {
id: '/_authed/categories/pending'
path: '/categories/pending'
fullPath: '/categories/pending'
preLoaderRoute: typeof AuthedCategoriesPendingRouteImport
'/_authed/event-reports': {
id: '/_authed/event-reports'
path: '/event-reports'
fullPath: '/event-reports'
preLoaderRoute: typeof AuthedEventReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou/documents': {
id: '/_authed/dimzou/documents'
path: '/dimzou/documents'
fullPath: '/dimzou/documents'
preLoaderRoute: typeof AuthedDimzouDocumentsRouteImport
'/_authed/dimzou-translations': {
id: '/_authed/dimzou-translations'
path: '/dimzou-translations'
fullPath: '/dimzou-translations'
preLoaderRoute: typeof AuthedDimzouTranslationsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou/publications': {
id: '/_authed/dimzou/publications'
path: '/dimzou/publications'
fullPath: '/dimzou/publications'
preLoaderRoute: typeof AuthedDimzouPublicationsRouteImport
'/_authed/devices': {
id: '/_authed/devices'
path: '/devices'
fullPath: '/devices'
preLoaderRoute: typeof AuthedDevicesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/demands': {
id: '/_authed/exc/demands'
path: '/exc/demands'
fullPath: '/exc/demands'
preLoaderRoute: typeof AuthedExcDemandsRouteImport
'/_authed/db-studio': {
id: '/_authed/db-studio'
path: '/db-studio'
fullPath: '/db-studio'
preLoaderRoute: typeof AuthedDbStudioRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/dispatches': {
id: '/_authed/exc/dispatches'
path: '/exc/dispatches'
fullPath: '/exc/dispatches'
preLoaderRoute: typeof AuthedExcDispatchesRouteImport
'/_authed/comment-reports': {
id: '/_authed/comment-reports'
path: '/comment-reports'
fullPath: '/comment-reports'
preLoaderRoute: typeof AuthedCommentReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/orders': {
id: '/_authed/exc/orders'
path: '/exc/orders'
fullPath: '/exc/orders'
preLoaderRoute: typeof AuthedExcOrdersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/providers': {
id: '/_authed/exc/providers'
path: '/exc/providers'
fullPath: '/exc/providers'
preLoaderRoute: typeof AuthedExcProvidersRouteImport
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/studio/articles': {
id: '/_authed/studio/articles'
path: '/studio/articles'
fullPath: '/studio/articles'
preLoaderRoute: typeof AuthedStudioArticlesRouteImport
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/robot-authors': {
id: '/_authed/studio/robot-authors'
path: '/studio/robot-authors'
fullPath: '/studio/robot-authors'
preLoaderRoute: typeof AuthedStudioRobotAuthorsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/series': {
id: '/_authed/studio/series'
path: '/studio/series'
fullPath: '/studio/series'
preLoaderRoute: typeof AuthedStudioSeriesRouteImport
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/subtopics': {
id: '/_authed/studio/subtopics'
path: '/studio/subtopics'
fullPath: '/studio/subtopics'
preLoaderRoute: typeof AuthedStudioSubtopicsRouteImport
'/_authed/admin-users': {
id: '/_authed/admin-users'
path: '/admin-users'
fullPath: '/admin-users'
preLoaderRoute: typeof AuthedAdminUsersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/users/': {
@ -586,12 +500,118 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedUsersIndexRouteImport
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/exc/providers': {
id: '/_authed/exc/providers'
path: '/exc/providers'
fullPath: '/exc/providers'
preLoaderRoute: typeof AuthedExcProvidersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/orders': {
id: '/_authed/exc/orders'
path: '/exc/orders'
fullPath: '/exc/orders'
preLoaderRoute: typeof AuthedExcOrdersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/dispatches': {
id: '/_authed/exc/dispatches'
path: '/exc/dispatches'
fullPath: '/exc/dispatches'
preLoaderRoute: typeof AuthedExcDispatchesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/demands': {
id: '/_authed/exc/demands'
path: '/exc/demands'
fullPath: '/exc/demands'
preLoaderRoute: typeof AuthedExcDemandsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou/publications': {
id: '/_authed/dimzou/publications'
path: '/dimzou/publications'
fullPath: '/dimzou/publications'
preLoaderRoute: typeof AuthedDimzouPublicationsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou/documents': {
id: '/_authed/dimzou/documents'
path: '/dimzou/documents'
fullPath: '/dimzou/documents'
preLoaderRoute: typeof AuthedDimzouDocumentsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/categories/pending': {
id: '/_authed/categories/pending'
path: '/categories/pending'
fullPath: '/categories/pending'
preLoaderRoute: typeof AuthedCategoriesPendingRouteImport
parentRoute: typeof AuthedRoute
}
}
}
interface AuthedRouteChildren {
AuthedAdminUsersRoute: typeof AuthedAdminUsersRoute
AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute
AuthedDbStudioRoute: typeof AuthedDbStudioRoute
AuthedDevicesRoute: typeof AuthedDevicesRoute
AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute
AuthedEventReportsRoute: typeof AuthedEventReportsRoute
@ -621,6 +641,7 @@ interface AuthedRouteChildren {
const AuthedRouteChildren: AuthedRouteChildren = {
AuthedAdminUsersRoute: AuthedAdminUsersRoute,
AuthedCommentReportsRoute: AuthedCommentReportsRoute,
AuthedDbStudioRoute: AuthedDbStudioRoute,
AuthedDevicesRoute: AuthedDevicesRoute,
AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute,
AuthedEventReportsRoute: AuthedEventReportsRoute,

View File

@ -0,0 +1,69 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { openInternalSurface } from '@/api/modules/internal-surfaces'
import { PERM, requirePermission } from '@/auth/permissions'
import { handleApiError } from '@/lib/errors'
import { PageHeader } from '@/components/page-header'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export const Route = createFileRoute('/_authed/db-studio')({
beforeLoad: () => requirePermission(PERM.DB_STUDIO_ACCESS),
component: DbStudioPage,
})
function DbStudioPage() {
const [busy, setBusy] = useState(false)
const open = async () => {
setBusy(true)
// The tab must be opened synchronously inside the click handler, or the
// popup blocker kills it once the request resolves. `noopener` is not
// usable here (it makes window.open return null), so detach manually.
const tab = window.open('about:blank', '_blank')
try {
const res = await openInternalSurface('db-studio')
if (tab) {
tab.opener = null
tab.location.replace(res.data.url)
} else {
// popup blocked — fall back to navigating this tab
window.location.assign(res.data.url)
}
} catch (error) {
tab?.close()
handleApiError(error)
} finally {
setBusy(false)
}
}
return (
<div>
<PageHeader
title="数据库管理台"
description="在浏览器中查询与编辑生产数据库Supabase Studio"
/>
<Card className="max-w-lg">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
60 30
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground text-sm">
SQL
使使
</p>
<Button onClick={open} disabled={busy}>
{busy ? '正在打开…' : '打开数据库管理台'}
</Button>
</CardContent>
</Card>
</div>
)
}