gongxiangp2/components/department-dashboard/UserPermissionsDashboard.tsx

251 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import DepartmentShell from './DepartmentShell'
type GroupRef = { slug: string; name: string }
type DepartmentGroup = GroupRef & {
attachedPath: string
source: 'own' | 'inherit' | string
}
type Person = {
userId: string
username: string
displayName: string
jobNumber: string | null
departmentPath: string | null
departmentName: string | null
departmentGroups: DepartmentGroup[]
personalGroups: GroupRef[]
}
export default function UserPermissionsDashboard() {
const { data: session, status } = useSession()
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
const [keyword, setKeyword] = useState('')
const [people, setPeople] = useState<Person[]>([])
const [groups, setGroups] = useState<GroupRef[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const search = useCallback(async (needle: string) => {
if (!needle.trim()) {
setPeople([])
return
}
setLoading(true)
setError(null)
try {
const response = await fetch(
`/api/organization/user-permissions?q=${encodeURIComponent(needle.trim())}`,
{ cache: 'no-store' },
)
const body = await response.json().catch(() => null)
if (!response.ok) throw new Error(body?.error || '搜索失败')
setPeople(body.people || [])
setGroups(body.groups || [])
} catch (searchError) {
setError(searchError instanceof Error ? searchError.message : '搜索失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (status !== 'authenticated' || !isSuperAdmin) return
const handle = window.setTimeout(() => {
void search(keyword)
}, 250)
return () => window.clearTimeout(handle)
}, [keyword, status, isSuperAdmin, search])
const mutate = async (body: Record<string, unknown>, success: string) => {
setSaving(true)
setError(null)
setNotice(null)
try {
const response = await fetch('/api/organization/user-permissions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const payload = await response.json().catch(() => null)
if (!response.ok) throw new Error(payload?.error || '保存失败')
setNotice(success)
await search(keyword)
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : '保存失败')
} finally {
setSaving(false)
}
}
return (
<DepartmentShell>
<main className="flex h-[calc(100dvh-72px)] flex-col overflow-hidden px-4 pt-4 sm:px-6 lg:px-8">
<header className="shrink-0 pb-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[#214ba7]">治理与设置</p>
<div className="mt-1 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-[-0.03em] text-[#101828]">按人看权限</h1>
<p className="mt-1 max-w-3xl text-sm leading-6 text-[#66758b]">
搜九千七这种人,看他部门带了哪组,自己又加了哪组。不要再改账号上的部门名。
</p>
</div>
<Link
href="/workspaces/governance/roles-permissions"
className="text-sm font-semibold text-[#1677ff] hover:underline"
>
回到权限组
</Link>
</div>
<label className="sr-only" htmlFor="user-permission-search">搜索人员</label>
<input
id="user-permission-search"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜花名 / 工号 / 账号,例如九千七"
className="mt-4 h-11 w-full max-w-xl rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#5a9ce6]/40"
/>
</header>
{status === 'loading' ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">加载中…</div>
) : !isSuperAdmin ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
只有超级管理员可以改人员权限。
</div>
) : (
<div className="min-h-0 flex-1 overflow-auto pb-6">
{error && (
<div className="mb-4 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>
)}
{notice && (
<div className="mb-4 rounded-lg border border-[#b7ebc6] bg-[#f0fff4] px-4 py-3 text-sm text-[#087052]">{notice}</div>
)}
{loading && <p className="mb-3 text-sm text-[#66758b]">搜索中…</p>}
{!keyword.trim() ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
先搜一个人。部门带来的组不能在这里拿掉,要去权限组里改部门名单。
</div>
) : people.length === 0 && !loading ? (
<div className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-10 text-sm text-[#66758b]">
没有找到这个人,或他还没有钉钉绑定。
</div>
) : (
<ul className="space-y-3">
{people.map((person) => {
const personalSlugs = new Set(person.personalGroups.map((row) => row.slug))
const extraGroups = groups.filter((group) => !personalSlugs.has(group.slug))
return (
<li key={person.userId} className="rounded-xl border border-[#e2e8f0] bg-white px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-[#101828]">{person.displayName}</h2>
<p className="mt-1 text-sm text-[#66758b]">
@{person.username}
{person.jobNumber ? ` · ${person.jobNumber}` : ''}
{person.departmentName ? ` · ${person.departmentName}` : ''}
</p>
{person.departmentPath && (
<p className="mt-1 text-xs text-[#66758b]">{person.departmentPath}</p>
)}
</div>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs font-semibold text-[#4f6077]">部门带来的组</p>
{person.departmentGroups.length === 0 ? (
<p className="mt-2 text-sm text-[#66758b]">这个部门还没进组,暂时走旧的部门权限。</p>
) : (
<ul className="mt-2 space-y-1.5">
{person.departmentGroups.map((group) => (
<li key={`${person.userId}-${group.slug}`} className="text-sm text-[#101828]">
{group.name}
<span className="ml-2 text-xs text-[#66758b]">
{group.source === 'own'
? '本部门在组里'
: `随 ${group.attachedPath.split('/').pop()}`}
</span>
</li>
))}
</ul>
)}
</div>
<div>
<p className="text-xs font-semibold text-[#4f6077]">个人加的组</p>
{person.personalGroups.length === 0 ? (
<p className="mt-2 text-sm text-[#66758b]">还没有单独加组</p>
) : (
<ul className="mt-2 space-y-1.5">
{person.personalGroups.map((group) => (
<li key={group.slug} className="flex items-center justify-between gap-3 text-sm">
<span className="text-[#101828]">{group.name}</span>
<button
type="button"
disabled={saving}
onClick={() =>
void mutate(
{ action: 'remove-member', userId: person.userId, slug: group.slug },
`已把 ${person.displayName} 从「${group.name}」拿掉`,
)
}
className="text-xs font-semibold text-[#b42318] hover:underline"
>
去掉
</button>
</li>
))}
</ul>
)}
{extraGroups.length > 0 && (
<label className="mt-3 block">
<span className="sr-only">再加一组</span>
<Select
disabled={saving}
defaultValue=""
onChange={(event) => {
const slug = event.target.value
if (!slug) return
const group = extraGroups.find((item) => item.slug === slug)
void mutate(
{ action: 'add-member', userId: person.userId, slug },
`已把 ${person.displayName} 加进「${group?.name || slug}」`,
)
event.target.value = ''
}}
className="mt-1 h-9 w-full max-w-xs rounded-lg border border-[#e2e8f0] bg-[#f8fafc] px-2 text-sm"
>
<option value="">再加一组…</option>
{extraGroups.map((group) => (
<option key={group.slug} value={group.slug}>
{group.name}
</option>
))}
</Select>
</label>
)}
</div>
</div>
</li>
)
})}
</ul>
)}
</div>
)}
</main>
</DepartmentShell>
)
}