561 lines
38 KiB
TypeScript
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>
|
|
)
|
|
}
|