11import { requireAdmin } from "~/lib/auth" ;
22import { AdminLayout } from "~/components/AdminLayout" ;
33import { apiFetch } from "~/lib/api-client" ;
4- import { useRevalidator } from "react-router" ;
5- import { useState } from "react" ;
4+ import { Link , useRevalidator } from "react-router" ;
5+ import { useEffect , useState } from "react" ;
66import { toast } from "sonner" ;
77import { useAsyncAction } from "~/hooks/use-async-action" ;
88import { Button } from "~/components/ui/button" ;
99import { Input } from "~/components/ui/input" ;
1010import { Badge } from "~/components/ui/badge" ;
11+ import { Checkbox } from "~/components/ui/checkbox" ;
1112import { Tabs , TabsList , TabsTrigger , TabsContent } from "~/components/ui/tabs" ;
1213import { AdminPage , AdminPageHeader , AdminPanel , AdminToolbar } from "~/components/AdminPage" ;
1314import { Pagination } from "~/components/Pagination" ;
@@ -24,15 +25,32 @@ export function meta() {
2425 return [ { title : "封禁管理 · CNode Admin" } ] ;
2526}
2627
28+ export type UserGovernanceStatus = "muted" | "blocked" ;
29+
30+ export const USER_GOVERNANCE_STATUS_LABELS : Record < UserGovernanceStatus , string > = {
31+ muted : "禁言用户" ,
32+ blocked : "内容已屏蔽用户" ,
33+ } ;
34+
35+ export function userGovernanceActionLabel ( status : UserGovernanceStatus ) {
36+ return status === "muted" ? "解除禁言" : "恢复内容可见" ;
37+ }
38+
39+ function userGovernanceApiAction ( status : UserGovernanceStatus ) {
40+ return status === "muted" ? "unmute" : "unblock" ;
41+ }
42+
2743export async function loader ( { request } : any ) {
2844 await requireAdmin ( request ) ;
2945 const url = new URL ( request . url ) ;
3046 const page = Math . max ( 1 , Number ( url . searchParams . get ( "page" ) ) || 1 ) ;
3147 const limit = Math . min ( 100 , Number ( url . searchParams . get ( "limit" ) ) || 50 ) ;
3248 const tab = url . searchParams . get ( "tab" ) || "users" ;
49+ const userStatus : UserGovernanceStatus = url . searchParams . get ( "status" ) === "blocked" ? "blocked" : "muted" ;
3350 const cookie = request . headers . get ( "cookie" ) || "" ;
51+ const userParams = new URLSearchParams ( { page : String ( page ) , limit : String ( limit ) , status : userStatus } ) ;
3452 const [ usersRes , ipsRes ] = await Promise . all ( [
35- apiFetch < { success : boolean ; data : any [ ] ; total ?: number } > ( `/api/v1/admin/bans/users?page= ${ page } &limit= ${ limit } ` , {
53+ apiFetch < { success : boolean ; data : any [ ] ; total ?: number } > ( `/api/v1/admin/bans/users?${ userParams . toString ( ) } ` , {
3654 headers : { cookie } ,
3755 } ) ,
3856 apiFetch < { success : boolean ; data : any [ ] ; total ?: number } > ( `/api/v1/admin/bans/ips?page=${ page } &limit=${ limit } ` , { headers : { cookie } } ) ,
@@ -45,27 +63,71 @@ export async function loader({ request }: any) {
4563 page,
4664 limit,
4765 tab,
66+ userStatus,
4867 } ;
4968}
5069
5170export default function AdminBans ( { loaderData } : any ) {
5271 const { bannedUsers, bannedUsersTotal, bannedIps, bannedIpsTotal, page, limit, tab } = loaderData ;
72+ const userStatus = loaderData . userStatus as UserGovernanceStatus ;
5373 const { revalidate } = useRevalidator ( ) ;
5474 const [ ip , setIp ] = useState ( "" ) ;
5575 const [ reason , setReason ] = useState ( "" ) ;
76+ const [ selectedUserIds , setSelectedUserIds ] = useState < number [ ] > ( [ ] ) ;
77+
78+ useEffect ( ( ) => {
79+ setSelectedUserIds ( [ ] ) ;
80+ } , [ page , userStatus ] ) ;
5681
57- const { run : handleUnblock } = useAsyncAction (
82+ const currentUserIds = bannedUsers . map ( ( user : any ) => Number ( user . id ) ) ;
83+ const selectedCurrentUserIds = selectedUserIds . filter ( ( id ) => currentUserIds . includes ( id ) ) ;
84+ const allCurrentUsersSelected = currentUserIds . length > 0 && selectedCurrentUserIds . length === currentUserIds . length ;
85+ const userAction = userGovernanceApiAction ( userStatus ) ;
86+ const userActionLabel = userGovernanceActionLabel ( userStatus ) ;
87+ const userStatusLabel = USER_GOVERNANCE_STATUS_LABELS [ userStatus ] ;
88+
89+ const toggleUserSelection = ( id : number , checked : boolean ) => {
90+ setSelectedUserIds ( ( ids ) => checked ? Array . from ( new Set ( [ ...ids , id ] ) ) : ids . filter ( ( item ) => item !== id ) ) ;
91+ } ;
92+
93+ const toggleAllCurrentUsers = ( checked : boolean ) => {
94+ setSelectedUserIds ( ( ids ) => checked ? Array . from ( new Set ( [ ...ids , ...currentUserIds ] ) ) : ids . filter ( ( id ) => ! currentUserIds . includes ( id ) ) ) ;
95+ } ;
96+
97+ const { run : handleSingleUserGovernance } = useAsyncAction (
5898 async ( name : string ) => {
59- const res = await apiFetch < { success : boolean ; error_msg ?: string } > ( `/api/v1/user/${ name } /unblock ` , { method : "POST" } ) ;
99+ const res = await apiFetch < { success : boolean ; error_msg ?: string } > ( `/api/v1/user/${ name } /${ userAction } ` , { method : "POST" } ) ;
60100 return { ...res , name } ;
61101 } ,
62102 {
63103 onSuccess : ( result ) => {
64104 if ( result . success ) {
65- toast . success ( `已解禁 ${ result . name } ` ) ;
105+ toast . success ( `已 ${ userActionLabel } ${ result . name } ` ) ;
66106 revalidate ( ) ;
67107 } else {
68- toast . error ( result . error_msg || "解禁失败" ) ;
108+ toast . error ( result . error_msg || `${ userActionLabel } 失败` ) ;
109+ }
110+ } ,
111+ } ,
112+ ) ;
113+
114+ const { run : handleBulkUserGovernance , pending : bulkUserPending } = useAsyncAction (
115+ async ( ) => {
116+ const res = await apiFetch < { success : boolean ; error_msg ?: string ; processed ?: number ; skipped_ids ?: number [ ] } > ( "/api/v1/admin/users/bulk-governance" , {
117+ method : "POST" ,
118+ body : JSON . stringify ( { action : userAction , ids : selectedCurrentUserIds } ) ,
119+ } ) ;
120+ return res ;
121+ } ,
122+ {
123+ onSuccess : ( result ) => {
124+ if ( result . success ) {
125+ const skipped = result . skipped_ids ?. length || 0 ;
126+ toast . success ( `已${ userActionLabel } ${ result . processed || 0 } 个用户` , skipped ? { description : `已跳过 ${ skipped } 个目标` } : undefined ) ;
127+ setSelectedUserIds ( [ ] ) ;
128+ revalidate ( ) ;
129+ } else {
130+ toast . error ( result . error_msg || `批量${ userActionLabel } 失败` ) ;
69131 }
70132 } ,
71133 } ,
@@ -107,42 +169,73 @@ export default function AdminBans({ loaderData }: any) {
107169 return (
108170 < AdminLayout >
109171 < AdminPage >
110- < AdminPageHeader title = "封禁管理" description = "管理用户禁言和 IP 风控规则,保持社区秩序。" />
172+ < AdminPageHeader title = "封禁管理" description = "管理用户禁言、内容屏蔽和 IP 风控规则,保持社区秩序。" />
111173 < Tabs defaultValue = { tab } className = "space-y-4" >
112174 < TabsList className = "bg-card shadow-card" >
113- < TabsTrigger value = "users" > 用户封禁 </ TabsTrigger >
175+ < TabsTrigger value = "users" > 用户治理 </ TabsTrigger >
114176 < TabsTrigger value = "ips" > IP 封禁</ TabsTrigger >
115177 </ TabsList >
116178 < TabsContent value = "users" >
117- < AdminPanel title = "用户封禁" description = { `当前显示 ${ bannedUsers . length } / ${ bannedUsersTotal } 个禁言用户` } >
179+ < AdminPanel title = { userStatusLabel } description = { `当前显示 ${ bannedUsers . length } / ${ bannedUsersTotal } 个${ userStatusLabel } ` } >
180+ < AdminToolbar >
181+ < div className = "flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" >
182+ < div className = "flex gap-2" >
183+ < Button asChild size = "sm" variant = { userStatus === "muted" ? "default" : "outline" } >
184+ < Link to = { `/admin/bans?tab=users&status=muted&limit=${ limit } ` } > 禁言用户</ Link >
185+ </ Button >
186+ < Button asChild size = "sm" variant = { userStatus === "blocked" ? "default" : "outline" } >
187+ < Link to = { `/admin/bans?tab=users&status=blocked&limit=${ limit } ` } > 内容已屏蔽用户</ Link >
188+ </ Button >
189+ </ div >
190+ < Button size = "sm" onClick = { handleBulkUserGovernance } disabled = { bulkUserPending || selectedCurrentUserIds . length === 0 } >
191+ { bulkUserPending ? "处理中" : `批量${ userActionLabel } (${ selectedCurrentUserIds . length } )` }
192+ </ Button >
193+ </ div >
194+ </ AdminToolbar >
118195 < div className = "overflow-x-auto" >
119- < Table className = "min-w-[560px ]" >
196+ < Table className = "min-w-[680px ]" >
120197 < TableHeader >
121198 < TableRow >
199+ < TableHead className = "w-10" >
200+ < Checkbox checked = { allCurrentUsersSelected } onCheckedChange = { ( checked ) => toggleAllCurrentUsers ( checked === true ) } aria-label = { `选择当前页${ userStatusLabel } ` } />
201+ </ TableHead >
122202 < TableHead > 用户</ TableHead >
123203 < TableHead > 状态</ TableHead >
124- < TableHead > 操作</ TableHead >
204+ < TableHead className = "text-right" > 操作</ TableHead >
125205 </ TableRow >
126206 </ TableHeader >
127207 < TableBody >
128208 { bannedUsers . map ( ( u : any ) => (
129209 < TableRow key = { u . id } >
130- < TableCell > { u . loginname } </ TableCell >
131210 < TableCell >
132- < Badge variant = "destructive" > 禁言 </ Badge >
211+ < Checkbox checked = { selectedUserIds . includes ( Number ( u . id ) ) } onCheckedChange = { ( checked ) => toggleUserSelection ( Number ( u . id ) , checked === true ) } aria-label = { `选择 ${ u . loginname } ` } / >
133212 </ TableCell >
213+ < TableCell > { u . loginname } </ TableCell >
134214 < TableCell >
135- < Button size = "sm" variant = "ghost" onClick = { ( ) => handleUnblock ( u . loginname ) } >
136- 解禁
215+ < div className = "flex flex-wrap gap-1" >
216+ { u . is_muted && < Badge variant = "destructive" > 禁言</ Badge > }
217+ { u . is_block && < Badge variant = "destructive" > 内容已屏蔽</ Badge > }
218+ </ div >
219+ </ TableCell >
220+ < TableCell className = "text-right" >
221+ < Button size = "sm" variant = "ghost" onClick = { ( ) => handleSingleUserGovernance ( u . loginname ) } >
222+ { userActionLabel }
137223 </ Button >
138224 </ TableCell >
139225 </ TableRow >
140226 ) ) }
227+ { bannedUsers . length === 0 && (
228+ < TableRow >
229+ < TableCell colSpan = { 4 } className = "py-8 text-center text-sm text-muted-foreground" >
230+ 暂无{ userStatusLabel }
231+ </ TableCell >
232+ </ TableRow >
233+ ) }
141234 </ TableBody >
142235 </ Table >
143236 </ div >
144237 < div className = "px-4 pb-4" >
145- < Pagination page = { page } total = { bannedUsersTotal } limit = { limit } basePath = "/admin/bans" searchParams = { { tab : "users" } } />
238+ < Pagination page = { page } total = { bannedUsersTotal } limit = { limit } basePath = "/admin/bans" searchParams = { { tab : "users" , status : userStatus } } />
146239 </ div >
147240 </ AdminPanel >
148241 </ TabsContent >
0 commit comments