feat: 前端按钮基于 permissions 显隐 — 导出/新建报告/下载/删除

三处修改:
- TicketList 导出按钮基于 tickets:export 权限显隐
- 报告列表页新建/批量下载/批量删除按钮及各状态行内操作基于 reports:create/reports:download 显隐
- 报告详情页生成文档/下载报告/重新生成按钮基于 permissions 显隐
This commit is contained in:
gitadmin 2026-05-14 17:05:14 +08:00
parent 152241e666
commit 2d74f0a05b
3 changed files with 87 additions and 37 deletions

View File

@ -31,6 +31,14 @@ export default function ReportDetailPage() {
const [reportData, setReportData] = useState<any>(null) const [reportData, setReportData] = useState<any>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [permissions, setPermissions] = useState<string[]>([])
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
const fetchReport = () => { const fetchReport = () => {
fetch(`/api/reports/${params.id}`) fetch(`/api/reports/${params.id}`)
@ -84,16 +92,18 @@ export default function ReportDetailPage() {
setGenerating(false) setGenerating(false)
} }
const can = (perm: string) => permissions.includes('*') || permissions.includes(perm)
const renderRightButton = () => { const renderRightButton = () => {
if (!report) return null if (!report) return null
switch (report.status) { switch (report.status) {
case 'ready': case 'ready':
return ( return can('reports:create') ? (
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-emerald-600 hover:bg-emerald-700 text-white"> <Button size="sm" onClick={handleGenerate} loading={generating} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<FileText size={16} className="mr-1" /> <FileText size={16} className="mr-1" />
</Button> </Button>
) ) : null
case 'generating': case 'generating':
return ( return (
<Button size="sm" disabled className="bg-slate-400 text-white cursor-not-allowed"> <Button size="sm" disabled className="bg-slate-400 text-white cursor-not-allowed">
@ -102,17 +112,17 @@ export default function ReportDetailPage() {
</Button> </Button>
) )
case 'completed': case 'completed':
return ( return can('reports:download') ? (
<Button size="sm" onClick={() => window.open(`/api/reports/${report.id}/download`, '_blank')} className="bg-blue-600 hover:bg-blue-700 text-white"> <Button size="sm" onClick={() => window.open(`/api/reports/${report.id}/download`, '_blank')} className="bg-blue-600 hover:bg-blue-700 text-white">
<Download size={16} className="mr-1" /> <Download size={16} className="mr-1" />
</Button> </Button>
) ) : null
case 'failed': case 'failed':
return ( return can('reports:create') ? (
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-amber-500 hover:bg-amber-600 text-white"> <Button size="sm" onClick={handleGenerate} loading={generating} className="bg-amber-500 hover:bg-amber-600 text-white">
<RefreshCw size={16} className="mr-1" /> <RefreshCw size={16} className="mr-1" />
</Button> </Button>
) ) : null
default: default:
return null return null
} }

View File

@ -84,6 +84,14 @@ export default function ReportsPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false) const [batchDeleteOpen, setBatchDeleteOpen] = useState(false)
const [generatingIds, setGeneratingIds] = useState<Set<number>>(new Set()) const [generatingIds, setGeneratingIds] = useState<Set<number>>(new Set())
const [permissions, setPermissions] = useState<string[]>([])
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
const toggleSelect = (id: number) => { const toggleSelect = (id: number) => {
setSelectedIds(prev => { setSelectedIds(prev => {
@ -269,12 +277,16 @@ export default function ReportsPage() {
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" /> <Eye size={14} className="mr-0.5" />
</Button> </Button>
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}> <Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
</Button> </Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" /> <Trash2 size={14} className="text-red-500" />
</Button> </Button>
)}
</div> </div>
) )
case 'generating': case 'generating':
@ -291,12 +303,16 @@ export default function ReportsPage() {
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" /> <Eye size={14} className="mr-0.5" />
</Button> </Button>
{can('reports:download') && (
<Button variant="ghost" size="sm" onClick={() => window.open(`/api/reports/${r.id}/download`, '_blank')}> <Button variant="ghost" size="sm" onClick={() => window.open(`/api/reports/${r.id}/download`, '_blank')}>
<Download size={14} className="mr-0.5" /> <Download size={14} className="mr-0.5" />
</Button> </Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" /> <Trash2 size={14} className="text-red-500" />
</Button> </Button>
)}
</div> </div>
) )
case 'failed': case 'failed':
@ -305,25 +321,33 @@ export default function ReportsPage() {
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" /> <Eye size={14} className="mr-0.5" />
</Button> </Button>
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}> <Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
<RefreshCw size={14} className="mr-0.5" /> <RefreshCw size={14} className="mr-0.5" />
</Button> </Button>
)}
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" /> <Trash2 size={14} className="text-red-500" />
</Button> </Button>
)}
</div> </div>
) )
default: default:
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{can('reports:create') && (
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}> <Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" /> <Trash2 size={14} className="text-red-500" />
</Button> </Button>
)}
</div> </div>
) )
} }
} }
const can = (perm: string) => permissions.includes('*') || permissions.includes(perm)
const renderStatusBadge = (status: string) => { const renderStatusBadge = (status: string) => {
if (status === 'generating') { if (status === 'generating') {
return ( return (
@ -356,17 +380,23 @@ export default function ReportsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<> <>
{can('reports:download') && (
<Button size="sm" onClick={handleBatchDownload}> <Button size="sm" onClick={handleBatchDownload}>
<Archive size={16} className="mr-1" /> ({selectedIds.size}) <Archive size={16} className="mr-1" /> ({selectedIds.size})
</Button> </Button>
)}
{can('reports:create') && (
<Button size="sm" variant="danger" onClick={() => setBatchDeleteOpen(true)}> <Button size="sm" variant="danger" onClick={() => setBatchDeleteOpen(true)}>
<Trash2 size={16} className="mr-1" /> ({selectedIds.size}) <Trash2 size={16} className="mr-1" /> ({selectedIds.size})
</Button> </Button>
)}
</> </>
)} )}
{can('reports:create') && (
<Button size="sm" onClick={() => setShowCreate(!showCreate)}> <Button size="sm" onClick={() => setShowCreate(!showCreate)}>
<Plus size={16} className="mr-1" /> <Plus size={16} className="mr-1" />
</Button> </Button>
)}
</div> </div>
</div> </div>

View File

@ -166,8 +166,16 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({}) const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({})
const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({}) const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({})
const [ticketNoFilter, setTicketNoFilter] = useState('') const [ticketNoFilter, setTicketNoFilter] = useState('')
const [permissions, setPermissions] = useState<string[]>([])
const filterDropRef = useRef<HTMLDivElement>(null) const filterDropRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
.catch(() => {})
}, [])
// 列宽拖拽调整 // 列宽拖拽调整
const [colWidths, setColWidths] = useState<Record<string, number>>({}) const [colWidths, setColWidths] = useState<Record<string, number>>({})
const [resizingCol, setResizingCol] = useState<string | null>(null) const [resizingCol, setResizingCol] = useState<string | null>(null)
@ -429,7 +437,9 @@ function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColum
<Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}></Button> <Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}></Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(permissions.includes('*') || permissions.includes('tickets:export')) && (
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} /></Button> <Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} /></Button>
)}
</div> </div>
</div> </div>