gongxiangp2/components/department-dashboard/PermissionGroupsDashboard.tsx

561 lines
38 KiB
TypeScript

'use client'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import { ChevronRight, Search, Plus, RefreshCw, Check, X } from 'lucide-react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
gsap.registerPlugin(useGSAP)
import DepartmentShell from './DepartmentShell'
import {
describeDepartmentGroupStatus,
type MaterialPermissionFlags,
} from '@/lib/organization-material-permissions'
type OrgNode = {
id: string
name: string
path: string
level: number
parentId: string | null
children: OrgNode[]
}
type Member = {
userId: string
username: string
displayName: string
departmentPath: string | null
departmentName: string | null
}
type Group = {
slug: string
name: string
videoEarlyAccess?: boolean
permissions: Record<string, MaterialPermissionFlags>
departments: Array<{ id?: string; organizationUnitId?: string | null; path: string; name: string; status?: 'active' | 'invalid' }>
members: Member[]
}
type Candidate = {
userId: string
username: string
displayName: string
departmentPath: string | null
departmentName: string | null
}
type Payload = {
materialTypes: string[]
tree: OrgNode[]
groups: Group[]
}
const FLAG_COLUMNS: Array<{ key: Exclude<keyof MaterialPermissionFlags, 'canShare'>; label: string }> = [
{ key: 'canView', label: '可见' },
{ key: 'canDownload', label: '下载' },
{ key: 'canDownloadSource', label: '源文件' },
{ key: 'canEditAssets', label: '编辑图片' },
{ key: 'canAccessPrivate', label: '私密素材查看/下载' },
]
const emptyFlags = (): MaterialPermissionFlags => ({
canView: false,
canDownload: false,
canDownloadSource: false,
canShare: true,
canEditAssets: false,
canAccessPrivate: false,
})
function flattenTree(nodes: OrgNode[]): OrgNode[] {
const rows: OrgNode[] = []
const walk = (list: OrgNode[]) => {
for (const node of list) {
rows.push(node)
if (node.children.length > 0) walk(node.children)
}
}
walk(nodes)
return rows
}
function groupSummary(group: Group): string {
const invalid = group.departments.filter((row) => row.status === 'invalid').length
return `${group.departments.length - invalid} 个部门${invalid ? ` · ${invalid} 失效` : ''} · ${group.members.length} 人`
}
export default function PermissionGroupsDashboard({ embedded = false }: { embedded?: boolean }) {
const { data: session, status } = useSession()
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
const [payload, setPayload] = useState<Payload | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const [draft, setDraft] = useState<Record<string, MaterialPermissionFlags>>({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [newName, setNewName] = useState('')
const [renameValue, setRenameValue] = useState('')
const [deptKeyword, setDeptKeyword] = useState('')
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [personKeyword, setPersonKeyword] = useState('')
const [candidates, setCandidates] = useState<Candidate[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'permissions' | 'departments' | 'members' | 'settings'>('permissions')
const [creating, setCreating] = useState(false)
const [materialKeyword, setMaterialKeyword] = useState('')
const [groupKeyword, setGroupKeyword] = useState('')
const workbenchRef = useRef<HTMLDivElement>(null)
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const mutationActive = useRef(false)
const mounted = useRef(true)
const controllers = useRef(new Set<AbortController>())
const requestJson = useCallback(async (init?: RequestInit) => {
const controller = new AbortController()
controllers.current.add(controller)
const timeout = setTimeout(() => controller.abort(), 15_000)
try {
const response = await fetch('/api/organization/permission-groups', { ...init, signal: controller.signal })
const body = await response.json().catch((error) => {
if (error instanceof Error && error.name === 'AbortError') throw error
throw new Error('服务器返回了无效响应,请刷新列表核对。')
})
return { response, body }
} finally {
clearTimeout(timeout)
controllers.current.delete(controller)
}
}, [])
const load = useCallback(async (options: { afterSave?: boolean } = {}) => {
setLoading(true)
setError(null)
try {
const { response, body } = await requestJson({ cache: 'no-store' })
if (!response.ok) throw new Error(body?.error || '加载失败')
if (!Array.isArray(body?.groups) || !Array.isArray(body?.tree) || !Array.isArray(body?.materialTypes)) {
throw new Error('权限组数据格式异常,请重试')
}
if (!mounted.current) return false
const next = body as Payload
setPayload(next)
if (!options.afterSave) { setDirty(false); setDraft({}) }
setSelectedSlug((current) => {
if (current && next.groups.some((group) => group.slug === current)) return current
return next.groups[0]?.slug || null
})
setExpanded((prev) => {
if (prev.size > 0) return prev
const first = next.tree[0]
return first ? new Set([first.id]) : new Set()
})
return true
} catch (loadError) {
if (mounted.current) setError(options.afterSave
? '变更已保存,但列表刷新失败。请点击“刷新列表”重新读取,不用再次保存。'
: loadError instanceof Error && loadError.name === 'AbortError'
? '加载权限组超时,请点击“刷新列表”重试。'
: loadError instanceof Error ? loadError.message : '加载失败')
return false
} finally {
if (mounted.current) setLoading(false)
}
}, [requestJson])
useEffect(() => {
mounted.current = true
const activeControllers = controllers.current
return () => {
mounted.current = false
activeControllers.forEach(controller => controller.abort())
}
}, [])
useEffect(() => {
if (status === 'authenticated' && isSuperAdmin) void load()
if (status === 'authenticated' && !isSuperAdmin) setLoading(false)
}, [status, isSuperAdmin, load])
const selected = payload?.groups.find((group) => group.slug === selectedSlug) || null
const renameDirty = Boolean(selected && renameValue !== selected.name)
useEffect(() => {
setRenameValue(selected?.name || '')
}, [selected?.slug, selected?.name])
useEffect(() => {
setDirty(false)
setDraft({})
setPersonKeyword('')
setCandidates([])
setDeptKeyword('')
setMaterialKeyword('')
}, [selected?.slug])
useEffect(() => {
let active = true
const activeControllers = controllers.current
const needle = personKeyword.trim()
setCandidates([])
setSearchError(null)
setSearching(Boolean(needle))
if (!needle) return
const controller = new AbortController()
activeControllers.add(controller)
const handle = window.setTimeout(async () => {
const timeout = window.setTimeout(() => controller.abort(), 15_000)
try {
const response = await fetch(
`/api/organization/user-permission-inherits/candidates?q=${encodeURIComponent(needle)}`,
{ signal: controller.signal },
)
const body = await response.json()
if (!response.ok || !Array.isArray(body?.candidates)) throw new Error('搜索失败,请修改关键词或重试。')
if (mounted.current && active) setCandidates(body.candidates)
} catch (searchFailure) {
if (mounted.current && active) {
setSearchError(searchFailure instanceof Error && searchFailure.name === 'AbortError'
? '搜索超时,请修改关键词重试。' : '搜索失败,请修改关键词或重试。')
}
} finally {
window.clearTimeout(timeout)
if (mounted.current && active) setSearching(false)
}
}, 250)
return () => {
active = false
window.clearTimeout(handle)
controller.abort()
activeControllers.delete(controller)
}
}, [personKeyword])
const selectedFlags = useMemo(() => {
if (!selected || !payload) return {}
if (dirty) return draft
const flags: Record<string, MaterialPermissionFlags> = {}
for (const materialType of payload.materialTypes) {
flags[materialType] = selected.permissions[materialType]
? { ...selected.permissions[materialType] }
: emptyFlags()
}
return flags
}, [selected, payload, dirty, draft])
const memberIds = useMemo(() => new Set(selected?.members.map((row) => row.userId) || []), [selected])
const departmentPaths = useMemo(() => new Set(selected?.departments.filter((row) => row.status !== 'invalid').map((row) => row.path) || []), [selected])
const memberships = useMemo(
() =>
(payload?.groups || []).flatMap((group) =>
group.departments.filter((row) => row.status !== 'invalid').map((row) => ({ departmentPath: row.path, slug: group.slug, name: group.name })),
),
[payload],
)
const matchingPaths = useMemo(() => {
if (!payload || !deptKeyword.trim()) return null
const needle = deptKeyword.trim().toLowerCase()
const matched = new Set<string>()
for (const node of flattenTree(payload.tree)) {
if (node.name.toLowerCase().includes(needle) || node.path.toLowerCase().includes(needle)) {
matched.add(node.path)
const parts = node.path.split('/')
let acc = ''
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part
matched.add(acc)
}
}
}
return matched
}, [payload, deptKeyword])
const mutate = async (body: Record<string, unknown>, success: string) => {
if (mutationActive.current) return false
mutationActive.current = true
setSaving(true)
setError(null)
setNotice(null)
try {
const { response, body: payloadBody } = await requestJson({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) throw new Error(payloadBody?.error || '保存失败')
if (payloadBody?.ok !== true) throw new Error('保存结果尚未确认,请刷新列表核对,不要重复保存。')
if (!mounted.current) return false
setNotice(success)
if (typeof payloadBody?.slug === 'string') setSelectedSlug(payloadBody.slug)
if (body.action === 'save-pack') {
if (!payloadBody.permissions || typeof payloadBody.permissions !== 'object' || Array.isArray(payloadBody.permissions)) {
throw new Error('权限已保存,但未收到完整结果,请刷新列表核对。')
}
setPayload((current) => current && ({ ...current, groups: current.groups.map(group => (
group.slug === body.slug ? { ...group, permissions: payloadBody.permissions } : group
)) }))
setDirty(false)
setDraft({})
} else {
// 保存已完成,后续列表刷新有自己的状态和超时,不能继续显示“保存中”。
setSaving(false)
await load({ afterSave: true })
}
return true
} catch (saveError) {
if (mounted.current) setError(saveError instanceof Error && saveError.name === 'AbortError'
? '保存请求超时,结果尚未确认。请刷新列表核对,不要重复保存。'
: saveError instanceof Error ? saveError.message : '保存失败')
return false
} finally {
mutationActive.current = false
if (mounted.current) setSaving(false)
}
}
const updateFlag = (materialType: string, key: keyof MaterialPermissionFlags, value: boolean) => {
if (!selected || !payload) return
const next = { ...selectedFlags, [materialType]: { ...selectedFlags[materialType], [key]: value } }
setDraft(next)
setDirty(payload.materialTypes.some(type => FLAG_COLUMNS.some(column =>
Boolean(next[type]?.[column.key]) !== Boolean(selected.permissions[type]?.[column.key]),
)))
setNotice(null)
}
const savePack = async () => {
if (!selectedSlug || !payload) return
await mutate(
{
action: 'save-pack',
slug: selectedSlug,
permissions: payload.materialTypes.map((materialType) => ({
materialType,
...(selectedFlags[materialType] || emptyFlags()),
canShare: true,
})),
},
'已保存',
)
}
const createGroup = async () => {
const name = newName.trim()
if (!name) return
if ((dirty || renameDirty) && !window.confirm('当前组有未保存的修改,创建新组并切换会丢弃这些修改。继续?')) return
if (await mutate({ action: 'create', name }, `已创建「${name}」`)) { setNewName(''); setCreating(false); setActiveTab('permissions') }
}
const renameGroup = async () => {
if (!selectedSlug) return
const name = renameValue.trim()
if (!name || name === selected?.name) return
await mutate({ action: 'rename', slug: selectedSlug, name }, '组名已保存。')
}
const deleteGroup = async () => {
if (!selected) return
const ok = window.confirm(`删除「${selected.name}」后,本组部门和人员将失去这组提供的权限,未保存的修改也会丢弃。确定删除?`)
if (!ok) return
await mutate({ action: 'delete', slug: selected.slug }, '权限组已删除')
}
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const changedTypes = payload?.materialTypes.filter(type => FLAG_COLUMNS.some(column =>
Boolean(selectedFlags[type]?.[column.key]) !== Boolean(selected?.permissions[type]?.[column.key]),
)).length || 0
const visibleTypes = payload?.materialTypes.filter(type => type.toLowerCase().includes(materialKeyword.trim().toLowerCase())) || []
const visibleGroups = payload?.groups.filter(group => group.name.toLowerCase().includes(groupKeyword.trim().toLowerCase())) || []
const busy = saving || loading
const tabs = [
{ key: 'permissions', name: '素材权限' },
{ key: 'departments', name: '部门' },
{ key: 'members', name: '人员' },
{ key: 'settings', name: '组设置' },
] as const
const primary = 'inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#0050cb] px-4 text-sm font-semibold text-white hover:bg-[#003da3] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1677ff] disabled:cursor-not-allowed disabled:bg-[#dce5ef] disabled:text-[#66758b]'
const secondary = 'inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm font-medium text-[#4f6077] hover:bg-[#f8fafc] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50'
const field = 'min-h-11 w-full min-w-0 rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm text-[#101828] placeholder:text-[#66758b] focus:outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20 disabled:bg-[#f8fafc]'
useGSAP(() => {
if (!selected) return
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo('[data-permission-panel]', { y: 5, opacity: 0.8 }, {
y: 0, opacity: 1, duration: 0.18, ease: 'expo.out', clearProps: 'transform,opacity',
})
}, workbenchRef)
return () => motion.revert()
}, { scope: workbenchRef, dependencies: [selectedSlug, activeTab], revertOnUpdate: true })
useEffect(() => {
if (!dirty && !renameDirty) return
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [dirty, renameDirty])
const selectGroup = (slug: string) => {
if (slug === selectedSlug) return
if ((dirty || renameDirty) && !window.confirm('当前组有未保存的修改,切换后会丢弃。继续切换?')) return
setSelectedSlug(slug)
setError(null)
setNotice(null)
}
const renderNode = (node: OrgNode, depth = 0): ReactNode => {
if (matchingPaths && !matchingPaths.has(node.path)) return null
const hasChildren = node.children.length > 0
const isExpanded = Boolean(matchingPaths) || expanded.has(node.id)
const groupStatus = describeDepartmentGroupStatus(node.path, selectedSlug, memberships)
const isDirect = departmentPaths.has(node.path)
return (
<div key={node.id}>
<div className="flex min-h-[60px] items-center gap-1 border-b border-[#eef2f6] pr-3 hover:bg-[#f8fafc]" style={{ paddingLeft: Math.min(depth, 4) * 16 + 4 }}>
{hasChildren ? <button type="button" aria-expanded={isExpanded} aria-label={`${isExpanded ? '收起' : '展开'}${node.name}`} onClick={() => toggleExpand(node.id)} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] hover:bg-[#eef2f6] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]">
<ChevronRight className={`h-4 w-4 ${isExpanded ? 'rotate-90' : ''}`} />
</button> : <span className="w-11 shrink-0" />}
<div className="min-w-0 flex-1 py-2">
<p className={`break-words text-sm ${isDirect ? 'font-semibold text-[#0050cb]' : 'text-[#101828]'}`}>{node.name}</p>
<p className={`mt-0.5 break-words text-xs ${groupStatus.kind === 'other' ? 'text-[#9a4d08]' : 'text-[#66758b]'}`}>{groupStatus.label}</p>
</div>
{groupStatus.kind === 'other' ? <span className="shrink-0 text-xs text-[#9a4d08]">占用</span> :
<button type="button" disabled={busy || !selected} aria-label={`${isDirect ? '移除' : '加入'}${node.name}`} onClick={() => void mutate({ action: isDirect ? 'remove-department' : 'add-department', slug: selectedSlug, organizationUnitId: node.id, path: node.path }, isDirect ? `已从本组移除 ${node.name}` : `已把 ${node.name} 加入本组`)} className={`${secondary} shrink-0`}>
{isDirect ? '移除' : '加入'}
</button>}
</div>
{hasChildren && isExpanded && node.children.map(child => renderNode(child, depth + 1))}
</div>
)
}
const Frame = embedded ? Fragment : DepartmentShell
const Content = embedded ? 'div' : 'main'
const refreshButton = isSuperAdmin && <button type="button" disabled={busy} onClick={() => {
if ((dirty || renameDirty) && !window.confirm('当前修改尚未保存,刷新后将以服务器数据为准。继续?')) return
void load()
}} aria-label={loading ? '刷新中…' : '刷新列表'} title="刷新列表" className={`${secondary} w-11 px-0`}><RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin motion-reduce:animate-none' : ''}`} /></button>
return (
<Frame>
<Content className={embedded ? 'lg:flex lg:h-full lg:min-h-0 lg:flex-col' : 'px-4 py-5 sm:px-6 lg:flex lg:h-[calc(100dvh-72px)] lg:min-h-0 lg:flex-col lg:px-8'}>
{!embedded && <header className="mb-4 flex shrink-0 flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-[#101828]">权限组</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
{refreshButton}
<Link href="/workspaces/governance/permissions?view=people" aria-disabled={busy} onClick={event => {
if (busy || ((dirty || renameDirty) && !window.confirm('当前组有未保存的修改,离开后会丢弃。继续前往按人看权限?'))) event.preventDefault()
}} className={`${secondary} ${busy ? 'cursor-not-allowed opacity-50' : ''}`}>按人看权限<ChevronRight className="h-4 w-4" /></Link>
</div>
</header>}
{status === 'loading' || (loading && !payload) ? <section aria-busy="true" aria-label="加载权限组" className="space-y-4 rounded-xl border border-[#e2e8f0] bg-white p-6"><p className="text-sm text-[#66758b]">正在加载权限组…</p><div className="h-12 rounded-lg bg-[#eef2f6] motion-safe:animate-pulse" /><div className="h-64 rounded-lg bg-[#f8fafc] motion-safe:animate-pulse" /></section> : !isSuperAdmin ? <div className="rounded-xl border border-[#e2e8f0] bg-white p-6 text-sm text-[#66758b]">只有超级管理员可以改权限组。</div> : <>
{error && <div role="alert" className="mb-4 shrink-0 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>}
{notice && <div role="status" className="mb-3 flex shrink-0 items-center gap-1.5 text-sm text-[#087052]"><Check className="h-4 w-4 shrink-0" />{notice}</div>}
<fieldset disabled={busy} aria-label="权限组设置" className="m-0 grid min-w-0 gap-4 border-0 p-0 lg:min-h-0 lg:flex-1 lg:grid-cols-[224px_minmax(0,1fr)]">
<aside className="self-start rounded-xl border border-[#e2e8f0] bg-white lg:sticky lg:top-4 lg:flex lg:max-h-full lg:min-h-0 lg:flex-col">
<div className="border-b border-[#e2e8f0] p-3">
<div className="mb-2 flex items-center justify-between"><h2 className="text-sm font-semibold text-[#101828]">权限组 <span className="ml-1 font-normal text-[#66758b]">{payload?.groups.length || 0}</span></h2><button type="button" aria-expanded={creating} onClick={() => setCreating(!creating)} className="flex min-h-11 items-center gap-1 rounded-lg px-2 text-sm font-semibold text-[#0050cb] hover:bg-[#e6f4ff] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]"><Plus className="h-4 w-4" />新建</button>{embedded && refreshButton}</div>
{creating && <form className="mb-3 space-y-2 border-b border-[#e2e8f0] pb-3" onSubmit={event => { event.preventDefault(); void createGroup() }}>
<label htmlFor="new-permission-group" className="block text-xs font-semibold text-[#4f6077]">新建权限组</label>
<input id="new-permission-group" autoFocus value={newName} onChange={event => setNewName(event.target.value)} placeholder="例如:运营" className={field} />
<div className="flex gap-2"><button type="submit" disabled={!newName.trim()} className={`${primary} flex-1`}>创建</button><button type="button" onClick={() => { setCreating(false); setNewName('') }} className={secondary}>取消</button></div>
</form>}
<div className="hidden lg:block"><label className="sr-only" htmlFor="permission-group-search">搜索权限组</label><div className="relative"><Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" /><input id="permission-group-search" value={groupKeyword} onChange={event => setGroupKeyword(event.target.value)} placeholder="搜索权限组" className={`${field} pl-9`} /></div></div>
<div className="lg:hidden"><label htmlFor="mobile-permission-group" className="sr-only">选择权限组</label><select id="mobile-permission-group" value={selectedSlug || ''} onChange={event => selectGroup(event.target.value)} className={field}><option value="" disabled>选择权限组</option>{payload?.groups.map(group => <option key={group.slug} value={group.slug}>{group.name} · {groupSummary(group)}</option>)}</select></div>
</div>
<nav aria-label="权限组" className="hidden gap-1 p-2 lg:flex lg:max-h-[60dvh] lg:min-h-0 lg:flex-col lg:overflow-y-auto">
{visibleGroups.map(group => <button key={group.slug} type="button" aria-current={selectedSlug === group.slug ? 'true' : undefined} onClick={() => selectGroup(group.slug)} className={`min-h-[68px] min-w-[144px] shrink-0 rounded-lg px-3 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff] lg:min-w-0 lg:w-full ${selectedSlug === group.slug ? 'bg-[#e6f4ff] text-[#0050cb]' : 'text-[#101828] hover:bg-[#f8fafc]'}`}><span className="block break-words text-sm font-semibold">{group.name}</span><span className="mt-1 block text-xs text-[#66758b]">{groupSummary(group)}</span></button>)}
{visibleGroups.length === 0 && <p className="p-3 text-sm text-[#66758b]">{groupKeyword ? '未找到权限组' : '暂无权限组'}</p>}
</nav>
</aside>
<div ref={workbenchRef} className="min-w-0 rounded-xl border border-[#e2e8f0] bg-white lg:flex lg:h-full lg:min-h-0 lg:flex-col">
{!selected || !payload ? <div className="p-8 text-sm text-[#66758b]">请选择权限组</div> : <>
<header className="sr-only">
<h2>{selected.name}</h2>
</header>
<div role="tablist" aria-label="权限组配置" className="mt-4 grid shrink-0 grid-cols-4 border-b border-[#e2e8f0] px-2 sm:flex sm:px-6">
{tabs.map((tab, index) => <button key={tab.key} ref={element => { tabRefs.current[index] = element }} id={`permission-tab-${tab.key}`} role="tab" type="button" aria-selected={activeTab === tab.key} aria-controls={`permission-panel-${tab.key}`} tabIndex={activeTab === tab.key ? 0 : -1} onClick={() => setActiveTab(tab.key)} onKeyDown={event => {
let next = index
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
else if (event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = tabs.length - 1
else return
event.preventDefault(); setActiveTab(tabs[next].key); tabRefs.current[next]?.focus()
}} className={`flex min-h-12 min-w-0 items-center justify-center gap-1.5 border-b-2 px-1 text-xs font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-4px] focus-visible:outline-[#1677ff] sm:px-5 sm:text-sm ${activeTab === tab.key ? 'border-[#1677ff] text-[#0050cb]' : 'border-transparent text-[#66758b] hover:bg-[#f8fafc] hover:text-[#101828]'}`}>{tab.name}</button>)}
</div>
<section key={`${selectedSlug}-${activeTab}`} id={`permission-panel-${activeTab}`} aria-labelledby={`permission-tab-${activeTab}`} role="tabpanel" data-permission-panel tabIndex={0} className="min-w-0 px-4 py-5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff] sm:px-6 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
{activeTab === 'permissions' && <>
<div className="mb-4 flex justify-end">
<div className="w-full sm:w-60"><label className="sr-only" htmlFor="permission-type-search">搜索素材类型</label><div className="relative"><Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" /><input id="permission-type-search" value={materialKeyword} onChange={event => setMaterialKeyword(event.target.value)} placeholder="搜索素材类型" className={`${field} pl-9`} /></div></div>
</div>
<div className="hidden overflow-x-auto rounded-lg border border-[#e2e8f0] sm:block">
<table className="w-full min-w-[660px] border-collapse text-sm"><caption className="sr-only">{selected.name}的素材权限</caption><thead className="bg-[#f8fafc]"><tr><th scope="col" className="px-4 py-3 text-left font-semibold text-[#4f6077]">素材类型</th>{FLAG_COLUMNS.map(column => <th key={column.key} scope="col" className="px-2 py-3 text-center text-xs font-semibold text-[#4f6077]">{column.label}</th>)}</tr></thead><tbody>
{visibleTypes.map(type => <tr key={type} className="border-t border-[#e2e8f0] hover:bg-[#f8fafc]"><th scope="row" className="px-4 py-2 text-left font-medium text-[#101828]">{type}</th>{FLAG_COLUMNS.map(column => { const unavailable = (column.key === 'canEditAssets' || column.key === 'canAccessPrivate') && type !== '任务-详情'; return <td key={column.key} className="px-2 text-center">{unavailable ? <span aria-label={`${type} ${column.label}不适用`} title="不适用" className="text-xs text-[#66758b]">—</span> : <label className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg hover:bg-[#e6f4ff]"><input type="checkbox" aria-label={`${type} ${column.label}`} checked={Boolean(selectedFlags[type]?.[column.key])} onChange={event => updateFlag(type, column.key, event.target.checked)} className="h-[18px] w-[18px] accent-[#1677ff] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1677ff]" /></label>}</td> })}</tr>)}
</tbody></table>
</div>
<div className="divide-y divide-[#e2e8f0] border-y border-[#e2e8f0] sm:hidden">{visibleTypes.map(type => <div key={type} className="py-4"><h4 className="mb-2 text-sm font-semibold text-[#101828]">{type}</h4><div className="grid grid-cols-2 gap-x-2">{FLAG_COLUMNS.filter(column => type === '任务-详情' || !['canEditAssets', 'canAccessPrivate'].includes(column.key)).map(column => <label key={column.key} className="flex min-h-11 cursor-pointer items-center gap-2 rounded-lg text-sm text-[#4f6077]"><input type="checkbox" aria-label={`${type} ${column.label}`} checked={Boolean(selectedFlags[type]?.[column.key])} onChange={event => updateFlag(type, column.key, event.target.checked)} className="h-[18px] w-[18px] shrink-0 accent-[#1677ff] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1677ff]" />{column.label}</label>)}</div></div>)}</div>
{visibleTypes.length === 0 && <p className="py-8 text-sm text-[#66758b]">{materialKeyword ? '未找到素材类型' : '暂无素材类型'}</p>}
</>}
{activeTab === 'departments' && <>
<p className="mb-4 text-xs text-[#66758b]">立即生效</p>
<div className="grid gap-5 xl:grid-cols-[minmax(180px,0.8fr)_minmax(0,1.5fr)]">
<section><h4 className="mb-3 text-sm font-semibold text-[#4f6077]">部门 · {selected.departments.length}</h4>{selected.departments.length === 0 ? <p className="text-sm leading-6 text-[#66758b]">暂无部门</p> : <ul className="divide-y divide-[#e2e8f0]">{selected.departments.map(department => <li key={department.id || department.path} className="flex items-center justify-between gap-2 py-2"><div className="min-w-0"><p title={department.path} className="break-words text-sm font-medium text-[#101828]">{department.name}</p>{department.status === 'invalid' && <p className="text-xs leading-5 text-[#b42318]">关联已失效</p>}<p className="sr-only">{department.path}</p></div><button type="button" aria-label={`移除已加入的${department.name}`} className={secondary} onClick={() => void mutate({ action: 'remove-department', slug: selected.slug, associationId: department.id, organizationUnitId: department.organizationUnitId, path: department.path }, `已从本组移除 ${department.name}`)}>移除</button></li>)}</ul>}</section>
<section className="min-w-0"><label htmlFor="group-department-search" className="sr-only">查找并加入部门</label><input id="group-department-search" value={deptKeyword} onChange={event => setDeptKeyword(event.target.value)} placeholder="搜索部门名称或路径" className={field} /><nav aria-label="花名册组织树" className="mt-3 rounded-lg border border-[#e2e8f0]">{payload.tree.map(node => renderNode(node))}{(payload.tree.length === 0 || (matchingPaths && matchingPaths.size === 0)) && <p className="p-4 text-sm text-[#66758b]">{deptKeyword ? '未找到部门' : '暂无组织架构,请同步花名册'}</p>}</nav></section>
</div>
</>}
{activeTab === 'members' && <>
<div className="flex max-w-xl flex-wrap items-center gap-3"><label htmlFor="group-user-search" className="sr-only">搜人加入本组</label><div className="relative min-w-0 flex-1 basis-48"><Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" /><input id="group-user-search" value={personKeyword} onChange={event => setPersonKeyword(event.target.value)} placeholder="搜花名 / 工号 / 账号" className={`${field} pl-9 pr-11`} />{personKeyword && <button type="button" aria-label="清空人员搜索" onClick={() => setPersonKeyword('')} className="absolute right-0 top-0 flex h-11 w-11 items-center justify-center rounded-lg text-[#66758b] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]"><X className="h-4 w-4" /></button>}</div><span className="text-xs text-[#66758b]">立即生效</span>
<div aria-live="polite" className="w-full">{searching && <p className="text-sm text-[#66758b]">搜索中…</p>}{searchError && <p className="text-sm text-[#b42318]">{searchError}</p>}{personKeyword.trim() && !searching && !searchError && candidates.length === 0 && <p className="text-sm text-[#66758b]">未找到人员</p>}</div>
{candidates.length > 0 && <ul aria-label="人员搜索结果" className="w-full divide-y divide-[#e2e8f0] rounded-lg border border-[#e2e8f0]">{candidates.map(candidate => <li key={candidate.userId} className="flex items-center justify-between gap-3 p-3"><div className="min-w-0"><p className="break-words text-sm font-semibold text-[#101828]">{candidate.displayName}</p><p className="mt-1 break-words text-xs text-[#66758b]">{candidate.departmentName || '未确定部门'} · @{candidate.username}</p></div><button type="button" disabled={memberIds.has(candidate.userId)} className={secondary} onClick={async () => { if (await mutate({ action: 'add-member', slug: selected.slug, userId: candidate.userId }, `已把 ${candidate.displayName} 加入本组`)) setPersonKeyword('') }}>{memberIds.has(candidate.userId) ? '已在本组' : '加入'}</button></li>)}</ul>}
</div>
<h4 className="mb-2 mt-6 text-sm font-semibold text-[#4f6077]">已加入 · {selected.members.length}</h4>
{selected.members.length === 0 ? <p className="border-t border-[#e2e8f0] py-6 text-sm leading-6 text-[#66758b]">暂无人员</p> : <ul className="divide-y divide-[#e2e8f0] border-y border-[#e2e8f0]">{selected.members.map(member => <li key={member.userId} className="flex items-center justify-between gap-3 py-3"><div className="min-w-0"><p className="break-words text-sm font-semibold text-[#101828]">{member.displayName}<span className="ml-2 font-normal text-[#66758b]">@{member.username}</span></p><p title={member.departmentPath || undefined} className="mt-1 break-words text-xs text-[#66758b]">{member.departmentName || member.departmentPath || '未确定部门'}</p></div><button type="button" className={secondary} aria-label={`移除${member.displayName}`} onClick={() => void mutate({ action: 'remove-member', slug: selected.slug, userId: member.userId }, `已从本组移除 ${member.displayName}`)}>移除</button></li>)}</ul>}
</>}
{activeTab === 'settings' && <div className="max-w-2xl space-y-6">
<form onSubmit={event => { event.preventDefault(); void renameGroup() }}><label htmlFor="rename-permission-group" className="block text-base font-semibold text-[#101828]">组名</label><div className="mt-3 flex flex-wrap gap-2"><input id="rename-permission-group" value={renameValue} onChange={event => setRenameValue(event.target.value)} className={`${field} flex-1 basis-48`} /><button type="submit" disabled={!renameValue.trim() || renameValue.trim() === selected.name} className={secondary}>保存组名</button></div></form>
<section className="border-t border-[#e2e8f0] pt-5"><div className="flex items-start justify-between gap-4"><div><h3 className="text-base font-semibold text-[#101828]">视频提前访问</h3><p id="early-access-description" className="mt-2 text-sm leading-6 text-[#66758b]">跳过视频 15 天保护期 · 立即生效</p></div><button type="button" role="switch" aria-label="视频提前访问" aria-describedby="early-access-description" aria-checked={Boolean(selected.videoEarlyAccess)} onClick={() => void mutate({ action: 'set-video-early-access', slug: selected.slug, enabled: !selected.videoEarlyAccess }, selected.videoEarlyAccess ? '已关闭视频提前访问' : '已开启视频提前访问')} className="flex h-11 w-14 shrink-0 items-center justify-center rounded-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]"><span className={`relative inline-flex h-6 w-11 items-center rounded-full ${selected.videoEarlyAccess ? 'bg-[#1677ff]' : 'bg-[#66758b]'}`}><span className={`h-5 w-5 rounded-full bg-white transition-transform motion-reduce:transition-none ${selected.videoEarlyAccess ? 'translate-x-[22px]' : 'translate-x-0.5'}`} /></span></button></div></section>
<section className="border-t border-[#e2e8f0] pt-5"><button type="button" onClick={() => void deleteGroup()} className={`${secondary} border-[#f5c2c0] text-[#b42318] hover:bg-[#fff5f4]`}>删除权限组</button></section>
</div>}
</section>
{(activeTab === 'permissions' || dirty) && <footer className="sticky bottom-0 z-10 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-b-xl border-t border-[#e2e8f0] bg-white px-4 py-3 sm:px-6 lg:static">
<p role="status" aria-live="polite" className="text-sm font-medium text-[#9a4d08]">{dirty ? `${changedTypes} 项未保存` : ''}</p>
<div className="flex flex-wrap gap-2">{dirty && <button type="button" aria-label="撤销修改" onClick={() => { setDraft({}); setDirty(false); setNotice(null) }} className={secondary}>撤销</button>}<button type="button" aria-label={saving ? '保存中…' : '保存这组权限'} disabled={busy || !dirty || payload.materialTypes.length === 0} onClick={() => void savePack()} className={primary}>{saving ? '保存中…' : '保存'}</button></div>
</footer>}
</>}
</div>
</fieldset>
</>}
</Content>
</Frame>
)
}