feat: streamline permission group and user management interfaces

This commit is contained in:
WuYongJie 2026-10-07 17:28:02 +08:00
parent 73c1e98309
commit c9f5110074
6 changed files with 487 additions and 568 deletions

View File

@ -461,6 +461,12 @@
"description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。",
"projection": "<section class=\"ds-data-source\"><nav class=\"ds-data-source-nav\" aria-label=\"数据口径二级导航\"><a class=\"ds-data-source-item\" href=\"#\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"5\" r=\"2\"/><circle cx=\"5\" cy=\"19\" r=\"2\"/><circle cx=\"19\" cy=\"19\" r=\"2\"/><path d=\"M12 7v5M5 17v-2h14v2\"/></svg><span><b>公司组织架构</b><small>组织树与人员明细</small></span></a></nav><section class=\"ds-data-source-live\" aria-label=\"公司组织架构操作区\"><strong>组织架构</strong><p>沿用真实人员层级、归属明细与管理员权限。</p></section></section><style>.ds-data-source{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:360px;background:#fff;font-family:ui-sans-serif,system-ui}.ds-data-source-nav{border-right:1px solid #e2e8f0;background:#f8fafc;padding:24px 16px}.ds-data-source-item{display:flex;min-height:58px;align-items:center;gap:12px;border-radius:8px;background:#e6f4ff;padding:10px 12px;color:#0050cb;text-decoration:none}.ds-data-source-item svg{width:16px;height:16px;flex:none;fill:none;stroke:#1677ff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-data-source-item b,.ds-data-source-item small{display:block}.ds-data-source-item b{font-size:14px}.ds-data-source-item small{margin-top:2px;color:#315f91;font-size:12px}.ds-data-source-item:focus-visible{outline:2px solid #5a9ce6}.ds-data-source-live{min-width:0;background:#fafafa;padding:24px;color:#101828}.ds-data-source-live strong{font-size:20px}.ds-data-source-live p{margin:8px 0 0;color:#66758b;font-size:14px}@media(max-width:1023px){.ds-data-source{grid-template-columns:1fr}.ds-data-source-nav{border-right:0;border-bottom:1px solid #e2e8f0;padding:16px}.ds-data-source-item small{display:none}}</style>"
},
{
"name": "Permission Groups Primary Button",
"type": "button",
"description": "仅用于 PermissionGroupsDashboard 创建权限组与明确保存素材权限的深蓝主按钮;全局 Action Blue 不变。",
"projection": "<button class=\"ds-permission-groups-primary\" type=\"button\">保存这组权限</button><style>.ds-permission-groups-primary{display:inline-flex;min-height:44px;align-items:center;justify-content:center;gap:8px;border:0;border-radius:8px;background:#0050cb;padding:0 16px;color:#fff;font:600 14px/1.4 ui-sans-serif,system-ui;cursor:pointer}.ds-permission-groups-primary:hover{background:#003da3}.ds-permission-groups-primary:focus-visible{outline:2px solid #1677ff;outline-offset:2px}.ds-permission-groups-primary:disabled{cursor:not-allowed;background:#dce5ef;color:#66758b}</style>"
},
{
"name": "Standalone Material Library Shell",
"type": "navigation",

View File

@ -299,6 +299,23 @@ components:
- **Actions:** 顶栏只提供素材资产库定位和退出登录;素材上传、筛选、分享与下载能力仍由原业务权限控制。
- **Security:** `/` 与 `/workspaces/*` 同时经过会话声明、Middleware 和服务端 Layout 门禁,禁止仅靠 CSS 或条件隐藏导航。
### Permission Groups Workbench
- **Scope:** `PermissionGroupsDashboard` 延续 Department Shell 的深色导航轨、浅色画布与白色工作面,采用任务优先的 Operate 模式。
- **Structure:** 桌面左侧为可搜索的 224px 权限组列表,右侧固定“素材权限、部门、人员、组设置”四个页签。主区域以 flex 填满顶栏下方视口,页签内容独立滚动,底部保存栏作为正常 flex 项占位。
- **Mobile:** 1024px 以下以原生选择框切换权限组;640px 以下权限矩阵转为两列勾选列表,四个页签保留文字,保存栏在页面底部 sticky。
- **Save States:** 素材权限明确点击“保存”,有草稿时底栏显示“X 项未保存”并提供“撤销”;草稿跨页签与同组部门、人员管理动作保留。组名通过“保存组名”确认;部门、人员归属及视频提前访问切换立即生效,界面明确标注。
- **Minimal Copy:** 控件文字承担说明;移除重复标题、数量和大段帮助,仅保留未保存、立即生效、错误与确认信息。部门/人员/组设置无素材草稿时不显示保存栏;组织完整路径保留在悬停提示与辅助语义中。
- **Scoped Primary Action:** 本工作台的创建与权限保存按钮使用深蓝底(`#0050cb`)和白字,对比度约 6.98:1;hover 为 `#003da3`,focus-visible 保留 `#1677ff` 轮廓。此变体仅限权限组工作台,其他页面沿用全局 Action Blue。
- **Interaction:** 页签提供 tab/tabpanel 关联与方向键、Home/End 导航。组或页签切换以限定在工作面内的 GSAP `useGSAP` 呈现 180ms `expo.out` 轻移与显现;reduced-motion 时不运行 tween,更新与卸载时 revert 清理。
### User Permissions List
- **Scope:** `UserPermissionsDashboard` 属于 Operate 模式,沿用 Department Shell 的视觉语言。
- **Density:** 搜索结果使用一个白色列表,桌面共享“人员、部门组、个人组、加组”表头,常规人员行约 68px;姓名、账号/部门、继承来源和个人组均在同行,不再为每个人建立大卡片。
- **Responsive:** 1280px 以下转为两列;640px 以下加组与人物身份同一行,部门组和个人组在下方换行。长名称与多组允许行高自然增加。
- **Interaction:** 个人组提供具备人员/组名的移除按钮,独立加组选择器保留原提交行为;部门继承来源仅展示。完整部门路径保留在悬停提示及辅助语义中,错误与成功状态使用 alert/status。
## Do's and Don'ts
### Do:

View File

@ -1,7 +1,10 @@
import { act, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PermissionGroupsDashboard from './PermissionGroupsDashboard'
jest.mock('@gsap/react', () => ({ useGSAP: jest.fn() }))
jest.mock('gsap', () => ({ __esModule: true, default: { registerPlugin: jest.fn() } }))
jest.mock('next-auth/react', () => ({
useSession: () => ({
status: 'authenticated',
@ -156,7 +159,11 @@ async function renderDashboard() {
}
function getTaskDetailCheckbox(label: string) {
return screen.getByRole('checkbox', { name: `任务-详情 ${label}` })
return within(screen.getByRole('table')).getByRole('checkbox', { name: `任务-详情 ${label}` })
}
async function openDepartments() {
await userEvent.click(screen.getByRole('tab', { name: /部门/ }))
}
async function advanceAndFlush(ms: number) {
@ -177,13 +184,14 @@ describe('PermissionGroupsDashboard', () => {
await renderDashboard()
expect(await screen.findByRole('navigation', { name: '花名册组织树' })).toBeInTheDocument()
expect(within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /运营/ })).toBeInTheDocument()
expect(screen.getByText('已在本组')).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: '可见' })).toBeInTheDocument()
expect(screen.queryByRole('columnheader', { name: '分享' })).not.toBeInTheDocument()
expect(getTaskDetailCheckbox('编辑图片')).not.toBeChecked()
expect(getTaskDetailCheckbox('私密素材查看/下载')).not.toBeChecked()
await openDepartments()
expect(await screen.findByRole('navigation', { name: '花名册组织树' })).toBeInTheDocument()
expect(screen.getByText('已在本组')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '按人看权限' })).toHaveAttribute(
'href',
'/workspaces/governance/user-permissions',
@ -197,6 +205,7 @@ describe('PermissionGroupsDashboard', () => {
await userEvent.click(
within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /视觉/ }),
)
await openDepartments()
expect(await screen.findByText('已在「运营」')).toBeInTheDocument()
expect(screen.getByText('占用')).toBeInTheDocument()
@ -210,7 +219,8 @@ describe('PermissionGroupsDashboard', () => {
)
await renderDashboard()
await userEvent.click(await screen.findByRole('button', { name: '拿掉' }))
await openDepartments()
await userEvent.click(await screen.findByRole('button', { name: '移除运营中心A' }))
expect(fetchMock).toHaveBeenCalledWith(
'/api/organization/permission-groups',
@ -258,7 +268,7 @@ describe('PermissionGroupsDashboard', () => {
body: expect.stringContaining('"action":"save-pack"'),
}),
])
expect(screen.getByText('这组权限已保存,组和人一起变')).toBeInTheDocument()
expect(screen.getByText('已保存')).toBeInTheDocument()
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(getTaskDetailCheckbox('私密素材查看/下载')).toBeChecked()
})
@ -299,18 +309,19 @@ describe('PermissionGroupsDashboard', () => {
await renderDashboard()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
await user.click(await screen.findByRole('button', { name: '拿掉' }))
await user.click(screen.getByRole('tab', { name: /部门/ }))
await user.click(await screen.findByRole('button', { name: '移除运营中心A' }))
expect(await screen.findByText('已从本组拿掉 运营中心A')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled()
expect(await screen.findByText('已从本组移除 运营中心A')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存这组权限' })).not.toBeInTheDocument()
expect(screen.queryByText('保存中…')).not.toBeInTheDocument()
expect(screen.getByText('刷新中…')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '刷新中…' })).toBeDisabled()
await advanceAndFlush(15_100)
expect(await screen.findByText(/已保存,但列表刷新失败/)).toBeInTheDocument()
expect(screen.getByText(/不用再次保存/)).toBeInTheDocument()
expect(screen.getByText('已从本组拿掉 运营中心A')).toBeInTheDocument()
expect(screen.getByText('已从本组移除 运营中心A')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1)
})
@ -331,4 +342,170 @@ describe('PermissionGroupsDashboard', () => {
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeEnabled()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('在页签间切换会保留权限草稿,撤销后恢复原始权限', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(screen.getByRole('tab', { name: /人员/ }))
expect(screen.getByText('1 项未保存')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /素材权限/ }))
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
await user.click(screen.getByRole('button', { name: '撤销修改' }))
expect(getTaskDetailCheckbox('编辑图片')).not.toBeChecked()
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('改回原始勾选后不再提示未保存,首个修改不影响其他素材类型', async () => {
installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
expect(within(screen.getByRole('table')).getByRole('checkbox', { name: '3D场景 可见' })).toBeChecked()
await user.click(getTaskDetailCheckbox('编辑图片'))
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled()
})
it('拒绝切换其他组时保留草稿且不会发出写请求', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false)
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /视觉/ }))
expect(confirm).toHaveBeenCalledTimes(1)
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(fetchMock).toHaveBeenCalledTimes(1)
confirm.mockRestore()
})
it('部门搜索自动显示折叠树内的匹配部门及祖先', async () => {
const payload = createPayload()
payload.tree[0].children[0].children = [{ id: 'nested', name: '电商设计部', path: '江苏简尚品牌管理有限公司/运营中心A/电商设计部', parentId: 'ops', level: 3, children: [] }]
installFetchSequence(jsonResponse(payload))
await renderDashboard()
await openDepartments()
expect(screen.queryByText('电商设计部')).not.toBeInTheDocument()
await userEvent.type(screen.getByLabelText('查找并加入部门'), '电商设计')
expect(screen.getByText('电商设计部')).toBeInTheDocument()
expect(screen.getByText('随 运营中心A · 运营')).toBeInTheDocument()
})
it('组名失焦不写入,明确保存组名后保留素材权限草稿', async () => {
const renamed = createPayload()
renamed.groups[0].name = '运营新组'
const fetchMock = installFetchSequence(jsonResponse(createPayload()), jsonResponse({ ok: true }), jsonResponse(renamed))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(screen.getByRole('tab', { name: '组设置' }))
await user.clear(screen.getByLabelText('组名'))
await user.type(screen.getByLabelText('组名'), '运营新组')
await user.tab()
expect(fetchMock).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: '保存组名' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
await user.click(screen.getByRole('tab', { name: /素材权限/ }))
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ action: 'rename', slug: 'operations', name: '运营新组' }))
})
it('管理部门后的刷新保留未保存的素材权限', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()), jsonResponse({ ok: true }), jsonResponse(createPayload()))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('私密素材查看/下载'))
await user.click(screen.getByRole('tab', { name: /部门/ }))
await user.click(screen.getByRole('button', { name: '移除运营中心A' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
await user.click(screen.getByRole('tab', { name: /素材权限/ }))
expect(getTaskDetailCheckbox('私密素材查看/下载')).toBeChecked()
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeEnabled()
})
it('页签支持方向键切换并移动焦点', async () => {
installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
screen.getByRole('tab', { name: /素材权限/ }).focus()
await userEvent.keyboard('{ArrowRight}')
expect(screen.getByRole('tab', { name: /部门/ })).toHaveFocus()
expect(screen.getByRole('tab', { name: /部门/ })).toHaveAttribute('aria-selected', 'true')
await userEvent.keyboard('{End}')
expect(screen.getByRole('tab', { name: '组设置' })).toHaveFocus()
})
it('人员搜索失败显示可恢复错误,清空后移除错误状态', async () => {
installFetchSequence(jsonResponse(createPayload()), jsonResponse({ error: 'unavailable' }, false))
await renderDashboard()
const user = userEvent.setup()
await user.click(screen.getByRole('tab', { name: /人员/ }))
await user.type(screen.getByLabelText('搜人加入本组'), '测试')
expect(await screen.findByText('搜索失败,请修改关键词或重试。')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '清空人员搜索' }))
expect(screen.queryByText('搜索失败,请修改关键词或重试。')).not.toBeInTheDocument()
})
it('较早的人员搜索响应不会覆盖当前搜索结果', async () => {
jest.useFakeTimers()
let resolveFirst: (value: unknown) => void = () => {}
const firstRequest = new Promise(resolve => { resolveFirst = resolve })
installFetchSequence(jsonResponse(createPayload()), () => firstRequest, jsonResponse({ candidates: [{ userId: 'new', displayName: '当前候选', username: 'new', departmentName: '设计部', departmentPath: null }] }))
await renderDashboard()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
await user.click(screen.getByRole('tab', { name: /人员/ }))
await user.type(screen.getByLabelText('搜人加入本组'), 'first')
await advanceAndFlush(300)
await user.clear(screen.getByLabelText('搜人加入本组'))
await user.type(screen.getByLabelText('搜人加入本组'), 'second')
await advanceAndFlush(300)
expect(screen.getByText('当前候选')).toBeInTheDocument()
await act(async () => { resolveFirst({ ok: true, json: async () => ({ candidates: [{ userId: 'old', displayName: '过期候选', username: 'old' }] }) }); await Promise.resolve() })
expect(screen.getByText('当前候选')).toBeInTheDocument()
expect(screen.queryByText('过期候选')).not.toBeInTheDocument()
})
it('本页链接取消离开时保留素材权限草稿', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false)
await renderDashboard()
await userEvent.click(getTaskDetailCheckbox('编辑图片'))
expect(fireEvent.click(screen.getByRole('link', { name: '按人看权限' }))).toBe(false)
expect(confirm).toHaveBeenCalledTimes(1)
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(fetchMock).toHaveBeenCalledTimes(1)
confirm.mockRestore()
})
it('权限请求处理中阻止本页链接离开', async () => {
jest.useFakeTimers()
const fetchMock = installFetchSequence(jsonResponse(createPayload()), pendingUntilAbort('save-pack PUT'))
await renderDashboard()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
const link = screen.getByRole('link', { name: '按人看权限' })
expect(link).toHaveAttribute('aria-disabled', 'true')
expect(fireEvent.click(link)).toBe(false)
expect(fetchMock).toHaveBeenCalledTimes(2)
await advanceAndFlush(15_100)
})
it('创建新组与本页链接都保护未保存的组名', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false)
await renderDashboard()
const user = userEvent.setup()
await user.click(screen.getByRole('tab', { name: '组设置' }))
await user.type(screen.getByLabelText('组名'), '待保存')
expect(fireEvent.click(screen.getByRole('link', { name: '按人看权限' }))).toBe(false)
await user.click(screen.getByRole('button', { name: '新建' }))
await user.type(screen.getByLabelText('新建权限组'), '新组')
await user.click(screen.getByRole('button', { name: '创建' }))
expect(confirm).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(screen.getByLabelText('组名')).toHaveValue('运营待保存')
confirm.mockRestore()
})
})

