diff --git a/.impeccable/design.json b/.impeccable/design.json index 517b917..af44b54 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -461,6 +461,12 @@ "description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。", "projection": "
组织架构

沿用真实人员层级、归属明细与管理员权限。

" }, + { + "name": "Permission Groups Primary Button", + "type": "button", + "description": "仅用于 PermissionGroupsDashboard 创建权限组与明确保存素材权限的深蓝主按钮;全局 Action Blue 不变。", + "projection": "" + }, { "name": "Standalone Material Library Shell", "type": "navigation", diff --git a/DESIGN.md b/DESIGN.md index ac9e119..0bbd2e1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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: diff --git a/components/department-dashboard/PermissionGroupsDashboard.test.tsx b/components/department-dashboard/PermissionGroupsDashboard.test.tsx index a2440d1..1a0d16c 100644 --- a/components/department-dashboard/PermissionGroupsDashboard.test.tsx +++ b/components/department-dashboard/PermissionGroupsDashboard.test.tsx @@ -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() + }) }) diff --git a/components/department-dashboard/PermissionGroupsDashboard.tsx b/components/department-dashboard/PermissionGroupsDashboard.tsx index b414791..c1c8d5f 100644 --- a/components/department-dashboard/PermissionGroupsDashboard.tsx +++ b/components/department-dashboard/PermissionGroupsDashboard.tsx @@ -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([]) const [searching, setSearching] = useState(false) + const [searchError, setSearchError] = useState(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(null) + const tabRefs = useRef>([]) const mutationActive = useRef(false) const mounted = useRef(true) const controllers = useRef(new Set()) @@ -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(() => { + setDirty(false) + setDraft({}) + setPersonKeyword('') + setCandidates([]) + setDeptKeyword('') + setMaterialKeyword('') + }, [selected?.slug]) + + useEffect(() => { + let active = true + const activeControllers = controllers.current const needle = personKeyword.trim() - if (!needle) { - setCandidates([]) - return - } + 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 = {} - 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 (
-
- {hasChildren ? ( - - ) : ( - - )} -
-
-

- {node.name} -

-

{status.label}

-
- {status.kind === 'other' ? ( - 占用 - ) : ( - - )} +
+ {hasChildren ? : } +
+

{node.name}

+

{groupStatus.label}

