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 <noreply@anthropic.com>
This commit is contained in:
1236
src/api/types.gen.ts
1236
src/api/types.gen.ts
File diff suppressed because it is too large
Load Diff
53
src/auth/permissions.ts
Normal file
53
src/auth/permissions.ts
Normal file
@ -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: '/' })
|
||||
}
|
||||
}
|
||||
37
src/auth/store.test.ts
Normal file
37
src/auth/store.test.ts
Normal file
@ -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: 't@example.com',
|
||||
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 {
|
||||
BookOpenText,
|
||||
Bot,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Flag,
|
||||
FolderTree,
|
||||
Hash,
|
||||
@ -16,9 +18,11 @@ import {
|
||||
Receipt,
|
||||
ScrollText,
|
||||
Send,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface NavItem {
|
||||
@ -38,19 +42,19 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: '分类管理',
|
||||
items: [
|
||||
{ label: '分类树', to: '/categories', icon: FolderTree },
|
||||
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks },
|
||||
{ label: '分类树', to: '/categories', icon: FolderTree, permission: 'admin:category:read' },
|
||||
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks, permission: 'admin:category:approve' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '内容工作室',
|
||||
items: [
|
||||
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot },
|
||||
{ label: '文章系列', to: '/studio/series', icon: LayoutList },
|
||||
{ label: '文章', to: '/studio/articles', icon: Newspaper },
|
||||
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles },
|
||||
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText },
|
||||
{ label: '风格预设', to: '/studio/style-presets', icon: Palette },
|
||||
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot, permission: 'admin:studio:read' },
|
||||
{ label: '文章系列', to: '/studio/series', icon: LayoutList, permission: 'admin:studio:read' },
|
||||
{ label: '文章', to: '/studio/articles', icon: Newspaper, permission: 'admin:studio:read' },
|
||||
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles, permission: 'admin:studio:read' },
|
||||
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText, permission: 'admin:studio:read' },
|
||||
{ label: '风格预设', to: '/studio/style-presets', icon: Palette, permission: 'admin:studio:read' },
|
||||
{
|
||||
label: 'Agent 接入',
|
||||
to: '/studio/agent-tokens',
|
||||
@ -62,27 +66,36 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: '专长交易',
|
||||
items: [
|
||||
{ label: '需求监控', to: '/exc/demands', icon: ClipboardList },
|
||||
{ label: '派单监控', to: '/exc/dispatches', icon: Send },
|
||||
{ label: '订单监控', to: '/exc/orders', icon: Receipt },
|
||||
{ label: '服务商监控', to: '/exc/providers', icon: UserCheck },
|
||||
{ label: '需求监控', to: '/exc/demands', icon: ClipboardList, permission: 'admin:exc:read' },
|
||||
{ label: '派单监控', to: '/exc/dispatches', icon: Send, permission: 'admin:exc:read' },
|
||||
{ label: '订单监控', to: '/exc/orders', icon: Receipt, permission: 'admin:exc:read' },
|
||||
{ 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: '内容审核',
|
||||
items: [
|
||||
{ label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning },
|
||||
{ label: '被举报事件', to: '/event-reports', icon: Flag },
|
||||
{ label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages },
|
||||
{ label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning, permission: 'admin:comment:read' },
|
||||
{ label: '被举报事件', to: '/event-reports', icon: Flag, permission: 'admin:filex:read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '用户与系统',
|
||||
items: [
|
||||
{ label: 'SID 管理', to: '/sid', icon: Hash },
|
||||
{ label: '语言设置', to: '/locales', icon: Languages },
|
||||
{ label: '推送设备', to: '/devices', icon: Smartphone },
|
||||
{ label: '机器人会话', to: '/robots', icon: Inbox },
|
||||
{ label: '角色管理', to: '/roles', icon: Shield, permission: 'auth:role:read' },
|
||||
{ label: '用户角色', to: '/admin-users', icon: Users, permission: 'auth:role:assign' },
|
||||
{ label: 'SID 管理', to: '/sid', icon: Hash, permission: 'admin:sid:manage' },
|
||||
{ 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
|
||||
}
|
||||
if (error.status === 403) {
|
||||
toast.error('无权限执行此操作')
|
||||
return
|
||||
}
|
||||
toast.error(error.message || `请求失败(${error.code})`)
|
||||
return
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { LogOut, Moon, Sun } from 'lucide-react'
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -29,7 +29,8 @@ function AuthedLayout() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
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.
|
||||
const me = useQuery({
|
||||
@ -52,9 +53,8 @@ function AuthedLayout() {
|
||||
}
|
||||
}, [me.isError, navigate])
|
||||
|
||||
const isSuper = roles.includes('super-admin')
|
||||
const canSee = (permission?: string) =>
|
||||
!permission || isSuper || permissions.includes(permission)
|
||||
// roles/permissions subscriptions above keep this reactive.
|
||||
const canSee = (permission?: string) => !permission || can(permission)
|
||||
|
||||
const logout = () => {
|
||||
useAuthStore.getState().clear()
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
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 { useState } from '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'
|
||||
|
||||
export const Route = createFileRoute('/_authed/categories/')({
|
||||
beforeLoad: () => requirePermission(PERM.CATEGORY_READ),
|
||||
component: CategoriesPage,
|
||||
})
|
||||
|
||||
function CategoriesPage() {
|
||||
const canCreate = useCan(PERM.CATEGORY_CREATE)
|
||||
const queryClient = useQueryClient()
|
||||
const tree = useQuery({
|
||||
queryKey: ['categories', 'tree'],
|
||||
@ -57,10 +61,12 @@ function CategoriesPage() {
|
||||
title="分类树"
|
||||
description="平台服务分类体系;含未启用分类"
|
||||
actions={
|
||||
<Button onClick={() => openCreate(null)}>
|
||||
<Plus className="size-4" />
|
||||
新建顶级分类
|
||||
</Button>
|
||||
canCreate && (
|
||||
<Button onClick={() => openCreate(null)}>
|
||||
<Plus className="size-4" />
|
||||
新建顶级分类
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@ -113,6 +119,9 @@ function TreeNode({
|
||||
}) {
|
||||
const [open, setOpen] = useState(depth === 0)
|
||||
const children = node.children ?? []
|
||||
const canCreate = useCan(PERM.CATEGORY_CREATE)
|
||||
const canUpdate = useCan(PERM.CATEGORY_UPDATE)
|
||||
const canDelete = useCan(PERM.CATEGORY_DELETE)
|
||||
|
||||
return (
|
||||
<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.approval_status === 'pending' && <Badge variant="outline">待审核</Badge>}
|
||||
<span className="ml-auto hidden items-center gap-1 group-hover:flex">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => onCreateChild(node)} aria-label="新建子分类">
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => onEdit(node)} aria-label="编辑">
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
}
|
||||
title={`删除分类「${node.name ?? node.slug}」?`}
|
||||
description="删除后不可恢复;有子分类或被引用时后端会拒绝。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() => onDelete(node)}
|
||||
/>
|
||||
{canCreate && (
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => onCreateChild(node)} aria-label="新建子分类">
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => onEdit(node)} aria-label="编辑">
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
}
|
||||
title={`删除分类「${node.name ?? node.slug}」?`}
|
||||
description="删除后不可恢复;有子分类或被引用时后端会拒绝。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() => onDelete(node)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{open &&
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
@ -34,6 +35,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/categories/pending')({
|
||||
beforeLoad: () => requirePermission(PERM.CATEGORY_APPROVE),
|
||||
component: PendingCategoriesPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useCan } from '@/auth/store'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
@ -22,10 +24,12 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/comment-reports')({
|
||||
beforeLoad: () => requirePermission(PERM.COMMENT_READ),
|
||||
component: ReportedCommentsPage,
|
||||
})
|
||||
|
||||
function ReportedCommentsPage() {
|
||||
const canModerate = useCan(PERM.COMMENT_MODERATE)
|
||||
const [status, setStatus] = useState<string>('pending')
|
||||
const [detail, setDetail] = useState<ReportedComment | null>(null)
|
||||
const list = useListPage(
|
||||
@ -92,7 +96,9 @@ function ReportedCommentsPage() {
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<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
|
||||
trigger={
|
||||
<Button size="sm" variant="outline">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { NotificationDevice } from '@/api/types'
|
||||
@ -16,6 +17,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/devices')({
|
||||
beforeLoad: () => requirePermission(PERM.NOTIFICATION_READ),
|
||||
component: DevicesPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useCan } from '@/auth/store'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
@ -21,6 +23,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/dimzou-translations')({
|
||||
beforeLoad: () => requirePermission(PERM.DIMZOU_READ),
|
||||
component: DimzouTranslationsPage,
|
||||
})
|
||||
|
||||
@ -31,6 +34,7 @@ const STATUS_LABELS: Record<DimzouTranslation['status'], string> = {
|
||||
}
|
||||
|
||||
function DimzouTranslationsPage() {
|
||||
const canRetranslate = useCan(PERM.DIMZOU_RETRANSLATE)
|
||||
const [status, setStatus] = useState<string>('all')
|
||||
// language filter applies on Enter/blur to avoid a request per keystroke
|
||||
const [languageInput, setLanguageInput] = useState('')
|
||||
@ -111,18 +115,21 @@ function DimzouTranslationsPage() {
|
||||
},
|
||||
{
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" disabled={!row.original.draft_version_id}>
|
||||
重新机器翻译
|
||||
</Button>
|
||||
}
|
||||
title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}?`}
|
||||
description="将最新草稿版本中待译和失败的块重新入队机器翻译。"
|
||||
onConfirm={() => requeue(row.original)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
canRetranslate ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" disabled={!row.original.draft_version_id}>
|
||||
重新机器翻译
|
||||
</Button>
|
||||
}
|
||||
title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}?`}
|
||||
description="将最新草稿版本中待译和失败的块重新入队机器翻译。"
|
||||
onConfirm={() => requeue(row.original)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useCan } from '@/auth/store'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
@ -23,10 +25,12 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/event-reports')({
|
||||
beforeLoad: () => requirePermission(PERM.FILEX_READ),
|
||||
component: ReportedEventsPage,
|
||||
})
|
||||
|
||||
function ReportedEventsPage() {
|
||||
const canModerate = useCan(PERM.FILEX_MODERATE)
|
||||
const [status, setStatus] = useState<string>('pending')
|
||||
const [detail, setDetail] = useState<ReportedEvent | null>(null)
|
||||
const list = useListPage(
|
||||
@ -97,7 +101,9 @@ function ReportedEventsPage() {
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<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
|
||||
trigger={
|
||||
<Button size="sm" variant="outline">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
@ -25,6 +26,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/exc/demands')({
|
||||
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||
component: ExcDemandsPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { ExcDispatch } from '@/api/types'
|
||||
@ -17,6 +18,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/exc/dispatches')({
|
||||
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||
component: ExcDispatchesPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
@ -25,6 +26,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/exc/orders')({
|
||||
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||
component: ExcOrdersPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
@ -24,6 +25,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/exc/providers')({
|
||||
beforeLoad: () => requirePermission(PERM.EXC_READ),
|
||||
component: ExcProvidersPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
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 { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
@ -23,10 +25,14 @@ import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
export const Route = createFileRoute('/_authed/locales')({
|
||||
beforeLoad: () => requirePermission(PERM.LOCALE_READ),
|
||||
component: 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 locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales })
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@ -65,25 +71,29 @@ function LocalesPage() {
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.is_active}
|
||||
disabled={!canUpdate}
|
||||
onCheckedChange={(v) => toggle(row.original, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
}
|
||||
title={`删除语言 ${row.original.code}?`}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() => remove(row.original)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
canDelete ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="删除">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
}
|
||||
title={`删除语言 ${row.original.code}?`}
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={() => remove(row.original)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@ -93,10 +103,12 @@ function LocalesPage() {
|
||||
title="语言设置"
|
||||
description="平台支持的界面/内容语言"
|
||||
actions={
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
添加语言
|
||||
</Button>
|
||||
canCreate && (
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
添加语言
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DataTable
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import { issueRobotSessionToken } from '@/api/modules/auth'
|
||||
import { handleApiError } from '@/lib/errors'
|
||||
@ -10,6 +11,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
export const Route = createFileRoute('/_authed/robots')({
|
||||
beforeLoad: () => requirePermission(PERM.ROBOT_TOKEN_ISSUE),
|
||||
component: RobotsPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
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 { useState } from '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'
|
||||
|
||||
export const Route = createFileRoute('/_authed/sid')({
|
||||
beforeLoad: () => requirePermission(PERM.SID_MANAGE),
|
||||
component: SidPage,
|
||||
})
|
||||
|
||||
@ -190,6 +193,7 @@ function LuckyTab() {
|
||||
}
|
||||
|
||||
function AssignTab() {
|
||||
const canOverride = useCan(PERM.SID_OVERRIDE)
|
||||
const [sid, setSid] = useState('')
|
||||
const [checkResult, setCheckResult] = useState<SidCheckResult | null>(null)
|
||||
const [userUid, setUserUid] = useState('')
|
||||
@ -254,6 +258,11 @@ function AssignTab() {
|
||||
可分配
|
||||
</Badge>
|
||||
)}
|
||||
{checkResult.reserved && !canOverride && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
分配保留号段需要 admin:sid:override 权限
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-1.5">
|
||||
@ -266,7 +275,10 @@ function AssignTab() {
|
||||
placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={assign} disabled={!sid || !userUid || busy}>
|
||||
<Button
|
||||
onClick={assign}
|
||||
disabled={!sid || !userUid || busy || (checkResult?.reserved === true && !canOverride)}
|
||||
>
|
||||
分配 SID
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
@ -14,6 +15,7 @@ import { ShowOnceTokenDialog } from '@/components/show-once-token-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export const Route = createFileRoute('/_authed/studio/agent-tokens')({
|
||||
beforeLoad: () => requirePermission(PERM.STUDIO_AGENT_TOKEN_ISSUE),
|
||||
component: AgentTokensPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { StudioArticle } from '@/api/types'
|
||||
@ -19,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/studio/articles')({
|
||||
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||
component: ArticlesPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { StudioCategoryBrief } from '@/api/types'
|
||||
@ -10,6 +11,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export const Route = createFileRoute('/_authed/studio/category-briefs')({
|
||||
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||
component: CategoryBriefsPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useCan } from '@/auth/store'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
@ -28,12 +30,14 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/studio/robot-authors')({
|
||||
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||
component: RobotAuthorsPage,
|
||||
})
|
||||
|
||||
const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const
|
||||
|
||||
function RobotAuthorsPage() {
|
||||
const canManage = useCan(PERM.STUDIO_MANAGE)
|
||||
const queryClient = useQueryClient()
|
||||
const [status, setStatus] = useState('all')
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||
@ -114,17 +118,21 @@ function RobotAuthorsPage() {
|
||||
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(row.original)}>
|
||||
编辑
|
||||
</Button>
|
||||
{row.original.status === 'paused' ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'ready')}>
|
||||
恢复
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'paused')}>
|
||||
暂停
|
||||
</Button>
|
||||
{canManage && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(row.original)}>
|
||||
编辑
|
||||
</Button>
|
||||
{row.original.status === 'paused' ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'ready')}>
|
||||
恢复
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'paused')}>
|
||||
暂停
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { StudioSeries } from '@/api/types'
|
||||
@ -11,6 +12,7 @@ import { StatusPill } from '@/components/status-pill'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export const Route = createFileRoute('/_authed/studio/series')({
|
||||
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||
component: SeriesPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { StudioStylePreset } from '@/api/types'
|
||||
@ -18,6 +19,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const Route = createFileRoute('/_authed/studio/style-presets')({
|
||||
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||
component: StylePresetsPage,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { StudioSubtopic } from '@/api/types'
|
||||
import { fetchSubtopics } from '@/api/modules/studio'
|
||||
@ -8,6 +9,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { StatusPill } from '@/components/status-pill'
|
||||
|
||||
export const Route = createFileRoute('/_authed/studio/subtopics')({
|
||||
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
|
||||
component: SubtopicsPage,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user