View File

@ -3,7 +3,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import { ChevronRight } from 'lucide-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,
@ -98,6 +102,13 @@ export default function PermissionGroupsDashboard() {
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>())
@ -131,7 +142,7 @@ export default function PermissionGroupsDashboard() {
if (!mounted.current) return false
const next = body as Payload
setPayload(next)
setDirty(false)
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
@ -169,31 +180,57 @@ export default function PermissionGroupsDashboard() {
}, [status, isSuperAdmin, load])
const selected = payload?.groups.find((group) => group.slug === selectedSlug) || null
const renameDirty = Boolean(selected && renameValue !== selected.name)
useEffect(() => {
setRenameValue(selected?.name || '')
setDirty(false)
}, [selected?.slug, selected?.name])
useEffect(() => {
const needle = personKeyword.trim()
if (!needle) {
setDirty(false)
setDraft({})
setPersonKeyword('')
setCandidates([])
return
}
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 () => {
setSearching(true)
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().catch(() => null)
if (response.ok) setCandidates(body.candidates || [])
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 {
setSearching(false)
window.clearTimeout(timeout)
if (mounted.current && active) setSearching(false)
}
}, 250)
return () => window.clearTimeout(handle)
return () => {
active = false
window.clearTimeout(handle)
controller.abort()
activeControllers.delete(controller)
}
}, [personKeyword])
const selectedFlags = useMemo(() => {
@ -280,28 +317,14 @@ export default function PermissionGroupsDashboard() {
}
}
const startEdit = () => {
if (!selected || !payload) return
const next: Record<string, MaterialPermissionFlags> = {}
for (const materialType of payload.materialTypes) {
next[materialType] = selected.permissions[materialType]
? { ...selected.permissions[materialType] }
: emptyFlags()
}
setDraft(next)
setDirty(true)
}
const updateFlag = (materialType: string, key: keyof MaterialPermissionFlags, value: boolean) => {
if (!dirty) startEdit()
setDraft((current) => ({
...current,
[materialType]: {
...(current[materialType] || emptyFlags()),
[key]: value,
},
}))
setDirty(true)
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 () => {
@ -316,26 +339,27 @@ export default function PermissionGroupsDashboard() {
canShare: true,
})),
},
'这组权限已保存,组和人一起变',
'已保存',
)
}
const createGroup = async () => {
const name = newName.trim()
if (!name) return
if (await mutate({ action: 'create', name }, `已创建「${name}」`)) setNewName('')
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 }, '组名已改')
await mutate({ action: 'rename', slug: selectedSlug, name }, '组名已保存。')
}
const deleteGroup = async () => {
if (!selected) return
const ok = window.confirm(`删除「${selected.name}」后,里面的部门和人都不再用这组。确定?`)
const ok = window.confirm(`删除「${selected.name}」后,本组部门和人员将失去这组提供的权限,未保存的修改也会丢弃。确定删除?`)
if (!ok) return
await mutate({ action: 'delete', slug: selected.slug }, '权限组已删除')
}
@ -349,425 +373,178 @@ export default function PermissionGroupsDashboard() {
})
}
const renderNode = (node: OrgNode): ReactNode => {
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 = expanded.has(node.id)
const status = describeDepartmentGroupStatus(node.path, selectedSlug, memberships)
const inThisGroup = status.kind === 'own' || departmentPaths.has(node.path)
const statusClass =
status.kind === 'own'
? 'text-[#0050cb]'
: status.kind === 'other'
? 'text-[#9a4d08]'
: 'text-[#66758b]'
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 items-stretch">
{hasChildren ? (
<button
type="button"
aria-label={isExpanded ? `收起${node.name}` : `展开${node.name}`}
onClick={() => toggleExpand(node.id)}
className="flex w-8 shrink-0 items-center justify-center text-[#66758b] hover:text-[#101828]"
>
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
) : (
<span className="w-8 shrink-0" />
)}
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-[#eef2f6]">
<div className="min-w-0">
<p className={`truncate text-[13px] ${inThisGroup ? 'font-semibold text-[#0050cb]' : 'text-[#101828]'}`}>
{node.name}
</p>
<p className={`truncate text-[11px] ${statusClass}`}>{status.label}</p>
<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>
{status.kind === 'other' ? (
<span className="shrink-0 text-[11px] font-medium text-[#9a4d08]">占用</span>
) : (
<button
type="button"
disabled={saving || !selected}
onClick={() =>
void mutate(
{
action: inThisGroup ? 'remove-department' : 'add-department',
slug: selectedSlug,
path: node.path,
},
inThisGroup ? `已从本组拿掉 ${node.name}` : `已把 ${node.name} 加进本组`,
)
}
className="shrink-0 text-xs font-semibold text-[#1677ff] disabled:opacity-40"
>
{inThisGroup ? '拿掉' : '加入'}
</button>
)}
{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, path: node.path }, isDirect ? `已从本组移除 ${node.name}` : `已把 ${node.name} 加入本组`)} className={`${secondary} shrink-0`}>
{isDirect ? '移除' : '加入'}
</button>}
</div>
</div>
{hasChildren && isExpanded && (
<div className="ml-3 border-l border-[#e2e8f0] pl-1">
{node.children.map((child) => renderNode(child))}
</div>
)}
{hasChildren && isExpanded && node.children.map(child => renderNode(child, depth + 1))}
</div>
)
}
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">
<main className="px-4 py-5 sm:px-6 lg:flex lg:h-[calc(100dvh-72px)] lg:min-h-0 lg:flex-col lg:px-8">
<header className="mb-4 flex shrink-0 flex-wrap items-center 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>
<h1 className="text-2xl font-bold text-[#101828]">权限组</h1>
</div>
<div className="flex items-center gap-4">
{isSuperAdmin && (
<button type="button" disabled={saving || loading} onClick={() => {
if (dirty && !window.confirm('当前勾选尚未确认保存,刷新后将以服务器数据为准。继续?')) return
<div className="flex flex-wrap items-center gap-2">
{isSuperAdmin && <button type="button" disabled={busy} onClick={() => {
if (dirty && !window.confirm('当前勾选尚未保存,刷新后将以服务器数据为准。继续?')) return
void load()
}} className="min-h-10 text-sm font-semibold text-[#1677ff] hover:underline disabled:cursor-wait disabled:opacity-50">
{loading ? '刷新中…' : '刷新列表'}
</button>
)}
<Link
href="/workspaces/governance/user-permissions"
className="text-sm font-semibold text-[#1677ff] hover:underline"
>
按人看权限
</Link>
</div>
}} aria-label={loading ? '刷新中…' : '刷新列表'} title="刷新列表" className={`${secondary} w-11 px-0`}><RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin motion-reduce:animate-none' : ''}`} /></button>}
<Link href="/workspaces/governance/user-permissions" 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) ? (
<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>
) : (
<>
{error && (
<div role="alert" className="mb-4 rounded-lg border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-sm text-[#b42318]">{error}</div>
)}
{notice && (
<div role="status" className="mb-4 rounded-lg border border-[#b7ebc6] bg-[#f0fff4] px-4 py-3 text-sm text-[#087052]">{notice}</div>
)}
{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={saving || loading} aria-label="权限组设置" className="m-0 grid min-h-0 min-w-0 flex-1 gap-4 border-0 p-0 pb-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white">
<form
className="shrink-0 border-b border-[#e2e8f0] p-3"
onSubmit={(event) => {
event.preventDefault()
void createGroup()
}}
>
<label className="block text-xs font-semibold text-[#4f6077]" htmlFor="new-permission-group">
新建权限组
</label>
<div className="mt-2 flex gap-2">
<input
id="new-permission-group"
value={newName}
onChange={(event) => setNewName(event.target.value)}
placeholder="例如:运营"
className="h-10 min-w-0 flex-1 rounded-lg border border-[#e2e8f0] bg-[#f8fafc] px-3 text-sm text-[#101828] outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#5a9ce6]/40"
/>
<button
type="submit"
disabled={saving || !newName.trim()}
className="h-10 rounded-lg bg-[#1677ff] px-3 text-sm font-semibold text-white disabled:opacity-40"
>
创建
</button>
<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></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} · {group.departments.length} 个部门 · {group.members.length} 人</option>)}</select></div>
</div>
</form>
<nav className="min-h-0 flex-1 overflow-y-auto p-2" aria-label="权限组">
{(payload?.groups || []).map((group) => (
<button
key={group.slug}
type="button"
onClick={() => {
if (dirty && selectedSlug !== group.slug) {
const ok = window.confirm('当前组有未保存的权限修改,切换后会丢掉。继续?')
if (!ok) return
}
setSelectedSlug(group.slug)
setDirty(false)
}}
className={`mb-1 w-full rounded-md px-3 py-2 text-left text-[13px] ${
selectedSlug === group.slug
? 'bg-[#e6f4ff] font-semibold text-[#0050cb]'
: 'text-[#101828] hover:bg-[#eef2f6]'
}`}
>
<span className="block truncate">{group.name}</span>
<span className="mt-0.5 block text-[11px] font-medium text-[#66758b]">
{group.departments.length} 个部门 · {group.members.length} 人
</span>
</button>
))}
<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]">{group.departments.length} 个部门 · {group.members.length} 人</span></button>)}
{visibleGroups.length === 0 && <p className="p-3 text-sm text-[#66758b]">{groupKeyword ? '未找到权限组' : '暂无权限组'}</p>}
</nav>
</aside>
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white">
{!selected || !payload ? (
<div className="px-5 py-10 text-sm text-[#66758b]">左边建一组,或选已有的组</div>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<div className="flex flex-col gap-3 border-b border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<label className="sr-only" htmlFor="rename-permission-group">组名</label>
<input
id="rename-permission-group"
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onBlur={() => void renameGroup()}
className="h-10 w-full max-w-sm rounded-lg border border-[#e2e8f0] bg-[#f8fafc] px-3 text-lg font-bold text-[#101828] outline-none focus:border-[#1677ff]"
/>
<p className="mt-2 text-sm text-[#4f6077]">
部门进组是底子,人进组是加餐。没单独进组的下级,跟着最近的上级组走。
</p>
</div>
<div className="flex items-center gap-3 rounded-lg border border-[#e2e8f0] bg-[#f8fafc] px-4 py-2.5">
<div className="min-w-0">
<p className="text-sm font-semibold text-[#101828]">视频提前访问</p>
<p className="mt-0.5 text-xs text-[#66758b]">
开启后,本组成员不受视频类素材 15 天保护期限制
</p>
</div>
<button
type="button"
role="switch"
aria-checked={Boolean(selected.videoEarlyAccess)}
disabled={saving}
onClick={() =>
void mutate(
{ action: 'set-video-early-access', slug: selected.slug, enabled: !selected.videoEarlyAccess },
selected.videoEarlyAccess ? '已关闭视频提前访问' : '已开启视频提前访问',
)
}
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
selected.videoEarlyAccess ? 'bg-[#1677ff]' : 'bg-[#cfd8e3]'
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${
selected.videoEarlyAccess ? 'left-[22px]' : 'left-0.5'
}`}
/>
</button>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={saving || !dirty}
onClick={() => void savePack()}
className="h-9 rounded-lg bg-[#1677ff] px-3 text-sm font-semibold text-white disabled:opacity-40"
>
{saving ? '保存中…' : '保存这组权限'}
</button>
<button
type="button"
disabled={saving}
onClick={() => void deleteGroup()}
className="h-9 rounded-lg border border-[#f5c2c0] px-3 text-sm font-semibold text-[#b42318]"
>
删除这组
</button>
</div>
<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>
<div className="grid gap-0 border-b border-[#e2e8f0] lg:grid-cols-2">
<div className="border-b border-[#e2e8f0] lg:border-b-0 lg:border-r">
<div className="border-b border-[#e2e8f0] px-5 py-3">
<p className="text-xs font-semibold text-[#4f6077]">本组部门</p>
<p className="mt-1 text-xs text-[#66758b]">
已加 {selected.departments.length} 个。已在别组的部门不能再加进来。
</p>
{selected.departments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{selected.departments.map((department) => (
<button
key={department.path}
type="button"
disabled={saving}
onClick={() =>
void mutate(
{ action: 'remove-department', slug: selected.slug, path: department.path },
`已拿掉 ${department.name}`,
)
}
className="rounded-full border border-[#e2e8f0] bg-[#f8fafc] px-2.5 py-1 text-xs text-[#4f6077] hover:border-[#f5c2c0] hover:text-[#b42318]"
title={department.path}
>
{department.name} ×
</button>
))}
<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>
)}
<input
value={deptKeyword}
onChange={(event) => setDeptKeyword(event.target.value)}
placeholder="搜索花名册部门"
className="mt-3 h-9 w-full rounded-lg border border-[#e2e8f0] bg-[#f8fafc] px-3 text-sm outline-none focus:border-[#1677ff]"
/>
</div>
<nav aria-label="花名册组织树" className="max-h-[320px] overflow-y-auto p-2">
{payload.tree.map((node) => renderNode(node))}
</nav>
<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>}
</>}
<div>
<div className="border-b border-[#e2e8f0] px-5 py-3">
<p className="text-xs font-semibold text-[#4f6077]">本组人员</p>
<p className="mt-1 text-xs text-[#66758b]">
人还留在自己的花名册部门。加上这组后,部门能看的还在,这组多出来的也能看。
</p>
<label className="sr-only" htmlFor="group-user-search">搜人加入本组</label>
<input
id="group-user-search"
value={personKeyword}
onChange={(event) => setPersonKeyword(event.target.value)}
placeholder="搜花名 / 工号 / 账号"
className="mt-3 h-9 w-full rounded-lg border border-[#e2e8f0] bg-[#f8fafc] px-3 text-sm outline-none focus:border-[#1677ff]"
/>
{searching && <p className="mt-2 text-xs text-[#66758b]">搜索中…</p>}
{candidates.length > 0 && (
<ul className="mt-2 divide-y divide-[#eef2f6] rounded-lg border border-[#e2e8f0]">
{candidates.map((candidate) => (
<li key={candidate.userId} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[#101828]">{candidate.displayName}</p>
<p className="truncate text-xs text-[#66758b]">
{candidate.departmentName || '未确定'} · @{candidate.username}
</p>
</div>
<button
type="button"
disabled={saving || memberIds.has(candidate.userId)}
onClick={() => {
void mutate(
{ action: 'add-member', slug: selected.slug, userId: candidate.userId },
`已把 ${candidate.displayName} 加进本组`,
)
setPersonKeyword('')
setCandidates([])
}}
className="shrink-0 rounded-md bg-[#1677ff] px-2.5 py-1 text-xs font-semibold text-white disabled:opacity-40"
>
{memberIds.has(candidate.userId) ? '已在本组' : '加入'}
</button>
</li>
))}
</ul>
)}
</div>
<table className="min-w-full text-left text-sm">
<thead className="bg-[#f8fafc] text-xs text-[#66758b]">
<tr>
<th className="px-5 py-3 font-semibold">已进组的人</th>
<th className="px-3 py-3 font-semibold">花名册部门</th>
<th className="px-3 py-3 font-semibold" />
</tr>
</thead>
<tbody>
{selected.members.length === 0 ? (
<tr>
<td colSpan={3} className="px-5 py-8 text-sm text-[#66758b]">
还没有人单独进这组
</td>
</tr>
) : (
selected.members.map((row) => (
<tr key={row.userId} className="border-t border-[#eef2f6]">
<td className="px-5 py-2.5 font-medium text-[#101828]">
{row.displayName}
<span className="ml-2 text-xs font-normal text-[#66758b]">@{row.username}</span>
</td>
<td className="px-3 py-2.5 text-[#4f6077]">{row.departmentName || '—'}</td>
<td className="px-3 py-2.5 text-right">
<button
type="button"
disabled={saving}
onClick={() =>
void mutate(
{ action: 'remove-member', slug: selected.slug, userId: row.userId },
`已把 ${row.displayName} 从本组拿掉`,
)
}
className="text-xs font-semibold text-[#b42318] hover:underline"
>
去掉
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{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.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><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, 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>
</>}
<div className="overflow-x-auto" role="region" aria-label="素材类型权限,可横向滚动" tabIndex={0}>
<table className="w-full min-w-[640px] whitespace-nowrap text-left text-sm">
<thead className="bg-[#f8fafc] text-xs uppercase tracking-[0.08em] text-[#66758b]">
<tr>
<th className="px-5 py-3 font-semibold">素材类型</th>
{FLAG_COLUMNS.map((column) => (
<th key={column.key} className="px-3 py-3 font-semibold">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{payload.materialTypes.map((materialType) => {
const flags = selectedFlags[materialType] || emptyFlags()
return (
<tr key={materialType} className="border-t border-[#eef2f6]">
<td className="px-5 py-2.5 font-medium text-[#101828]">{materialType}</td>
{FLAG_COLUMNS.map((column) => {
const taskDetailOnlyDisabled =
(column.key === 'canEditAssets' || column.key === 'canAccessPrivate') &&
materialType !== '任务-详情'
const disabledTitle =
column.key === 'canAccessPrivate'
? '仅任务-详情支持私密素材查看/下载'
: '仅任务-详情支持单图编辑'
return (
<td key={column.key} className="px-3 py-2.5">
<input
type="checkbox"
checked={Boolean(flags[column.key])}
disabled={taskDetailOnlyDisabled}
title={taskDetailOnlyDisabled ? disabledTitle : undefined}
onChange={(event) => updateFlag(materialType, column.key, event.target.checked)}
aria-label={`${materialType} ${column.label}`}
/>
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
{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>
</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>
</>
)}
</>}
</main>
</DepartmentShell>
)

View File

@ -59,9 +59,9 @@ describe('UserPermissionsDashboard', () => {
await userEvent.type(screen.getByLabelText('搜索人员'), '九千七')
expect(await screen.findByRole('heading', { level: 2, name: '九千七' })).toBeInTheDocument()
expect(screen.getByText('部门带来的组')).toBeInTheDocument()
expect(screen.getAllByText('部门组')[0]).toBeInTheDocument()
expect(screen.getByText('随 运营中心A')).toBeInTheDocument()
expect(screen.getByText('个人加的组')).toBeInTheDocument()
expect(screen.getAllByText('个人组')[0]).toBeInTheDocument()
expect(screen.getByText('视觉')).toBeInTheDocument()
expect(screen.getAllByText('运营').length).toBeGreaterThan(0)
})

View File

@ -4,6 +4,7 @@ import Select from '@/components/ui/Select'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { ChevronRight, Search, X } from 'lucide-react'
import { useSession } from 'next-auth/react'
import DepartmentShell from './DepartmentShell'
@ -92,30 +93,18 @@ export default function UserPermissionsDashboard() {
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"
>
回到权限组
<header className="shrink-0 pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold text-[#101828]">按人看权限</h1>
<Link href="/workspaces/governance/roles-permissions" className="inline-flex min-h-11 items-center gap-1 rounded-lg px-2 text-sm font-medium text-[#4f6077] hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]">
权限组<ChevronRight className="h-4 w-4" />
</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"
/>
<div className="relative mt-3 max-w-xl">
<Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" />
<input id="user-permission-search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜花名 / 工号 / 账号" className="h-11 w-full rounded-lg border border-[#cfd8e3] bg-white pl-9 pr-3 text-sm text-[#101828] placeholder:text-[#66758b] focus:outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20" />
</div>
</header>
{status === 'loading' ? (
@ -127,120 +116,73 @@ export default function UserPermissionsDashboard() {
) : (
<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>
<div role="alert" 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>
<div role="status" className="mb-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">
<div className="overflow-hidden rounded-xl border border-[#e2e8f0] bg-white">
<div aria-hidden="true" className="hidden grid-cols-[minmax(150px,1fr)_minmax(140px,1fr)_minmax(180px,1.3fr)_160px] gap-4 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-3 text-xs font-semibold text-[#4f6077] xl:grid">
<span>人员</span><span>部门组</span><span>个人组</span><span>加组</span>
</div>
<ul aria-label="人员权限" className="divide-y divide-[#e2e8f0]">
{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}` : ''}
<li key={person.userId} className="grid min-w-0 grid-cols-[minmax(0,1fr)_120px] gap-x-4 gap-y-2 px-4 py-3 sm:grid-cols-2 xl:grid-cols-[minmax(150px,1fr)_minmax(140px,1fr)_minmax(180px,1.3fr)_160px] xl:items-center">
<div className="min-w-0">
<h2 className="break-words text-sm font-semibold text-[#101828]">{person.displayName}</h2>
<p title={person.departmentPath || undefined} className="mt-1 break-words text-xs 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>
)}
{person.departmentPath && <span className="sr-only">{person.departmentPath}</span>}
</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>
<div className="col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1 xl:block">
<span className="shrink-0 pt-1 text-xs text-[#66758b] xl:sr-only">部门组</span>
{person.departmentGroups.length === 0 ? <p className="text-sm leading-6 text-[#66758b]">旧部门权限</p> :
<ul className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{person.departmentGroups.map((group) => (
<li key={`${person.userId}-${group.slug}`} className="break-words text-sm leading-6 text-[#101828]">
{group.name}<span title={group.attachedPath} className="ml-2 text-xs text-[#66758b]">{group.source === 'own' ? '本部门' : `随 ${group.attachedPath.split('/').pop()}`}</span>
</li>
))}
</ul>
)}
))}</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>
<div className="col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1 xl:block">
<span className="shrink-0 pt-3.5 text-xs text-[#66758b] xl:sr-only">个人组</span>
{person.personalGroups.length === 0 ? <p className="py-3 text-sm text-[#66758b]">—<span className="sr-only">未单独加组</span></p> :
<ul className="flex min-w-0 flex-wrap gap-2">{person.personalGroups.map((group) => (
<li key={group.slug} className="inline-flex min-w-0 items-center rounded-lg bg-[#f8fafc] pl-3 text-sm text-[#101828]">
<span className="min-w-0 break-words">{group.name}</span>
<button type="button" disabled={saving} aria-label={`将${person.displayName}移出${group.name}组`} title={`移出${group.name}组`} onClick={() => void mutate({ action: 'remove-member', userId: person.userId, slug: group.slug }, `已将 ${person.displayName} 移出「${group.name}」`)} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] hover:bg-[#fff5f4] hover:text-[#b42318] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"><X className="h-4 w-4" /></button>
</li>
))}
</ul>
)}
{extraGroups.length > 0 && (
<label className="mt-3 block">
<span className="sr-only">再加一组</span>
<Select
disabled={saving}
defaultValue=""
onChange={(event) => {
))}</ul>}
</div>
<div className="col-start-2 row-start-1 min-w-0 sm:col-start-auto sm:row-start-auto">
{extraGroups.length > 0 && <Select aria-label={`为${person.displayName}加组`} 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}」`,
)
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>
}} className="min-h-11 w-full max-w-[160px] rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm text-[#4f6077] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]">
<option value="">加组…</option>{extraGroups.map((group) => <option key={group.slug} value={group.slug}>{group.name}</option>)}
</Select>}
</div>
</li>
)
})}
</ul>
</div>
)}
</div>
)}