+ {groupStatus.kind === 'other' ? 占用 : + }
- {hasChildren && isExpanded && ( -
- {node.children.map((child) => renderNode(child))} -
- )} + {hasChildren && isExpanded && node.children.map(child => renderNode(child, depth + 1))}
) } return ( -
-
-

治理与设置

-
-
-

权限组

-

- 一组就是一份权限。部门树跟数据口径同一套花名册组织。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。 -

-
-
- {isSuperAdmin && ( - - )} - - 按人看权限 - -
+
+
+
+

权限组

+
+
+ {isSuperAdmin && } + { + if (busy || ((dirty || renameDirty) && !window.confirm('当前组有未保存的修改,离开后会丢弃。继续前往按人看权限?'))) event.preventDefault() + }} className={`${secondary} ${busy ? 'cursor-not-allowed opacity-50' : ''}`}>按人看权限
- {status === 'loading' || (loading && !payload) ? ( -
加载中…
- ) : !isSuperAdmin ? ( -
- 只有超级管理员可以改权限组。 -
- ) : ( - <> - {error && ( -
{error}
- )} - {notice && ( -
{notice}
- )} + {status === 'loading' || (loading && !payload) ?

正在加载权限组…

: !isSuperAdmin ?
只有超级管理员可以改权限组。
: <> + {error &&
{error}
} + {notice &&
{notice}
} -
- +
+ -
- {!selected || !payload ? ( -
左边建一组,或选已有的组
- ) : ( -
-
-
- - 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]" - /> -

- 部门进组是底子,人进组是加餐。没单独进组的下级,跟着最近的上级组走。 -

-
-
-
-

视频提前访问

-

- 开启后,本组成员不受视频类素材 15 天保护期限制 -

-
- -
-
- - -
+
+ {!selected || !payload ?
请选择权限组
: <> +
+

{selected.name}

+
+
+ {tabs.map((tab, index) => )} +
+ +
+ {activeTab === 'permissions' && <> +
+
setMaterialKeyword(event.target.value)} placeholder="搜索素材类型" className={`${field} pl-9`} />
- -
-
-
-

本组部门

-

- 已加 {selected.departments.length} 个。已在别组的部门不能再加进来。 -

- {selected.departments.length > 0 && ( -
- {selected.departments.map((department) => ( - - ))} -
- )} - 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]" - /> -
- -
- -
-
-

本组人员

-

- 人还留在自己的花名册部门。加上这组后,部门能看的还在,这组多出来的也能看。 -

- - 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 &&

搜索中…

} - {candidates.length > 0 && ( -
    - {candidates.map((candidate) => ( -
  • -
    -

    {candidate.displayName}

    -

    - {candidate.departmentName || '未确定'} · @{candidate.username} -

    -
    - -
  • - ))} -
- )} -
- - - - - - - - - {selected.members.length === 0 ? ( - - - - ) : ( - selected.members.map((row) => ( - - - - - - )) - )} - -
已进组的人花名册部门 -
- 还没有人单独进这组 -
- {row.displayName} - @{row.username} - {row.departmentName || '—'} - -
-
+
+ {FLAG_COLUMNS.map(column => )} + {visibleTypes.map(type => {FLAG_COLUMNS.map(column => { const unavailable = (column.key === 'canEditAssets' || column.key === 'canAccessPrivate') && type !== '任务-详情'; return })})} +
{selected.name}的素材权限
素材类型{column.label}
{type}{unavailable ? — : }
+
{visibleTypes.map(type =>

{type}

{FLAG_COLUMNS.filter(column => type === '任务-详情' || !['canEditAssets', 'canAccessPrivate'].includes(column.key)).map(column => )}
)}
+ {visibleTypes.length === 0 &&

{materialKeyword ? '未找到素材类型' : '暂无素材类型'}

} + } -
- - - - - {FLAG_COLUMNS.map((column) => ( - - ))} - - - - {payload.materialTypes.map((materialType) => { - const flags = selectedFlags[materialType] || emptyFlags() - return ( - - - {FLAG_COLUMNS.map((column) => { - const taskDetailOnlyDisabled = - (column.key === 'canEditAssets' || column.key === 'canAccessPrivate') && - materialType !== '任务-详情' - const disabledTitle = - column.key === 'canAccessPrivate' - ? '仅任务-详情支持私密素材查看/下载' - : '仅任务-详情支持单图编辑' - return ( - - ) - })} - - ) - })} - -
素材类型 - {column.label} -
{materialType} - updateFlag(materialType, column.key, event.target.checked)} - aria-label={`${materialType} ${column.label}`} - /> -
+ {activeTab === 'departments' && <> +

立即生效

+
+

已加入 · {selected.departments.length}

{selected.departments.length === 0 ?

暂无部门

:
    {selected.departments.map(department =>
  • {department.name}

    {department.path}

  • )}
}
+
setDeptKeyword(event.target.value)} placeholder="搜索部门名称或路径" className={field} />
-
- )} -
-
- - )} + } + + {activeTab === 'members' && <> +
setPersonKeyword(event.target.value)} placeholder="搜花名 / 工号 / 账号" className={`${field} pl-9 pr-11`} />{personKeyword && }
立即生效 +
{searching &&

搜索中…

}{searchError &&

{searchError}

}{personKeyword.trim() && !searching && !searchError && candidates.length === 0 &&

未找到人员

}
+ {candidates.length > 0 &&
    {candidates.map(candidate =>
  • {candidate.displayName}

    {candidate.departmentName || '未确定部门'} · @{candidate.username}

  • )}
} +
+

已加入 · {selected.members.length}

+ {selected.members.length === 0 ?

暂无人员

:
    {selected.members.map(member =>
  • {member.displayName}@{member.username}

    {member.departmentName || member.departmentPath || '未确定部门'}

  • )}
} + } + + {activeTab === 'settings' &&
+
{ event.preventDefault(); void renameGroup() }}>
setRenameValue(event.target.value)} className={`${field} flex-1 basis-48`} />
+

