diff --git a/DESIGN.md b/DESIGN.md index 0bbd2e1..9e68570 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -119,7 +119,7 @@ components: 品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。 -它保留品牌中心自己的五个职责域、16 个小项入口,以及素材库、自动化工作流、人员架构、数据口径与价值指标五项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。 +它保留品牌中心自己的五个职责域、14 个小项入口,以及素材库、自动化工作流、组织架构、权限管理与价值指标等已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。 **Key Characteristics:** @@ -129,14 +129,17 @@ components: - 白色面板以 8–12px 圆角、细边框和克制阴影呈现。 - 移动端导航是完整的模态抽屉;内容状态始终诚实标注。 - 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。 -- 数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。 -- 人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。 +- 组织架构复用真实组织树与人员明细,提供“品牌中心 / 全公司”范围切换,沿用原管理员权限。 +- 权限管理提供“权限组 / 人员权限”两个视图,侧栏只有一个入口。 - 素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。 - 进行中项目明细采用可键盘聚焦的横向滚动数据表:桌面固定“审批单标题”首列并保留 14 项业务字段,移动端转换为同字段的语义化定义列表;节点时间在数值下方标注对应流程节点名称。 - 按时交付明细使用紧凑的三项流程筛选条;当前项用浅蓝底和蓝字表达,未选项悬停为灰底,切换时同步更新公式、数量和横向明细表。 - 团队负荷明细按部门展开人员和流程,商品企划部从人员、流程归属和平均负荷统计中排除;页面明确标注排除部门与指定人员。 - 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧先标注当前钉钉组织架构在职人数,再参照业务趋势界面按“美术设计流程”和“内容视频流程”分成两个近 8 周收到/完结趋势区,并可展开每周明细。流程按当前节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点;不展示历史人员快照口径。 -- 团队职能页面把用户确认的思维导图转为四个可扫描的团队职责面板:团队名和负责人作为入口,服务/定位、能力/岗位、职责作为稳定层级;四组克制色彩只用于区分团队,节点在宽屏双列排布、窄屏纵向堆叠,不复制长图画布或产生页面级横向滚动。 +- 团队职能页面把花名册团队及数据库职责配置组织成可扫描的工作面:团队名和负责人作为入口,服务/定位、能力/岗位、职责作为稳定层级;四组克制色彩循环用于区分团队,节点在宽屏多列排布、窄屏纵向堆叠,不复制长图画布或产生页面级横向滚动。 +- 团队职能直接进入部门选择与职责内容,主工作面距壳顶栏保留 24px 间距;不展示重复页面大标题、装饰图标、概述及说明图例,不增加返回部门看板按钮。页面与职责地图标题保留为读屏标题,跨页导航使用现有侧栏。 +- 团队列表以同步数据库中品牌中心直接下级部门为准,不限定四个固定名称;阅读态只显示职责内容,超级管理员点击“编辑”后显示负责人选择、说明与分组条目表单。保存后给出简短反馈;冲突保留草稿,取消时重新加载,未保存时离开或切换部门须确认。 +- 团队职能维护的保存按钮与编辑文字使用 `#0050cb` 深蓝色,保存按钮 hover 使用 `#003da3`,保证小字号操作内容的对比度;不改变既有导航色彩。 - 业绩转化页面使用 176px 六品牌二级栏;右侧先展示“流程 × 商品ID”列表,再在下方展开所选商品的身份信息、三组指标摘要、单指标每日趋势与每日明细。不得增加第三个常驻侧栏,也不得把八项指标拆成八张装饰卡;缺失日期用趋势断点和 `—` 表示,与真实零值严格区分。 ## Colors @@ -203,15 +206,15 @@ components: 右侧通过同源 iframe 直接嵌入 `/performance-survey` 与 `/work-review`,保留原业务的真实数据、权限和交互。嵌入业务中所有 `href="/"` 的跨应用返回必须使用 `target="_top"`,确保返回品牌中心根页面而不是在 iframe 内再次嵌套首页。 -数据口径同样使用 232px 二级栏,但只有“公司组织架构”一个直接菜单,不渲染“数据来源”分组、折叠触发器、说明头或数量。桌面为左栏右工作面,移动端菜单移动到工作面顶部。右侧复用主线 `/organization` 的真实组织树与人员明细内容并保持原管理员权限;嵌入态只移除重复的旧壳,不复制数据逻辑。 +组织架构进入 /workspaces/organization/organization-structure,顶部以紧凑范围导航切换“品牌中心 / 全公司”,默认品牌中心。直接复用组织树与人员明细,不增加单项二级菜单或说明面板。 -人员架构直接进入 `/workspaces/organization/people-structure`,在 Department Shell 内复用同一真实组织工作面。组织树只显示公司组织架构中的“品牌中心”节点及其下级,标题、总人数、在职/离职统计和人员表都使用该范围;若同步数据中没有该节点,必须显示明确的同步提示,禁止回退到公司全量。 +权限管理进入 /workspaces/governance/permissions,顶部切换“权限组 / 人员权限”。两种工作面保持挂载,切换时保留权限草稿、当前组与人员搜索。刷新移到权限组列表标题行,移除嵌入工作面的重复标题和跨页按钮。 **The Two-Level Workflow Rule.** 自动化工作流的二级栏只包含两个业务菜单,不添加标题、数量、说明头或入口卡;右侧始终是当前菜单对应的真实操作面。 -**The Direct Data Source Rule.** 数据口径二级栏直接显示“公司组织架构”,不增加“数据来源”折叠层;右侧必须复用真实组织架构内容和原权限边界。 +**The Direct Data Source Rule.** 组织架构直接复用真实组织工作面与原权限边界,两个范围使用同一花名册来源。 -**The Brand Center Scope Rule.** 人员架构只呈现公司组织架构中的“品牌中心”节点及其下级人员;公司级 `/organization` 保持全量视图,节点缺失时显示同步提示而不是回退全量。 +**The Brand Center Scope Rule.** 品牌中心范围只呈现该节点及其下级人员,节点缺失显示同步提示,禁止自动回退全公司;全公司范围由用户明确切换。 **The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。 @@ -265,10 +268,10 @@ components: - 侧栏从五个职责域直接开始,不显示独立“部门总览”入口;根路径与“核心指标”入口指向同一默认工作面。 - 账户入口固定在侧栏最下方并向上展开菜单;顶栏只保留页面定位与导航折叠控制。 - 子页面标题直接承接首屏内容,不在标题上方增加重复的面包屑面板。 -- 16 个小项在侧栏使用 16px 语义线性图标;选中项图标随文字切为白色,页面标题区不重复摆放同一导航图标。 +- 14 个小项在侧栏使用 16px 语义线性图标;选中项图标随文字切为白色,页面标题区不重复摆放同一导航图标。 - 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。 -- “人员架构”进入 `/workspaces/organization/people-structure`,在 Department Shell 内复用真实组织工作面并聚焦“品牌中心”;公司级 `/organization` 继续保留全量视图。 -- “数据口径”使用单项二级栏直接显示“公司组织架构”;有管理员权限时复用真实组织工作面,无权限时显示明确权限提示。 +- “组织架构”集中品牌中心与全公司范围,默认品牌中心,旧人员架构和数据口径路径分别跳转至对应范围。 +- “权限管理”集中权限组与人员权限视图,旧路径跳转至对应视图,不再保留独立侧栏入口。 - 素材资产库根据会话中的品牌中心访问声明选择外壳;范围外普通用户的独立顶栏只显示“素材资产库”和“退出登录”,不得出现品牌中心链接。 - 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。 @@ -321,15 +324,15 @@ components: ### Do: - **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。 -- **Do** 保持 5 个职责域、16 个小项、8 个框架入口,以及素材库、自动化工作流、人员架构、数据口径和价值指标五项已接入业务的产品真相。 +- **Do** 保持 5 个职责域、14 个小项与 7 个框架入口,合并入口以真实职责为准。 - **Do** 让价值指标直接复用素材资产库数据,按资产类型显示复用率,并按下载次数降序展示明细。 - **Do** 让品牌知识库沿用素材资产库的搜索、分类、排序与内容网格节奏,并在数据未接入时显示诚实空状态。 - **Do** 明确区分真实数据、示例值、公式待接入与空数据。 - **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。 - **Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。 - **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。 -- **Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。 -- **Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。 +- **Do** 将花名册组织数据集中到组织架构,沿用主线管理员权限。 +- **Do** 保持品牌中心范围的组织与人员边界,权限视图切换保留未保存草稿。 - **Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。 ### Don't: @@ -342,6 +345,6 @@ components: - **Don't** 在自动化工作流二级栏顶部显示标题、接入数量、统计区或独立说明区。 - **Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。 - **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。 -- **Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。 -- **Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。 +- **Don't** 为同一职责增加重复侧栏入口、单项二级菜单或占位说明卡。 +- **Don't** 让品牌中心范围自动回退全公司,或复制脱离主线数据源的人员列表。 - **Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。 diff --git a/Dockerfile b/Dockerfile index 674d2fb..92501d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,6 +102,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260918_add_performance_conversion_sync/migration.sql" && \ test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" && \ test -f "/app/prisma/migrations/20260924_add_certificate_tested_on/migration.sql" +RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identity/migration.sql" && \ + test -f "/app/prisma/migrations/20261008_team_function_configs/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -156,6 +158,10 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/scripts/safe-production-migrate.mjs ./scripts/safe-production-migrate.mjs COPY --from=builder /app/scripts/create-verified-production-backup.mjs ./scripts/create-verified-production-backup.mjs +COPY --from=builder /app/scripts/seed-team-function-config.ts ./scripts/seed-team-function-config.ts +COPY --from=builder /app/lib ./lib +COPY --from=builder /app/tsconfig.json ./tsconfig.json +COPY --from=builder /app/data/team-function-initial-content.json ./data/team-function-initial-content.json COPY --from=builder /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT RUN test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \ test -x /app/node_modules/prisma/node_modules/@prisma/engines/schema-engine-linux-musl && \ diff --git a/PRODUCT.md b/PRODUCT.md index 0f266e6..669126f 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,9 +23,9 @@ web - 根路径 `/` 直接跳转至 `/workspaces/dashboard/core-metrics`,不再展示独立部门总览页。 - “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。 - “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。 -- “人员架构”在部门工作台内复用主线 `/organization` 的真实组织树、人员明细与原权限设置,并只聚焦“品牌中心”节点及其下级人员;公司级 `/organization` 全量视图保持不变。 +- “组织架构”统一品牌中心与全公司范围,默认品牌中心,复用真实组织树、人员明细和原管理员权限。 - 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。 -- “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。 +- “权限管理”统一权限组与人员权限视图;组织数据归组织管理,不再保留数据口径的独立入口。旧链接跳转至对应的新视图。 - “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。 - “进行中项目”项目明细按业务导出表展示审批标题、审批编号、需求人、需求部门、平台、品牌、类目、SPU、需求类型、标准时效、当前节点与负责人;需求人取流程发起人,需求部门按 HR 人员架构匹配。 - 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取已确认的“内容主管分配任务”节点创建时间,结束时间按规则分支取“编导审核”或“内容主管审核”节点完成时间。内容流程中多个主管分配节点、编导制作节点及普通领导者任务均不等同于最终审核;未知 activityId 不得仅凭负责人身份、AGREE 结果或完成状态推断为审核节点。 @@ -39,8 +39,8 @@ web - “品牌知识库”参照素材资产库的浏览工作面建立搜索、知识类型切换、排序与内容网格结构;真实知识数据和上传能力未接入前必须显示明确空状态,不虚构知识条目或数量。 - “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。 - “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的当前在职人数、收到流程数和完结流程数;人数统一读取当前钉钉组织架构,不还原历史人员快照。每个部门再按美术设计流程、内容视频流程分别展示近 8 周趋势与明细;流程按当前任务负责人部门归属映射,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。 -- “团队职能”按用户确认的职责图展示品牌创意A部、品牌创意B部、空间设计部和综合支持部四个团队,明确负责人、服务品牌、前置能力、岗位及职责边界;它是静态职责参考,不展示虚构人数或经营指标。 -- 除素材资产库、自动化工作流、人员架构、数据口径、价值指标和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 +- “团队职能”实时读取花名册中品牌中心的直接下级部门;部门与负责人显示当前数据库名称,负责人由超级管理员按在职人员 ID 明确指定,不按花名、职级猜测。服务品牌、能力、岗位、职责及团队说明由独立配置表保存,通过页内编辑维护;配置绑定部门 ID,HR 同步改名不覆盖职责,负责人离职或调出后显示关联失效。旧职责图内容保留为显式 ID 导入模板,不参与运行时兜底。 +- 除素材资产库、自动化工作流、组织架构、团队职能、权限管理、价值指标和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 - 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。 ## Brand Commitments diff --git a/app/api/organization/permission-groups/route.test.ts b/app/api/organization/permission-groups/route.test.ts index d7368ac..0f4397c 100644 --- a/app/api/organization/permission-groups/route.test.ts +++ b/app/api/organization/permission-groups/route.test.ts @@ -2,8 +2,9 @@ import { getServerSession } from 'next-auth' import { prisma } from '@/lib/prisma' -import { isSuperAdmin } from '@/lib/department-permission' +import { getAllMaterialTypes, isSuperAdmin } from '@/lib/department-permission' import { savePermissionPackItems } from '@/lib/user-permission-inherit' +import { findConflictingDepartmentGroup, loadPermissionGroupDepartments } from '@/lib/permission-groups' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), @@ -17,7 +18,11 @@ jest.mock('@/lib/prisma', () => ({ prisma: { materialPermissionPack: { findUnique: jest.fn(), + findMany: jest.fn(), }, + organizationUnit: { findFirst: jest.fn(), findMany: jest.fn() }, + performanceSurveyEmployeeProfile: { findMany: jest.fn() }, + materialPermissionPackDepartment: { upsert: jest.fn(), deleteMany: jest.fn() }, }, })) @@ -35,10 +40,10 @@ jest.mock('@/lib/user-permission-inherit', () => ({ jest.mock('@/lib/permission-groups', () => ({ findConflictingDepartmentGroup: jest.fn(), makePermissionGroupSlug: jest.fn(), - seedDefaultGroupDepartments: jest.fn(), + loadPermissionGroupDepartments: jest.fn(), })) -const { PUT } = require('@/app/api/organization/permission-groups/route') as typeof import('@/app/api/organization/permission-groups/route') +const { GET, PUT } = require('@/app/api/organization/permission-groups/route') as typeof import('@/app/api/organization/permission-groups/route') describe('/api/organization/permission-groups 保存权限', () => { beforeEach(() => { @@ -115,4 +120,58 @@ describe('/api/organization/permission-groups 保存权限', () => { expect(response.status).toBe(403) expect(savePermissionPackItems).not.toHaveBeenCalled() }) + + test('组页面展示数据库当前部门;未确认身份的历史关联明确失效', async () => { + ;(prisma.materialPermissionPack.findMany as jest.Mock).mockResolvedValue([{ id: 'visual-pack', slug: 'visual', name: '管理员自定义名', items: [], inheritors: [] }]) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([{ id: 'stable-dept', name: '新部门', path: '公司/新部门', level: 1, parentId: null, sourceDeptId: 42 }]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) + ;(getAllMaterialTypes as jest.Mock).mockResolvedValue(['任务-详情']) + ;(loadPermissionGroupDepartments as jest.Mock).mockResolvedValue([ + { id: 'known', packId: 'visual-pack', departmentPath: '公司/旧部门', organizationUnitId: 'stable-dept', organizationUnit: { id: 'stable-dept', path: '公司/新部门', name: '新部门', isActive: true } }, + { id: 'unknown', packId: 'visual-pack', departmentPath: '公司/已消失部门', organizationUnitId: null, organizationUnit: null }, + ]) + const response = await GET() + expect(response.status).toBe(200) + const body = await response.json() + expect(body.groups[0].name).toBe('管理员自定义名') + expect(body.groups[0].departments).toMatchObject([ + { id: 'known', path: '公司/新部门', name: '新部门', status: 'active' }, + { id: 'unknown', path: '公司/已消失部门', status: 'invalid' }, + ]) + expect(body.tree).toHaveLength(1) + expect(body.tree[0].path).toBe('公司/新部门') + }) + + test('按稳定部门 ID 加组,忽略客户端的旧名称路径', async () => { + ;(prisma.organizationUnit.findFirst as jest.Mock).mockResolvedValue({ path: '公司/新部门' }) + ;(findConflictingDepartmentGroup as jest.Mock).mockResolvedValue(null) + ;(prisma.materialPermissionPackDepartment.upsert as jest.Mock).mockResolvedValue({ packId: 'visual-pack' }) + const response = await PUT({ json: async () => ({ action: 'add-department', slug: 'visual', organizationUnitId: 'stable-dept', path: '公司/旧部门' }) } as never) + expect(response.status).toBe(200) + expect(prisma.materialPermissionPackDepartment.upsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { organizationUnitId: 'stable-dept' }, + create: { packId: 'visual-pack', organizationUnitId: 'stable-dept', departmentPath: '公司/新部门' }, + })) + }) + + test('失效关联可以精确移除,并限定在当前权限组内', async () => { + const response = await PUT({ json: async () => ({ action: 'remove-department', slug: 'visual', associationId: 'stale-row' }) } as never) + expect(response.status).toBe(200) + expect(prisma.materialPermissionPackDepartment.deleteMany).toHaveBeenCalledWith({ where: { packId: 'visual-pack', id: 'stale-row' } }) + expect(prisma.organizationUnit.findFirst).not.toHaveBeenCalled() + }) + + test('并发加组不能把其他组的绑定当作本组保存成功', async () => { + ;(prisma.organizationUnit.findFirst as jest.Mock).mockResolvedValue({ path: '公司/部门' }) + ;(findConflictingDepartmentGroup as jest.Mock).mockResolvedValue(null) + ;(prisma.materialPermissionPackDepartment.upsert as jest.Mock).mockResolvedValue({ packId: 'other-pack' }) + const response = await PUT({ json: async () => ({ action: 'add-department', slug: 'visual', organizationUnitId: 'stable-dept' }) } as never) + expect(response.status).toBe(409) + }) + + test('旧页面只发送部门名称时要求刷新,不能凭名称授予权限', async () => { + const response = await PUT({ json: async () => ({ action: 'add-department', slug: 'visual', path: '公司/部门' }) } as never) + expect(response.status).toBe(400) + expect(prisma.materialPermissionPackDepartment.upsert).not.toHaveBeenCalled() + }) }) diff --git a/app/api/organization/permission-groups/route.ts b/app/api/organization/permission-groups/route.ts index bf204b6..2fe1f28 100644 --- a/app/api/organization/permission-groups/route.ts +++ b/app/api/organization/permission-groups/route.ts @@ -5,23 +5,21 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { getAllMaterialTypes, isSuperAdmin } from '@/lib/department-permission' import { filterSelectableMaterialTypes } from '@/lib/material-type-options' -import { BINDING_STATUS } from '@/lib/employee-directory' +import { BINDING_STATUS, PERMISSION_PATH_PROFILE_SELECT } from '@/lib/employee-directory' import { selectVisibleOrganizationPeople } from '@/lib/auto-personnel-rules' import { collectOccupiedOrganizationPaths, lastOrganizationPathSegment, normalizeTaskDetailOnlyFlags, - normalizeOrganizationPath, pruneOrganizationTree, resolveEmployeeOrganizationPaths, } from '@/lib/organization-material-permissions' import { findConflictingDepartmentGroup, makePermissionGroupSlug, - seedDefaultGroupDepartments, + loadPermissionGroupDepartments, } from '@/lib/permission-groups' import { - ensureDefaultPermissionPacks, packItemsToMap, savePermissionPackItems, } from '@/lib/user-permission-inherit' @@ -60,9 +58,6 @@ export async function GET() { return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }) } - await ensureDefaultPermissionPacks() - await seedDefaultGroupDepartments() - const [packs, units, rosterProfiles, materialTypes, departmentRows] = await Promise.all([ prisma.materialPermissionPack.findMany({ orderBy: { name: 'asc' }, @@ -95,7 +90,7 @@ export async function GET() { take: 1, select: { employeeProfile: { - select: { displayName: true, departmentPath: true, departmentName: true }, + select: { ...PERMISSION_PATH_PROFILE_SELECT, displayName: true }, }, }, }, @@ -121,17 +116,7 @@ export async function GET() { }, }), getAllMaterialTypes().then(filterSelectableMaterialTypes), - prisma.materialPermissionPackDepartment - .findMany({ - orderBy: { departmentPath: 'asc' }, - select: { packId: true, departmentPath: true }, - }) - .catch((error) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2021') { - return [] as Array<{ packId: string; departmentPath: string }> - } - throw error - }), + loadPermissionGroupDepartments(), ]) const nodeMap = new Map() @@ -158,13 +143,13 @@ export async function GET() { ...selectVisibleOrganizationPeople(rosterProfiles).flatMap((profile) => resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId), ), - ...departmentRows.map((row) => row.departmentPath), + ...departmentRows.flatMap((row) => row.organizationUnit?.isActive ? [row.organizationUnit.path] : []), ]) const tree = pruneOrganizationTree(roots, occupiedPaths) - const departmentsByPack = new Map() + const departmentsByPack = new Map() for (const row of departmentRows) { const list = departmentsByPack.get(row.packId) || [] - list.push(row.departmentPath) + list.push(row) departmentsByPack.set(row.packId, list) } @@ -176,18 +161,22 @@ export async function GET() { name: pack.name, videoEarlyAccess: pack.videoEarlyAccess, permissions: packItemsToMap(pack.items), - departments: (departmentsByPack.get(pack.id) || []).map((path) => ({ - path, - name: lastOrganizationPathSegment(path) || path, + departments: (departmentsByPack.get(pack.id) || []).map((row) => ({ + id: row.id, + organizationUnitId: row.organizationUnitId, + path: row.organizationUnit?.path || row.departmentPath, + name: row.organizationUnit?.name || lastOrganizationPathSegment(row.departmentPath), + status: row.organizationUnit?.isActive ? 'active' : 'invalid', })), members: pack.inheritors.map((row) => { const profile = row.user.employeeBindings[0]?.employeeProfile + const currentPath = resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId)[0] || null return { userId: row.userId, username: row.user.username, displayName: profile?.displayName || row.user.realName || row.user.username, - departmentPath: profile?.departmentPath || null, - departmentName: profile?.departmentName || null, + departmentPath: currentPath, + departmentName: currentPath ? lastOrganizationPathSegment(currentPath) : null, } }), })), @@ -204,7 +193,6 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }) } - await ensureDefaultPermissionPacks() const body = await request.json() const action = String(body?.action || '').trim() @@ -285,22 +273,23 @@ export async function PUT(request: NextRequest) { } if (action === 'add-department' || action === 'remove-department') { - const path = normalizeOrganizationPath(body?.path) - if (!path) return NextResponse.json({ error: '请选择部门' }, { status: 400 }) - const unit = await prisma.organizationUnit.findFirst({ - where: { isActive: true, path }, - select: { path: true }, - }) - if (!unit) return NextResponse.json({ error: '花名册里没有这个部门' }, { status: 400 }) - + const organizationUnitId = String(body?.organizationUnitId || '').trim() if (action === 'remove-department') { + const associationId = String(body?.associationId || '').trim() + if (!associationId && !organizationUnitId) return NextResponse.json({ error: '请刷新列表后选择部门' }, { status: 400 }) await prisma.materialPermissionPackDepartment.deleteMany({ - where: { packId: pack.id, departmentPath: path }, + where: { packId: pack.id, ...(associationId ? { id: associationId } : { organizationUnitId }) }, }) return NextResponse.json({ ok: true }) } - - const conflict = await findConflictingDepartmentGroup(path, pack.id) + if (!organizationUnitId) return NextResponse.json({ error: '请刷新列表后选择部门' }, { status: 400 }) + const unit = await prisma.organizationUnit.findFirst({ + where: { isActive: true, id: organizationUnitId }, + select: { path: true }, + }) + if (!unit) return NextResponse.json({ error: '花名册里没有这个部门' }, { status: 400 }) + const path = unit.path + const conflict = await findConflictingDepartmentGroup(organizationUnitId, pack.id) if (conflict) { const name = lastOrganizationPathSegment(path) || path return NextResponse.json( @@ -310,13 +299,15 @@ export async function PUT(request: NextRequest) { } try { - await prisma.materialPermissionPackDepartment.upsert({ - where: { - packId_departmentPath: { packId: pack.id, departmentPath: path }, - }, + const association = await prisma.materialPermissionPackDepartment.upsert({ + where: { organizationUnitId }, update: {}, - create: { packId: pack.id, departmentPath: path }, + create: { packId: pack.id, organizationUnitId, departmentPath: path }, + select: { packId: true }, }) + if (association.packId !== pack.id) { + return NextResponse.json({ error: '这个部门已在别的权限组里,请刷新列表。' }, { status: 409 }) + } } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const name = lastOrganizationPathSegment(path) || path diff --git a/app/api/organization/team-functions/route.test.ts b/app/api/organization/team-functions/route.test.ts new file mode 100644 index 0000000..c6141ee --- /dev/null +++ b/app/api/organization/team-functions/route.test.ts @@ -0,0 +1,58 @@ +/** @jest-environment node */ +import { NextRequest } from 'next/server' +import { getServerSession } from 'next-auth' +import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access' +import { isSuperAdmin } from '@/lib/department-permission' +import { getTeamFunctions, saveTeamFunction, TeamFunctionError } from '@/lib/team-functions' +import { GET, PUT } from './route' +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() })) +jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() })) +jest.mock('@/lib/team-functions', () => ({ ...jest.requireActual('@/lib/team-functions'), getTeamFunctions: jest.fn(), saveTeamFunction: jest.fn() })) +jest.mock('@/lib/prisma', () => ({ prisma: {} })) +const input = { organizationUnitId: 'team', revision: 0, leadEmployeeProfileId: null, description: '说明', branches: [] } +const request = (body: unknown) => new NextRequest('http://local/api/organization/team-functions', { method: 'PUT', body: JSON.stringify(body) }) +beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'actor', role: 'USER' } }) + ;(resolveBrandCenterAccessForUser as jest.Mock).mockResolvedValue(true) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) + ;(getTeamFunctions as jest.Mock).mockResolvedValue({ teams: [], canEdit: false }) + ;(saveTeamFunction as jest.Mock).mockResolvedValue({ revision: 1 }) +}) +test('品牌中心成员可读,不取得维护权限或候选人员', async () => { + const response = await GET() + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(getTeamFunctions).toHaveBeenCalledWith(false) +}) +test('未登录及非品牌中心成员拒绝读取', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + expect((await GET()).status).toBe(403) + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'actor' } }) + ;(resolveBrandCenterAccessForUser as jest.Mock).mockResolvedValue(false) + expect((await GET()).status).toBe(403) + expect(getTeamFunctions).not.toHaveBeenCalled() +}) +test('客户端提交的管理员身份不能绕过数据库检查', async () => { + expect((await PUT(request({ ...input, isSuperAdmin: true }))).status).toBe(403) + expect(saveTeamFunction).not.toHaveBeenCalled() +}) +test('超级管理员使用服务端身份保存', async () => { + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + expect((await PUT(request(input))).status).toBe(200) + expect(saveTeamFunction).toHaveBeenCalledWith(input, 'actor') +}) +test('空分组、超长条目及错误修订号拒绝写入', async () => { + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + for (const body of [{ ...input, revision: -1 }, { ...input, branches: [{ label: '', items: ['职责'] }] }, { ...input, description: 'x'.repeat(2001) }]) { + expect((await PUT(request(body))).status).toBe(400) + } + expect(saveTeamFunction).not.toHaveBeenCalled() +}) +test('版本冲突返回可恢复的错误', async () => { + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + ;(saveTeamFunction as jest.Mock).mockRejectedValue(new TeamFunctionError('配置已更新', 409)) + expect((await PUT(request(input))).status).toBe(409) +}) diff --git a/app/api/organization/team-functions/route.ts b/app/api/organization/team-functions/route.ts new file mode 100644 index 0000000..a950a12 --- /dev/null +++ b/app/api/organization/team-functions/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access' +import { isSuperAdmin } from '@/lib/department-permission' +import { getTeamFunctions, saveTeamFunction, TeamFunctionError } from '@/lib/team-functions' +import { teamFunctionInputSchema } from '@/lib/team-function-contract' + +export const dynamic = 'force-dynamic' + +async function identity() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) return null + const user = session.user + if (!(await resolveBrandCenterAccessForUser(user.id, { role: user.role, isSuperAdmin: user.isSuperAdmin }))) return null + return { userId: user.id, canEdit: await isSuperAdmin(user.id) } +} + +function failure(error: unknown) { + if (error instanceof TeamFunctionError) return NextResponse.json({ error: error.message }, { status: error.status }) + console.error('团队职能请求失败', error) + return NextResponse.json({ error: '团队职能暂时无法读取或保存,请重试。' }, { status: 500 }) +} + +export async function GET() { + try { + const access = await identity() + if (!access) return NextResponse.json({ error: '无权访问品牌中心团队职能。' }, { status: 403 }) + return NextResponse.json(await getTeamFunctions(access.canEdit), { headers: { 'Cache-Control': 'private, no-store' } }) + } catch (error) { return failure(error) } +} + +export async function PUT(request: NextRequest) { + try { + const access = await identity() + if (!access?.canEdit) return NextResponse.json({ error: '仅超级管理员可以维护团队职能。' }, { status: 403 }) + const parsed = teamFunctionInputSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) return NextResponse.json({ error: '请检查负责人、说明和职责条目的格式或长度。' }, { status: 400 }) + return NextResponse.json(await saveTeamFunction(parsed.data, access.userId)) + } catch (error) { return failure(error) } +} diff --git a/app/api/organization/user-permissions/route.test.ts b/app/api/organization/user-permissions/route.test.ts new file mode 100644 index 0000000..641d788 --- /dev/null +++ b/app/api/organization/user-permissions/route.test.ts @@ -0,0 +1,57 @@ +/** @jest-environment node */ +import { prisma } from '@/lib/prisma' +import { getServerSession } from 'next-auth' +import { isSuperAdmin } from '@/lib/department-permission' +import { loadPermissionGroupDepartments } from '@/lib/permission-groups' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() })) +jest.mock('@/lib/user-permission-inherit', () => ({ ensureDefaultPermissionPacks: jest.fn() })) +jest.mock('@/lib/permission-groups', () => ({ loadPermissionGroupDepartments: jest.fn() })) +jest.mock('@/lib/prisma', () => ({ prisma: { + userEmployeeBinding: { findMany: jest.fn() }, + materialPermissionPack: { findMany: jest.fn() }, + organizationUnit: { findMany: jest.fn() }, +} })) +const { GET } = require('./route') as typeof import('./route') + +describe('按人看权限与实时部门组一致', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin' } }) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + ;(prisma.userEmployeeBinding.findMany as jest.Mock).mockResolvedValue([{ + userId: 'person', user: { username: 'sample', permissionInherits: [] }, + employeeProfile: { displayName: '示例人员', departmentPath: '公司/旧部门', + departmentMemberships: [{ sourceDeptId: '42', departmentPath: '公司/旧部门' }] }, + }]) + ;(prisma.materialPermissionPack.findMany as jest.Mock).mockResolvedValue([{ id: 'pack', slug: 'sample', name: '示例组' }]) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([{ sourceDeptId: 42, path: '公司/新中心/新部门' }]) + ;(loadPermissionGroupDepartments as jest.Mock).mockResolvedValue([{ + packId: 'pack', departmentPath: '公司/旧部门', organizationUnit: { path: '公司/新中心/新部门', isActive: true }, + }]) + }) + + test('员工快照和关联快照都过期时,仍显示相同 ID 的当前部门组', async () => { + const response = await GET({ url: 'https://local/api/organization/user-permissions?q=sample' } as never) + expect(response.status).toBe(200) + expect((await response.json()).people[0]).toMatchObject({ + departmentPath: '公司/新中心/新部门', departmentName: '新部门', + departmentGroups: [{ slug: 'sample', attachedPath: '公司/新中心/新部门', source: 'own' }], + }) + }) + + test.each([null, { path: '公司/旧部门', isActive: false }])('未确认或已下线的绑定不显示成有效组', async (unit) => { + ;(loadPermissionGroupDepartments as jest.Mock).mockResolvedValue([{ packId: 'pack', departmentPath: '公司/旧部门', organizationUnit: unit }]) + const response = await GET({ url: 'https://local/api/organization/user-permissions?q=sample' } as never) + expect((await response.json()).people[0].departmentGroups).toEqual([]) + }) + + test('无权查看的人不能查询员工或组数据', async () => { + ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) + const response = await GET({ url: 'https://local/api/organization/user-permissions?q=sample' } as never) + expect(response.status).toBe(403) + expect(prisma.userEmployeeBinding.findMany).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/organization/user-permissions/route.ts b/app/api/organization/user-permissions/route.ts index a5f4867..c75846c 100644 --- a/app/api/organization/user-permissions/route.ts +++ b/app/api/organization/user-permissions/route.ts @@ -1,12 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' -import { Prisma } from '@prisma/client' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { isSuperAdmin } from '@/lib/department-permission' -import { BINDING_STATUS } from '@/lib/employee-directory' -import { findNearestGroupMemberships } from '@/lib/organization-material-permissions' -import { ensureDefaultPermissionPacks } from '@/lib/user-permission-inherit' +import { BINDING_STATUS, PERMISSION_PATH_PROFILE_SELECT } from '@/lib/employee-directory' +import { findNearestGroupMemberships, resolveEmployeeOrganizationPaths, lastOrganizationPathSegment } from '@/lib/organization-material-permissions' +import { loadPermissionGroupDepartments } from '@/lib/permission-groups' async function requireSuperAdmin() { const session = await getServerSession(authOptions) @@ -21,14 +20,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }) } - await ensureDefaultPermissionPacks() - const keyword = String(new URL(request.url).searchParams.get('q') || '').trim() if (keyword.length < 1) { return NextResponse.json({ people: [], groups: [] }) } - const [bindings, packs] = await Promise.all([ + const [bindings, packs, departmentRows, units] = await Promise.all([ prisma.userEmployeeBinding.findMany({ where: { status: BINDING_STATUS.ACTIVE, @@ -53,7 +50,7 @@ export async function GET(request: NextRequest) { }, }, employeeProfile: { - select: { displayName: true, departmentPath: true, departmentName: true, jobNumber: true }, + select: { ...PERMISSION_PATH_PROFILE_SELECT, displayName: true, departmentName: true, jobNumber: true }, }, }, }), @@ -61,30 +58,31 @@ export async function GET(request: NextRequest) { orderBy: { name: 'asc' }, select: { id: true, slug: true, name: true }, }), + loadPermissionGroupDepartments(), + prisma.organizationUnit.findMany({ + where: { isActive: true }, select: { sourceDeptId: true, path: true }, + }), ]) - - const departmentRows = await prisma.materialPermissionPackDepartment - .findMany({ - select: { packId: true, departmentPath: true }, - }) - .catch((error) => { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2021') { - return [] as Array<{ packId: string; departmentPath: string }> - } - throw error - }) - + const pathBySourceDeptId = new Map(units.map((unit) => [String(unit.sourceDeptId), unit.path])) const memberships = departmentRows.flatMap((row) => { const pack = packs.find((item) => item.id === row.packId) - if (!pack) return [] - return [{ departmentPath: row.departmentPath, slug: pack.slug, name: pack.name }] + if (!pack || !row.organizationUnit?.isActive) return [] + return [{ departmentPath: row.organizationUnit.path, slug: pack.slug, name: pack.name }] }) return NextResponse.json({ groups: packs.map((pack) => ({ slug: pack.slug, name: pack.name })), people: bindings.map((binding) => { - const path = binding.employeeProfile.departmentPath - const nearest = path ? findNearestGroupMemberships(path, memberships) : null + const paths = resolveEmployeeOrganizationPaths(binding.employeeProfile, pathBySourceDeptId) + const path = paths[0] || null + const departmentGroups = Array.from(new Map(paths.flatMap((currentPath) => { + const nearest = findNearestGroupMemberships(currentPath, memberships) + return nearest ? nearest.items.map((item) => { + const group = { slug: item.slug, name: item.name, attachedPath: nearest.attachedPath, + source: nearest.attachedPath === currentPath ? 'own' : 'inherit' } + return [`${item.slug}:${nearest.attachedPath}`, group] as const + }) : [] + })).values()) const personalGroups = binding.user.permissionInherits.map((row) => ({ slug: row.pack.slug, name: row.pack.name, @@ -95,15 +93,8 @@ export async function GET(request: NextRequest) { displayName: binding.employeeProfile.displayName || binding.user.realName || binding.user.username, jobNumber: binding.employeeProfile.jobNumber, departmentPath: path, - departmentName: binding.employeeProfile.departmentName, - departmentGroups: nearest - ? nearest.items.map((item) => ({ - slug: item.slug, - name: item.name, - attachedPath: nearest.attachedPath, - source: nearest.attachedPath === path ? 'own' : 'inherit', - })) - : [], + departmentName: path ? lastOrganizationPathSegment(path) : null, + departmentGroups, personalGroups, } }), diff --git a/app/workspaces/[section]/[item]/page.tsx b/app/workspaces/[section]/[item]/page.tsx index 5789624..c9409aa 100644 --- a/app/workspaces/[section]/[item]/page.tsx +++ b/app/workspaces/[section]/[item]/page.tsx @@ -1,16 +1,14 @@ import type { Metadata } from 'next' -import { notFound } from 'next/navigation' +import { notFound, redirect } from 'next/navigation' import { Suspense } from 'react' import AutomationWorkflowsDashboard from '@/components/department-dashboard/AutomationWorkflowsDashboard' -import PermissionGroupsDashboard from '@/components/department-dashboard/PermissionGroupsDashboard' -import UserPermissionsDashboard from '@/components/department-dashboard/UserPermissionsDashboard' +import PermissionsDashboard from '@/components/department-dashboard/PermissionsDashboard' import BrandHealthDashboard from '@/components/department-dashboard/BrandHealthDashboard' import BusinessTrendsDashboard from '@/components/department-dashboard/BusinessTrendsDashboard' import TeamCapacityDashboard from '@/components/department-dashboard/TeamCapacityDashboard' import CoreMetricsDashboard from '@/components/department-dashboard/CoreMetricsDashboard' -import DataDefinitionsDashboard from '@/components/department-dashboard/DataDefinitionsDashboard' import DepartmentShell from '@/components/department-dashboard/DepartmentShell' -import PeopleStructureDashboard from '@/components/department-dashboard/PeopleStructureDashboard' +import OrganizationStructureDashboard from '@/components/department-dashboard/OrganizationStructureDashboard' import TeamFunctionsDashboard from '@/components/department-dashboard/TeamFunctionsDashboard' import ValueMetricsDashboard from '@/components/department-dashboard/ValueMetricsDashboard' import BrandKnowledgeDashboard from '@/components/department-dashboard/BrandKnowledgeDashboard' @@ -19,6 +17,7 @@ import WorkspacePlaceholder from '@/components/department-dashboard/WorkspacePla import { getFrameworkWorkspaceParams, getWorkspaceItem, + WORKSPACE_REDIRECTS, } from '@/components/department-dashboard/workspace-navigation' type WorkspaceItemPageProps = { @@ -26,6 +25,7 @@ type WorkspaceItemPageProps = { section: string item: string } + searchParams?: { view?: string; scope?: string } } function AutomationWorkflowsFallback() { @@ -61,31 +61,17 @@ export function generateMetadata({ params }: WorkspaceItemPageProps): Metadata { } } - if (workspace.section.key === 'governance' && workspace.item.key === 'data-definitions') { + if (workspace.section.key === 'governance' && workspace.item.key === 'permissions') { return { - title: '数据口径 | 治理与设置 | 品牌中心部门看板', - description: '通过公司组织架构查看人员层级与归属明细。', + title: '权限管理 | 治理与设置 | 品牌中心部门看板', + description: '集中配置权限组与查看人员的权限组归属。', } } - if (workspace.section.key === 'governance' && workspace.item.key === 'roles-permissions') { + if (workspace.section.key === 'organization' && workspace.item.key === 'organization-structure') { return { - title: '权限组 | 治理与设置 | 品牌中心部门看板', - description: '用权限组同时挂部门和人,运营中心进同一组,人可以进多组。', - } - } - - if (workspace.section.key === 'governance' && workspace.item.key === 'user-permissions') { - return { - title: '按人看权限 | 治理与设置 | 品牌中心部门看板', - description: '按人查看部门带来的权限组和单独加的组。', - } - } - - if (workspace.section.key === 'organization' && workspace.item.key === 'people-structure') { - return { - title: '人员架构 | 组织管理 | 品牌中心部门看板', - description: '查看公司组织架构中品牌中心的组织层级与人员明细。', + title: '组织架构 | 组织管理 | 品牌中心部门看板', + description: '按品牌中心或全公司范围查看花名册组织层级与人员明细。', } } @@ -131,7 +117,9 @@ export function generateMetadata({ params }: WorkspaceItemPageProps): Metadata { } } -export default function WorkspaceItemPage({ params }: WorkspaceItemPageProps) { +export default function WorkspaceItemPage({ params, searchParams }: WorkspaceItemPageProps) { + const legacyTarget = WORKSPACE_REDIRECTS[`${params.section}/${params.item}`] + if (legacyTarget) redirect(legacyTarget) const workspace = getWorkspaceItem(params.section, params.item) if (!workspace) { @@ -146,8 +134,8 @@ export default function WorkspaceItemPage({ params }: WorkspaceItemPageProps) { ) } - if (workspace.section.key === 'organization' && workspace.item.key === 'people-structure') { - return + if (workspace.section.key === 'organization' && workspace.item.key === 'organization-structure') { + return } if (workspace.section.key === 'organization' && workspace.item.key === 'team-functions') { @@ -166,16 +154,8 @@ export default function WorkspaceItemPage({ params }: WorkspaceItemPageProps) { return } - if (workspace.section.key === 'governance' && workspace.item.key === 'data-definitions') { - return - } - - if (workspace.section.key === 'governance' && workspace.item.key === 'roles-permissions') { - return - } - - if (workspace.section.key === 'governance' && workspace.item.key === 'user-permissions') { - return + if (workspace.section.key === 'governance' && workspace.item.key === 'permissions') { + return } if (workspace.item.status !== 'framework') notFound() diff --git a/components/board/MaterialsHeader.tsx b/components/board/MaterialsHeader.tsx index eb8c434..9bd83c7 100644 --- a/components/board/MaterialsHeader.tsx +++ b/components/board/MaterialsHeader.tsx @@ -425,8 +425,8 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead - 治理与设置 - 组织架构、规则与系统配置 + 素材库后台 + 素材管理、统计与系统配置 diff --git a/components/department-dashboard/BrandCenterDashboard.test.tsx b/components/department-dashboard/BrandCenterDashboard.test.tsx index d224aa6..27d7fb6 100644 --- a/components/department-dashboard/BrandCenterDashboard.test.tsx +++ b/components/department-dashboard/BrandCenterDashboard.test.tsx @@ -21,10 +21,10 @@ describe('BrandCenterDashboard', () => { expect(within(workspaces).queryByRole('link', { name: '中心仪表盘' })).not.toBeInTheDocument() expect(within(dashboard).getByRole('link', { name: /核心指标/ })).toHaveAttribute('href', '/workspaces/dashboard/core-metrics') expect(within(dashboard).getByRole('link', { name: /业务趋势/ })).toHaveAttribute('href', '/workspaces/dashboard/business-trends') - expect(within(assets).getByRole('link', { name: /素材资产库/ })).toHaveAttribute('href', '/assets/material-library') + expect(within(assets).getByRole('link', { name: /^素材资产库/ })).toHaveAttribute('href', '/assets/material-library') expect(within(assets).getByRole('link', { name: /自动化工作流/ })).toHaveAttribute('href', '/workspaces/assets/automation-workflows') expect(within(assets).getByText('2 项工作流已接入')).toBeInTheDocument() - expect(within(workspaces).getAllByRole('link')).toHaveLength(17) + expect(within(workspaces).getAllByRole('link')).toHaveLength(14) expect(within(workspaces).queryByRole('link', { name: '新品上新' })).not.toBeInTheDocument() expect(within(workspaces).queryByRole('link', { name: '创意生产' })).not.toBeInTheDocument() expect(within(workspaces).queryByRole('link', { name: '项目协同' })).not.toBeInTheDocument() diff --git a/components/department-dashboard/DataDefinitionsDashboard.test.tsx b/components/department-dashboard/DataDefinitionsDashboard.test.tsx deleted file mode 100644 index 439cde3..0000000 --- a/components/department-dashboard/DataDefinitionsDashboard.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { render, screen } from '@testing-library/react' -import DataDefinitionsDashboard from './DataDefinitionsDashboard' - -let sessionState = { - data: { user: { name: '本地超级管理员', role: 'SYSTEM_ADMIN' } }, - status: 'authenticated', -} - -jest.mock('next-auth/react', () => ({ - useSession: () => sessionState, - signOut: jest.fn(), -})) - -jest.mock('@/components/organization/OrganizationWorkspace', () => ({ - __esModule: true, - default: ({ embedded }: { embedded?: boolean }) => ( -
- 真实公司组织架构 -
- ), -})) - -describe('DataDefinitionsDashboard', () => { - beforeEach(() => { - sessionState = { - data: { user: { name: '本地超级管理员', role: 'SYSTEM_ADMIN' } }, - status: 'authenticated', - } - }) - - it('二级边栏直接显示公司组织架构并复用真实操作面', () => { - render() - - const navigation = screen.getByRole('navigation', { name: '数据口径二级导航' }) - expect(navigation).not.toHaveTextContent('数据来源') - expect(screen.getByRole('link', { name: /公司组织架构/ })).toHaveAttribute('aria-current', 'page') - expect(screen.getByTestId('organization-workspace')).toHaveAttribute('data-embedded', 'true') - }) - - it('沿用组织架构原有管理员权限', () => { - sessionState = { - data: { user: { name: '普通成员', role: 'USER' } }, - status: 'authenticated', - } - - render() - - expect(screen.getByRole('heading', { name: '暂无组织架构查看权限' })).toBeInTheDocument() - expect(screen.queryByTestId('organization-workspace')).not.toBeInTheDocument() - }) -}) diff --git a/components/department-dashboard/DataDefinitionsDashboard.tsx b/components/department-dashboard/DataDefinitionsDashboard.tsx deleted file mode 100644 index a27aaa1..0000000 --- a/components/department-dashboard/DataDefinitionsDashboard.tsx +++ /dev/null @@ -1,54 +0,0 @@ -'use client' - -import { Network } from 'lucide-react' -import { useSession } from 'next-auth/react' -import Link from 'next/link' -import OrganizationWorkspace from '@/components/organization/OrganizationWorkspace' -import DepartmentShell from './DepartmentShell' - -const ORGANIZATION_ROLES = new Set(['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN']) - -export default function DataDefinitionsDashboard() { - const { data: session, status } = useSession() - const role = (session?.user as { role?: string } | undefined)?.role || '' - const canViewOrganization = ORGANIZATION_ROLES.has(role) - - return ( - -
- - -
- {status === 'loading' ? ( -
-
-
- ) : canViewOrganization ? ( - - ) : ( -
-
-

暂无组织架构查看权限

-

请联系系统管理员开通组织架构访问权限。

-
-
- )} -
-
-
- ) -} diff --git a/components/department-dashboard/DepartmentAccountMenu.test.tsx b/components/department-dashboard/DepartmentAccountMenu.test.tsx new file mode 100644 index 0000000..13f71fe --- /dev/null +++ b/components/department-dashboard/DepartmentAccountMenu.test.tsx @@ -0,0 +1,16 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import DepartmentAccountMenu from './DepartmentAccountMenu' +let role = 'SYSTEM_ADMIN' +jest.mock('next-auth/react', () => ({ useSession: () => ({ data: { user: { name: '验收用户', role } } }), signOut: jest.fn() })) +test.each(['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'])('管理员 %s 可找到原后台入口', current => { + role = current + render() + fireEvent.click(screen.getByRole('button', { name: '打开账户菜单' })) + expect(screen.getByRole('menuitem', { name: '素材库后台' })).toHaveAttribute('href', '/admin') +}) +test('普通用户没有后台入口', () => { + role = 'USER' + render() + fireEvent.click(screen.getByRole('button', { name: '打开账户菜单' })) + expect(screen.queryByRole('menuitem', { name: '素材库后台' })).not.toBeInTheDocument() +}) diff --git a/components/department-dashboard/DepartmentAccountMenu.tsx b/components/department-dashboard/DepartmentAccountMenu.tsx index 5dcb3fe..e84fdc6 100644 --- a/components/department-dashboard/DepartmentAccountMenu.tsx +++ b/components/department-dashboard/DepartmentAccountMenu.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { signOut, useSession } from 'next-auth/react' -import { ChevronDown, LogOut, UserRound } from 'lucide-react' +import { ChevronDown, LogOut, Settings2, UserRound } from 'lucide-react' import { useEffect, useRef, useState } from 'react' type DepartmentAccountMenuProps = { @@ -17,6 +17,7 @@ export default function DepartmentAccountMenu({ collapsed, onNavigate }: Departm const triggerRef = useRef(null) const userName = session?.user?.name || session?.user?.email || '当前用户' const initials = userName.slice(0, 1).toUpperCase() + const canAccessMaterialAdmin = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(session?.user?.role ?? '') useEffect(() => { const closeMenu = (event: MouseEvent) => { @@ -56,6 +57,15 @@ export default function DepartmentAccountMenu({ collapsed, onNavigate }: Departm