feat(auth): wire permission gating through menu, routes and actions
The store's can()/useCan() helpers existed but were unused. Now: - src/auth/permissions.ts PERM catalog mirrors backend keys - every nav item carries a permission; sidebar filter reuses can() - requirePermission() beforeLoad guard on all child routes (direct URLs redirect home when the session lacks the key) - useCan() hides/disables mutation buttons: moderation actions, retranslate, robot-author edit/pause, category + locale CRUD, and the reserved-SID assign path (admin:sid:override hint) - 403 responses toast 无权限执行此操作; can() unit tests added Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+1192
-44
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
import { redirect } from '@tanstack/react-router'
|
||||||
|
import { can, useAuthStore } from '@/auth/store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin permission keys — mirrors the backend catalog
|
||||||
|
* (Modules/{Module}/app/Permissions/{Module}Permissions.php).
|
||||||
|
*/
|
||||||
|
export const PERM = {
|
||||||
|
CATEGORY_READ: 'admin:category:read',
|
||||||
|
CATEGORY_CREATE: 'admin:category:create',
|
||||||
|
CATEGORY_UPDATE: 'admin:category:update',
|
||||||
|
CATEGORY_DELETE: 'admin:category:delete',
|
||||||
|
CATEGORY_APPROVE: 'admin:category:approve',
|
||||||
|
COMMENT_READ: 'admin:comment:read',
|
||||||
|
COMMENT_MODERATE: 'admin:comment:moderate',
|
||||||
|
FILEX_READ: 'admin:filex:read',
|
||||||
|
FILEX_MODERATE: 'admin:filex:moderate',
|
||||||
|
DIMZOU_READ: 'admin:dimzou:read',
|
||||||
|
DIMZOU_RETRANSLATE: 'admin:dimzou:retranslate',
|
||||||
|
EXC_READ: 'admin:exc:read',
|
||||||
|
STUDIO_READ: 'admin:studio:read',
|
||||||
|
STUDIO_MANAGE: 'admin:studio:manage',
|
||||||
|
STUDIO_AGENT_TOKEN_ISSUE: 'admin:studio:agent-token:issue',
|
||||||
|
LOCALE_READ: 'admin:locale:read',
|
||||||
|
LOCALE_CREATE: 'admin:locale:create',
|
||||||
|
LOCALE_UPDATE: 'admin:locale:update',
|
||||||
|
LOCALE_DELETE: 'admin:locale:delete',
|
||||||
|
SID_MANAGE: 'admin:sid:manage',
|
||||||
|
SID_OVERRIDE: 'admin:sid:override',
|
||||||
|
NOTIFICATION_READ: 'admin:notification:read',
|
||||||
|
ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue',
|
||||||
|
// role management — only super-admin holds these
|
||||||
|
ROLE_READ: 'auth:role:read',
|
||||||
|
ROLE_CREATE: 'auth:role:create',
|
||||||
|
ROLE_UPDATE: 'auth:role:update',
|
||||||
|
ROLE_DELETE: 'auth:role:delete',
|
||||||
|
ROLE_ASSIGN: 'auth:role:assign',
|
||||||
|
PERMISSION_READ: 'auth:permission:read',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Permission = (typeof PERM)[keyof typeof PERM]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route `beforeLoad` guard. Redirects home when the session is loaded and
|
||||||
|
* lacks the permission; lets the request through while the session is still
|
||||||
|
* empty (the API's 403 is the backstop, and /auth/me refreshes the store).
|
||||||
|
*/
|
||||||
|
export function requirePermission(permission: Permission): void {
|
||||||
|
const { user } = useAuthStore.getState()
|
||||||
|
if (user && !can(permission)) {
|
||||||
|
throw redirect({ to: '/' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { can, hasRole, useAuthStore } from '@/auth/store'
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
kind: 'user' as const,
|
||||||
|
uid: '01TEST',
|
||||||
|
username: 'tester',
|
||||||
|
email: '[email protected]',
|
||||||
|
phone: null,
|
||||||
|
display_name: '测试',
|
||||||
|
avatar: null,
|
||||||
|
status: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('can()', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.getState().clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('denies when the permission is missing', () => {
|
||||||
|
useAuthStore.getState().setSession(user, ['admin'], ['admin:category:read'])
|
||||||
|
expect(can('admin:category:read')).toBe(true)
|
||||||
|
expect(can('admin:sid:manage')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('super-admin passes every permission check', () => {
|
||||||
|
useAuthStore.getState().setSession(user, ['super-admin'], [])
|
||||||
|
expect(can('admin:sid:manage')).toBe(true)
|
||||||
|
expect(can('anything:at:all')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hasRole matches exact role names', () => {
|
||||||
|
useAuthStore.getState().setSession(user, ['admin'], [])
|
||||||
|
expect(hasRole('admin')).toBe(true)
|
||||||
|
expect(hasRole('super-admin')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
|
BookOpenText,
|
||||||
Bot,
|
Bot,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
Flag,
|
Flag,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Hash,
|
Hash,
|
||||||
@@ -16,9 +18,11 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Send,
|
Send,
|
||||||
|
Shield,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -38,19 +42,19 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
label: '分类管理',
|
label: '分类管理',
|
||||||
items: [
|
items: [
|
||||||
{ label: '分类树', to: '/categories', icon: FolderTree },
|
{ label: '分类树', to: '/categories', icon: FolderTree, permission: 'admin:category:read' },
|
||||||
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks },
|
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks, permission: 'admin:category:approve' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '内容工作室',
|
label: '内容工作室',
|
||||||
items: [
|
items: [
|
||||||
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot },
|
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot, permission: 'admin:studio:read' },
|
||||||
{ label: '文章系列', to: '/studio/series', icon: LayoutList },
|
{ label: '文章系列', to: '/studio/series', icon: LayoutList, permission: 'admin:studio:read' },
|
||||||
{ label: '文章', to: '/studio/articles', icon: Newspaper },
|
{ label: '文章', to: '/studio/articles', icon: Newspaper, permission: 'admin:studio:read' },
|
||||||
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles },
|
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles, permission: 'admin:studio:read' },
|
||||||
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText },
|
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText, permission: 'admin:studio:read' },
|
||||||
{ label: '风格预设', to: '/studio/style-presets', icon: Palette },
|
{ label: '风格预设', to: '/studio/style-presets', icon: Palette, permission: 'admin:studio:read' },
|
||||||
{
|
{
|
||||||
label: 'Agent 接入',
|
label: 'Agent 接入',
|
||||||
to: '/studio/agent-tokens',
|
to: '/studio/agent-tokens',
|
||||||
@@ -62,27 +66,36 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
label: '专长交易',
|
label: '专长交易',
|
||||||
items: [
|
items: [
|
||||||
{ label: '需求监控', to: '/exc/demands', icon: ClipboardList },
|
{ label: '需求监控', to: '/exc/demands', icon: ClipboardList, permission: 'admin:exc:read' },
|
||||||
{ label: '派单监控', to: '/exc/dispatches', icon: Send },
|
{ label: '派单监控', to: '/exc/dispatches', icon: Send, permission: 'admin:exc:read' },
|
||||||
{ label: '订单监控', to: '/exc/orders', icon: Receipt },
|
{ label: '订单监控', to: '/exc/orders', icon: Receipt, permission: 'admin:exc:read' },
|
||||||
{ label: '服务商监控', to: '/exc/providers', icon: UserCheck },
|
{ label: '服务商监控', to: '/exc/providers', icon: UserCheck, permission: 'admin:exc:read' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dimzou 内容',
|
||||||
|
items: [
|
||||||
|
{ label: '文档监控', to: '/dimzou/documents', icon: FileText, permission: 'admin:dimzou:read' },
|
||||||
|
{ label: '发布监控', to: '/dimzou/publications', icon: BookOpenText, permission: 'admin:dimzou:read' },
|
||||||
|
{ label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages, permission: 'admin:dimzou:read' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '内容审核',
|
label: '内容审核',
|
||||||
items: [
|
items: [
|
||||||
{ label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning },
|
{ label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning, permission: 'admin:comment:read' },
|
||||||
{ label: '被举报事件', to: '/event-reports', icon: Flag },
|
{ label: '被举报事件', to: '/event-reports', icon: Flag, permission: 'admin:filex:read' },
|
||||||
{ label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '用户与系统',
|
label: '用户与系统',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'SID 管理', to: '/sid', icon: Hash },
|
{ label: '角色管理', to: '/roles', icon: Shield, permission: 'auth:role:read' },
|
||||||
{ label: '语言设置', to: '/locales', icon: Languages },
|
{ label: '用户角色', to: '/admin-users', icon: Users, permission: 'auth:role:assign' },
|
||||||
{ label: '推送设备', to: '/devices', icon: Smartphone },
|
{ label: 'SID 管理', to: '/sid', icon: Hash, permission: 'admin:sid:manage' },
|
||||||
{ label: '机器人会话', to: '/robots', icon: Inbox },
|
{ label: '语言设置', to: '/locales', icon: Languages, permission: 'admin:locale:read' },
|
||||||
|
{ label: '推送设备', to: '/devices', icon: Smartphone, permission: 'admin:notification:read' },
|
||||||
|
{ label: '机器人会话', to: '/robots', icon: Inbox, permission: 'auth:robot-token:issue' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export function handleApiError<T extends FieldValues>(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (error.status === 403) {
|
||||||
|
toast.error('无权限执行此操作')
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.error(error.message || `请求失败(${error.code})`)
|
toast.error(error.message || `请求失败(${error.code})`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { LogOut, Moon, Sun } from 'lucide-react'
|
import { LogOut, Moon, Sun } from 'lucide-react'
|
||||||
import { fetchMe } from '@/api/modules/auth'
|
import { fetchMe } from '@/api/modules/auth'
|
||||||
import { isAuthenticated, useAuthStore } from '@/auth/store'
|
import { can, isAuthenticated, useAuthStore } from '@/auth/store'
|
||||||
import { NAV_GROUPS } from '@/components/layout/nav'
|
import { NAV_GROUPS } from '@/components/layout/nav'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -29,7 +29,8 @@ function AuthedLayout() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const roles = useAuthStore((s) => s.roles)
|
const roles = useAuthStore((s) => s.roles)
|
||||||
const permissions = useAuthStore((s) => s.permissions)
|
// subscribe so the sidebar re-filters when /auth/me refreshes permissions
|
||||||
|
useAuthStore((s) => s.permissions)
|
||||||
|
|
||||||
// Re-validate the session on mount / focus; keeps roles+permissions fresh.
|
// Re-validate the session on mount / focus; keeps roles+permissions fresh.
|
||||||
const me = useQuery({
|
const me = useQuery({
|
||||||
@@ -52,9 +53,8 @@ function AuthedLayout() {
|
|||||||
}
|
}
|
||||||
}, [me.isError, navigate])
|
}, [me.isError, navigate])
|
||||||
|
|
||||||
const isSuper = roles.includes('super-admin')
|
// roles/permissions subscriptions above keep this reactive.
|
||||||
const canSee = (permission?: string) =>
|
const canSee = (permission?: string) => !permission || can(permission)
|
||||||
!permission || isSuper || permissions.includes(permission)
|
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
useAuthStore.getState().clear()
|
useAuthStore.getState().clear()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { useCan } from '@/auth/store'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
|
import { ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
@@ -15,10 +17,12 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/categories/')({
|
export const Route = createFileRoute('/_authed/categories/')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.CATEGORY_READ),
|
||||||
component: CategoriesPage,
|
component: CategoriesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function CategoriesPage() {
|
function CategoriesPage() {
|
||||||
|
const canCreate = useCan(PERM.CATEGORY_CREATE)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const tree = useQuery({
|
const tree = useQuery({
|
||||||
queryKey: ['categories', 'tree'],
|
queryKey: ['categories', 'tree'],
|
||||||
@@ -57,10 +61,12 @@ function CategoriesPage() {
|
|||||||
title="分类树"
|
title="分类树"
|
||||||
description="平台服务分类体系;含未启用分类"
|
description="平台服务分类体系;含未启用分类"
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => openCreate(null)}>
|
canCreate && (
|
||||||
<Plus className="size-4" />
|
<Button onClick={() => openCreate(null)}>
|
||||||
新建顶级分类
|
<Plus className="size-4" />
|
||||||
</Button>
|
新建顶级分类
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -113,6 +119,9 @@ function TreeNode({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(depth === 0)
|
const [open, setOpen] = useState(depth === 0)
|
||||||
const children = node.children ?? []
|
const children = node.children ?? []
|
||||||
|
const canCreate = useCan(PERM.CATEGORY_CREATE)
|
||||||
|
const canUpdate = useCan(PERM.CATEGORY_UPDATE)
|
||||||
|
const canDelete = useCan(PERM.CATEGORY_DELETE)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(depth > 0 && 'border-l')} style={{ marginLeft: depth > 0 ? 20 : 0 }}>
|
<div className={cn(depth > 0 && 'border-l')} style={{ marginLeft: depth > 0 ? 20 : 0 }}>
|
||||||
@@ -132,24 +141,30 @@ function TreeNode({
|
|||||||
{!node.is_active && <Badge variant="secondary">未启用</Badge>}
|
{!node.is_active && <Badge variant="secondary">未启用</Badge>}
|
||||||
{node.approval_status === 'pending' && <Badge variant="outline">待审核</Badge>}
|
{node.approval_status === 'pending' && <Badge variant="outline">待审核</Badge>}
|
||||||
<span className="ml-auto hidden items-center gap-1 group-hover:flex">
|
<span className="ml-auto hidden items-center gap-1 group-hover:flex">
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => onCreateChild(node)} aria-label="新建子分类">
|
{canCreate && (
|
||||||
<Plus className="size-4" />
|
<Button variant="ghost" size="icon-sm" onClick={() => onCreateChild(node)} aria-label="新建子分类">
|
||||||
</Button>
|
<Plus className="size-4" />
|
||||||
<Button variant="ghost" size="icon-sm" onClick={() => onEdit(node)} aria-label="编辑">
|
</Button>
|
||||||
<Pencil className="size-4" />
|
)}
|
||||||
</Button>
|
{canUpdate && (
|
||||||
<ConfirmDialog
|
<Button variant="ghost" size="icon-sm" onClick={() => onEdit(node)} aria-label="编辑">
|
||||||
trigger={
|
<Pencil className="size-4" />
|
||||||
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
</Button>
|
||||||
<Trash2 className="size-4 text-destructive" />
|
)}
|
||||||
</Button>
|
{canDelete && (
|
||||||
}
|
<ConfirmDialog
|
||||||
title={`删除分类「${node.name ?? node.slug}」?`}
|
trigger={
|
||||||
description="删除后不可恢复;有子分类或被引用时后端会拒绝。"
|
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
||||||
confirmLabel="删除"
|
<Trash2 className="size-4 text-destructive" />
|
||||||
destructive
|
</Button>
|
||||||
onConfirm={() => onDelete(node)}
|
}
|
||||||
/>
|
title={`删除分类「${node.name ?? node.slug}」?`}
|
||||||
|
description="删除后不可恢复;有子分类或被引用时后端会拒绝。"
|
||||||
|
confirmLabel="删除"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => onDelete(node)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{open &&
|
{open &&
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/categories/pending')({
|
export const Route = createFileRoute('/_authed/categories/pending')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.CATEGORY_APPROVE),
|
||||||
component: PendingCategoriesPage,
|
component: PendingCategoriesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { useCan } from '@/auth/store'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -22,10 +24,12 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/comment-reports')({
|
export const Route = createFileRoute('/_authed/comment-reports')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.COMMENT_READ),
|
||||||
component: ReportedCommentsPage,
|
component: ReportedCommentsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ReportedCommentsPage() {
|
function ReportedCommentsPage() {
|
||||||
|
const canModerate = useCan(PERM.COMMENT_MODERATE)
|
||||||
const [status, setStatus] = useState<string>('pending')
|
const [status, setStatus] = useState<string>('pending')
|
||||||
const [detail, setDetail] = useState<ReportedComment | null>(null)
|
const [detail, setDetail] = useState<ReportedComment | null>(null)
|
||||||
const list = useListPage(
|
const list = useListPage(
|
||||||
@@ -92,7 +96,9 @@ function ReportedCommentsPage() {
|
|||||||
header: '操作',
|
header: '操作',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{row.original.is_blocked ? (
|
{!canModerate ? (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
) : row.original.is_blocked ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { NotificationDevice } from '@/api/types'
|
import type { NotificationDevice } from '@/api/types'
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/devices')({
|
export const Route = createFileRoute('/_authed/devices')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.NOTIFICATION_READ),
|
||||||
component: DevicesPage,
|
component: DevicesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { useCan } from '@/auth/store'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/dimzou-translations')({
|
export const Route = createFileRoute('/_authed/dimzou-translations')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.DIMZOU_READ),
|
||||||
component: DimzouTranslationsPage,
|
component: DimzouTranslationsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ const STATUS_LABELS: Record<DimzouTranslation['status'], string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DimzouTranslationsPage() {
|
function DimzouTranslationsPage() {
|
||||||
|
const canRetranslate = useCan(PERM.DIMZOU_RETRANSLATE)
|
||||||
const [status, setStatus] = useState<string>('all')
|
const [status, setStatus] = useState<string>('all')
|
||||||
// language filter applies on Enter/blur to avoid a request per keystroke
|
// language filter applies on Enter/blur to avoid a request per keystroke
|
||||||
const [languageInput, setLanguageInput] = useState('')
|
const [languageInput, setLanguageInput] = useState('')
|
||||||
@@ -111,18 +115,21 @@ function DimzouTranslationsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '操作',
|
header: '操作',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<ConfirmDialog
|
canRetranslate ? (
|
||||||
trigger={
|
<ConfirmDialog
|
||||||
<Button size="sm" variant="outline" disabled={!row.original.draft_version_id}>
|
trigger={
|
||||||
重新机器翻译
|
<Button size="sm" variant="outline" disabled={!row.original.draft_version_id}>
|
||||||
</Button>
|
重新机器翻译
|
||||||
}
|
</Button>
|
||||||
title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}?`}
|
}
|
||||||
description="将最新草稿版本中待译和失败的块重新入队机器翻译。"
|
title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}?`}
|
||||||
onConfirm={() => requeue(row.original)}
|
description="将最新草稿版本中待译和失败的块重新入队机器翻译。"
|
||||||
/>
|
onConfirm={() => requeue(row.original)}
|
||||||
),
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { useCan } from '@/auth/store'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -23,10 +25,12 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/event-reports')({
|
export const Route = createFileRoute('/_authed/event-reports')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.FILEX_READ),
|
||||||
component: ReportedEventsPage,
|
component: ReportedEventsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ReportedEventsPage() {
|
function ReportedEventsPage() {
|
||||||
|
const canModerate = useCan(PERM.FILEX_MODERATE)
|
||||||
const [status, setStatus] = useState<string>('pending')
|
const [status, setStatus] = useState<string>('pending')
|
||||||
const [detail, setDetail] = useState<ReportedEvent | null>(null)
|
const [detail, setDetail] = useState<ReportedEvent | null>(null)
|
||||||
const list = useListPage(
|
const list = useListPage(
|
||||||
@@ -97,7 +101,9 @@ function ReportedEventsPage() {
|
|||||||
header: '操作',
|
header: '操作',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{row.original.is_blocked ? (
|
{!canModerate ? (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
) : row.original.is_blocked ? (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button size="sm" variant="outline">
|
<Button size="sm" variant="outline">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/exc/demands')({
|
export const Route = createFileRoute('/_authed/exc/demands')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||||
component: ExcDemandsPage,
|
component: ExcDemandsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { ExcDispatch } from '@/api/types'
|
import type { ExcDispatch } from '@/api/types'
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/exc/dispatches')({
|
export const Route = createFileRoute('/_authed/exc/dispatches')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||||
component: ExcDispatchesPage,
|
component: ExcDispatchesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/exc/orders')({
|
export const Route = createFileRoute('/_authed/exc/orders')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||||
component: ExcOrdersPage,
|
component: ExcOrdersPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/exc/providers')({
|
export const Route = createFileRoute('/_authed/exc/providers')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||||
component: ExcProvidersPage,
|
component: ExcProvidersPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { useCan } from '@/auth/store'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
@@ -23,10 +25,14 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/locales')({
|
export const Route = createFileRoute('/_authed/locales')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.LOCALE_READ),
|
||||||
component: LocalesPage,
|
component: LocalesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function LocalesPage() {
|
function LocalesPage() {
|
||||||
|
const canCreate = useCan(PERM.LOCALE_CREATE)
|
||||||
|
const canUpdate = useCan(PERM.LOCALE_UPDATE)
|
||||||
|
const canDelete = useCan(PERM.LOCALE_DELETE)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales })
|
const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales })
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
@@ -65,25 +71,29 @@ function LocalesPage() {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Switch
|
<Switch
|
||||||
checked={row.original.is_active}
|
checked={row.original.is_active}
|
||||||
|
disabled={!canUpdate}
|
||||||
onCheckedChange={(v) => toggle(row.original, v)}
|
onCheckedChange={(v) => toggle(row.original, v)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '操作',
|
header: '操作',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<ConfirmDialog
|
canDelete ? (
|
||||||
trigger={
|
<ConfirmDialog
|
||||||
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
trigger={
|
||||||
<Trash2 className="size-4 text-destructive" />
|
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
||||||
</Button>
|
<Trash2 className="size-4 text-destructive" />
|
||||||
}
|
</Button>
|
||||||
title={`删除语言 ${row.original.code}?`}
|
}
|
||||||
confirmLabel="删除"
|
title={`删除语言 ${row.original.code}?`}
|
||||||
destructive
|
confirmLabel="删除"
|
||||||
onConfirm={() => remove(row.original)}
|
destructive
|
||||||
/>
|
onConfirm={() => remove(row.original)}
|
||||||
),
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -93,10 +103,12 @@ function LocalesPage() {
|
|||||||
title="语言设置"
|
title="语言设置"
|
||||||
description="平台支持的界面/内容语言"
|
description="平台支持的界面/内容语言"
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
canCreate && (
|
||||||
<Plus className="size-4" />
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
添加语言
|
<Plus className="size-4" />
|
||||||
</Button>
|
添加语言
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { issueRobotSessionToken } from '@/api/modules/auth'
|
import { issueRobotSessionToken } from '@/api/modules/auth'
|
||||||
import { handleApiError } from '@/lib/errors'
|
import { handleApiError } from '@/lib/errors'
|
||||||
@@ -10,6 +11,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/robots')({
|
export const Route = createFileRoute('/_authed/robots')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.ROBOT_TOKEN_ISSUE),
|
||||||
component: RobotsPage,
|
component: RobotsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { useCan } from '@/auth/store'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
@@ -26,6 +28,7 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/sid')({
|
export const Route = createFileRoute('/_authed/sid')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.SID_MANAGE),
|
||||||
component: SidPage,
|
component: SidPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,6 +193,7 @@ function LuckyTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AssignTab() {
|
function AssignTab() {
|
||||||
|
const canOverride = useCan(PERM.SID_OVERRIDE)
|
||||||
const [sid, setSid] = useState('')
|
const [sid, setSid] = useState('')
|
||||||
const [checkResult, setCheckResult] = useState<SidCheckResult | null>(null)
|
const [checkResult, setCheckResult] = useState<SidCheckResult | null>(null)
|
||||||
const [userUid, setUserUid] = useState('')
|
const [userUid, setUserUid] = useState('')
|
||||||
@@ -254,6 +258,11 @@ function AssignTab() {
|
|||||||
可分配
|
可分配
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{checkResult.reserved && !canOverride && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
分配保留号段需要 admin:sid:override 权限
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
@@ -266,7 +275,10 @@ function AssignTab() {
|
|||||||
placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX"
|
placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={assign} disabled={!sid || !userUid || busy}>
|
<Button
|
||||||
|
onClick={assign}
|
||||||
|
disabled={!sid || !userUid || busy || (checkResult?.reserved === true && !canOverride)}
|
||||||
|
>
|
||||||
分配 SID
|
分配 SID
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { KeyRound } from 'lucide-react'
|
import { KeyRound } from 'lucide-react'
|
||||||
@@ -14,6 +15,7 @@ import { ShowOnceTokenDialog } from '@/components/show-once-token-dialog'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/studio/agent-tokens')({
|
export const Route = createFileRoute('/_authed/studio/agent-tokens')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.STUDIO_AGENT_TOKEN_ISSUE),
|
||||||
component: AgentTokensPage,
|
component: AgentTokensPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { StudioArticle } from '@/api/types'
|
import type { StudioArticle } from '@/api/types'
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/studio/articles')({
|
export const Route = createFileRoute('/_authed/studio/articles')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||||
component: ArticlesPage,
|
component: ArticlesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { StudioCategoryBrief } from '@/api/types'
|
import type { StudioCategoryBrief } from '@/api/types'
|
||||||
@@ -10,6 +11,7 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/studio/category-briefs')({
|
export const Route = createFileRoute('/_authed/studio/category-briefs')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||||
component: CategoryBriefsPage,
|
component: CategoryBriefsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { useCan } from '@/auth/store'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
@@ -28,12 +30,14 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/studio/robot-authors')({
|
export const Route = createFileRoute('/_authed/studio/robot-authors')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||||
component: RobotAuthorsPage,
|
component: RobotAuthorsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const
|
const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const
|
||||||
|
|
||||||
function RobotAuthorsPage() {
|
function RobotAuthorsPage() {
|
||||||
|
const canManage = useCan(PERM.STUDIO_MANAGE)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [status, setStatus] = useState('all')
|
const [status, setStatus] = useState('all')
|
||||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||||
@@ -114,17 +118,21 @@ function RobotAuthorsPage() {
|
|||||||
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
|
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditing(row.original)}>
|
{canManage && (
|
||||||
编辑
|
<>
|
||||||
</Button>
|
<Button size="sm" variant="outline" onClick={() => setEditing(row.original)}>
|
||||||
{row.original.status === 'paused' ? (
|
编辑
|
||||||
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'ready')}>
|
</Button>
|
||||||
恢复
|
{row.original.status === 'paused' ? (
|
||||||
</Button>
|
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'ready')}>
|
||||||
) : (
|
恢复
|
||||||
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'paused')}>
|
</Button>
|
||||||
暂停
|
) : (
|
||||||
</Button>
|
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'paused')}>
|
||||||
|
暂停
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { StudioSeries } from '@/api/types'
|
import type { StudioSeries } from '@/api/types'
|
||||||
@@ -11,6 +12,7 @@ import { StatusPill } from '@/components/status-pill'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/studio/series')({
|
export const Route = createFileRoute('/_authed/studio/series')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||||
component: SeriesPage,
|
component: SeriesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { StudioStylePreset } from '@/api/types'
|
import type { StudioStylePreset } from '@/api/types'
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/studio/style-presets')({
|
export const Route = createFileRoute('/_authed/studio/style-presets')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||||
component: StylePresetsPage,
|
component: StylePresetsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { StudioSubtopic } from '@/api/types'
|
import type { StudioSubtopic } from '@/api/types'
|
||||||
import { fetchSubtopics } from '@/api/modules/studio'
|
import { fetchSubtopics } from '@/api/modules/studio'
|
||||||
@@ -8,6 +9,7 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { StatusPill } from '@/components/status-pill'
|
import { StatusPill } from '@/components/status-pill'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/studio/subtopics')({
|
export const Route = createFileRoute('/_authed/studio/subtopics')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||||
component: SubtopicsPage,
|
component: SubtopicsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user