视频提前访问

跳过视频 15 天保护期 · 立即生效

+
+
} + + + {(activeTab === 'permissions' || dirty) &&
+

{dirty ? `${changedTypes} 项未保存` : ''}

+
{dirty && }
+
} + } +
+ + }
) diff --git a/components/department-dashboard/UserPermissionsDashboard.test.tsx b/components/department-dashboard/UserPermissionsDashboard.test.tsx index 72be441..7797061 100644 --- a/components/department-dashboard/UserPermissionsDashboard.test.tsx +++ b/components/department-dashboard/UserPermissionsDashboard.test.tsx @@ -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) }) diff --git a/components/department-dashboard/UserPermissionsDashboard.tsx b/components/department-dashboard/UserPermissionsDashboard.tsx index 77fa105..43ff585 100644 --- a/components/department-dashboard/UserPermissionsDashboard.tsx +++ b/components/department-dashboard/UserPermissionsDashboard.tsx @@ -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 (
-
-

治理与设置

-
-
-

按人看权限

-

- 搜九千七这种人,看他部门带了哪组,自己又加了哪组。不要再改账号上的部门名。 -

-
- - 回到权限组 +
+
+

按人看权限

+ + 权限组
- 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" - /> +
+ + 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" /> +
{status === 'loading' ? ( @@ -127,120 +116,73 @@ export default function UserPermissionsDashboard() { ) : (
{error && ( -
{error}
+
{error}
)} {notice && ( -
{notice}
+
{notice}
)} {loading &&

搜索中…

} {!keyword.trim() ? (
- 先搜一个人。部门带来的组不能在这里拿掉,要去权限组里改部门名单。 + 搜索人员查看权限组
) : people.length === 0 && !loading ? (
- 没有找到这个人,或他还没有钉钉绑定。 + 未找到已绑定人员
) : ( -
    - {people.map((person) => { - const personalSlugs = new Set(person.personalGroups.map((row) => row.slug)) - const extraGroups = groups.filter((group) => !personalSlugs.has(group.slug)) - return ( -
  • -
    -
    -

    {person.displayName}

    -

    - @{person.username} - {person.jobNumber ? ` · ${person.jobNumber}` : ''} - {person.departmentName ? ` · ${person.departmentName}` : ''} +

    + +
      + {people.map((person) => { + const personalSlugs = new Set(person.personalGroups.map((row) => row.slug)) + const extraGroups = groups.filter((group) => !personalSlugs.has(group.slug)) + return ( +
    • +
      +

      {person.displayName}

      +

      + @{person.username}{person.jobNumber ? ` · ${person.jobNumber}` : ''}{person.departmentName ? ` · ${person.departmentName}` : ''}

      - {person.departmentPath && ( -

      {person.departmentPath}

      - )} + {person.departmentPath && {person.departmentPath}}
      -
    - -
    -
    -

    部门带来的组

    - {person.departmentGroups.length === 0 ? ( -

    这个部门还没进组,暂时走旧的部门权限。

    - ) : ( -
      - {person.departmentGroups.map((group) => ( -
    • - {group.name} - - {group.source === 'own' - ? '本部门在组里' - : `随 ${group.attachedPath.split('/').pop()}`} - -
    • - ))} -
    - )} +
    + 部门组 + {person.departmentGroups.length === 0 ?

    旧部门权限

    : +
      {person.departmentGroups.map((group) => ( +
    • + {group.name}{group.source === 'own' ? '本部门' : `随 ${group.attachedPath.split('/').pop()}`} +
    • + ))}
    }
    -
    -

    个人加的组

    - {person.personalGroups.length === 0 ? ( -

    还没有单独加组

    - ) : ( -
      - {person.personalGroups.map((group) => ( -
    • - {group.name} - -
    • - ))} -
    - )} - {extraGroups.length > 0 && ( - - )} +
    + 个人组 + {person.personalGroups.length === 0 ?

    —未单独加组

    : +
      {person.personalGroups.map((group) => ( +
    • + {group.name} + +
    • + ))}
    }
    -
    -
  • - ) - })} -
+
+ {extraGroups.length > 0 && } +
+ + ) + })} + +
)}
)}