fix: bind permissions to roster identities and persist team functions
This commit is contained in:
parent
c9f5110074
commit
858e9a690e
35
DESIGN.md
35
DESIGN.md
@ -119,7 +119,7 @@ components:
|
|||||||
|
|
||||||
品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。
|
品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。
|
||||||
|
|
||||||
它保留品牌中心自己的五个职责域、16 个小项入口,以及素材库、自动化工作流、人员架构、数据口径与价值指标五项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。
|
它保留品牌中心自己的五个职责域、14 个小项入口,以及素材库、自动化工作流、组织架构、权限管理与价值指标等已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。
|
||||||
|
|
||||||
**Key Characteristics:**
|
**Key Characteristics:**
|
||||||
|
|
||||||
@ -129,14 +129,17 @@ components:
|
|||||||
- 白色面板以 8–12px 圆角、细边框和克制阴影呈现。
|
- 白色面板以 8–12px 圆角、细边框和克制阴影呈现。
|
||||||
- 移动端导航是完整的模态抽屉;内容状态始终诚实标注。
|
- 移动端导航是完整的模态抽屉;内容状态始终诚实标注。
|
||||||
- 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。
|
- 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。
|
||||||
- 数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。
|
- 组织架构复用真实组织树与人员明细,提供“品牌中心 / 全公司”范围切换,沿用原管理员权限。
|
||||||
- 人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。
|
- 权限管理提供“权限组 / 人员权限”两个视图,侧栏只有一个入口。
|
||||||
- 素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。
|
- 素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。
|
||||||
- 进行中项目明细采用可键盘聚焦的横向滚动数据表:桌面固定“审批单标题”首列并保留 14 项业务字段,移动端转换为同字段的语义化定义列表;节点时间在数值下方标注对应流程节点名称。
|
- 进行中项目明细采用可键盘聚焦的横向滚动数据表:桌面固定“审批单标题”首列并保留 14 项业务字段,移动端转换为同字段的语义化定义列表;节点时间在数值下方标注对应流程节点名称。
|
||||||
- 按时交付明细使用紧凑的三项流程筛选条;当前项用浅蓝底和蓝字表达,未选项悬停为灰底,切换时同步更新公式、数量和横向明细表。
|
- 按时交付明细使用紧凑的三项流程筛选条;当前项用浅蓝底和蓝字表达,未选项悬停为灰底,切换时同步更新公式、数量和横向明细表。
|
||||||
- 团队负荷明细按部门展开人员和流程,商品企划部从人员、流程归属和平均负荷统计中排除;页面明确标注排除部门与指定人员。
|
- 团队负荷明细按部门展开人员和流程,商品企划部从人员、流程归属和平均负荷统计中排除;页面明确标注排除部门与指定人员。
|
||||||
- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧先标注当前钉钉组织架构在职人数,再参照业务趋势界面按“美术设计流程”和“内容视频流程”分成两个近 8 周收到/完结趋势区,并可展开每周明细。流程按当前节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点;不展示历史人员快照口径。
|
- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧先标注当前钉钉组织架构在职人数,再参照业务趋势界面按“美术设计流程”和“内容视频流程”分成两个近 8 周收到/完结趋势区,并可展开每周明细。流程按当前节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点;不展示历史人员快照口径。
|
||||||
- 团队职能页面把用户确认的思维导图转为四个可扫描的团队职责面板:团队名和负责人作为入口,服务/定位、能力/岗位、职责作为稳定层级;四组克制色彩只用于区分团队,节点在宽屏双列排布、窄屏纵向堆叠,不复制长图画布或产生页面级横向滚动。
|
- 团队职能页面把花名册团队及数据库职责配置组织成可扫描的工作面:团队名和负责人作为入口,服务/定位、能力/岗位、职责作为稳定层级;四组克制色彩循环用于区分团队,节点在宽屏多列排布、窄屏纵向堆叠,不复制长图画布或产生页面级横向滚动。
|
||||||
|
- 团队职能直接进入部门选择与职责内容,主工作面距壳顶栏保留 24px 间距;不展示重复页面大标题、装饰图标、概述及说明图例,不增加返回部门看板按钮。页面与职责地图标题保留为读屏标题,跨页导航使用现有侧栏。
|
||||||
|
- 团队列表以同步数据库中品牌中心直接下级部门为准,不限定四个固定名称;阅读态只显示职责内容,超级管理员点击“编辑”后显示负责人选择、说明与分组条目表单。保存后给出简短反馈;冲突保留草稿,取消时重新加载,未保存时离开或切换部门须确认。
|
||||||
|
- 团队职能维护的保存按钮与编辑文字使用 `#0050cb` 深蓝色,保存按钮 hover 使用 `#003da3`,保证小字号操作内容的对比度;不改变既有导航色彩。
|
||||||
- 业绩转化页面使用 176px 六品牌二级栏;右侧先展示“流程 × 商品ID”列表,再在下方展开所选商品的身份信息、三组指标摘要、单指标每日趋势与每日明细。不得增加第三个常驻侧栏,也不得把八项指标拆成八张装饰卡;缺失日期用趋势断点和 `—` 表示,与真实零值严格区分。
|
- 业绩转化页面使用 176px 六品牌二级栏;右侧先展示“流程 × 商品ID”列表,再在下方展开所选商品的身份信息、三组指标摘要、单指标每日趋势与每日明细。不得增加第三个常驻侧栏,也不得把八项指标拆成八张装饰卡;缺失日期用趋势断点和 `—` 表示,与真实零值严格区分。
|
||||||
|
|
||||||
## Colors
|
## Colors
|
||||||
@ -203,15 +206,15 @@ components:
|
|||||||
|
|
||||||
右侧通过同源 iframe 直接嵌入 `/performance-survey` 与 `/work-review`,保留原业务的真实数据、权限和交互。嵌入业务中所有 `href="/"` 的跨应用返回必须使用 `target="_top"`,确保返回品牌中心根页面而不是在 iframe 内再次嵌套首页。
|
右侧通过同源 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 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 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。
|
**The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。
|
||||||
|
|
||||||
@ -265,10 +268,10 @@ components:
|
|||||||
- 侧栏从五个职责域直接开始,不显示独立“部门总览”入口;根路径与“核心指标”入口指向同一默认工作面。
|
- 侧栏从五个职责域直接开始,不显示独立“部门总览”入口;根路径与“核心指标”入口指向同一默认工作面。
|
||||||
- 账户入口固定在侧栏最下方并向上展开菜单;顶栏只保留页面定位与导航折叠控制。
|
- 账户入口固定在侧栏最下方并向上展开菜单;顶栏只保留页面定位与导航折叠控制。
|
||||||
- 子页面标题直接承接首屏内容,不在标题上方增加重复的面包屑面板。
|
- 子页面标题直接承接首屏内容,不在标题上方增加重复的面包屑面板。
|
||||||
- 16 个小项在侧栏使用 16px 语义线性图标;选中项图标随文字切为白色,页面标题区不重复摆放同一导航图标。
|
- 14 个小项在侧栏使用 16px 语义线性图标;选中项图标随文字切为白色,页面标题区不重复摆放同一导航图标。
|
||||||
- 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。
|
- 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。
|
||||||
- “人员架构”进入 `/workspaces/organization/people-structure`,在 Department Shell 内复用真实组织工作面并聚焦“品牌中心”;公司级 `/organization` 继续保留全量视图。
|
- “组织架构”集中品牌中心与全公司范围,默认品牌中心,旧人员架构和数据口径路径分别跳转至对应范围。
|
||||||
- “数据口径”使用单项二级栏直接显示“公司组织架构”;有管理员权限时复用真实组织工作面,无权限时显示明确权限提示。
|
- “权限管理”集中权限组与人员权限视图,旧路径跳转至对应视图,不再保留独立侧栏入口。
|
||||||
- 素材资产库根据会话中的品牌中心访问声明选择外壳;范围外普通用户的独立顶栏只显示“素材资产库”和“退出登录”,不得出现品牌中心链接。
|
- 素材资产库根据会话中的品牌中心访问声明选择外壳;范围外普通用户的独立顶栏只显示“素材资产库”和“退出登录”,不得出现品牌中心链接。
|
||||||
- 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。
|
- 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。
|
||||||
|
|
||||||
@ -321,15 +324,15 @@ components:
|
|||||||
### Do:
|
### Do:
|
||||||
|
|
||||||
- **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。
|
- **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。
|
||||||
- **Do** 保持 5 个职责域、16 个小项、8 个框架入口,以及素材库、自动化工作流、人员架构、数据口径和价值指标五项已接入业务的产品真相。
|
- **Do** 保持 5 个职责域、14 个小项与 7 个框架入口,合并入口以真实职责为准。
|
||||||
- **Do** 让价值指标直接复用素材资产库数据,按资产类型显示复用率,并按下载次数降序展示明细。
|
- **Do** 让价值指标直接复用素材资产库数据,按资产类型显示复用率,并按下载次数降序展示明细。
|
||||||
- **Do** 让品牌知识库沿用素材资产库的搜索、分类、排序与内容网格节奏,并在数据未接入时显示诚实空状态。
|
- **Do** 让品牌知识库沿用素材资产库的搜索、分类、排序与内容网格节奏,并在数据未接入时显示诚实空状态。
|
||||||
- **Do** 明确区分真实数据、示例值、公式待接入与空数据。
|
- **Do** 明确区分真实数据、示例值、公式待接入与空数据。
|
||||||
- **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。
|
- **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。
|
||||||
- **Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。
|
- **Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。
|
||||||
- **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。
|
- **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。
|
||||||
- **Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。
|
- **Do** 将花名册组织数据集中到组织架构,沿用主线管理员权限。
|
||||||
- **Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。
|
- **Do** 保持品牌中心范围的组织与人员边界,权限视图切换保留未保存草稿。
|
||||||
- **Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。
|
- **Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。
|
||||||
|
|
||||||
### Don't:
|
### Don't:
|
||||||
@ -342,6 +345,6 @@ components:
|
|||||||
- **Don't** 在自动化工作流二级栏顶部显示标题、接入数量、统计区或独立说明区。
|
- **Don't** 在自动化工作流二级栏顶部显示标题、接入数量、统计区或独立说明区。
|
||||||
- **Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。
|
- **Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。
|
||||||
- **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。
|
- **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。
|
||||||
- **Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。
|
- **Don't** 为同一职责增加重复侧栏入口、单项二级菜单或占位说明卡。
|
||||||
- **Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。
|
- **Don't** 让品牌中心范围自动回退全公司,或复制脱离主线数据源的人员列表。
|
||||||
- **Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。
|
- **Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。
|
||||||
|
|||||||
@ -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/20260918_add_performance_conversion_sync/migration.sql" && \
|
||||||
test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/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"
|
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。这里提前失败,避免再把缺路由的镜像发出去。
|
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
|
||||||
@ -156,6 +158,10 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
COPY --from=builder /app/prisma ./prisma
|
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/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/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
|
COPY --from=builder /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT
|
||||||
RUN test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \
|
RUN test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \
|
||||||
test -x /app/node_modules/prisma/node_modules/@prisma/engines/schema-engine-linux-musl && \
|
test -x /app/node_modules/prisma/node_modules/@prisma/engines/schema-engine-linux-musl && \
|
||||||
|
|||||||
@ -23,9 +23,9 @@ web
|
|||||||
- 根路径 `/` 直接跳转至 `/workspaces/dashboard/core-metrics`,不再展示独立部门总览页。
|
- 根路径 `/` 直接跳转至 `/workspaces/dashboard/core-metrics`,不再展示独立部门总览页。
|
||||||
- “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。
|
- “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。
|
||||||
- “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。
|
- “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。
|
||||||
- “人员架构”在部门工作台内复用主线 `/organization` 的真实组织树、人员明细与原权限设置,并只聚焦“品牌中心”节点及其下级人员;公司级 `/organization` 全量视图保持不变。
|
- “组织架构”统一品牌中心与全公司范围,默认品牌中心,复用真实组织树、人员明细和原管理员权限。
|
||||||
- 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。
|
- 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。
|
||||||
- “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。
|
- “权限管理”统一权限组与人员权限视图;组织数据归组织管理,不再保留数据口径的独立入口。旧链接跳转至对应的新视图。
|
||||||
- “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。
|
- “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。
|
||||||
- “进行中项目”项目明细按业务导出表展示审批标题、审批编号、需求人、需求部门、平台、品牌、类目、SPU、需求类型、标准时效、当前节点与负责人;需求人取流程发起人,需求部门按 HR 人员架构匹配。
|
- “进行中项目”项目明细按业务导出表展示审批标题、审批编号、需求人、需求部门、平台、品牌、类目、SPU、需求类型、标准时效、当前节点与负责人;需求人取流程发起人,需求部门按 HR 人员架构匹配。
|
||||||
- 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取已确认的“内容主管分配任务”节点创建时间,结束时间按规则分支取“编导审核”或“内容主管审核”节点完成时间。内容流程中多个主管分配节点、编导制作节点及普通领导者任务均不等同于最终审核;未知 activityId 不得仅凭负责人身份、AGREE 结果或完成状态推断为审核节点。
|
- 美术设计流程的明细开始时间取“品牌策划主管分配任务”节点创建时间,结束时间取“品牌策划主管审核”节点完成时间;内容视频流程的明细开始时间取已确认的“内容主管分配任务”节点创建时间,结束时间按规则分支取“编导审核”或“内容主管审核”节点完成时间。内容流程中多个主管分配节点、编导制作节点及普通领导者任务均不等同于最终审核;未知 activityId 不得仅凭负责人身份、AGREE 结果或完成状态推断为审核节点。
|
||||||
@ -39,8 +39,8 @@ web
|
|||||||
- “品牌知识库”参照素材资产库的浏览工作面建立搜索、知识类型切换、排序与内容网格结构;真实知识数据和上传能力未接入前必须显示明确空状态,不虚构知识条目或数量。
|
- “品牌知识库”参照素材资产库的浏览工作面建立搜索、知识类型切换、排序与内容网格结构;真实知识数据和上传能力未接入前必须显示明确空状态,不虚构知识条目或数量。
|
||||||
- “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。
|
- “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。
|
||||||
- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的当前在职人数、收到流程数和完结流程数;人数统一读取当前钉钉组织架构,不还原历史人员快照。每个部门再按美术设计流程、内容视频流程分别展示近 8 周趋势与明细;流程按当前任务负责人部门归属映射,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。
|
- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的当前在职人数、收到流程数和完结流程数;人数统一读取当前钉钉组织架构,不还原历史人员快照。每个部门再按美术设计流程、内容视频流程分别展示近 8 周趋势与明细;流程按当前任务负责人部门归属映射,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。
|
||||||
- “团队职能”按用户确认的职责图展示品牌创意A部、品牌创意B部、空间设计部和综合支持部四个团队,明确负责人、服务品牌、前置能力、岗位及职责边界;它是静态职责参考,不展示虚构人数或经营指标。
|
- “团队职能”实时读取花名册中品牌中心的直接下级部门;部门与负责人显示当前数据库名称,负责人由超级管理员按在职人员 ID 明确指定,不按花名、职级猜测。服务品牌、能力、岗位、职责及团队说明由独立配置表保存,通过页内编辑维护;配置绑定部门 ID,HR 同步改名不覆盖职责,负责人离职或调出后显示关联失效。旧职责图内容保留为显式 ID 导入模板,不参与运行时兜底。
|
||||||
- 除素材资产库、自动化工作流、人员架构、数据口径、价值指标和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
|
- 除素材资产库、自动化工作流、组织架构、团队职能、权限管理、价值指标和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
|
||||||
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
|
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
|
||||||
|
|
||||||
## Brand Commitments
|
## Brand Commitments
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
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 { savePermissionPackItems } from '@/lib/user-permission-inherit'
|
||||||
|
import { findConflictingDepartmentGroup, loadPermissionGroupDepartments } from '@/lib/permission-groups'
|
||||||
|
|
||||||
jest.mock('next-auth', () => ({
|
jest.mock('next-auth', () => ({
|
||||||
getServerSession: jest.fn(),
|
getServerSession: jest.fn(),
|
||||||
@ -17,7 +18,11 @@ jest.mock('@/lib/prisma', () => ({
|
|||||||
prisma: {
|
prisma: {
|
||||||
materialPermissionPack: {
|
materialPermissionPack: {
|
||||||
findUnique: jest.fn(),
|
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', () => ({
|
jest.mock('@/lib/permission-groups', () => ({
|
||||||
findConflictingDepartmentGroup: jest.fn(),
|
findConflictingDepartmentGroup: jest.fn(),
|
||||||
makePermissionGroupSlug: 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 保存权限', () => {
|
describe('/api/organization/permission-groups 保存权限', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -115,4 +120,58 @@ describe('/api/organization/permission-groups 保存权限', () => {
|
|||||||
expect(response.status).toBe(403)
|
expect(response.status).toBe(403)
|
||||||
expect(savePermissionPackItems).not.toHaveBeenCalled()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,23 +5,21 @@ import { authOptions } from '@/lib/auth'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getAllMaterialTypes, isSuperAdmin } from '@/lib/department-permission'
|
import { getAllMaterialTypes, isSuperAdmin } from '@/lib/department-permission'
|
||||||
import { filterSelectableMaterialTypes } from '@/lib/material-type-options'
|
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 { selectVisibleOrganizationPeople } from '@/lib/auto-personnel-rules'
|
||||||
import {
|
import {
|
||||||
collectOccupiedOrganizationPaths,
|
collectOccupiedOrganizationPaths,
|
||||||
lastOrganizationPathSegment,
|
lastOrganizationPathSegment,
|
||||||
normalizeTaskDetailOnlyFlags,
|
normalizeTaskDetailOnlyFlags,
|
||||||
normalizeOrganizationPath,
|
|
||||||
pruneOrganizationTree,
|
pruneOrganizationTree,
|
||||||
resolveEmployeeOrganizationPaths,
|
resolveEmployeeOrganizationPaths,
|
||||||
} from '@/lib/organization-material-permissions'
|
} from '@/lib/organization-material-permissions'
|
||||||
import {
|
import {
|
||||||
findConflictingDepartmentGroup,
|
findConflictingDepartmentGroup,
|
||||||
makePermissionGroupSlug,
|
makePermissionGroupSlug,
|
||||||
seedDefaultGroupDepartments,
|
loadPermissionGroupDepartments,
|
||||||
} from '@/lib/permission-groups'
|
} from '@/lib/permission-groups'
|
||||||
import {
|
import {
|
||||||
ensureDefaultPermissionPacks,
|
|
||||||
packItemsToMap,
|
packItemsToMap,
|
||||||
savePermissionPackItems,
|
savePermissionPackItems,
|
||||||
} from '@/lib/user-permission-inherit'
|
} from '@/lib/user-permission-inherit'
|
||||||
@ -60,9 +58,6 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 })
|
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDefaultPermissionPacks()
|
|
||||||
await seedDefaultGroupDepartments()
|
|
||||||
|
|
||||||
const [packs, units, rosterProfiles, materialTypes, departmentRows] = await Promise.all([
|
const [packs, units, rosterProfiles, materialTypes, departmentRows] = await Promise.all([
|
||||||
prisma.materialPermissionPack.findMany({
|
prisma.materialPermissionPack.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@ -95,7 +90,7 @@ export async function GET() {
|
|||||||
take: 1,
|
take: 1,
|
||||||
select: {
|
select: {
|
||||||
employeeProfile: {
|
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),
|
getAllMaterialTypes().then(filterSelectableMaterialTypes),
|
||||||
prisma.materialPermissionPackDepartment
|
loadPermissionGroupDepartments(),
|
||||||
.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
|
|
||||||
}),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const nodeMap = new Map<string, OrgNode>()
|
const nodeMap = new Map<string, OrgNode>()
|
||||||
@ -158,13 +143,13 @@ export async function GET() {
|
|||||||
...selectVisibleOrganizationPeople(rosterProfiles).flatMap((profile) =>
|
...selectVisibleOrganizationPeople(rosterProfiles).flatMap((profile) =>
|
||||||
resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId),
|
resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId),
|
||||||
),
|
),
|
||||||
...departmentRows.map((row) => row.departmentPath),
|
...departmentRows.flatMap((row) => row.organizationUnit?.isActive ? [row.organizationUnit.path] : []),
|
||||||
])
|
])
|
||||||
const tree = pruneOrganizationTree(roots, occupiedPaths)
|
const tree = pruneOrganizationTree(roots, occupiedPaths)
|
||||||
const departmentsByPack = new Map<string, string[]>()
|
const departmentsByPack = new Map<string, typeof departmentRows>()
|
||||||
for (const row of departmentRows) {
|
for (const row of departmentRows) {
|
||||||
const list = departmentsByPack.get(row.packId) || []
|
const list = departmentsByPack.get(row.packId) || []
|
||||||
list.push(row.departmentPath)
|
list.push(row)
|
||||||
departmentsByPack.set(row.packId, list)
|
departmentsByPack.set(row.packId, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,18 +161,22 @@ export async function GET() {
|
|||||||
name: pack.name,
|
name: pack.name,
|
||||||
videoEarlyAccess: pack.videoEarlyAccess,
|
videoEarlyAccess: pack.videoEarlyAccess,
|
||||||
permissions: packItemsToMap(pack.items),
|
permissions: packItemsToMap(pack.items),
|
||||||
departments: (departmentsByPack.get(pack.id) || []).map((path) => ({
|
departments: (departmentsByPack.get(pack.id) || []).map((row) => ({
|
||||||
path,
|
id: row.id,
|
||||||
name: lastOrganizationPathSegment(path) || path,
|
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) => {
|
members: pack.inheritors.map((row) => {
|
||||||
const profile = row.user.employeeBindings[0]?.employeeProfile
|
const profile = row.user.employeeBindings[0]?.employeeProfile
|
||||||
|
const currentPath = resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId)[0] || null
|
||||||
return {
|
return {
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
username: row.user.username,
|
username: row.user.username,
|
||||||
displayName: profile?.displayName || row.user.realName || row.user.username,
|
displayName: profile?.displayName || row.user.realName || row.user.username,
|
||||||
departmentPath: profile?.departmentPath || null,
|
departmentPath: currentPath,
|
||||||
departmentName: profile?.departmentName || null,
|
departmentName: currentPath ? lastOrganizationPathSegment(currentPath) : null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
@ -204,7 +193,6 @@ export async function PUT(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 })
|
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDefaultPermissionPacks()
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const action = String(body?.action || '').trim()
|
const action = String(body?.action || '').trim()
|
||||||
|
|
||||||
@ -285,22 +273,23 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'add-department' || action === 'remove-department') {
|
if (action === 'add-department' || action === 'remove-department') {
|
||||||
const path = normalizeOrganizationPath(body?.path)
|
const organizationUnitId = String(body?.organizationUnitId || '').trim()
|
||||||
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 })
|
|
||||||
|
|
||||||
if (action === 'remove-department') {
|
if (action === 'remove-department') {
|
||||||
|
const associationId = String(body?.associationId || '').trim()
|
||||||
|
if (!associationId && !organizationUnitId) return NextResponse.json({ error: '请刷新列表后选择部门' }, { status: 400 })
|
||||||
await prisma.materialPermissionPackDepartment.deleteMany({
|
await prisma.materialPermissionPackDepartment.deleteMany({
|
||||||
where: { packId: pack.id, departmentPath: path },
|
where: { packId: pack.id, ...(associationId ? { id: associationId } : { organizationUnitId }) },
|
||||||
})
|
})
|
||||||
return NextResponse.json({ ok: true })
|
return NextResponse.json({ ok: true })
|
||||||
}
|
}
|
||||||
|
if (!organizationUnitId) return NextResponse.json({ error: '请刷新列表后选择部门' }, { status: 400 })
|
||||||
const conflict = await findConflictingDepartmentGroup(path, pack.id)
|
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) {
|
if (conflict) {
|
||||||
const name = lastOrganizationPathSegment(path) || path
|
const name = lastOrganizationPathSegment(path) || path
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -310,13 +299,15 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.materialPermissionPackDepartment.upsert({
|
const association = await prisma.materialPermissionPackDepartment.upsert({
|
||||||
where: {
|
where: { organizationUnitId },
|
||||||
packId_departmentPath: { packId: pack.id, departmentPath: path },
|
|
||||||
},
|
|
||||||
update: {},
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||||
const name = lastOrganizationPathSegment(path) || path
|
const name = lastOrganizationPathSegment(path) || path
|
||||||
|
|||||||
58
app/api/organization/team-functions/route.test.ts
Normal file
58
app/api/organization/team-functions/route.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
41
app/api/organization/team-functions/route.ts
Normal file
41
app/api/organization/team-functions/route.ts
Normal file
@ -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) }
|
||||||
|
}
|
||||||
57
app/api/organization/user-permissions/route.test.ts
Normal file
57
app/api/organization/user-permissions/route.test.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,12 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { isSuperAdmin } from '@/lib/department-permission'
|
import { isSuperAdmin } from '@/lib/department-permission'
|
||||||
import { BINDING_STATUS } from '@/lib/employee-directory'
|
import { BINDING_STATUS, PERMISSION_PATH_PROFILE_SELECT } from '@/lib/employee-directory'
|
||||||
import { findNearestGroupMemberships } from '@/lib/organization-material-permissions'
|
import { findNearestGroupMemberships, resolveEmployeeOrganizationPaths, lastOrganizationPathSegment } from '@/lib/organization-material-permissions'
|
||||||
import { ensureDefaultPermissionPacks } from '@/lib/user-permission-inherit'
|
import { loadPermissionGroupDepartments } from '@/lib/permission-groups'
|
||||||
|
|
||||||
async function requireSuperAdmin() {
|
async function requireSuperAdmin() {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
@ -21,14 +20,12 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 })
|
return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDefaultPermissionPacks()
|
|
||||||
|
|
||||||
const keyword = String(new URL(request.url).searchParams.get('q') || '').trim()
|
const keyword = String(new URL(request.url).searchParams.get('q') || '').trim()
|
||||||
if (keyword.length < 1) {
|
if (keyword.length < 1) {
|
||||||
return NextResponse.json({ people: [], groups: [] })
|
return NextResponse.json({ people: [], groups: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [bindings, packs] = await Promise.all([
|
const [bindings, packs, departmentRows, units] = await Promise.all([
|
||||||
prisma.userEmployeeBinding.findMany({
|
prisma.userEmployeeBinding.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: BINDING_STATUS.ACTIVE,
|
status: BINDING_STATUS.ACTIVE,
|
||||||
@ -53,7 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
employeeProfile: {
|
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' },
|
orderBy: { name: 'asc' },
|
||||||
select: { id: true, slug: true, name: true },
|
select: { id: true, slug: true, name: true },
|
||||||
}),
|
}),
|
||||||
|
loadPermissionGroupDepartments(),
|
||||||
|
prisma.organizationUnit.findMany({
|
||||||
|
where: { isActive: true }, select: { sourceDeptId: true, path: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
const pathBySourceDeptId = new Map(units.map((unit) => [String(unit.sourceDeptId), unit.path]))
|
||||||
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 memberships = departmentRows.flatMap((row) => {
|
const memberships = departmentRows.flatMap((row) => {
|
||||||
const pack = packs.find((item) => item.id === row.packId)
|
const pack = packs.find((item) => item.id === row.packId)
|
||||||
if (!pack) return []
|
if (!pack || !row.organizationUnit?.isActive) return []
|
||||||
return [{ departmentPath: row.departmentPath, slug: pack.slug, name: pack.name }]
|
return [{ departmentPath: row.organizationUnit.path, slug: pack.slug, name: pack.name }]
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
groups: packs.map((pack) => ({ slug: pack.slug, name: pack.name })),
|
groups: packs.map((pack) => ({ slug: pack.slug, name: pack.name })),
|
||||||
people: bindings.map((binding) => {
|
people: bindings.map((binding) => {
|
||||||
const path = binding.employeeProfile.departmentPath
|
const paths = resolveEmployeeOrganizationPaths(binding.employeeProfile, pathBySourceDeptId)
|
||||||
const nearest = path ? findNearestGroupMemberships(path, memberships) : null
|
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) => ({
|
const personalGroups = binding.user.permissionInherits.map((row) => ({
|
||||||
slug: row.pack.slug,
|
slug: row.pack.slug,
|
||||||
name: row.pack.name,
|
name: row.pack.name,
|
||||||
@ -95,15 +93,8 @@ export async function GET(request: NextRequest) {
|
|||||||
displayName: binding.employeeProfile.displayName || binding.user.realName || binding.user.username,
|
displayName: binding.employeeProfile.displayName || binding.user.realName || binding.user.username,
|
||||||
jobNumber: binding.employeeProfile.jobNumber,
|
jobNumber: binding.employeeProfile.jobNumber,
|
||||||
departmentPath: path,
|
departmentPath: path,
|
||||||
departmentName: binding.employeeProfile.departmentName,
|
departmentName: path ? lastOrganizationPathSegment(path) : null,
|
||||||
departmentGroups: nearest
|
departmentGroups,
|
||||||
? nearest.items.map((item) => ({
|
|
||||||
slug: item.slug,
|
|
||||||
name: item.name,
|
|
||||||
attachedPath: nearest.attachedPath,
|
|
||||||
source: nearest.attachedPath === path ? 'own' : 'inherit',
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
personalGroups,
|
personalGroups,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import AutomationWorkflowsDashboard from '@/components/department-dashboard/AutomationWorkflowsDashboard'
|
import AutomationWorkflowsDashboard from '@/components/department-dashboard/AutomationWorkflowsDashboard'
|
||||||
import PermissionGroupsDashboard from '@/components/department-dashboard/PermissionGroupsDashboard'
|
import PermissionsDashboard from '@/components/department-dashboard/PermissionsDashboard'
|
||||||
import UserPermissionsDashboard from '@/components/department-dashboard/UserPermissionsDashboard'
|
|
||||||
import BrandHealthDashboard from '@/components/department-dashboard/BrandHealthDashboard'
|
import BrandHealthDashboard from '@/components/department-dashboard/BrandHealthDashboard'
|
||||||
import BusinessTrendsDashboard from '@/components/department-dashboard/BusinessTrendsDashboard'
|
import BusinessTrendsDashboard from '@/components/department-dashboard/BusinessTrendsDashboard'
|
||||||
import TeamCapacityDashboard from '@/components/department-dashboard/TeamCapacityDashboard'
|
import TeamCapacityDashboard from '@/components/department-dashboard/TeamCapacityDashboard'
|
||||||
import CoreMetricsDashboard from '@/components/department-dashboard/CoreMetricsDashboard'
|
import CoreMetricsDashboard from '@/components/department-dashboard/CoreMetricsDashboard'
|
||||||
import DataDefinitionsDashboard from '@/components/department-dashboard/DataDefinitionsDashboard'
|
|
||||||
import DepartmentShell from '@/components/department-dashboard/DepartmentShell'
|
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 TeamFunctionsDashboard from '@/components/department-dashboard/TeamFunctionsDashboard'
|
||||||
import ValueMetricsDashboard from '@/components/department-dashboard/ValueMetricsDashboard'
|
import ValueMetricsDashboard from '@/components/department-dashboard/ValueMetricsDashboard'
|
||||||
import BrandKnowledgeDashboard from '@/components/department-dashboard/BrandKnowledgeDashboard'
|
import BrandKnowledgeDashboard from '@/components/department-dashboard/BrandKnowledgeDashboard'
|
||||||
@ -19,6 +17,7 @@ import WorkspacePlaceholder from '@/components/department-dashboard/WorkspacePla
|
|||||||
import {
|
import {
|
||||||
getFrameworkWorkspaceParams,
|
getFrameworkWorkspaceParams,
|
||||||
getWorkspaceItem,
|
getWorkspaceItem,
|
||||||
|
WORKSPACE_REDIRECTS,
|
||||||
} from '@/components/department-dashboard/workspace-navigation'
|
} from '@/components/department-dashboard/workspace-navigation'
|
||||||
|
|
||||||
type WorkspaceItemPageProps = {
|
type WorkspaceItemPageProps = {
|
||||||
@ -26,6 +25,7 @@ type WorkspaceItemPageProps = {
|
|||||||
section: string
|
section: string
|
||||||
item: string
|
item: string
|
||||||
}
|
}
|
||||||
|
searchParams?: { view?: string; scope?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutomationWorkflowsFallback() {
|
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 {
|
return {
|
||||||
title: '数据口径 | 治理与设置 | 品牌中心部门看板',
|
title: '权限管理 | 治理与设置 | 品牌中心部门看板',
|
||||||
description: '通过公司组织架构查看人员层级与归属明细。',
|
description: '集中配置权限组与查看人员的权限组归属。',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.section.key === 'governance' && workspace.item.key === 'roles-permissions') {
|
if (workspace.section.key === 'organization' && workspace.item.key === 'organization-structure') {
|
||||||
return {
|
return {
|
||||||
title: '权限组 | 治理与设置 | 品牌中心部门看板',
|
title: '组织架构 | 组织管理 | 品牌中心部门看板',
|
||||||
description: '用权限组同时挂部门和人,运营中心进同一组,人可以进多组。',
|
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: '查看公司组织架构中品牌中心的组织层级与人员明细。',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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)
|
const workspace = getWorkspaceItem(params.section, params.item)
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@ -146,8 +134,8 @@ export default function WorkspaceItemPage({ params }: WorkspaceItemPageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.section.key === 'organization' && workspace.item.key === 'people-structure') {
|
if (workspace.section.key === 'organization' && workspace.item.key === 'organization-structure') {
|
||||||
return <PeopleStructureDashboard />
|
return <OrganizationStructureDashboard scope={searchParams?.scope === 'company' ? 'company' : 'brand'} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.section.key === 'organization' && workspace.item.key === 'team-functions') {
|
if (workspace.section.key === 'organization' && workspace.item.key === 'team-functions') {
|
||||||
@ -166,16 +154,8 @@ export default function WorkspaceItemPage({ params }: WorkspaceItemPageProps) {
|
|||||||
return <PerformanceConversionDashboard />
|
return <PerformanceConversionDashboard />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.section.key === 'governance' && workspace.item.key === 'data-definitions') {
|
if (workspace.section.key === 'governance' && workspace.item.key === 'permissions') {
|
||||||
return <DataDefinitionsDashboard />
|
return <PermissionsDashboard view={searchParams?.view === 'people' ? 'people' : 'groups'} />
|
||||||
}
|
|
||||||
|
|
||||||
if (workspace.section.key === 'governance' && workspace.item.key === 'roles-permissions') {
|
|
||||||
return <PermissionGroupsDashboard />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspace.section.key === 'governance' && workspace.item.key === 'user-permissions') {
|
|
||||||
return <UserPermissionsDashboard />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspace.item.status !== 'framework') notFound()
|
if (workspace.item.status !== 'framework') notFound()
|
||||||
|
|||||||
@ -425,8 +425,8 @@ export default function MaterialsHeader({ session, sectionLabel }: MaterialsHead
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-[13px] font-semibold">治理与设置</span>
|
<span className="block text-[13px] font-semibold">素材库后台</span>
|
||||||
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">组织架构、规则与系统配置</span>
|
<span className="mt-0.5 block text-[12px] leading-snug text-[#6b7075]">素材管理、统计与系统配置</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -21,10 +21,10 @@ describe('BrandCenterDashboard', () => {
|
|||||||
expect(within(workspaces).queryByRole('link', { name: '中心仪表盘' })).not.toBeInTheDocument()
|
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/core-metrics')
|
||||||
expect(within(dashboard).getByRole('link', { name: /业务趋势/ })).toHaveAttribute('href', '/workspaces/dashboard/business-trends')
|
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).getByRole('link', { name: /自动化工作流/ })).toHaveAttribute('href', '/workspaces/assets/automation-workflows')
|
||||||
expect(within(assets).getByText('2 项工作流已接入')).toBeInTheDocument()
|
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()
|
expect(within(workspaces).queryByRole('link', { name: '创意生产' })).not.toBeInTheDocument()
|
||||||
expect(within(workspaces).queryByRole('link', { name: '项目协同' })).not.toBeInTheDocument()
|
expect(within(workspaces).queryByRole('link', { name: '项目协同' })).not.toBeInTheDocument()
|
||||||
|
|||||||
@ -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 }) => (
|
|
||||||
<div data-testid="organization-workspace" data-embedded={embedded ? 'true' : 'false'}>
|
|
||||||
真实公司组织架构
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('DataDefinitionsDashboard', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionState = {
|
|
||||||
data: { user: { name: '本地超级管理员', role: 'SYSTEM_ADMIN' } },
|
|
||||||
status: 'authenticated',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('二级边栏直接显示公司组织架构并复用真实操作面', () => {
|
|
||||||
render(<DataDefinitionsDashboard />)
|
|
||||||
|
|
||||||
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(<DataDefinitionsDashboard />)
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: '暂无组织架构查看权限' })).toBeInTheDocument()
|
|
||||||
expect(screen.queryByTestId('organization-workspace')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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 (
|
|
||||||
<DepartmentShell>
|
|
||||||
<main className="flex h-[calc(100dvh-72px)] min-h-[560px] flex-col overflow-hidden bg-white lg:grid lg:grid-cols-[232px_minmax(0,1fr)]">
|
|
||||||
<aside className="shrink-0 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-4 lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:py-6">
|
|
||||||
<nav aria-label="数据口径二级导航">
|
|
||||||
<Link
|
|
||||||
href="/workspaces/governance/data-definitions"
|
|
||||||
aria-current="page"
|
|
||||||
className="flex min-h-[58px] min-w-0 items-center gap-3 rounded-lg bg-[#e6f4ff] px-3 py-2.5 text-left text-[#0050cb] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6]"
|
|
||||||
>
|
|
||||||
<Network className="h-4 w-4 shrink-0 text-[#1677ff]" aria-hidden="true" />
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-bold">公司组织架构</span>
|
|
||||||
<span className="mt-0.5 hidden truncate text-xs text-[#315f91] lg:block">组织树与人员明细</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section aria-label="公司组织架构操作区" className="min-h-0 min-w-0 flex-1 overflow-y-auto bg-[#fafafa]">
|
|
||||||
{status === 'loading' ? (
|
|
||||||
<div className="grid min-h-full place-items-center p-6" aria-label="正在加载公司组织架构" aria-busy="true">
|
|
||||||
<div className="h-20 w-full max-w-3xl animate-pulse rounded-xl bg-[#e8edf3]" />
|
|
||||||
</div>
|
|
||||||
) : canViewOrganization ? (
|
|
||||||
<OrganizationWorkspace embedded />
|
|
||||||
) : (
|
|
||||||
<div className="grid min-h-full place-items-center p-6">
|
|
||||||
<div className="max-w-md rounded-xl border border-[#e2e8f0] bg-white px-6 py-8 text-center">
|
|
||||||
<h1 className="text-lg font-bold text-[#101828]">暂无组织架构查看权限</h1>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[#66758b]">请联系系统管理员开通组织架构访问权限。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</DepartmentShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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(<DepartmentAccountMenu collapsed={false} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '打开账户菜单' }))
|
||||||
|
expect(screen.getByRole('menuitem', { name: '素材库后台' })).toHaveAttribute('href', '/admin')
|
||||||
|
})
|
||||||
|
test('普通用户没有后台入口', () => {
|
||||||
|
role = 'USER'
|
||||||
|
render(<DepartmentAccountMenu collapsed={false} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '打开账户菜单' }))
|
||||||
|
expect(screen.queryByRole('menuitem', { name: '素材库后台' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { signOut, useSession } from 'next-auth/react'
|
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'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
type DepartmentAccountMenuProps = {
|
type DepartmentAccountMenuProps = {
|
||||||
@ -17,6 +17,7 @@ export default function DepartmentAccountMenu({ collapsed, onNavigate }: Departm
|
|||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const userName = session?.user?.name || session?.user?.email || '当前用户'
|
const userName = session?.user?.name || session?.user?.email || '当前用户'
|
||||||
const initials = userName.slice(0, 1).toUpperCase()
|
const initials = userName.slice(0, 1).toUpperCase()
|
||||||
|
const canAccessMaterialAdmin = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(session?.user?.role ?? '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const closeMenu = (event: MouseEvent) => {
|
const closeMenu = (event: MouseEvent) => {
|
||||||
@ -56,6 +57,15 @@ export default function DepartmentAccountMenu({ collapsed, onNavigate }: Departm
|
|||||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||||
个人资料
|
个人资料
|
||||||
</Link>
|
</Link>
|
||||||
|
{canAccessMaterialAdmin && <Link
|
||||||
|
href="/admin"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => { setOpen(false); onNavigate?.() }}
|
||||||
|
className="flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium hover:bg-[#f2f5f8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
素材库后台
|
||||||
|
</Link>}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import {
|
|||||||
ChartNoAxesCombined,
|
ChartNoAxesCombined,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Database,
|
|
||||||
Gauge,
|
Gauge,
|
||||||
GaugeCircle,
|
GaugeCircle,
|
||||||
LibraryBig,
|
LibraryBig,
|
||||||
@ -21,7 +20,6 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
UserRound,
|
|
||||||
UsersRound,
|
UsersRound,
|
||||||
Workflow,
|
Workflow,
|
||||||
X,
|
X,
|
||||||
@ -49,12 +47,10 @@ const ITEM_ICONS: Record<string, LucideIcon> = {
|
|||||||
'brand-knowledge': BookOpen,
|
'brand-knowledge': BookOpen,
|
||||||
'automation-workflows': Workflow,
|
'automation-workflows': Workflow,
|
||||||
'value-metrics': ChartColumn,
|
'value-metrics': ChartColumn,
|
||||||
'people-structure': Network,
|
'organization-structure': Network,
|
||||||
'team-functions': Blocks,
|
'team-functions': Blocks,
|
||||||
'performance-review': ClipboardCheck,
|
'performance-review': ClipboardCheck,
|
||||||
'roles-permissions': ShieldCheck,
|
permissions: ShieldCheck,
|
||||||
'user-permissions': UserRound,
|
|
||||||
'data-definitions': Database,
|
|
||||||
'editing-capabilities': SlidersHorizontal,
|
'editing-capabilities': SlidersHorizontal,
|
||||||
notifications: BellRing,
|
notifications: BellRing,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import OrganizationStructureDashboard from './OrganizationStructureDashboard'
|
||||||
|
|
||||||
|
let role = 'SYSTEM_ADMIN'
|
||||||
|
jest.mock('next-auth/react', () => ({ useSession: () => ({ status: 'authenticated', data: { user: { role } } }) }))
|
||||||
|
jest.mock('./DepartmentShell', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }))
|
||||||
|
jest.mock('@/components/organization/OrganizationWorkspace', () => ({
|
||||||
|
__esModule: true, default: ({ scopeRootName }: { scopeRootName?: string }) => <div data-testid="organization-workspace" data-scope={scopeRootName || 'company'} />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => { role = 'SYSTEM_ADMIN' })
|
||||||
|
test('品牌中心与全公司使用同一真实组件并明确切换范围', () => {
|
||||||
|
const { rerender } = render(<OrganizationStructureDashboard />)
|
||||||
|
expect(screen.getByTestId('organization-workspace')).toHaveAttribute('data-scope', '品牌中心')
|
||||||
|
rerender(<OrganizationStructureDashboard scope="company" />)
|
||||||
|
expect(screen.getByTestId('organization-workspace')).toHaveAttribute('data-scope', 'company')
|
||||||
|
expect(screen.getByRole('link', { name: '全公司' })).toHaveAttribute('aria-current', 'page')
|
||||||
|
})
|
||||||
|
test('普通成员不能挂载全公司组织工作面', () => {
|
||||||
|
role = 'USER'
|
||||||
|
render(<OrganizationStructureDashboard scope="company" />)
|
||||||
|
expect(screen.getByRole('heading', { name: '暂无组织架构查看权限' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('organization-workspace')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import OrganizationWorkspace from '@/components/organization/OrganizationWorkspace'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
import DepartmentShell from './DepartmentShell'
|
||||||
|
import WorkspaceViewNavigation from './WorkspaceViewNavigation'
|
||||||
|
|
||||||
|
export default function OrganizationStructureDashboard({ scope = 'brand' }: { scope?: 'brand' | 'company' }) {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const role = (session?.user as { role?: string } | undefined)?.role || ''
|
||||||
|
const canViewOrganization = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)
|
||||||
|
return (
|
||||||
|
<DepartmentShell>
|
||||||
|
<div className="min-h-[calc(100dvh-72px)] bg-[#fafafa]">
|
||||||
|
<div className="border-b border-[#e2e8f0] bg-white px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<WorkspaceViewNavigation label="组织架构范围" items={[
|
||||||
|
{ label: '品牌中心', href: '/workspaces/organization/organization-structure', active: scope === 'brand' },
|
||||||
|
{ label: '全公司', href: '/workspaces/organization/organization-structure?scope=company', active: scope === 'company' },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
{status === 'loading' ? <div aria-busy="true" aria-label="正在加载组织架构" className="px-5 py-10 text-sm text-[#66758b]">加载中…</div> : canViewOrganization ? <OrganizationWorkspace key={scope} embedded treeAsSecondarySidebar heading="组织架构"
|
||||||
|
scopeRootName={scope === 'brand' ? '品牌中心' : undefined}
|
||||||
|
description={`${scope === 'brand' ? '品牌中心' : '全公司'} · HR 花名册 · 每日 02:00 自动同步`} /> :
|
||||||
|
<div className="px-5 py-10 text-sm text-[#66758b]"><h1 className="mb-2 text-lg font-semibold text-[#101828]">暂无组织架构查看权限</h1>请联系系统管理员。</div>}
|
||||||
|
</div>
|
||||||
|
</DepartmentShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import PeopleStructureDashboard from './PeopleStructureDashboard'
|
|
||||||
|
|
||||||
jest.mock('./DepartmentShell', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="department-shell">{children}</div>,
|
|
||||||
}))
|
|
||||||
|
|
||||||
jest.mock('@/components/organization/OrganizationWorkspace', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: (props: Record<string, unknown>) => (
|
|
||||||
<div
|
|
||||||
data-testid="organization-workspace"
|
|
||||||
data-embedded={String(props.embedded)}
|
|
||||||
data-scope={String(props.scopeRootName)}
|
|
||||||
data-heading={String(props.heading)}
|
|
||||||
data-tree-as-secondary-sidebar={String(props.treeAsSecondarySidebar)}
|
|
||||||
>
|
|
||||||
{String(props.description)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('PeopleStructureDashboard', () => {
|
|
||||||
it('在部门工作台内复用真实组织架构并聚焦品牌中心', () => {
|
|
||||||
render(<PeopleStructureDashboard />)
|
|
||||||
|
|
||||||
expect(screen.getByTestId('department-shell')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('organization-workspace')).toHaveAttribute('data-embedded', 'true')
|
|
||||||
expect(screen.getByTestId('organization-workspace')).toHaveAttribute('data-scope', '品牌中心')
|
|
||||||
expect(screen.getByTestId('organization-workspace')).toHaveAttribute('data-heading', '人员架构')
|
|
||||||
expect(screen.getByTestId('organization-workspace')).toHaveAttribute('data-tree-as-secondary-sidebar', 'true')
|
|
||||||
expect(screen.getByText(/公司组织架构 · 品牌中心在职人员/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import DepartmentShell from './DepartmentShell'
|
|
||||||
import OrganizationWorkspace from '@/components/organization/OrganizationWorkspace'
|
|
||||||
|
|
||||||
export default function PeopleStructureDashboard() {
|
|
||||||
return (
|
|
||||||
<DepartmentShell>
|
|
||||||
<div
|
|
||||||
className="h-[calc(100dvh-72px)] min-h-[560px] overflow-y-auto bg-[#fafafa]"
|
|
||||||
aria-label="品牌中心人员架构操作区"
|
|
||||||
>
|
|
||||||
<OrganizationWorkspace
|
|
||||||
embedded
|
|
||||||
scopeRootName="品牌中心"
|
|
||||||
heading="人员架构"
|
|
||||||
description="公司组织架构 · 品牌中心在职人员 · 数据源:HR 花名册(每日 02:00 自动同步)"
|
|
||||||
treeAsSecondarySidebar
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DepartmentShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -40,7 +40,7 @@ type Payload = {
|
|||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
permissions: Record<string, PermissionFlags>
|
permissions: Record<string, PermissionFlags>
|
||||||
departments: Array<{ path: string; name: string }>
|
departments: Array<{ id?: string; path: string; name: string; status?: 'active' | 'invalid' }>
|
||||||
members: Array<{
|
members: Array<{
|
||||||
userId: string
|
userId: string
|
||||||
username: string
|
username: string
|
||||||
@ -194,7 +194,7 @@ describe('PermissionGroupsDashboard', () => {
|
|||||||
expect(screen.getByText('已在本组')).toBeInTheDocument()
|
expect(screen.getByText('已在本组')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('link', { name: '按人看权限' })).toHaveAttribute(
|
expect(screen.getByRole('link', { name: '按人看权限' })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/workspaces/governance/user-permissions',
|
'/workspaces/governance/permissions?view=people',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -229,12 +229,26 @@ describe('PermissionGroupsDashboard', () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
action: 'remove-department',
|
action: 'remove-department',
|
||||||
slug: 'operations',
|
slug: 'operations',
|
||||||
|
organizationUnitId: 'ops',
|
||||||
path: '江苏简尚品牌管理有限公司/运营中心A',
|
path: '江苏简尚品牌管理有限公司/运营中心A',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('失效的旧关联不会当作有效部门,并能按关联 ID 清理', async () => {
|
||||||
|
const payload = createPayload()
|
||||||
|
payload.groups[0].departments = [{ id: 'stale', path: '公司/旧部门', name: '旧部门', status: 'invalid' }]
|
||||||
|
const fetchMock = installFetchSequence(jsonResponse(payload), jsonResponse({ ok: true }), jsonResponse(createPayload()))
|
||||||
|
await renderDashboard()
|
||||||
|
await openDepartments()
|
||||||
|
expect(await screen.findByText('关联已失效')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText(/0 个部门 · 1 失效/).length).toBeGreaterThan(0)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '移除已加入的旧部门' }))
|
||||||
|
const write = fetchMock.mock.calls.find(([, init]) => init?.method === 'PUT')
|
||||||
|
expect(JSON.parse(write?.[1].body)).toMatchObject({ action: 'remove-department', associationId: 'stale', slug: 'operations' })
|
||||||
|
})
|
||||||
|
|
||||||
it('save-pack 成功后只发起首屏 GET 和一次 PUT,并直接应用后端返回的标准化权限', async () => {
|
it('save-pack 成功后只发起首屏 GET 和一次 PUT,并直接应用后端返回的标准化权限', async () => {
|
||||||
const nextPermissions = {
|
const nextPermissions = {
|
||||||
'3D场景': flags(),
|
'3D场景': flags(),
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { ChevronRight, Search, Plus, RefreshCw, Check, X } from 'lucide-react'
|
import { ChevronRight, Search, Plus, RefreshCw, Check, X } from 'lucide-react'
|
||||||
@ -36,7 +36,7 @@ type Group = {
|
|||||||
name: string
|
name: string
|
||||||
videoEarlyAccess?: boolean
|
videoEarlyAccess?: boolean
|
||||||
permissions: Record<string, MaterialPermissionFlags>
|
permissions: Record<string, MaterialPermissionFlags>
|
||||||
departments: Array<{ path: string; name: string }>
|
departments: Array<{ id?: string; organizationUnitId?: string | null; path: string; name: string; status?: 'active' | 'invalid' }>
|
||||||
members: Member[]
|
members: Member[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +83,12 @@ function flattenTree(nodes: OrgNode[]): OrgNode[] {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PermissionGroupsDashboard() {
|
function groupSummary(group: Group): string {
|
||||||
|
const invalid = group.departments.filter((row) => row.status === 'invalid').length
|
||||||
|
return `${group.departments.length - invalid} 个部门${invalid ? ` · ${invalid} 失效` : ''} · ${group.members.length} 人`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PermissionGroupsDashboard({ embedded = false }: { embedded?: boolean }) {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
|
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
|
||||||
|
|
||||||
@ -246,12 +251,12 @@ export default function PermissionGroupsDashboard() {
|
|||||||
}, [selected, payload, dirty, draft])
|
}, [selected, payload, dirty, draft])
|
||||||
|
|
||||||
const memberIds = useMemo(() => new Set(selected?.members.map((row) => row.userId) || []), [selected])
|
const memberIds = useMemo(() => new Set(selected?.members.map((row) => row.userId) || []), [selected])
|
||||||
const departmentPaths = useMemo(() => new Set(selected?.departments.map((row) => row.path) || []), [selected])
|
const departmentPaths = useMemo(() => new Set(selected?.departments.filter((row) => row.status !== 'invalid').map((row) => row.path) || []), [selected])
|
||||||
|
|
||||||
const memberships = useMemo(
|
const memberships = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(payload?.groups || []).flatMap((group) =>
|
(payload?.groups || []).flatMap((group) =>
|
||||||
group.departments.map((row) => ({ departmentPath: row.path, slug: group.slug, name: group.name })),
|
group.departments.filter((row) => row.status !== 'invalid').map((row) => ({ departmentPath: row.path, slug: group.slug, name: group.name })),
|
||||||
),
|
),
|
||||||
[payload],
|
[payload],
|
||||||
)
|
)
|
||||||
@ -433,7 +438,7 @@ export default function PermissionGroupsDashboard() {
|
|||||||
<p className={`mt-0.5 break-words text-xs ${groupStatus.kind === 'other' ? 'text-[#9a4d08]' : 'text-[#66758b]'}`}>{groupStatus.label}</p>
|
<p className={`mt-0.5 break-words text-xs ${groupStatus.kind === 'other' ? 'text-[#9a4d08]' : 'text-[#66758b]'}`}>{groupStatus.label}</p>
|
||||||
</div>
|
</div>
|
||||||
{groupStatus.kind === 'other' ? <span className="shrink-0 text-xs text-[#9a4d08]">占用</span> :
|
{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`}>
|
<button type="button" disabled={busy || !selected} aria-label={`${isDirect ? '移除' : '加入'}${node.name}`} onClick={() => void mutate({ action: isDirect ? 'remove-department' : 'add-department', slug: selectedSlug, organizationUnitId: node.id, path: node.path }, isDirect ? `已从本组移除 ${node.name}` : `已把 ${node.name} 加入本组`)} className={`${secondary} shrink-0`}>
|
||||||
{isDirect ? '移除' : '加入'}
|
{isDirect ? '移除' : '加入'}
|
||||||
</button>}
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
@ -442,23 +447,27 @@ export default function PermissionGroupsDashboard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Frame = embedded ? Fragment : DepartmentShell
|
||||||
|
const Content = embedded ? 'div' : 'main'
|
||||||
|
const refreshButton = isSuperAdmin && <button type="button" disabled={busy} onClick={() => {
|
||||||
|
if ((dirty || renameDirty) && !window.confirm('当前修改尚未保存,刷新后将以服务器数据为准。继续?')) return
|
||||||
|
void load()
|
||||||
|
}} aria-label={loading ? '刷新中…' : '刷新列表'} title="刷新列表" className={`${secondary} w-11 px-0`}><RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin motion-reduce:animate-none' : ''}`} /></button>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DepartmentShell>
|
<Frame>
|
||||||
<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">
|
<Content className={embedded ? 'lg:flex lg:h-full lg:min-h-0 lg:flex-col' : 'px-4 py-5 sm:px-6 lg:flex lg:h-[calc(100dvh-72px)] lg:min-h-0 lg:flex-col lg:px-8'}>
|
||||||
<header className="mb-4 flex shrink-0 flex-wrap items-center justify-between gap-3">
|
{!embedded && <header className="mb-4 flex shrink-0 flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#101828]">权限组</h1>
|
<h1 className="text-2xl font-bold text-[#101828]">权限组</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{isSuperAdmin && <button type="button" disabled={busy} onClick={() => {
|
{refreshButton}
|
||||||
if (dirty && !window.confirm('当前勾选尚未保存,刷新后将以服务器数据为准。继续?')) return
|
<Link href="/workspaces/governance/permissions?view=people" aria-disabled={busy} onClick={event => {
|
||||||
void load()
|
|
||||||
}} aria-label={loading ? '刷新中…' : '刷新列表'} title="刷新列表" className={`${secondary} w-11 px-0`}><RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin motion-reduce:animate-none' : ''}`} /></button>}
|
|
||||||
<Link href="/workspaces/governance/user-permissions" aria-disabled={busy} onClick={event => {
|
|
||||||
if (busy || ((dirty || renameDirty) && !window.confirm('当前组有未保存的修改,离开后会丢弃。继续前往按人看权限?'))) event.preventDefault()
|
if (busy || ((dirty || renameDirty) && !window.confirm('当前组有未保存的修改,离开后会丢弃。继续前往按人看权限?'))) event.preventDefault()
|
||||||
}} className={`${secondary} ${busy ? 'cursor-not-allowed opacity-50' : ''}`}>按人看权限<ChevronRight className="h-4 w-4" /></Link>
|
}} className={`${secondary} ${busy ? 'cursor-not-allowed opacity-50' : ''}`}>按人看权限<ChevronRight className="h-4 w-4" /></Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>}
|
||||||
|
|
||||||
{status === 'loading' || (loading && !payload) ? <section aria-busy="true" aria-label="加载权限组" className="space-y-4 rounded-xl border border-[#e2e8f0] bg-white p-6"><p className="text-sm text-[#66758b]">正在加载权限组…</p><div className="h-12 rounded-lg bg-[#eef2f6] motion-safe:animate-pulse" /><div className="h-64 rounded-lg bg-[#f8fafc] motion-safe:animate-pulse" /></section> : !isSuperAdmin ? <div className="rounded-xl border border-[#e2e8f0] bg-white p-6 text-sm text-[#66758b]">只有超级管理员可以改权限组。</div> : <>
|
{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>}
|
{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>}
|
||||||
@ -467,17 +476,17 @@ export default function PermissionGroupsDashboard() {
|
|||||||
<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)]">
|
<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">
|
<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="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>
|
<div className="mb-2 flex items-center justify-between"><h2 className="text-sm font-semibold text-[#101828]">权限组 <span className="ml-1 font-normal text-[#66758b]">{payload?.groups.length || 0}</span></h2><button type="button" aria-expanded={creating} onClick={() => setCreating(!creating)} className="flex min-h-11 items-center gap-1 rounded-lg px-2 text-sm font-semibold text-[#0050cb] hover:bg-[#e6f4ff] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff]"><Plus className="h-4 w-4" />新建</button>{embedded && refreshButton}</div>
|
||||||
{creating && <form className="mb-3 space-y-2 border-b border-[#e2e8f0] pb-3" onSubmit={event => { event.preventDefault(); void createGroup() }}>
|
{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>
|
<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} />
|
<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>
|
<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>}
|
</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="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 className="lg:hidden"><label htmlFor="mobile-permission-group" className="sr-only">选择权限组</label><select id="mobile-permission-group" value={selectedSlug || ''} onChange={event => selectGroup(event.target.value)} className={field}><option value="" disabled>选择权限组</option>{payload?.groups.map(group => <option key={group.slug} value={group.slug}>{group.name} · {groupSummary(group)}</option>)}</select></div>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="权限组" className="hidden gap-1 p-2 lg:flex lg:max-h-[60dvh] lg:min-h-0 lg:flex-col lg:overflow-y-auto">
|
<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.map(group => <button key={group.slug} type="button" aria-current={selectedSlug === group.slug ? 'true' : undefined} onClick={() => selectGroup(group.slug)} className={`min-h-[68px] min-w-[144px] shrink-0 rounded-lg px-3 py-3 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff] lg:min-w-0 lg:w-full ${selectedSlug === group.slug ? 'bg-[#e6f4ff] text-[#0050cb]' : 'text-[#101828] hover:bg-[#f8fafc]'}`}><span className="block break-words text-sm font-semibold">{group.name}</span><span className="mt-1 block text-xs text-[#66758b]">{groupSummary(group)}</span></button>)}
|
||||||
{visibleGroups.length === 0 && <p className="p-3 text-sm text-[#66758b]">{groupKeyword ? '未找到权限组' : '暂无权限组'}</p>}
|
{visibleGroups.length === 0 && <p className="p-3 text-sm text-[#66758b]">{groupKeyword ? '未找到权限组' : '暂无权限组'}</p>}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@ -516,7 +525,7 @@ export default function PermissionGroupsDashboard() {
|
|||||||
{activeTab === 'departments' && <>
|
{activeTab === 'departments' && <>
|
||||||
<p className="mb-4 text-xs text-[#66758b]">立即生效</p>
|
<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)]">
|
<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><h4 className="mb-3 text-sm font-semibold text-[#4f6077]">部门 · {selected.departments.length}</h4>{selected.departments.length === 0 ? <p className="text-sm leading-6 text-[#66758b]">暂无部门</p> : <ul className="divide-y divide-[#e2e8f0]">{selected.departments.map(department => <li key={department.id || department.path} className="flex items-center justify-between gap-2 py-2"><div className="min-w-0"><p title={department.path} className="break-words text-sm font-medium text-[#101828]">{department.name}</p>{department.status === 'invalid' && <p className="text-xs leading-5 text-[#b42318]">关联已失效</p>}<p className="sr-only">{department.path}</p></div><button type="button" aria-label={`移除已加入的${department.name}`} className={secondary} onClick={() => void mutate({ action: 'remove-department', slug: selected.slug, associationId: department.id, organizationUnitId: department.organizationUnitId, path: department.path }, `已从本组移除 ${department.name}`)}>移除</button></li>)}</ul>}</section>
|
||||||
<section className="min-w-0"><label htmlFor="group-department-search" className="sr-only">查找并加入部门</label><input id="group-department-search" value={deptKeyword} onChange={event => setDeptKeyword(event.target.value)} placeholder="搜索部门名称或路径" className={field} /><nav aria-label="花名册组织树" className="mt-3 rounded-lg border border-[#e2e8f0]">{payload.tree.map(node => renderNode(node))}{(payload.tree.length === 0 || (matchingPaths && matchingPaths.size === 0)) && <p className="p-4 text-sm text-[#66758b]">{deptKeyword ? '未找到部门' : '暂无组织架构,请同步花名册'}</p>}</nav></section>
|
<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>
|
||||||
</>}
|
</>}
|
||||||
@ -545,7 +554,7 @@ export default function PermissionGroupsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</>}
|
</>}
|
||||||
</main>
|
</Content>
|
||||||
</DepartmentShell>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
|
import PermissionsDashboard from './PermissionsDashboard'
|
||||||
|
|
||||||
|
jest.mock('./DepartmentShell', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }))
|
||||||
|
jest.mock('./PermissionGroupsDashboard', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <label>待保存组名<input defaultValue="视觉" /></label>,
|
||||||
|
}))
|
||||||
|
jest.mock('./UserPermissionsDashboard', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <label>查找人员<input defaultValue="" /></label>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('切换视图保留权限草稿与人员搜索,不暴露隐藏视图控件', () => {
|
||||||
|
const { rerender } = render(<PermissionsDashboard />)
|
||||||
|
const nav = screen.getByRole('navigation', { name: '权限管理视图' })
|
||||||
|
expect(within(nav).getByRole('link', { name: '权限组' })).toHaveAttribute('aria-current', 'page')
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: '待保存组名' }), { target: { value: '设计团队' } })
|
||||||
|
expect(screen.queryByRole('textbox', { name: '查找人员' })).not.toBeInTheDocument()
|
||||||
|
rerender(<PermissionsDashboard view="people" />)
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: '查找人员' }), { target: { value: '示例成员' } })
|
||||||
|
expect(screen.queryByRole('textbox', { name: '待保存组名' })).not.toBeInTheDocument()
|
||||||
|
rerender(<PermissionsDashboard view="groups" />)
|
||||||
|
expect(screen.getByRole('textbox', { name: '待保存组名' })).toHaveValue('设计团队')
|
||||||
|
rerender(<PermissionsDashboard view="people" />)
|
||||||
|
expect(screen.getByRole('textbox', { name: '查找人员' })).toHaveValue('示例成员')
|
||||||
|
})
|
||||||
29
components/department-dashboard/PermissionsDashboard.tsx
Normal file
29
components/department-dashboard/PermissionsDashboard.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import DepartmentShell from './DepartmentShell'
|
||||||
|
import PermissionGroupsDashboard from './PermissionGroupsDashboard'
|
||||||
|
import UserPermissionsDashboard from './UserPermissionsDashboard'
|
||||||
|
import WorkspaceViewNavigation from './WorkspaceViewNavigation'
|
||||||
|
|
||||||
|
export default function PermissionsDashboard({ view = 'groups' }: { view?: 'groups' | 'people' }) {
|
||||||
|
return (
|
||||||
|
<DepartmentShell>
|
||||||
|
<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">
|
||||||
|
<h1 className="text-2xl font-bold text-[#101828]">权限管理</h1>
|
||||||
|
<WorkspaceViewNavigation label="权限管理视图" items={[
|
||||||
|
{ label: '权限组', href: '/workspaces/governance/permissions', active: view === 'groups' },
|
||||||
|
{ label: '人员权限', href: '/workspaces/governance/permissions?view=people', active: view === 'people' },
|
||||||
|
]} />
|
||||||
|
</header>
|
||||||
|
{/* Keep both workspaces mounted so switching views preserves drafts and search. */}
|
||||||
|
<div hidden={view !== 'groups'} className={view === 'groups' ? 'min-h-0 lg:flex-1' : 'hidden'}>
|
||||||
|
<PermissionGroupsDashboard embedded />
|
||||||
|
</div>
|
||||||
|
<div hidden={view !== 'people'} className={view === 'people' ? 'min-h-0 lg:flex-1' : 'hidden'}>
|
||||||
|
<UserPermissionsDashboard embedded />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</DepartmentShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,45 +1,61 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import TeamFunctionsDashboard from './TeamFunctionsDashboard'
|
import TeamFunctionsDashboard from './TeamFunctionsDashboard'
|
||||||
|
import type { TeamFunctionPayload } from '@/lib/team-function-contract'
|
||||||
jest.mock('next-auth/react', () => ({
|
jest.mock('next-auth/react', () => ({ useSession: () => ({ data: { user: { name: '验收用户', role: 'SYSTEM_ADMIN' } } }), signOut: jest.fn() }))
|
||||||
useSession: () => ({ data: { user: { name: '本地超级管理员' } } }),
|
const data: TeamFunctionPayload = { canEdit: true, teams: [
|
||||||
signOut: jest.fn(),
|
{ organizationUnitId: 'dept-id', name: '花名册部门', path: '公司/品牌中心/花名册部门', lead: { employeeProfileId: 'person-id', name: '花名册负责人' }, leadStatus: 'active', description: '数据库职责', branches: [{ label: '职责', items: ['数据库条目'] }], revision: 2, candidates: [{ employeeProfileId: 'person-id', name: '花名册负责人', jobNumber: '001' }] },
|
||||||
}))
|
{ organizationUnitId: 'new-id', name: '新增部门', path: '公司/品牌中心/新增部门', lead: null, leadStatus: 'unset', description: '', branches: [], revision: 0, candidates: [] },
|
||||||
|
] }
|
||||||
describe('TeamFunctionsDashboard', () => {
|
beforeEach(() => {
|
||||||
it('shows department navigation and the selected team introduction', () => {
|
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => JSON.parse(JSON.stringify(data)) })
|
||||||
render(<TeamFunctionsDashboard />)
|
window.confirm = jest.fn().mockReturnValue(true)
|
||||||
|
})
|
||||||
expect(screen.getByRole('heading', { level: 1, name: '团队职能' })).toBeInTheDocument()
|
test('通过接口加载当前部门及职责,没有旧硬编码团队或返回按钮', async () => {
|
||||||
expect(screen.getByRole('heading', { level: 2, name: '品牌中心职责地图' })).toBeInTheDocument()
|
render(<TeamFunctionsDashboard />)
|
||||||
expect(screen.getByRole('complementary', { name: '团队职能二级部门导航' })).toBeInTheDocument()
|
expect(await screen.findByRole('region', { name: '花名册部门部门介绍' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('数据库条目')).toBeInTheDocument()
|
||||||
for (const team of ['品牌创意A部', '品牌创意B部', '空间设计部', '综合支持部']) {
|
expect(screen.queryByText('品牌创意A部')).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: new RegExp(team) })).toBeInTheDocument()
|
expect(screen.queryByRole('link', { name: '返回部门看板' })).not.toBeInTheDocument()
|
||||||
}
|
fireEvent.click(screen.getByRole('button', { name: /新增部门/ }))
|
||||||
|
expect(screen.getByText('尚未配置团队职能。')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('region', { name: '品牌创意A部部门介绍' })).toBeInTheDocument()
|
})
|
||||||
expect(screen.getByText('负责朴美、索菲亚、顾家品牌的视觉创意与内容前置策划,统筹品牌调性、商品卖点及页面与短视频创意。')).toBeInTheDocument()
|
test('编辑保存使用部门、人员 ID 和修订号;成功显示更新的内容', async () => {
|
||||||
|
;(fetch as jest.Mock).mockImplementation(async (_url, options) => ({ ok: true, json: async () => options?.method === 'PUT' ? { revision: 3 } : JSON.parse(JSON.stringify(data)) }))
|
||||||
for (const item of ['朴美', '索菲亚', '顾家', '品牌视觉策划设计', 'AI+平面+策划', '编导/短视频策划', '品牌调性', '商品卖点', '页面/短视频创意', '经验与复盘']) {
|
render(<TeamFunctionsDashboard />)
|
||||||
expect(screen.getByRole('region', { name: '品牌创意A部部门介绍' }).querySelector(`[data-team-item="${item}"]`)).not.toBeNull()
|
fireEvent.click(await screen.findByRole('button', { name: '编辑' }))
|
||||||
}
|
fireEvent.change(screen.getByLabelText('团队说明'), { target: { value: '新说明' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^保存$/ }))
|
||||||
expect(screen.getByRole('link', { name: '返回部门看板' })).toHaveAttribute('href', '/')
|
expect(await screen.findByRole('status')).toHaveTextContent('已保存')
|
||||||
})
|
expect(screen.getByText('新说明')).toBeInTheDocument()
|
||||||
|
const call = (fetch as jest.Mock).mock.calls.find(([, options]) => options?.method === 'PUT')
|
||||||
it('switches the department introduction from the secondary navigation', () => {
|
expect(JSON.parse(call[1].body)).toMatchObject({ organizationUnitId: 'dept-id', leadEmployeeProfileId: 'person-id', revision: 2, description: '新说明' })
|
||||||
render(<TeamFunctionsDashboard />)
|
})
|
||||||
|
test('冲突不丢弃草稿,取消后重新读取当前版本', async () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: /空间设计部/ }))
|
;(fetch as jest.Mock).mockImplementation(async (_url, options) => ({ ok: options?.method !== 'PUT', json: async () => options?.method === 'PUT' ? { error: '配置已更新' } : JSON.parse(JSON.stringify(data)) }))
|
||||||
|
render(<TeamFunctionsDashboard />)
|
||||||
expect(screen.getByRole('region', { name: '空间设计部部门介绍' })).toBeInTheDocument()
|
fireEvent.click(await screen.findByRole('button', { name: '编辑' }))
|
||||||
expect(screen.getByText('负责空间、置景、灯光、机位与拍摄一体化,为内容生产提供场景和拍摄执行能力。')).toBeInTheDocument()
|
fireEvent.change(screen.getByLabelText('团队说明'), { target: { value: '保留的草稿' } })
|
||||||
expect(screen.queryByRole('region', { name: '品牌创意A部部门介绍' })).not.toBeInTheDocument()
|
fireEvent.click(screen.getByRole('button', { name: /^保存$/ }))
|
||||||
expect(screen.getByText('美术指导')).toBeInTheDocument()
|
expect(await screen.findByRole('alert')).toHaveTextContent('配置已更新')
|
||||||
|
expect(screen.getByLabelText('团队说明')).toHaveValue('保留的草稿')
|
||||||
fireEvent.click(screen.getByRole('button', { name: /品牌创意B部/ }))
|
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||||
expect(screen.getByRole('region', { name: '品牌创意B部部门介绍' })).toBeInTheDocument()
|
expect(await screen.findByText('数据库职责')).toBeInTheDocument()
|
||||||
expect(screen.getByText('欧意朗')).toBeInTheDocument()
|
})
|
||||||
})
|
test('拒绝丢弃时部门切换保留草稿', async () => {
|
||||||
|
;(window.confirm as jest.Mock).mockReturnValue(false)
|
||||||
|
render(<TeamFunctionsDashboard />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '编辑' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('团队说明'), { target: { value: '未保存' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /新增部门/ }))
|
||||||
|
expect(screen.getByLabelText('团队说明')).toHaveValue('未保存')
|
||||||
|
expect(screen.getByRole('region', { name: '花名册部门部门介绍' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
test('无维护权限隐藏编辑,API失败显示错误及重试入口', async () => {
|
||||||
|
;(fetch as jest.Mock).mockResolvedValue({ ok: false, json: async () => ({ error: '加载失败' }) })
|
||||||
|
render(<TeamFunctionsDashboard />)
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('加载失败')
|
||||||
|
;(fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => ({ ...data, canEdit: false }) })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
await waitFor(() => expect(screen.getByText('数据库职责')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: '编辑' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,69 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import { Check, UsersRound } from 'lucide-react'
|
||||||
import { ArrowLeft, Blocks, BriefcaseBusiness, Check, Layers3, UsersRound } from 'lucide-react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
import { teamFunctionInputSchema, type FunctionBranch, type TeamFunction, type TeamFunctionInput, type TeamFunctionPayload } from '@/lib/team-function-contract'
|
||||||
import DepartmentShell from './DepartmentShell'
|
import DepartmentShell from './DepartmentShell'
|
||||||
|
|
||||||
type FunctionBranch = {
|
|
||||||
label: string
|
|
||||||
items: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type TeamFunction = {
|
|
||||||
name: string
|
|
||||||
lead: string
|
|
||||||
description: string
|
|
||||||
tone: 'coral' | 'navy' | 'blue' | 'teal'
|
|
||||||
branches: FunctionBranch[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEAM_FUNCTIONS: TeamFunction[] = [
|
|
||||||
{
|
|
||||||
name: '品牌创意A部',
|
|
||||||
lead: '伯言',
|
|
||||||
description: '负责朴美、索菲亚、顾家品牌的视觉创意与内容前置策划,统筹品牌调性、商品卖点及页面与短视频创意。',
|
|
||||||
tone: 'coral',
|
|
||||||
branches: [
|
|
||||||
{ label: '服务品牌', items: ['朴美', '索菲亚', '顾家'] },
|
|
||||||
{ label: '前置能力', items: ['品牌视觉策划设计', 'AI+平面+策划', '编导/短视频策划'] },
|
|
||||||
{ label: '职责', items: ['品牌调性', '商品卖点', '页面/短视频创意', '经验与复盘'] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '品牌创意B部',
|
|
||||||
lead: '清云',
|
|
||||||
description: '负责欧意朗、卓禾、亿家达品牌的视觉创意与内容前置策划,推动品牌辅导、页面及短视频创意落地。',
|
|
||||||
tone: 'navy',
|
|
||||||
branches: [
|
|
||||||
{ label: '服务品牌', items: ['欧意朗', '卓禾', '亿家达'] },
|
|
||||||
{ label: '前置能力', items: ['品牌视觉策划设计', 'AI+平面+策划', '品牌辅导'] },
|
|
||||||
{ label: '职责', items: ['品牌调性', '商品卖点', '页面/短视频创意', '经验与复盘'] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '空间设计部',
|
|
||||||
lead: '希文',
|
|
||||||
description: '负责空间、置景、灯光、机位与拍摄一体化,为内容生产提供场景和拍摄执行能力。',
|
|
||||||
tone: 'blue',
|
|
||||||
branches: [
|
|
||||||
{ label: '定位', items: ['空间、置景、灯光、机位、拍摄一体化'] },
|
|
||||||
{ label: '岗位', items: ['美术指导', '空间设计师', '摄影师'] },
|
|
||||||
{ label: '职责', items: ['场景概念', '布局搭建', '灯光机位', '拍摄执行', '场景复用'] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '综合支持部',
|
|
||||||
lead: '无邪',
|
|
||||||
description: '承接3D、AI、平面、修图、视频后期等生产支持,沉淀资产标准并推进批量生产。',
|
|
||||||
tone: 'teal',
|
|
||||||
branches: [
|
|
||||||
{ label: '岗位', items: ['3D设计师', '平面设计师', 'AIGC设计师', '视频后期', '修图'] },
|
|
||||||
{ label: '职责', items: ['3D/AI/平面/修图', '剪辑与包装', '资产与标准', '批量生产'] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const toneStyles = {
|
const toneStyles = {
|
||||||
coral: {
|
coral: {
|
||||||
accent: '#e4473f',
|
accent: '#e4473f',
|
||||||
@ -95,7 +36,16 @@ const toneStyles = {
|
|||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function Branch({ branch, tone }: { branch: FunctionBranch; tone: TeamFunction['tone'] }) {
|
type Tone = keyof typeof toneStyles
|
||||||
|
const tones: Tone[] = ['coral', 'navy', 'blue', 'teal']
|
||||||
|
const fieldClass = 'min-h-11 w-full rounded-lg border border-[#dfe5ed] bg-white px-3 py-2 text-sm text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc]'
|
||||||
|
const buttonClass = 'min-h-11 rounded-lg px-3 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50'
|
||||||
|
|
||||||
|
function leadLabel(team: TeamFunction) {
|
||||||
|
return team.lead?.name ?? (team.leadStatus === 'invalid' ? '关联已失效' : '未设置')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Branch({ branch, tone }: { branch: FunctionBranch; tone: Tone }) {
|
||||||
const colors = toneStyles[tone]
|
const colors = toneStyles[tone]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -109,9 +59,9 @@ function Branch({ branch, tone }: { branch: FunctionBranch; tone: TeamFunction['
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="relative grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{branch.items.map((item) => (
|
{branch.items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={index}
|
||||||
data-team-item={item}
|
data-team-item={item}
|
||||||
className="flex min-h-10 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-semibold leading-5"
|
className="flex min-h-10 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-semibold leading-5"
|
||||||
style={{ borderColor: colors.line, backgroundColor: colors.node, color: '#26364f' }}
|
style={{ borderColor: colors.line, backgroundColor: colors.node, color: '#26364f' }}
|
||||||
@ -126,39 +76,84 @@ function Branch({ branch, tone }: { branch: FunctionBranch; tone: TeamFunction['
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamFunctionsDashboard() {
|
export default function TeamFunctionsDashboard() {
|
||||||
const [selectedTeamName, setSelectedTeamName] = useState(TEAM_FUNCTIONS[0].name)
|
const [payload, setPayload] = useState<TeamFunctionPayload | null>(null)
|
||||||
const selectedTeam = TEAM_FUNCTIONS.find((team) => team.name === selectedTeamName) || TEAM_FUNCTIONS[0]
|
const [selectedId, setSelectedId] = useState('')
|
||||||
const colors = toneStyles[selectedTeam.tone]
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [draft, setDraft] = useState<TeamFunctionInput | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError('')
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/organization/team-functions', { cache: 'no-store' })
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) throw new Error(data.error || '团队职能加载失败。')
|
||||||
|
setPayload(data)
|
||||||
|
setSelectedId(current => data.teams.some((team: TeamFunction) => team.organizationUnitId === current) ? current : data.teams[0]?.organizationUnitId ?? '')
|
||||||
|
} catch (failure) { setError(failure instanceof Error ? failure.message : '团队职能加载失败。') }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { void load() }, [load])
|
||||||
|
const selectedTeam = payload?.teams.find(team => team.organizationUnitId === selectedId)
|
||||||
|
const tone = tones[Math.max(0, payload?.teams.findIndex(team => team.organizationUnitId === selectedId) ?? 0) % tones.length]
|
||||||
|
const colors = toneStyles[tone]
|
||||||
|
const original = selectedTeam ? { organizationUnitId: selectedTeam.organizationUnitId, revision: selectedTeam.revision,
|
||||||
|
leadEmployeeProfileId: selectedTeam.lead?.employeeProfileId ?? null, description: selectedTeam.description, branches: selectedTeam.branches } : null
|
||||||
|
const dirty = !!draft && JSON.stringify(draft) !== JSON.stringify(original)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return
|
||||||
|
const guard = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||||||
|
const guardLink = (event: MouseEvent) => {
|
||||||
|
const link = (event.target as Element)?.closest('a[href]')
|
||||||
|
if (link && !window.confirm('有未保存的修改,放弃并离开?')) { event.preventDefault(); event.stopPropagation() }
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', guard)
|
||||||
|
document.addEventListener('click', guardLink, true)
|
||||||
|
return () => { window.removeEventListener('beforeunload', guard); document.removeEventListener('click', guardLink, true) }
|
||||||
|
}, [dirty])
|
||||||
|
const save = async () => {
|
||||||
|
const parsed = teamFunctionInputSchema.safeParse(draft && { ...draft, branches: draft.branches.map(branch => ({ ...branch, items: branch.items.map(item => item.trim()).filter(Boolean) })) })
|
||||||
|
if (!parsed.success) { setError('分组名称和条目不能为空,请检查内容长度。'); return }
|
||||||
|
setBusy(true); setError(''); setNotice('')
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/organization/team-functions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parsed.data) })
|
||||||
|
const result = await response.json()
|
||||||
|
if (!response.ok) throw new Error(result.error || '保存失败,请重试。')
|
||||||
|
setPayload(current => current && { ...current, teams: current.teams.map(team => {
|
||||||
|
if (team.organizationUnitId !== parsed.data.organizationUnitId) return team
|
||||||
|
const lead = team.candidates.find(person => person.employeeProfileId === parsed.data.leadEmployeeProfileId)
|
||||||
|
return { ...team, ...parsed.data, revision: result.revision, lead: lead ? { employeeProfileId: lead.employeeProfileId, name: lead.name } : null, leadStatus: lead ? 'active' : 'unset' }
|
||||||
|
}) })
|
||||||
|
setDraft(null); setNotice('已保存')
|
||||||
|
} catch (failure) { setError(failure instanceof Error ? failure.message : '保存失败,请重试。') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DepartmentShell>
|
<DepartmentShell>
|
||||||
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
|
<main className="mx-auto max-w-[1600px] px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<header className="border-b border-[#dfe4ea] pb-6">
|
<h1 className="sr-only">团队职能</h1>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[#fff8d9] text-[#725400]">
|
{error && <p role="alert" className="mb-4 text-sm text-[#b42318]">{error}{!draft && <button type="button" className={`${buttonClass} ml-2 text-[#1677ff]`} onClick={() => void load()}>重试</button>}</p>}
|
||||||
<Blocks className="h-6 w-6" aria-hidden="true" />
|
{notice && <p role="status" className="mb-3 text-sm text-[#087052]">{notice}</p>}
|
||||||
</div>
|
{loading ? <p aria-busy="true" className="text-sm text-[#667085]">正在加载团队职能…</p> : !selectedTeam ? <p className="text-sm text-[#667085]">{error ? '未能加载团队。' : '品牌中心暂无部门,请核对花名册同步。'}</p> :
|
||||||
<h1 className="mt-6 text-balance text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl">团队职能</h1>
|
<section aria-labelledby="team-functions-map-title" className="overflow-hidden rounded-xl border border-[#dfe5ed] bg-white lg:grid lg:grid-cols-[232px_minmax(0,1fr)]">
|
||||||
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">
|
|
||||||
按团队查看品牌服务、前置能力、岗位与职责边界,作为协作分工和流程归属的统一参考。
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section aria-labelledby="team-functions-map-title" className="mt-6 overflow-hidden rounded-xl border border-[#dfe5ed] bg-white shadow-[0_6px_18px_rgba(15,23,42,0.05)] lg:grid lg:grid-cols-[232px_minmax(0,1fr)]">
|
|
||||||
<aside aria-label="团队职能二级部门导航" className="border-b border-[#e4e9f0] bg-[#f8fafc] lg:border-b-0 lg:border-r">
|
<aside aria-label="团队职能二级部门导航" className="border-b border-[#e4e9f0] bg-[#f8fafc] lg:border-b-0 lg:border-r">
|
||||||
<div className="hidden border-b border-[#e4e9f0] px-4 py-4 lg:block">
|
<h2 id="team-functions-map-title" className="sr-only">品牌中心职责地图</h2>
|
||||||
<p className="text-xs font-bold text-[#66758b]">部门</p>
|
|
||||||
<h2 id="team-functions-map-title" className="mt-1 text-sm font-bold text-[#101828]">品牌中心职责地图</h2>
|
|
||||||
</div>
|
|
||||||
<nav className="flex gap-1 overflow-x-auto p-2 lg:block lg:space-y-1 lg:overflow-visible" aria-label="选择部门">
|
<nav className="flex gap-1 overflow-x-auto p-2 lg:block lg:space-y-1 lg:overflow-visible" aria-label="选择部门">
|
||||||
{TEAM_FUNCTIONS.map((team) => {
|
{payload!.teams.map((team, index) => {
|
||||||
const teamColors = toneStyles[team.tone]
|
const teamColors = toneStyles[tones[index % tones.length]]
|
||||||
const selected = selectedTeam.name === team.name
|
const selected = selectedId === team.organizationUnitId
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={team.name}
|
key={team.organizationUnitId}
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={selected ? 'page' : undefined}
|
aria-current={selected ? 'page' : undefined}
|
||||||
onClick={() => setSelectedTeamName(team.name)}
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (selected || (dirty && !window.confirm('有未保存的修改,放弃并切换部门?'))) return
|
||||||
|
setSelectedId(team.organizationUnitId); setDraft(null); setError(''); setNotice('')
|
||||||
|
}}
|
||||||
className={`flex min-h-[58px] shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] lg:w-full ${selected ? 'bg-[#1677ff] text-white' : 'text-[#4f6077] hover:bg-[#eef2f6] hover:text-[#101828]'}`}
|
className={`flex min-h-[58px] shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] lg:w-full ${selected ? 'bg-[#1677ff] text-white' : 'text-[#4f6077] hover:bg-[#eef2f6] hover:text-[#101828]'}`}
|
||||||
>
|
>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: selected ? 'rgba(255,255,255,0.16)' : teamColors.accentSoft, color: selected ? '#fff' : teamColors.accent }}>
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: selected ? 'rgba(255,255,255,0.16)' : teamColors.accentSoft, color: selected ? '#fff' : teamColors.accent }}>
|
||||||
@ -166,7 +161,7 @@ export default function TeamFunctionsDashboard() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-sm font-bold">{team.name}</span>
|
<span className="block truncate text-sm font-bold">{team.name}</span>
|
||||||
<span className={`mt-0.5 block truncate text-xs ${selected ? 'text-white/75' : 'text-[#667085]'}`}>负责人:{team.lead}</span>
|
<span className={`mt-0.5 block truncate text-xs ${selected ? 'text-white/85' : 'text-[#667085]'}`}>负责人:{leadLabel(team)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@ -175,39 +170,50 @@ export default function TeamFunctionsDashboard() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section aria-label={`${selectedTeam.name}部门介绍`} className="min-w-0">
|
<section aria-label={`${selectedTeam.name}部门介绍`} className="min-w-0">
|
||||||
<header className="flex flex-col gap-4 border-b border-[#e4e9f0] px-5 py-5 sm:px-7 sm:py-6 lg:flex-row lg:items-start lg:justify-between">
|
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e4e9f0] px-5 py-4 sm:px-6">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: colors.accentSoft, color: colors.accent }}>
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: colors.accentSoft, color: colors.accent }}>
|
||||||
<UsersRound className="h-5 w-5" aria-hidden="true" />
|
<UsersRound className="h-5 w-5" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold" style={{ color: colors.accentMuted }}>当前部门</p>
|
<h2 className="text-xl font-bold text-[#101828]">{selectedTeam.name}</h2>
|
||||||
<h2 className="mt-1 text-2xl font-bold tracking-[-0.02em] text-[#101828]">{selectedTeam.name}</h2>
|
<p className="mt-1 text-sm font-semibold" style={{ color: colors.accentMuted }}>负责人:{leadLabel(selectedTeam)}</p>
|
||||||
<p className="mt-1 text-sm font-semibold" style={{ color: colors.accentMuted }}>负责人:{selectedTeam.lead}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-xs font-bold text-[#5f7189]" aria-label="职责地图说明">
|
{payload!.canEdit && !draft && <button type="button" className={`${buttonClass} text-[#0050cb] hover:bg-[#edf3ff]`} onClick={() => { setDraft(original && { ...original, branches: original.branches.map(branch => ({ ...branch, items: [...branch.items] })) }); setNotice(''); setError('') }}>编辑</button>}
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-lg bg-[#f8fafc] px-3 py-2"><BriefcaseBusiness className="h-3.5 w-3.5" aria-hidden="true" />服务与定位</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-lg bg-[#f8fafc] px-3 py-2"><Layers3 className="h-3.5 w-3.5" aria-hidden="true" />能力与岗位</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
{draft ? <form className="space-y-4 px-5 py-4 sm:px-6" onSubmit={event => { event.preventDefault(); void save() }}>
|
||||||
<div className="px-5 py-5 sm:px-7 sm:py-6">
|
<label className="block text-sm font-semibold text-[#344054]">负责人
|
||||||
<p className="max-w-3xl text-base leading-7 text-[#4f6077]">{selectedTeam.description}</p>
|
<select className={`${fieldClass} mt-2`} value={draft.leadEmployeeProfileId ?? ''} disabled={busy} onChange={event => setDraft({ ...draft, leadEmployeeProfileId: event.target.value || null })}>
|
||||||
<div className="relative mt-6 space-y-4 md:space-y-5 md:before:absolute md:before:bottom-5 md:before:left-[116px] md:before:top-5 md:before:w-px md:before:bg-[#e9edf3]">
|
<option value="">未设置</option>
|
||||||
{selectedTeam.branches.map((branch) => <Branch key={branch.label} branch={branch} tone={selectedTeam.tone} />)}
|
{selectedTeam.candidates.map(person => <option key={person.employeeProfileId} value={person.employeeProfileId}>{person.name}{person.jobNumber ? ` · ${person.jobNumber}` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm font-semibold text-[#344054]">团队说明
|
||||||
|
<textarea className={`${fieldClass} mt-2 resize-y font-normal`} rows={3} maxLength={2000} disabled={busy} value={draft.description} onChange={event => setDraft({ ...draft, description: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
{draft.branches.map((branch, index) => <fieldset key={index} className="grid gap-3 border-t border-[#e4e9f0] pt-4 sm:grid-cols-[160px_minmax(0,1fr)_auto]">
|
||||||
|
<legend className="sr-only">职责分组 {index + 1}</legend>
|
||||||
|
<label className="text-sm font-semibold text-[#344054]">分组名称 {index + 1}<input className={`${fieldClass} mt-2 font-normal`} maxLength={40} required disabled={busy} value={branch.label} onChange={event => setDraft({ ...draft, branches: draft.branches.map((row, i) => i === index ? { ...row, label: event.target.value } : row) })} /></label>
|
||||||
|
<label className="text-sm font-semibold text-[#344054]">条目 {index + 1}<textarea className={`${fieldClass} mt-2 resize-y font-normal`} placeholder="每行一项" rows={Math.min(6, Math.max(3, branch.items.length))} disabled={busy} value={branch.items.join('\n')} onChange={event => setDraft({ ...draft, branches: draft.branches.map((row, i) => i === index ? { ...row, items: event.target.value.split('\n') } : row) })} /></label>
|
||||||
|
<button type="button" aria-label={`移除分组 ${index + 1}`} disabled={busy} className={`${buttonClass} self-start text-[#b42318] sm:mt-7`} onClick={() => setDraft({ ...draft, branches: draft.branches.filter((_, i) => i !== index) })}>移除</button>
|
||||||
|
</fieldset>)}
|
||||||
|
<button type="button" disabled={busy || draft.branches.length >= 10} className={`${buttonClass} text-[#0050cb]`} onClick={() => setDraft({ ...draft, branches: [...draft.branches, { label: '', items: [''] }] })}>添加分组</button>
|
||||||
|
<div className="flex items-center gap-2 border-t border-[#e4e9f0] pt-4">
|
||||||
|
<button type="submit" disabled={busy || !dirty} className={`${buttonClass} bg-[#0050cb] text-white hover:bg-[#003da3]`}>{busy ? '保存中…' : '保存'}</button>
|
||||||
|
<button type="button" disabled={busy} className={`${buttonClass} text-[#475467] hover:bg-[#f2f4f7]`} onClick={() => { if (dirty && !window.confirm('放弃未保存的修改?')) return; setDraft(null); setNotice(''); void load() }}>取消</button>
|
||||||
|
{dirty && <span className="text-xs text-[#667085]">未保存</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form> : <div className="px-5 py-4 sm:px-6">
|
||||||
|
{selectedTeam.description && <p className="max-w-3xl text-base leading-7 text-[#4f6077]">{selectedTeam.description}</p>}
|
||||||
|
{!selectedTeam.description && !selectedTeam.branches.length && <p className="text-sm text-[#667085]">尚未配置团队职能。</p>}
|
||||||
|
<div className="relative mt-4 space-y-4 md:before:absolute md:before:bottom-5 md:before:left-[116px] md:before:top-5 md:before:w-px md:before:bg-[#e9edf3]">
|
||||||
|
{selectedTeam.branches.map((branch, index) => <Branch key={index} branch={branch} tone={tone} />)}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="mt-8 inline-flex min-h-11 items-center gap-2 rounded-lg bg-[#1677ff] px-4 text-sm font-bold text-white transition-colors hover:bg-[#0f67df] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
|
||||||
返回部门看板
|
|
||||||
</Link>
|
|
||||||
</main>
|
</main>
|
||||||
</DepartmentShell>
|
</DepartmentShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Select from '@/components/ui/Select'
|
import Select from '@/components/ui/Select'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ChevronRight, Search, X } from 'lucide-react'
|
import { ChevronRight, Search, X } from 'lucide-react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
@ -26,7 +26,7 @@ type Person = {
|
|||||||
personalGroups: GroupRef[]
|
personalGroups: GroupRef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserPermissionsDashboard() {
|
export default function UserPermissionsDashboard({ embedded = false }: { embedded?: boolean }) {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
|
const isSuperAdmin = Boolean((session?.user as { isSuperAdmin?: boolean } | undefined)?.isSuperAdmin)
|
||||||
|
|
||||||
@ -90,18 +90,20 @@ export default function UserPermissionsDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Frame = embedded ? Fragment : DepartmentShell
|
||||||
|
const Content = embedded ? 'div' : 'main'
|
||||||
return (
|
return (
|
||||||
<DepartmentShell>
|
<Frame>
|
||||||
<main className="flex h-[calc(100dvh-72px)] flex-col overflow-hidden px-4 pt-4 sm:px-6 lg:px-8">
|
<Content className={embedded ? 'flex min-h-[320px] flex-col lg:h-full lg:min-h-0' : 'flex h-[calc(100dvh-72px)] flex-col overflow-hidden px-4 pt-4 sm:px-6 lg:px-8'}>
|
||||||
<header className="shrink-0 pb-4">
|
<header className="shrink-0 pb-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
{!embedded && <div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 className="text-2xl font-bold text-[#101828]">按人看权限</h1>
|
<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]">
|
<Link href="/workspaces/governance/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" />
|
权限组<ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>}
|
||||||
<label className="sr-only" htmlFor="user-permission-search">搜索人员</label>
|
<label className="sr-only" htmlFor="user-permission-search">搜索人员</label>
|
||||||
<div className="relative mt-3 max-w-xl">
|
<div className={`relative max-w-xl ${embedded ? '' : 'mt-3'}`}>
|
||||||
<Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" />
|
<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" />
|
<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>
|
</div>
|
||||||
@ -150,7 +152,7 @@ export default function UserPermissionsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 flex min-w-0 items-start gap-3 sm:col-span-1 xl:block">
|
<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>
|
<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> :
|
{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) => (
|
<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]">
|
<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>
|
{group.name}<span title={group.attachedPath} className="ml-2 text-xs text-[#66758b]">{group.source === 'own' ? '本部门' : `随 ${group.attachedPath.split('/').pop()}`}</span>
|
||||||
@ -186,7 +188,7 @@ export default function UserPermissionsDashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</Content>
|
||||||
</DepartmentShell>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
16
components/department-dashboard/WorkspaceViewNavigation.tsx
Normal file
16
components/department-dashboard/WorkspaceViewNavigation.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type ViewLink = { label: string; href: string; active: boolean }
|
||||||
|
|
||||||
|
export default function WorkspaceViewNavigation({ label, items }: { label: string; items: ViewLink[] }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={label} className="flex min-w-0 gap-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link key={item.href} href={item.href} scroll={false} aria-current={item.active ? 'page' : undefined}
|
||||||
|
className={`inline-flex min-h-11 items-center justify-center rounded-lg px-4 text-sm font-medium focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#1677ff] ${item.active ? 'bg-[#e6f4ff] text-[#0050cb]' : 'text-[#4f6077] hover:bg-white'}`}>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,16 +2,17 @@ import {
|
|||||||
getFrameworkWorkspaceParams,
|
getFrameworkWorkspaceParams,
|
||||||
getWorkspaceItem,
|
getWorkspaceItem,
|
||||||
WORKSPACE_SECTIONS,
|
WORKSPACE_SECTIONS,
|
||||||
|
WORKSPACE_REDIRECTS,
|
||||||
} from './workspace-navigation'
|
} from './workspace-navigation'
|
||||||
|
|
||||||
describe('workspace navigation', () => {
|
describe('workspace navigation', () => {
|
||||||
it('为16个小项提供唯一入口,并为未接入项生成独立框架页', () => {
|
it('合并重复导航入口,并为未接入项生成独立框架页', () => {
|
||||||
const items = WORKSPACE_SECTIONS.flatMap((section) => section.items)
|
const items = WORKSPACE_SECTIONS.flatMap((section) => section.items)
|
||||||
const hrefs = items.map((item) => item.href)
|
const hrefs = items.map((item) => item.href)
|
||||||
|
|
||||||
expect(items).toHaveLength(16)
|
expect(items).toHaveLength(14)
|
||||||
expect(new Set(hrefs).size).toBe(16)
|
expect(new Set(hrefs).size).toBe(14)
|
||||||
expect(getFrameworkWorkspaceParams()).toHaveLength(8)
|
expect(getFrameworkWorkspaceParams()).toHaveLength(7)
|
||||||
expect(getWorkspaceItem('business', 'brand-building')).toBeNull()
|
expect(getWorkspaceItem('business', 'brand-building')).toBeNull()
|
||||||
expect(WORKSPACE_SECTIONS.find((section) => section.key === 'business')?.items).toEqual([
|
expect(WORKSPACE_SECTIONS.find((section) => section.key === 'business')?.items).toEqual([
|
||||||
expect.objectContaining({ key: 'performance-conversion', title: '业绩转化' }),
|
expect.objectContaining({ key: 'performance-conversion', title: '业绩转化' }),
|
||||||
@ -23,24 +24,20 @@ describe('workspace navigation', () => {
|
|||||||
status: 'connected',
|
status: 'connected',
|
||||||
productName: '素材资产库复用率',
|
productName: '素材资产库复用率',
|
||||||
})
|
})
|
||||||
expect(getWorkspaceItem('governance', 'roles-permissions')?.item).toMatchObject({
|
expect(getWorkspaceItem('governance', 'permissions')?.item).toMatchObject({
|
||||||
title: '权限组',
|
title: '权限管理',
|
||||||
href: '/workspaces/governance/roles-permissions',
|
href: '/workspaces/governance/permissions',
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
})
|
})
|
||||||
expect(getWorkspaceItem('governance', 'user-permissions')?.item).toMatchObject({
|
expect(getWorkspaceItem('organization', 'organization-structure')?.item).toMatchObject({
|
||||||
title: '按人看权限',
|
href: '/workspaces/organization/organization-structure',
|
||||||
href: '/workspaces/governance/user-permissions',
|
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
|
productName: '公司与品牌中心组织架构',
|
||||||
})
|
})
|
||||||
expect(getWorkspaceItem('organization', 'people-structure')?.item).toMatchObject({
|
expect(getWorkspaceItem('governance', 'data-definitions')).toBeNull()
|
||||||
href: '/workspaces/organization/people-structure',
|
expect(getWorkspaceItem('organization', 'team-functions')?.item.status).toBe('connected')
|
||||||
status: 'connected',
|
expect(getWorkspaceItem('governance', 'user-permissions')).toBeNull()
|
||||||
productName: '品牌中心人员架构',
|
expect(WORKSPACE_REDIRECTS['governance/user-permissions']).toBe('/workspaces/governance/permissions?view=people')
|
||||||
})
|
expect(WORKSPACE_REDIRECTS['governance/data-definitions']).toBe('/workspaces/organization/organization-structure?scope=company')
|
||||||
expect(getWorkspaceItem('governance', 'data-definitions')?.item).toMatchObject({
|
|
||||||
status: 'connected',
|
|
||||||
productName: '公司组织架构',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -71,28 +71,26 @@ export const WORKSPACE_SECTIONS: WorkspaceSection[] = [
|
|||||||
accentText: '#725400',
|
accentText: '#725400',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'people-structure',
|
key: 'organization-structure',
|
||||||
title: '人员架构',
|
title: '组织架构',
|
||||||
href: '/workspaces/organization/people-structure',
|
href: '/workspaces/organization/organization-structure',
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
productName: '品牌中心人员架构',
|
productName: '公司与品牌中心组织架构',
|
||||||
},
|
},
|
||||||
{ key: 'team-functions', title: '团队职能', href: '/workspaces/organization/team-functions', status: 'framework' },
|
{ key: 'team-functions', title: '团队职能', href: '/workspaces/organization/team-functions', status: 'connected', productName: '花名册与职责配置' },
|
||||||
{ key: 'performance-review', title: '绩效考核', href: '/workspaces/organization/performance-review', status: 'framework' },
|
{ key: 'performance-review', title: '绩效考核', href: '/workspaces/organization/performance-review', status: 'framework' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'governance',
|
key: 'governance',
|
||||||
title: '治理与设置',
|
title: '治理与设置',
|
||||||
description: '统一权限、数据口径和通知机制,支撑可控、可追溯的协作。',
|
description: '管理访问权限、编辑规则和通知机制。',
|
||||||
summary: '建立部门级工作规则',
|
summary: '建立部门级工作规则',
|
||||||
accent: '#3569df',
|
accent: '#3569df',
|
||||||
accentSoft: '#edf3ff',
|
accentSoft: '#edf3ff',
|
||||||
accentText: '#214ba7',
|
accentText: '#214ba7',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'roles-permissions', title: '权限组', href: '/workspaces/governance/roles-permissions', status: 'connected', productName: '素材权限组' },
|
{ key: 'permissions', title: '权限管理', href: '/workspaces/governance/permissions', status: 'connected', productName: '权限组与人员权限' },
|
||||||
{ key: 'user-permissions', title: '按人看权限', href: '/workspaces/governance/user-permissions', status: 'connected', productName: '给指定的人加组' },
|
|
||||||
{ key: 'data-definitions', title: '数据口径', href: '/workspaces/governance/data-definitions', status: 'connected', productName: '公司组织架构' },
|
|
||||||
{ key: 'editing-capabilities', title: '编辑能力', href: '/workspaces/governance/editing-capabilities', status: 'framework' },
|
{ key: 'editing-capabilities', title: '编辑能力', href: '/workspaces/governance/editing-capabilities', status: 'framework' },
|
||||||
{ key: 'notifications', title: '通知机制', href: '/workspaces/governance/notifications', status: 'framework' },
|
{ key: 'notifications', title: '通知机制', href: '/workspaces/governance/notifications', status: 'framework' },
|
||||||
],
|
],
|
||||||
@ -105,6 +103,14 @@ export function getWorkspaceItem(sectionKey: string, itemKey: string) {
|
|||||||
return section && item ? { section, item } : null
|
return section && item ? { section, item } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve bookmarked routes while keeping one navigation entry per responsibility.
|
||||||
|
export const WORKSPACE_REDIRECTS: Record<string, string> = {
|
||||||
|
'governance/roles-permissions': '/workspaces/governance/permissions',
|
||||||
|
'governance/user-permissions': '/workspaces/governance/permissions?view=people',
|
||||||
|
'governance/data-definitions': '/workspaces/organization/organization-structure?scope=company',
|
||||||
|
'organization/people-structure': '/workspaces/organization/organization-structure',
|
||||||
|
}
|
||||||
|
|
||||||
export function getFrameworkWorkspaceParams() {
|
export function getFrameworkWorkspaceParams() {
|
||||||
return WORKSPACE_SECTIONS.flatMap((section) =>
|
return WORKSPACE_SECTIONS.flatMap((section) =>
|
||||||
section.items
|
section.items
|
||||||
|
|||||||
123
data/team-function-initial-content.json
Normal file
123
data/team-function-initial-content.json
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "品牌创意A部",
|
||||||
|
"lead": "伯言",
|
||||||
|
"description": "负责朴美、索菲亚、顾家品牌的视觉创意与内容前置策划,统筹品牌调性、商品卖点及页面与短视频创意。",
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"label": "服务品牌",
|
||||||
|
"items": [
|
||||||
|
"朴美",
|
||||||
|
"索菲亚",
|
||||||
|
"顾家"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "前置能力",
|
||||||
|
"items": [
|
||||||
|
"品牌视觉策划设计",
|
||||||
|
"AI+平面+策划",
|
||||||
|
"编导/短视频策划"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "职责",
|
||||||
|
"items": [
|
||||||
|
"品牌调性",
|
||||||
|
"商品卖点",
|
||||||
|
"页面/短视频创意",
|
||||||
|
"经验与复盘"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "品牌创意B部",
|
||||||
|
"lead": "清云",
|
||||||
|
"description": "负责欧意朗、卓禾、亿家达品牌的视觉创意与内容前置策划,推动品牌辅导、页面及短视频创意落地。",
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"label": "服务品牌",
|
||||||
|
"items": [
|
||||||
|
"欧意朗",
|
||||||
|
"卓禾",
|
||||||
|
"亿家达"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "前置能力",
|
||||||
|
"items": [
|
||||||
|
"品牌视觉策划设计",
|
||||||
|
"AI+平面+策划",
|
||||||
|
"品牌辅导"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "职责",
|
||||||
|
"items": [
|
||||||
|
"品牌调性",
|
||||||
|
"商品卖点",
|
||||||
|
"页面/短视频创意",
|
||||||
|
"经验与复盘"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "空间设计部",
|
||||||
|
"lead": "希文",
|
||||||
|
"description": "负责空间、置景、灯光、机位与拍摄一体化,为内容生产提供场景和拍摄执行能力。",
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"label": "定位",
|
||||||
|
"items": [
|
||||||
|
"空间、置景、灯光、机位、拍摄一体化"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "岗位",
|
||||||
|
"items": [
|
||||||
|
"美术指导",
|
||||||
|
"空间设计师",
|
||||||
|
"摄影师"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "职责",
|
||||||
|
"items": [
|
||||||
|
"场景概念",
|
||||||
|
"布局搭建",
|
||||||
|
"灯光机位",
|
||||||
|
"拍摄执行",
|
||||||
|
"场景复用"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "综合支持部",
|
||||||
|
"lead": "无邪",
|
||||||
|
"description": "承接3D、AI、平面、修图、视频后期等生产支持,沉淀资产标准并推进批量生产。",
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"label": "岗位",
|
||||||
|
"items": [
|
||||||
|
"3D设计师",
|
||||||
|
"平面设计师",
|
||||||
|
"AIGC设计师",
|
||||||
|
"视频后期",
|
||||||
|
"修图"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "职责",
|
||||||
|
"items": [
|
||||||
|
"3D/AI/平面/修图",
|
||||||
|
"剪辑与包装",
|
||||||
|
"资产与标准",
|
||||||
|
"批量生产"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
26
docs/团队职能数据库化与素材库后台入口-2026-10-08.md
Normal file
26
docs/团队职能数据库化与素材库后台入口-2026-10-08.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# 团队职能数据库化与素材库后台入口
|
||||||
|
|
||||||
|
团队列表读取 `OrganizationUnit` 中唯一的品牌中心节点的有效直接下级部门,包括暂无人员或职责配置的部门。部门 ID 绑定 `TeamFunctionConfig`,名称不进入职责配置;HR 同步改名后仍读取同一配置。直属下级部门范围以 parentId 为准,不按前端部门名数组筛选。
|
||||||
|
|
||||||
|
负责人存 `PerformanceSurveyEmployeeProfile.id`,显示名和当前任职路径从花名册读取。HR 镜像没有明确的部门负责人字段,因此只接受超级管理员从本部门及下级在职候选人员中明确选择,不以职级、姓名、直属主管名自动判定。离职或调出后标记关联失效;重新保存时拒绝无效人员。未开通素材库账号的花名册人员同样可以作为负责人。
|
||||||
|
|
||||||
|
职责描述与分组条目存配置表;HR 同步不修改它们。读取不创建配置、不自动初始化。新增部门显示空态,停止同步的部门不再显示,但其配置和修改记录保留。普通品牌中心成员只能读取;维护资格通过数据库 `isSuperAdmin` 检查,候选人员列表只返回给维护者。保存使用串行化事务、版本校验、前后快照审计;审计失败时职责修改回滚。不会授予负责人任何素材权限。
|
||||||
|
|
||||||
|
前端保留紧凑工作面,点击编辑后内联维护负责人、说明和职责分组。失败及冲突不丢弃草稿;取消重新读取;部门切换及导航离开在存在修改时确认。接口 `/api/organization/team-functions`,迁移 `20261008_team_function_configs`。
|
||||||
|
|
||||||
|
原四套确认的职责图内容保留于 `data/team-function-initial-content.json`,只供一次性导入,页面及接口不导入它。使用 `scripts/seed-team-function-config.ts` 为一个明确部门 ID 导入一套模板;人员必须显式提供档案 ID,可选择暂不设置。默认只展示计划,`--apply` 才写入;已有配置拒绝覆盖。示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npx tsx scripts/seed-team-function-config.ts --department-id <部门ID> --template "品牌创意A部" --actor-user-id <超级管理员ID> --lead-profile-id <人员档案ID>
|
||||||
|
# 核对输出中的当前部门、人员和内容后,用完全相同的参数加 --apply。
|
||||||
|
```
|
||||||
|
|
||||||
|
发布步骤:先备份并恢复到隔离数据库验证,再通过安全迁移器应用迁移(连同此前未发布的权限部门身份迁移),使用迁移审计回执部署应用。迁移器镜像带有只供人工调用的导入脚本;用 `--entrypoint /app/node_modules/.bin/tsx` 调用,默认只读,明确 `--apply` 后写入。读取当前组织和候选人员,人工核对旧职责图对应的实际部门 ID、负责人 ID 后逐个导入。组织改名、合并或职责调整不能通过相似名称猜测映射,未确认的配置留空。
|
||||||
|
|
||||||
|
本次发布前线上核实品牌中心有 5 个有效直属部门;品牌创意 A、B 与综合支持部的旧内容和负责人能唯一对应。用户已明确确认旧空间设计部职责迁入当前空间视觉部,负责人档案希文当前属于该部门。新增品牌设计部暂无已确认职责内容,保持待配置,由超级管理员维护。
|
||||||
|
|
||||||
|
素材库后台一直保留 `/admin`,线上实际检查到数据统计、素材管理、分类管理、部门权限配置、超级管理员及公网流量。找不到的原因是原素材库账户菜单把入口叫“治理与设置”,品牌中心账户菜单又缺少链接。本轮将素材库菜单名称明确为“素材库后台”,并在品牌中心账户菜单添加同名 `/admin` 入口,仅 ADMIN、SYSTEM_ADMIN、DEPT_ADMIN 显示,沿用后台本身的访问和数据范围控制。
|
||||||
|
|
||||||
|
验证证据:7 个相关套件、27 项测试通过;Prisma Client 与 schema 一致;类型检查、修改文件 lint 通过。使用独立本地 PostgreSQL 15 应用真实迁移,验证外键、部门和人员改名、离职、版本冲突、审计回滚、停用部门,以及导入脚本默认不写、显式导入、不覆盖已有配置。浏览器预览的团队职能直接调用真实服务访问此独立数据库,实际保存负责人后刷新保留,并恢复原样例设置;桌面、390px 手机、768px 平板无页面横向溢出或控制台错误,均只有一个 main/h1。账户菜单后台入口已实际显示;线上后台读取为只读检查。
|
||||||
|
|
||||||
|
本地预览 `http://localhost:3113/workspaces/organization/team-functions`,样例数据库容器 `gongxiangp2-team-functions-qa-20261008`,端口仅绑定 `127.0.0.1:55438`,保留供本地预览使用。其余预览页面仍使用模拟 API。截图位于 `tmp/permission-groups-qa/team-functions-db-*.png` 和 `material-admin-entry-desktop.png`。当前没有提交 Git 或部署,也没有写入生产业务数据。
|
||||||
47
docs/权限组部门身份修复-2026-10-08.md
Normal file
47
docs/权限组部门身份修复-2026-10-08.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# 权限组部门身份修复
|
||||||
|
|
||||||
|
组织来源为报表库 `hr_groups`,由 `organization-sync` 默认每天上海时间 02:00 同步。
|
||||||
|
同步以 `sourceDeptId` 更新同一个 `OrganizationUnit.id`,改名、换上级不重建身份;上游删除只下线。
|
||||||
|
权限组归属由管理员保存的数据库配置决定,不能根据部门名称自动推断。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 部门关联保存 `organizationUnitId` 外键;旧 `departmentPath` 保留为历史快照。
|
||||||
|
- 权限组页面、按人看权限和鉴权统一使用绑定部门的当前有效路径。
|
||||||
|
- 花名册中有部门 ID 时,只使用组织数据库解析的当前任职,忽略旧路径快照;一人多部门仍取各任职最近组的并集。
|
||||||
|
- 移除按名称自动挂接默认组的逻辑;管理页面不再创建默认组或重置组名。
|
||||||
|
- 无有效身份的历史关联显示“关联已失效”,不参与组鉴权,可按关联记录 ID 精确清理。
|
||||||
|
- 部门只允许加入一个组,约束由稳定身份的数据库唯一索引保证;并发冲突不会返回假成功。
|
||||||
|
|
||||||
|
## 数据迁移与边界
|
||||||
|
|
||||||
|
`20261008_permission_group_department_identity` 只回填当前有效且完整路径唯一匹配的部门。
|
||||||
|
无法证明的历史关联保留为空,不按简称、同名或名称相似度猜测。迁移不修改组里的素材权限或个人成员。
|
||||||
|
迁移失败时不得部署依赖新字段的应用。
|
||||||
|
|
||||||
|
生产只读盘点:17 条关联,16 条精确匹配;1 条为“视觉”组的“效率部(懂业务的技术部)”。
|
||||||
|
当前组织目录唯一部门为“效率部”,上游 ID `920560736`。
|
||||||
|
该条已先备份旧记录与目标身份,再修正为当前完整路径;未修改权限勾选或个人成员。
|
||||||
|
修复后只读复核为 17 条关联、17 条当前有效部门唯一精确匹配、0 条未匹配。
|
||||||
|
受保护的生产备份位于 `/mnt/PoolOne/apps/youyidian-3d/releases/permission-identity-repair-20261008`,包含 before 与 after JSON,目录权限 0700。
|
||||||
|
这是让现有版本恢复关联的单条数据修复;稳定部门 ID 的新代码及数据库迁移尚未提交、发布。
|
||||||
|
|
||||||
|
`scripts/repair-permission-group-department.mjs` 用明确的关联 ID、当前部门 ID、预期组 ID 和预期旧路径复核。
|
||||||
|
默认只读预演;`--apply` 必须指定 `--backup-dir`,写前备份,事务内检查身份和记录未变化。
|
||||||
|
此工具依赖新字段,须在上述数据库迁移完成后使用。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
覆盖改名与移动、部门下线、旧路径复用、一人多部门、最近上级继承、组冲突与并发、失效关联清理和管理员组名保留。
|
||||||
|
在独立 PostgreSQL 容器执行迁移 SQL,验证精确回填、歧义与失效保留、唯一约束和外键保护。
|
||||||
|
相关 12 个测试套件、86 项测试通过;Prisma 生成、客户端校验、类型检查和修改文件 lint 通过。
|
||||||
|
本地隔离样例完成桌面及 390px 移动端检查,无横向溢出,失效关联提示和按记录 ID 移除正常。
|
||||||
|
截图位于 `tmp/permission-groups-qa/identity-desktop.png`、`identity-mobile.png`。样例操作没有写入生产数据。
|
||||||
|
浏览器控制会话恢复后,已补充当前生产版本的登录态界面验收:
|
||||||
|
- 权限组「视觉」的部门列表显示「效率部」及当前完整路径。
|
||||||
|
- 按人看权限搜索「魔礼红」,显示「效率部」、部门组「视觉 · 本部门」,个人组为空;与权限组页面一致。
|
||||||
|
- 桌面及 390px 移动端显示正常,移动端文档宽度与视口均为 390px,无横向溢出。
|
||||||
|
- 生产截图位于 `tmp/permission-groups-qa/production-efficiency-person-desktop.png`、`production-efficiency-person-mobile.png`;权限组截图为 `browser-control-recovered.png`。
|
||||||
|
上述结果验证单条生产关联修正的显示结果;未操作权限勾选或个人加组,未以魔礼红身份执行真实下载。
|
||||||
|
稳定 ID 新代码的生产迁移、部署及上线后的鉴权验收仍待完成。
|
||||||
|
生产鉴权和界面验收必须在应用上线后按真实数据检查;本地样例验收不能代替生产验收。
|
||||||
26
docs/治理与组织入口合并-2026-10-08.md
Normal file
26
docs/治理与组织入口合并-2026-10-08.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# 治理与组织入口合并
|
||||||
|
|
||||||
|
侧栏治理与设置保留一个「权限管理」,页内通过「权限组 / 人员权限」切换视角。权限工作面保留挂载,切换不会丢弃勾选草稿、当前组和人员搜索。刷新按钮移到组列表标题行,嵌入时移除重复标题和跨页入口。
|
||||||
|
|
||||||
|
组织管理使用一个「组织架构」入口,默认品牌中心,另可明确切换全公司。两个范围复用同一个组织组件和组织 API,继续使用既有管理员权限;品牌中心节点缺失时保留原同步提示,不回退全公司。
|
||||||
|
|
||||||
|
旧路径在服务端跳转:
|
||||||
|
|
||||||
|
| 原路径 | 新路径 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/workspaces/governance/roles-permissions` | `/workspaces/governance/permissions` |
|
||||||
|
| `/workspaces/governance/user-permissions` | `/workspaces/governance/permissions?view=people` |
|
||||||
|
| `/workspaces/governance/data-definitions` | `/workspaces/organization/organization-structure?scope=company` |
|
||||||
|
| `/workspaces/organization/people-structure` | `/workspaces/organization/organization-structure` |
|
||||||
|
|
||||||
|
移除两套旧组织页面容器及其测试,更新 PRODUCT.md、DESIGN.md 与导航图标。此次入口整合不修改生产数据,权限 API 与前一项稳定部门 ID 修复共用既有工作区改动。
|
||||||
|
|
||||||
|
验证:10 个相关套件、44 项测试通过;类型检查与修改文件 lint 通过。Impeccable polish 检查了桌面、390px 手机与 768px 平板范围布局;浏览器实际确认草稿和人员搜索在视图切换后保留,组织样例从品牌中心 2 人切换为全公司 3 人。没有文档横向溢出、重复 main/h1 或控制台错误。新容器的静态设计扫描返回空结果。
|
||||||
|
|
||||||
|
使用真实页面组件、本地授权样例及模拟 API 的隔离预览为 `http://localhost:3113`;截图位于 `tmp/permission-groups-qa/consolidated-*.png`。这些结果是本地验收,不是新代码已上线的证明。旧路径的实际 HTTP 跳转与新权限身份迁移需要在正式应用发布时复核。
|
||||||
|
|
||||||
|
当前尚未提交、部署。发布时必须连同前一项权限身份修复处理其数据库迁移,不能只部署依赖新字段的应用代码。
|
||||||
|
|
||||||
|
预览纠正:用户发现组织架构与团队职能显示相同内容。原因是隔离预览入口按 `/organization/` 前缀统一渲染组织架构,正式 Next 路由分别映射两套组件。预览已改为完整路径匹配,团队职能加载原有职责地图组件;未加载的其他页面显示明确提示,避免误显示权限页或组织页。重新构建后从桌面和 390px 手机侧栏实际切换,确认组织架构显示花名册组织树、团队职能显示职责地图,团队选择正常且控制台无错误。截图为 `tmp/permission-groups-qa/team-functions-route-fixed-*.png` 和 `organization-route-fixed-desktop.png`。团队职能沿用原有静态配置,本次没有把它改为花名册数据库驱动。
|
||||||
|
|
||||||
|
团队职能收紧:依用户反馈,移除占高的图标、重复大标题、页面概述、部门导航标题区、当前部门标记和说明图例;必要读屏标题继续保留。去掉遗留的返回部门看板链接,跨页导航沿用侧栏。收紧详情标题与内容间距,保留负责人、团队介绍和所有职责条目。桌面面板顶部由 301px 提至 96px,390px 手机实测宽度 375px,不发生页面横向溢出,部门切换正常,控制台无错误。2 项既有组件测试及文件 lint 通过;Impeccable distill/polish 采用桌面与手机实际页面核查。截图为 `tmp/permission-groups-qa/team-functions-compact-desktop.png` 和 `team-functions-compact-mobile.png`。仍是本地改动,未提交或部署。
|
||||||
@ -105,7 +105,7 @@ describe('roster material visibility', () => {
|
|||||||
})
|
})
|
||||||
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
||||||
{
|
{
|
||||||
departmentPath: '江苏简尚品牌管理有限公司/运营中心A',
|
organizationUnit: { path: '江苏简尚品牌管理有限公司/运营中心A', isActive: true },
|
||||||
pack: {
|
pack: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -141,7 +141,7 @@ describe('roster material visibility', () => {
|
|||||||
})
|
})
|
||||||
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
||||||
{
|
{
|
||||||
departmentPath: '江苏简尚品牌管理有限公司/品牌中心',
|
organizationUnit: { path: '江苏简尚品牌管理有限公司/品牌中心', isActive: true },
|
||||||
pack: {
|
pack: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -181,7 +181,7 @@ describe('roster material visibility', () => {
|
|||||||
})
|
})
|
||||||
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
||||||
{
|
{
|
||||||
departmentPath: '江苏简尚品牌管理有限公司/品牌中心',
|
organizationUnit: { path: '江苏简尚品牌管理有限公司/品牌中心', isActive: true },
|
||||||
pack: {
|
pack: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -268,11 +268,12 @@ describe('roster material visibility', () => {
|
|||||||
;(prisma.userEmployeeBinding.findFirst as jest.Mock).mockResolvedValue({
|
;(prisma.userEmployeeBinding.findFirst as jest.Mock).mockResolvedValue({
|
||||||
employeeProfile: {
|
employeeProfile: {
|
||||||
departmentPath: '江苏简尚品牌管理有限公司/运营中心A/事业一部/品类十九部',
|
departmentPath: '江苏简尚品牌管理有限公司/运营中心A/事业一部/品类十九部',
|
||||||
sourcePayload: { syncedFields: { departmentIds: '[20]' } },
|
sourcePayload: { syncedFields: { departmentIds: '[19,20]' } },
|
||||||
departmentMemberships: [{ sourceDeptId: '20', departmentPath: null }],
|
departmentMemberships: [{ sourceDeptId: '20', departmentPath: null }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([
|
;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ sourceDeptId: 19, path: '江苏简尚品牌管理有限公司/运营中心A/事业一部/品类十九部' },
|
||||||
{
|
{
|
||||||
sourceDeptId: 20,
|
sourceDeptId: 20,
|
||||||
path: '江苏简尚品牌管理有限公司/品牌中心/空间视觉部',
|
path: '江苏简尚品牌管理有限公司/品牌中心/空间视觉部',
|
||||||
@ -280,7 +281,7 @@ describe('roster material visibility', () => {
|
|||||||
])
|
])
|
||||||
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([
|
||||||
{
|
{
|
||||||
departmentPath: '江苏简尚品牌管理有限公司/运营中心A',
|
organizationUnit: { path: '江苏简尚品牌管理有限公司/运营中心A', isActive: true },
|
||||||
pack: {
|
pack: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -294,7 +295,7 @@ describe('roster material visibility', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
departmentPath: '江苏简尚品牌管理有限公司/品牌中心',
|
organizationUnit: { path: '江苏简尚品牌管理有限公司/品牌中心', isActive: true },
|
||||||
pack: {
|
pack: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -333,6 +333,20 @@ describe('organization people counts', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('部门改名或移动后只认数据库中相同 ID 的当前路径,不保留旧路径授权', () => {
|
||||||
|
expect(resolveEmployeeOrganizationPaths({
|
||||||
|
departmentPath: '公司/旧部门',
|
||||||
|
departmentMemberships: [{ sourceDeptId: '42', departmentPath: '公司/旧部门' }],
|
||||||
|
}, new Map([['42', '公司/新中心/新部门']]))).toEqual(['公司/新中心/新部门'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('有部门 ID 但部门已下线时,不用旧路径恢复权限', () => {
|
||||||
|
expect(resolveEmployeeOrganizationPaths({
|
||||||
|
departmentPath: '公司/旧部门',
|
||||||
|
departmentMemberships: [{ sourceDeptId: '42', departmentPath: '公司/旧部门' }],
|
||||||
|
}, new Map())).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('用户继承叠在部门权限上,能给的加上,不能把部门已有的拿掉', () => {
|
test('用户继承叠在部门权限上,能给的加上,不能把部门已有的拿掉', () => {
|
||||||
const merged = mergeDepartmentAndInheritPacks(
|
const merged = mergeDepartmentAndInheritPacks(
|
||||||
{
|
{
|
||||||
|
|||||||
36
lib/__tests__/permission-group-identity.test.ts
Normal file
36
lib/__tests__/permission-group-identity.test.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/** @jest-environment node */
|
||||||
|
import { loadDepartmentGroupPacks } from '@/lib/permission-groups'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
jest.mock('@/lib/prisma', () => ({ prisma: { materialPermissionPackDepartment: { findMany: jest.fn() } } }))
|
||||||
|
const item = { materialType: '任务-详情', canView: true, canDownload: true, canShare: true, canDownloadSource: true, canEditAssets: true, canAccessPrivate: true }
|
||||||
|
|
||||||
|
describe('权限组鉴权使用绑定部门的当前身份', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks())
|
||||||
|
|
||||||
|
test('绑定快照未改名也能命中当前路径,包括编辑与私密权限', async () => {
|
||||||
|
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([{
|
||||||
|
departmentPath: '公司/旧部门', organizationUnit: { path: '公司/新中心/新部门', isActive: true }, pack: { items: [item] },
|
||||||
|
}])
|
||||||
|
const packs = await loadDepartmentGroupPacks(['公司/新中心/新部门/小组'])
|
||||||
|
expect(packs.get('公司/新中心/新部门/小组')?.['任务-详情']).toEqual(itemWithoutType())
|
||||||
|
expect(prisma.materialPermissionPackDepartment.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: { organizationUnit: { isActive: true, path: { in: ['公司', '公司/新中心', '公司/新中心/新部门', '公司/新中心/新部门/小组'] } } },
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('部门移动后旧树上的人不能继续借用旧路径获得权限', async () => {
|
||||||
|
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([{
|
||||||
|
departmentPath: '公司/旧部门', organizationUnit: { path: '公司/新部门', isActive: true }, pack: { items: [item] },
|
||||||
|
}])
|
||||||
|
expect((await loadDepartmentGroupPacks(['公司/旧部门'])).size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([null, { path: '公司/旧部门', isActive: false }])('失效身份即使路径同名也不授予权限', async (unit) => {
|
||||||
|
;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([{
|
||||||
|
departmentPath: '公司/旧部门', organizationUnit: unit, pack: { items: [item] },
|
||||||
|
}])
|
||||||
|
expect((await loadDepartmentGroupPacks(['公司/旧部门'])).size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function itemWithoutType() { const { materialType: _, ...flags } = item; return flags }
|
||||||
62
lib/__tests__/team-functions.test.ts
Normal file
62
lib/__tests__/team-functions.test.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/** @jest-environment node */
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getTeamFunctions, saveTeamFunction } from '@/lib/team-functions'
|
||||||
|
|
||||||
|
jest.mock('@/lib/prisma', () => ({ prisma: {
|
||||||
|
organizationUnit: { findMany: jest.fn() }, performanceSurveyEmployeeProfile: { findMany: jest.fn() },
|
||||||
|
teamFunctionConfig: { findMany: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn(), create: jest.fn(), updateMany: jest.fn() },
|
||||||
|
teamFunctionConfigAudit: { create: jest.fn() }, $transaction: jest.fn(),
|
||||||
|
} }))
|
||||||
|
const config = { id: 'cfg', organizationUnitId: 'team', leadEmployeeProfileId: 'lead', description: '职责', branches: [{ label: '职责', items: ['设计'] }], revision: 2 }
|
||||||
|
const input = { organizationUnitId: 'team', leadEmployeeProfileId: 'lead', description: '更新职责', branches: config.branches, revision: 2 }
|
||||||
|
const profiles = [{ id: 'lead', displayName: '新花名', realName: '姓名', jobNumber: '001', employmentState: 'ACTIVE', departmentPath: '公司/旧部门', sourcePayload: null,
|
||||||
|
departmentMemberships: [{ sourceDeptId: '2', departmentPath: '公司/旧部门' }] }]
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([
|
||||||
|
{ id: 'brand', name: '品牌中心', path: '公司/品牌中心', sourceDeptId: 1, parentId: null },
|
||||||
|
{ id: 'team', name: '新部门名', path: '公司/品牌中心/新部门名', sourceDeptId: 2, parentId: 'brand' },
|
||||||
|
{ id: 'outside', name: '公司部门', path: '公司/公司部门', sourceDeptId: 3, parentId: null },
|
||||||
|
])
|
||||||
|
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue(profiles)
|
||||||
|
;(prisma.teamFunctionConfig.findMany as jest.Mock).mockResolvedValue([config])
|
||||||
|
;(prisma.teamFunctionConfig.findUnique as jest.Mock).mockResolvedValue(config)
|
||||||
|
;(prisma.teamFunctionConfig.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
|
||||||
|
;(prisma.teamFunctionConfig.findUniqueOrThrow as jest.Mock).mockResolvedValue({ ...config, ...input, revision: 3 })
|
||||||
|
;(prisma.$transaction as jest.Mock).mockImplementation(callback => callback(prisma))
|
||||||
|
})
|
||||||
|
test('名称从当前花名册读取,过期部门快照不改变稳定 ID 归属', async () => {
|
||||||
|
const result = await getTeamFunctions(false)
|
||||||
|
expect(result.teams).toHaveLength(1)
|
||||||
|
expect(result.teams[0]).toMatchObject({ name: '新部门名', lead: { employeeProfileId: 'lead', name: '新花名' }, candidates: [] })
|
||||||
|
expect(prisma.teamFunctionConfig.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
test('新增 HR 部门自动出现,未配置职责保持为空', async () => {
|
||||||
|
;(prisma.teamFunctionConfig.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
expect((await getTeamFunctions(true)).teams[0]).toMatchObject({ revision: 0, description: '', branches: [], leadStatus: 'unset', candidates: [{ employeeProfileId: 'lead' }] })
|
||||||
|
})
|
||||||
|
test.each(['RESIGNED', 'moved'])('负责人离职或调出后显示失效,不能继续保存该负责人 %s', async state => {
|
||||||
|
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{ ...profiles[0], employmentState: state === 'RESIGNED' ? state : 'ACTIVE', departmentMemberships: [{ sourceDeptId: state === 'moved' ? '3' : '2' }] }])
|
||||||
|
expect((await getTeamFunctions(true)).teams[0].leadStatus).toBe('invalid')
|
||||||
|
await expect(saveTeamFunction(input, 'actor')).rejects.toMatchObject({ status: 400 })
|
||||||
|
expect(prisma.teamFunctionConfig.updateMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
test('同名品牌中心节点拒绝猜测范围', async () => {
|
||||||
|
;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([{ id: 'a', name: '品牌中心' }, { id: 'b', name: '品牌中心' }])
|
||||||
|
await expect(getTeamFunctions(false)).rejects.toMatchObject({ status: 409 })
|
||||||
|
})
|
||||||
|
test('过期修订不覆盖其他人的修改', async () => {
|
||||||
|
await expect(saveTeamFunction({ ...input, revision: 1 }, 'actor')).rejects.toMatchObject({ status: 409 })
|
||||||
|
expect(prisma.teamFunctionConfig.updateMany).not.toHaveBeenCalled()
|
||||||
|
expect(prisma.teamFunctionConfigAudit.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
test('保存及前后快照同一事务,部门改名不会新建配置', async () => {
|
||||||
|
expect(await saveTeamFunction(input, 'actor')).toEqual({ revision: 3 })
|
||||||
|
expect(prisma.teamFunctionConfig.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'cfg', revision: 2 } }))
|
||||||
|
expect(prisma.teamFunctionConfigAudit.create).toHaveBeenCalledWith({ data: expect.objectContaining({ actorUserId: 'actor', before: expect.objectContaining({ revision: 2 }), after: expect.objectContaining({ revision: 3 }) }) })
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' })
|
||||||
|
})
|
||||||
|
test('非当前品牌中心部门不得写入', async () => {
|
||||||
|
await expect(saveTeamFunction({ ...input, organizationUnitId: 'outside' }, 'actor')).rejects.toMatchObject({ status: 409 })
|
||||||
|
expect(prisma.teamFunctionConfig.updateMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
@ -1,12 +1,13 @@
|
|||||||
/** @jest-environment node */
|
/** @jest-environment node */
|
||||||
|
|
||||||
import { packItemsToMap, savePermissionPackItems } from '@/lib/user-permission-inherit'
|
import { ensureDefaultPermissionPacks, packItemsToMap, savePermissionPackItems } from '@/lib/user-permission-inherit'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
jest.mock('@/lib/prisma', () => ({
|
jest.mock('@/lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
materialPermissionPack: {
|
materialPermissionPack: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
upsert: jest.fn(),
|
||||||
},
|
},
|
||||||
materialPermissionPackItem: {
|
materialPermissionPackItem: {
|
||||||
deleteMany: jest.fn(),
|
deleteMany: jest.fn(),
|
||||||
@ -66,6 +67,13 @@ describe('user permission inherit helpers', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('读取默认组不会覆盖管理员的组名,也不会按部门名称自动加组', async () => {
|
||||||
|
;(prisma.materialPermissionPack.upsert as jest.Mock).mockResolvedValue({ id: 'pack', _count: { items: 1 } })
|
||||||
|
await ensureDefaultPermissionPacks()
|
||||||
|
expect(prisma.materialPermissionPack.upsert).toHaveBeenCalledWith(expect.objectContaining({ update: {} }))
|
||||||
|
expect(prisma.materialPermissionPackItem.createMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
test('savePermissionPackItems 会把非任务详情的私密权限强制保存为 false', async () => {
|
test('savePermissionPackItems 会把非任务详情的私密权限强制保存为 false', async () => {
|
||||||
await savePermissionPackItems('visual', [
|
await savePermissionPackItems('visual', [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -144,7 +144,7 @@ export async function getEmployeeByUserId(
|
|||||||
return profile ? toEntry(profile, userId) : null
|
return profile ? toEntry(profile, userId) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERMISSION_PATH_PROFILE_SELECT = {
|
export const PERMISSION_PATH_PROFILE_SELECT = {
|
||||||
departmentPath: true,
|
departmentPath: true,
|
||||||
sourcePayload: true,
|
sourcePayload: true,
|
||||||
departmentMemberships: {
|
departmentMemberships: {
|
||||||
|
|||||||
@ -271,10 +271,18 @@ export function resolveEmployeeOrganizationPaths(
|
|||||||
? pathBySourceDeptId
|
? pathBySourceDeptId
|
||||||
: new Map(Object.entries(pathBySourceDeptId))
|
: new Map(Object.entries(pathBySourceDeptId))
|
||||||
const memberships = profile.departmentMemberships || []
|
const memberships = profile.departmentMemberships || []
|
||||||
|
const departmentIds = collectEmployeeOrganizationSourceDeptIds(profile)
|
||||||
|
// A known department identity is authoritative. Renamed/moved/retired
|
||||||
|
// departments must not regain access through stale roster path snapshots.
|
||||||
|
if (departmentIds.length > 0) {
|
||||||
|
const currentPaths = collectEmployeeOrganizationPaths(departmentIds.map((id) => lookup.get(id)))
|
||||||
|
const primary = normalizeOrganizationPath(profile.departmentPath)
|
||||||
|
return currentPaths.includes(primary)
|
||||||
|
? [primary, ...currentPaths.filter((path) => path !== primary)] : currentPaths
|
||||||
|
}
|
||||||
return collectEmployeeOrganizationPaths([
|
return collectEmployeeOrganizationPaths([
|
||||||
profile.departmentPath,
|
profile.departmentPath,
|
||||||
...memberships.map((membership) => membership.departmentPath),
|
...memberships.map((membership) => membership.departmentPath),
|
||||||
...collectEmployeeOrganizationSourceDeptIds(profile).map((id) => lookup.get(id)),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,7 @@ import { Prisma } from '@prisma/client'
|
|||||||
import { prisma } from './prisma'
|
import { prisma } from './prisma'
|
||||||
import {
|
import {
|
||||||
findNearestGroupMemberships,
|
findNearestGroupMemberships,
|
||||||
lastOrganizationPathSegment,
|
|
||||||
LEGACY_DEPARTMENT_ALIASES,
|
|
||||||
normalizeOrganizationPath,
|
normalizeOrganizationPath,
|
||||||
isUnderOrganizationPath,
|
|
||||||
organizationPathChain,
|
organizationPathChain,
|
||||||
unionPermissionPacks,
|
unionPermissionPacks,
|
||||||
type PermissionPackMap,
|
type PermissionPackMap,
|
||||||
@ -26,68 +23,15 @@ const PACK_ITEM_SELECT = {
|
|||||||
canAccessPrivate: true,
|
canAccessPrivate: true,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function packTargetDepartment(slug: string): string | null {
|
/** Shared by both admin views. Historical snapshots never grant access. */
|
||||||
if (slug === 'operations') return '运营中心'
|
export async function loadPermissionGroupDepartments() {
|
||||||
if (slug === 'visual') return '视觉中心'
|
return prisma.materialPermissionPackDepartment.findMany({
|
||||||
return null
|
orderBy: { createdAt: 'asc' },
|
||||||
}
|
select: {
|
||||||
|
id: true, packId: true, departmentPath: true, organizationUnitId: true,
|
||||||
function unitMatchesPack(unitName: string, target: string): boolean {
|
organizationUnit: { select: { id: true, path: true, name: true, isActive: true } },
|
||||||
return unitName === target || LEGACY_DEPARTMENT_ALIASES[unitName] === target
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
function pickCanonicalGroupDepartments(paths: string[]): string[] {
|
|
||||||
const unique = Array.from(new Set(paths.map(normalizeOrganizationPath).filter(Boolean)))
|
|
||||||
return unique.filter(
|
|
||||||
(path) => !unique.some((other) => other !== path && isUnderOrganizationPath(path, other)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedDefaultGroupDepartments() {
|
|
||||||
try {
|
|
||||||
const packs = await prisma.materialPermissionPack.findMany({
|
|
||||||
where: { slug: { in: ['operations', 'visual'] } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
slug: true,
|
|
||||||
_count: { select: { departments: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const needsSeed = packs.filter((pack) => pack._count.departments === 0)
|
|
||||||
if (needsSeed.length === 0) return
|
|
||||||
|
|
||||||
const [units, occupiedRows] = await Promise.all([
|
|
||||||
prisma.organizationUnit.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
select: { path: true, name: true },
|
|
||||||
}),
|
|
||||||
prisma.materialPermissionPackDepartment.findMany({
|
|
||||||
select: { departmentPath: true },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
const occupied = new Set(occupiedRows.map((row) => row.departmentPath))
|
|
||||||
|
|
||||||
for (const pack of needsSeed) {
|
|
||||||
const target = packTargetDepartment(pack.slug)
|
|
||||||
if (!target) continue
|
|
||||||
const matched = units
|
|
||||||
.filter((unit) => unitMatchesPack(lastOrganizationPathSegment(unit.path) || unit.name, target))
|
|
||||||
.map((unit) => unit.path)
|
|
||||||
const canonical = pickCanonicalGroupDepartments(matched).filter((path) => !occupied.has(path))
|
|
||||||
if (canonical.length === 0) continue
|
|
||||||
await prisma.materialPermissionPackDepartment.createMany({
|
|
||||||
data: canonical.map((departmentPath) => ({
|
|
||||||
packId: pack.id,
|
|
||||||
departmentPath,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
for (const path of canonical) occupied.add(path)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (isMissingGroupTable(error)) return
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadDepartmentGroupPack(path: string): Promise<PermissionPackMap | null> {
|
export async function loadDepartmentGroupPack(path: string): Promise<PermissionPackMap | null> {
|
||||||
@ -104,9 +48,9 @@ export async function loadDepartmentGroupPacks(paths: string[]): Promise<Map<str
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const memberships = await prisma.materialPermissionPackDepartment.findMany({
|
const memberships = await prisma.materialPermissionPackDepartment.findMany({
|
||||||
where: { departmentPath: { in: candidatePaths } },
|
where: { organizationUnit: { isActive: true, path: { in: candidatePaths } } },
|
||||||
select: {
|
select: {
|
||||||
departmentPath: true,
|
organizationUnit: { select: { path: true, isActive: true } },
|
||||||
pack: {
|
pack: {
|
||||||
select: {
|
select: {
|
||||||
items: { select: PACK_ITEM_SELECT },
|
items: { select: PACK_ITEM_SELECT },
|
||||||
@ -114,8 +58,10 @@ export async function loadDepartmentGroupPacks(paths: string[]): Promise<Map<str
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const currentMemberships = memberships.flatMap((row) => row.organizationUnit?.isActive
|
||||||
|
? [{ ...row, departmentPath: row.organizationUnit.path }] : [])
|
||||||
for (const path of normalizedPaths) {
|
for (const path of normalizedPaths) {
|
||||||
const nearest = findNearestGroupMemberships(path, memberships)
|
const nearest = findNearestGroupMemberships(path, currentMemberships)
|
||||||
if (!nearest) continue
|
if (!nearest) continue
|
||||||
result.set(path, unionPermissionPacks(...nearest.items.map((row) => packItemsToMap(row.pack.items))))
|
result.set(path, unionPermissionPacks(...nearest.items.map((row) => packItemsToMap(row.pack.items))))
|
||||||
}
|
}
|
||||||
@ -126,9 +72,9 @@ export async function loadDepartmentGroupPacks(paths: string[]): Promise<Map<str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findConflictingDepartmentGroup(path: string, packId: string) {
|
export async function findConflictingDepartmentGroup(organizationUnitId: string, packId: string) {
|
||||||
const existing = await prisma.materialPermissionPackDepartment.findFirst({
|
const existing = await prisma.materialPermissionPackDepartment.findFirst({
|
||||||
where: { departmentPath: path, NOT: { packId } },
|
where: { organizationUnitId, NOT: { packId } },
|
||||||
select: { pack: { select: { slug: true, name: true } } },
|
select: { pack: { select: { slug: true, name: true } } },
|
||||||
})
|
})
|
||||||
return existing?.pack ?? null
|
return existing?.pack ?? null
|
||||||
|
|||||||
27
lib/team-function-contract.ts
Normal file
27
lib/team-function-contract.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const teamFunctionInputSchema = z.object({
|
||||||
|
organizationUnitId: z.string().trim().min(1).max(100),
|
||||||
|
revision: z.number().int().min(0),
|
||||||
|
leadEmployeeProfileId: z.string().trim().min(1).max(100).nullable(),
|
||||||
|
description: z.string().trim().max(2000),
|
||||||
|
branches: z.array(z.object({
|
||||||
|
label: z.string().trim().min(1).max(40),
|
||||||
|
items: z.array(z.string().trim().min(1).max(200)).min(1).max(30),
|
||||||
|
}).strict()).max(10),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
|
export type TeamFunctionInput = z.infer<typeof teamFunctionInputSchema>
|
||||||
|
export type FunctionBranch = TeamFunctionInput['branches'][number]
|
||||||
|
export type TeamFunction = {
|
||||||
|
organizationUnitId: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
lead: { employeeProfileId: string; name: string } | null
|
||||||
|
leadStatus: 'active' | 'unset' | 'invalid'
|
||||||
|
description: string
|
||||||
|
branches: FunctionBranch[]
|
||||||
|
revision: number
|
||||||
|
candidates: { employeeProfileId: string; name: string; jobNumber: string | null }[]
|
||||||
|
}
|
||||||
|
export type TeamFunctionPayload = { teams: TeamFunction[]; canEdit: boolean }
|
||||||
87
lib/team-functions.ts
Normal file
87
lib/team-functions.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { isRosterEmployed } from '@/lib/auto-personnel-rules'
|
||||||
|
import { resolveEmployeeOrganizationPaths, personBelongsToOrganizationPath } from '@/lib/organization-material-permissions'
|
||||||
|
import { teamFunctionInputSchema, type TeamFunctionInput, type TeamFunctionPayload } from '@/lib/team-function-contract'
|
||||||
|
|
||||||
|
export class TeamFunctionError extends Error {
|
||||||
|
constructor(message: string, public status: number) { super(message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// HR 没有部门负责人字段,配置只记录人工选择的人员 ID,不按姓名、职级推断。
|
||||||
|
async function loadRoster(db: Prisma.TransactionClient) {
|
||||||
|
const units = await db.organizationUnit.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, sourceDeptId: true, name: true, path: true, parentId: true },
|
||||||
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
const roots = units.filter(unit => unit.name === '品牌中心')
|
||||||
|
if (roots.length !== 1) throw new TeamFunctionError('品牌中心组织节点缺失或不唯一,请先核对花名册同步。', 409)
|
||||||
|
const teams = units.filter(unit => unit.parentId === roots[0].id)
|
||||||
|
const profiles = await db.performanceSurveyEmployeeProfile.findMany({
|
||||||
|
select: { id: true, displayName: true, realName: true, jobNumber: true, employmentState: true,
|
||||||
|
departmentPath: true, sourcePayload: true,
|
||||||
|
departmentMemberships: { select: { sourceDeptId: true, departmentPath: true } } },
|
||||||
|
orderBy: [{ displayName: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
const pathsById = new Map(units.map(unit => [String(unit.sourceDeptId), unit.path]))
|
||||||
|
const people = profiles.filter(profile => isRosterEmployed(profile.employmentState)).map(profile => ({
|
||||||
|
id: profile.id, name: profile.displayName || profile.realName || profile.jobNumber || '未命名人员',
|
||||||
|
jobNumber: profile.jobNumber, paths: resolveEmployeeOrganizationPaths(profile, pathsById),
|
||||||
|
}))
|
||||||
|
return { teams, people }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamFunctions(canEdit: boolean): Promise<TeamFunctionPayload> {
|
||||||
|
const { teams, people } = await loadRoster(prisma)
|
||||||
|
const configs = await prisma.teamFunctionConfig.findMany({ where: { organizationUnitId: { in: teams.map(team => team.id) } } })
|
||||||
|
return { canEdit, teams: teams.map(team => {
|
||||||
|
const config = configs.find(row => row.organizationUnitId === team.id)
|
||||||
|
const candidates = people.filter(person => personBelongsToOrganizationPath(person.paths, team.path))
|
||||||
|
const lead = candidates.find(person => person.id === config?.leadEmployeeProfileId)
|
||||||
|
const content = config ? teamFunctionInputSchema.parse({
|
||||||
|
organizationUnitId: team.id, revision: config.revision,
|
||||||
|
leadEmployeeProfileId: config.leadEmployeeProfileId, description: config.description, branches: config.branches,
|
||||||
|
}) : null
|
||||||
|
return { organizationUnitId: team.id, name: team.name, path: team.path,
|
||||||
|
lead: lead ? { employeeProfileId: lead.id, name: lead.name } : null,
|
||||||
|
leadStatus: lead ? 'active' : config?.leadEmployeeProfileId ? 'invalid' : 'unset',
|
||||||
|
description: content?.description ?? '', branches: content?.branches ?? [], revision: config?.revision ?? 0,
|
||||||
|
candidates: canEdit ? candidates.map(person => ({ employeeProfileId: person.id, name: person.name, jobNumber: person.jobNumber })) : [],
|
||||||
|
}
|
||||||
|
}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTeamFunction(input: TeamFunctionInput, actorUserId: string) {
|
||||||
|
const value = teamFunctionInputSchema.parse(input)
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async db => {
|
||||||
|
const { teams, people } = await loadRoster(db)
|
||||||
|
const team = teams.find(row => row.id === value.organizationUnitId)
|
||||||
|
if (!team) throw new TeamFunctionError('该部门已移出品牌中心或已停用,请刷新后重试。', 409)
|
||||||
|
if (value.leadEmployeeProfileId && !people.some(person => person.id === value.leadEmployeeProfileId && personBelongsToOrganizationPath(person.paths, team.path))) {
|
||||||
|
throw new TeamFunctionError('负责人必须是该部门范围内的在职花名册人员。', 400)
|
||||||
|
}
|
||||||
|
const before = await db.teamFunctionConfig.findUnique({ where: { organizationUnitId: team.id } })
|
||||||
|
if ((before?.revision ?? 0) !== value.revision) throw new TeamFunctionError('配置已被其他人更新,请取消编辑并重新加载。', 409)
|
||||||
|
const data = { description: value.description, branches: value.branches, leadEmployeeProfileId: value.leadEmployeeProfileId, updatedByUserId: actorUserId }
|
||||||
|
let saved
|
||||||
|
if (before) {
|
||||||
|
const result = await db.teamFunctionConfig.updateMany({ where: { id: before.id, revision: value.revision }, data: { ...data, revision: { increment: 1 } } })
|
||||||
|
if (result.count !== 1) throw new TeamFunctionError('配置已被其他人更新,请取消编辑并重新加载。', 409)
|
||||||
|
saved = await db.teamFunctionConfig.findUniqueOrThrow({ where: { id: before.id } })
|
||||||
|
} else {
|
||||||
|
saved = await db.teamFunctionConfig.create({ data: { ...data, organizationUnitId: team.id } })
|
||||||
|
}
|
||||||
|
const snapshot = (config: typeof saved) => ({ organizationUnitId: config.organizationUnitId, leadEmployeeProfileId: config.leadEmployeeProfileId, description: config.description, branches: config.branches, revision: config.revision })
|
||||||
|
await db.teamFunctionConfigAudit.create({ data: { configId: saved.id, actorUserId,
|
||||||
|
before: before ? snapshot(before) : Prisma.DbNull, after: snapshot(saved) } })
|
||||||
|
return { revision: saved.revision }
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && ['P2002', 'P2034'].includes(error.code)) {
|
||||||
|
throw new TeamFunctionError('配置或花名册已更新,请重新加载后重试。', 409)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,7 +46,7 @@ export async function ensureDefaultPermissionPacks() {
|
|||||||
for (const preset of DEFAULT_INHERIT_PACKS) {
|
for (const preset of DEFAULT_INHERIT_PACKS) {
|
||||||
const pack = await prisma.materialPermissionPack.upsert({
|
const pack = await prisma.materialPermissionPack.upsert({
|
||||||
where: { slug: preset.slug },
|
where: { slug: preset.slug },
|
||||||
update: { name: preset.name },
|
update: {},
|
||||||
create: { slug: preset.slug, name: preset.name },
|
create: { slug: preset.slug, name: preset.name },
|
||||||
select: { id: true, _count: { select: { items: true } } },
|
select: { id: true, _count: { select: { items: true } } },
|
||||||
})
|
})
|
||||||
@ -77,9 +77,6 @@ export async function ensureDefaultPermissionPacks() {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { seedDefaultGroupDepartments } = await import('./permission-groups')
|
|
||||||
await seedDefaultGroupDepartments()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PACK_ITEM_SELECT = {
|
const PACK_ITEM_SELECT = {
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
-- Bind only uniquely proven, active exact paths. Unresolved historical rows stay
|
||||||
|
-- visible for review; no name guessing or automatic permission assignment.
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE "material_permission_pack_departments"
|
||||||
|
ADD COLUMN "organization_unit_id" TEXT;
|
||||||
|
|
||||||
|
WITH unique_paths AS (
|
||||||
|
SELECT path, MIN(id) AS id
|
||||||
|
FROM organization_units WHERE is_active = TRUE
|
||||||
|
GROUP BY path HAVING COUNT(*) = 1
|
||||||
|
)
|
||||||
|
UPDATE material_permission_pack_departments AS membership
|
||||||
|
SET organization_unit_id = unit.id
|
||||||
|
FROM unique_paths AS unit
|
||||||
|
WHERE membership.department_path = unit.path;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "material_permission_pack_dept_unit_key"
|
||||||
|
ON "material_permission_pack_departments"("organization_unit_id");
|
||||||
|
CREATE INDEX "material_permission_pack_departments_pack_id_idx"
|
||||||
|
ON "material_permission_pack_departments"("pack_id");
|
||||||
|
ALTER TABLE "material_permission_pack_departments"
|
||||||
|
ADD CONSTRAINT "material_permission_pack_departments_organization_unit_id_fkey"
|
||||||
|
FOREIGN KEY ("organization_unit_id") REFERENCES "organization_units"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
DROP INDEX "material_permission_pack_dept_pack_path_key";
|
||||||
|
DROP INDEX "material_permission_pack_dept_path_key";
|
||||||
|
COMMIT;
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE "team_function_configs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organization_unit_id" TEXT NOT NULL,
|
||||||
|
"lead_employee_profile_id" TEXT,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"branches" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"revision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"updated_by_user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "team_function_configs_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "team_function_configs_revision_check" CHECK ("revision" > 0)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "team_function_configs_organization_unit_id_key" ON "team_function_configs"("organization_unit_id");
|
||||||
|
ALTER TABLE "team_function_configs" ADD CONSTRAINT "team_function_configs_organization_unit_id_fkey" FOREIGN KEY ("organization_unit_id") REFERENCES "organization_units"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "team_function_configs" ADD CONSTRAINT "team_function_configs_lead_employee_profile_id_fkey" FOREIGN KEY ("lead_employee_profile_id") REFERENCES "performance_survey_employee_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
CREATE TABLE "team_function_config_audits" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"config_id" TEXT NOT NULL,
|
||||||
|
"actor_user_id" TEXT NOT NULL,
|
||||||
|
"before" JSONB,
|
||||||
|
"after" JSONB NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "team_function_config_audits_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE INDEX "team_function_config_audits_config_id_created_at_idx" ON "team_function_config_audits"("config_id", "created_at");
|
||||||
|
ALTER TABLE "team_function_config_audits" ADD CONSTRAINT "team_function_config_audits_config_id_fkey" FOREIGN KEY ("config_id") REFERENCES "team_function_configs"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
COMMIT;
|
||||||
@ -502,12 +502,14 @@ model MaterialPermissionPackItem {
|
|||||||
model MaterialPermissionPackDepartment {
|
model MaterialPermissionPackDepartment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
packId String @map("pack_id")
|
packId String @map("pack_id")
|
||||||
|
organizationUnitId String? @unique(map: "material_permission_pack_dept_unit_key") @map("organization_unit_id")
|
||||||
|
/// 迁移前的路径快照,仅供失效关联的展示与清理;鉴权使用关联部门的当前路径。
|
||||||
departmentPath String @map("department_path")
|
departmentPath String @map("department_path")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
pack MaterialPermissionPack @relation(fields: [packId], references: [id], onDelete: Cascade)
|
pack MaterialPermissionPack @relation(fields: [packId], references: [id], onDelete: Cascade)
|
||||||
|
organizationUnit OrganizationUnit? @relation(fields: [organizationUnitId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
@@unique([packId, departmentPath], map: "material_permission_pack_dept_pack_path_key")
|
@@index([packId])
|
||||||
@@unique([departmentPath], map: "material_permission_pack_dept_path_key")
|
|
||||||
@@map("material_permission_pack_departments")
|
@@map("material_permission_pack_departments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -748,6 +750,7 @@ model PerformanceSurveyEmployeeProfile {
|
|||||||
workReviews WorkReviewReview[] @relation("WorkReviewReviewer")
|
workReviews WorkReviewReview[] @relation("WorkReviewReviewer")
|
||||||
submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterEmployee")
|
submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterEmployee")
|
||||||
managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerEmployee")
|
managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerEmployee")
|
||||||
|
ledTeamFunctionConfigs TeamFunctionConfig[]
|
||||||
|
|
||||||
@@index([displayName])
|
@@index([displayName])
|
||||||
@@index([dingEmail])
|
@@index([dingEmail])
|
||||||
@ -786,6 +789,8 @@ model OrganizationUnit {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
parent OrganizationUnit? @relation("OrganizationHierarchy", fields: [parentId], references: [id])
|
parent OrganizationUnit? @relation("OrganizationHierarchy", fields: [parentId], references: [id])
|
||||||
children OrganizationUnit[] @relation("OrganizationHierarchy")
|
children OrganizationUnit[] @relation("OrganizationHierarchy")
|
||||||
|
permissionGroupDepartments MaterialPermissionPackDepartment[]
|
||||||
|
teamFunctionConfig TeamFunctionConfig?
|
||||||
|
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
@@index([sourceParentId])
|
@@index([sourceParentId])
|
||||||
@ -794,6 +799,35 @@ model OrganizationUnit {
|
|||||||
@@map("organization_units")
|
@@map("organization_units")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 职责配置独立于 HR 镜像;部门和负责人只保存稳定身份,名称在读取时取花名册。
|
||||||
|
model TeamFunctionConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
organizationUnitId String @unique @map("organization_unit_id")
|
||||||
|
leadEmployeeProfileId String? @map("lead_employee_profile_id")
|
||||||
|
description String @default("")
|
||||||
|
branches Json @default("[]")
|
||||||
|
revision Int @default(1)
|
||||||
|
updatedByUserId String @map("updated_by_user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
organizationUnit OrganizationUnit @relation(fields: [organizationUnitId], references: [id], onDelete: Restrict)
|
||||||
|
leadEmployeeProfile PerformanceSurveyEmployeeProfile? @relation(fields: [leadEmployeeProfileId], references: [id], onDelete: Restrict)
|
||||||
|
audits TeamFunctionConfigAudit[]
|
||||||
|
@@map("team_function_configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TeamFunctionConfigAudit {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
configId String @map("config_id")
|
||||||
|
actorUserId String @map("actor_user_id")
|
||||||
|
before Json?
|
||||||
|
after Json
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
config TeamFunctionConfig @relation(fields: [configId], references: [id], onDelete: Restrict)
|
||||||
|
@@index([configId, createdAt])
|
||||||
|
@@map("team_function_config_audits")
|
||||||
|
}
|
||||||
|
|
||||||
/// 素材库账号与权威人员档案的正式绑定关系。
|
/// 素材库账号与权威人员档案的正式绑定关系。
|
||||||
/// 取代此前"运行时按钉钉ID/邮箱/sourcePayload 逐层软匹配"的隐式关联做法。
|
/// 取代此前"运行时按钉钉ID/邮箱/sourcePayload 逐层软匹配"的隐式关联做法。
|
||||||
/// 活跃绑定的唯一性由部分唯一索引保证(仅约束 status='ACTIVE',见对应 migration),
|
/// 活跃绑定的唯一性由部分唯一索引保证(仅约束 status='ACTIVE',见对应 migration),
|
||||||
|
|||||||
@ -1521,6 +1521,8 @@ const expectedMigrationNames = new Set([
|
|||||||
'20260918_add_performance_conversion_sync',
|
'20260918_add_performance_conversion_sync',
|
||||||
'20260923_add_certificate_report_metadata',
|
'20260923_add_certificate_report_metadata',
|
||||||
'20260924_add_certificate_tested_on',
|
'20260924_add_certificate_tested_on',
|
||||||
|
'20261008_permission_group_department_identity',
|
||||||
|
'20261008_team_function_configs',
|
||||||
]);
|
]);
|
||||||
if (migrations.length !== expectedMigrationNames.size
|
if (migrations.length !== expectedMigrationNames.size
|
||||||
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
|
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
|
||||||
|
|||||||
@ -65,6 +65,8 @@ test('migration deployment gate recognizes the latest approved schema migration'
|
|||||||
|
|
||||||
assert.ok(gateStart >= 0 && gateEnd > gateStart, 'migration gate list is missing')
|
assert.ok(gateStart >= 0 && gateEnd > gateStart, 'migration gate list is missing')
|
||||||
const gate = source.slice(gateStart, gateEnd)
|
const gate = source.slice(gateStart, gateEnd)
|
||||||
|
assert.match(gate, /20261008_permission_group_department_identity/)
|
||||||
|
assert.match(gate, /20261008_team_function_configs/)
|
||||||
assert.match(gate, /20260831_add_private_task_detail/)
|
assert.match(gate, /20260831_add_private_task_detail/)
|
||||||
assert.match(gate, /20260908_add_brand_knowledge/)
|
assert.match(gate, /20260908_add_brand_knowledge/)
|
||||||
assert.match(gate, /20260914_add_brand_knowledge_engagement/)
|
assert.match(gate, /20260914_add_brand_knowledge_engagement/)
|
||||||
@ -76,6 +78,7 @@ test('migration deployment gate recognizes the latest approved schema migration'
|
|||||||
|
|
||||||
test('direct replacement runs database and dependency gates before switching traffic', () => {
|
test('direct replacement runs database and dependency gates before switching traffic', () => {
|
||||||
const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
const functionStart = source.indexOf('direct_replace_active_container() {')
|
const functionStart = source.indexOf('direct_replace_active_container() {')
|
||||||
const functionEnd = source.indexOf('\n}\n\ncontainer_upstream_connection_count()', functionStart)
|
const functionEnd = source.indexOf('\n}\n\ncontainer_upstream_connection_count()', functionStart)
|
||||||
assert.ok(functionStart >= 0 && functionEnd > functionStart, 'direct replacement function is missing')
|
assert.ok(functionStart >= 0 && functionEnd > functionStart, 'direct replacement function is missing')
|
||||||
|
|||||||
41
scripts/repair-permission-group-department.mjs
Normal file
41
scripts/repair-permission-group-department.mjs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// Explicit, reviewed identity repair. Default is read-only; no department-name mapping.
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { parseArgs } from 'node:util'
|
||||||
|
|
||||||
|
const { values } = parseArgs({ options: {
|
||||||
|
'association-id': { type: 'string' }, 'organization-unit-id': { type: 'string' },
|
||||||
|
'expected-pack-id': { type: 'string' }, 'expected-old-path': { type: 'string' },
|
||||||
|
'backup-dir': { type: 'string' }, apply: { type: 'boolean', default: false },
|
||||||
|
} })
|
||||||
|
for (const key of ['association-id', 'organization-unit-id', 'expected-pack-id', 'expected-old-path']) {
|
||||||
|
if (!values[key]) throw new Error(`Missing --${key}`)
|
||||||
|
}
|
||||||
|
if (values.apply && !values['backup-dir']) throw new Error('--apply requires --backup-dir')
|
||||||
|
const db = new PrismaClient()
|
||||||
|
try {
|
||||||
|
const row = await db.materialPermissionPackDepartment.findUnique({ where: { id: values['association-id'] } })
|
||||||
|
const unit = await db.organizationUnit.findUnique({ where: { id: values['organization-unit-id'] } })
|
||||||
|
if (!row || !unit?.isActive || row.packId !== values['expected-pack-id'] || row.departmentPath !== values['expected-old-path']) throw new Error('Reviewed association or current active department differs; stopped')
|
||||||
|
if (row.organizationUnitId && row.organizationUnitId !== unit.id) throw new Error('Association already bound to another identity; stopped')
|
||||||
|
const conflict = await db.materialPermissionPackDepartment.findUnique({ where: { organizationUnitId: unit.id } })
|
||||||
|
if (conflict && conflict.id !== row.id) throw new Error('Department identity already assigned; stopped')
|
||||||
|
const plan = { before: row, after: { organizationUnitId: unit.id, departmentPath: unit.path }, sourceDeptId: unit.sourceDeptId }
|
||||||
|
console.log(JSON.stringify({ mode: values.apply ? 'apply' : 'dry-run', ...plan }, null, 2))
|
||||||
|
if (values.apply) {
|
||||||
|
const dir = path.resolve(values['backup-dir'])
|
||||||
|
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||||
|
await writeFile(path.join(dir, `permission-department-${row.id}-${Date.now()}.json`), JSON.stringify(plan, null, 2), { mode: 0o600, flag: 'wx' })
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
const currentUnit = await tx.organizationUnit.findUnique({ where: { id: unit.id } })
|
||||||
|
if (!currentUnit?.isActive || currentUnit.path !== unit.path) throw new Error('Department changed during repair; stopped')
|
||||||
|
const result = await tx.materialPermissionPackDepartment.updateMany({
|
||||||
|
where: { id: row.id, packId: row.packId, departmentPath: row.departmentPath, organizationUnitId: row.organizationUnitId },
|
||||||
|
data: plan.after,
|
||||||
|
})
|
||||||
|
if (result.count !== 1) throw new Error('Association changed during repair; stopped')
|
||||||
|
}, { isolationLevel: 'Serializable' })
|
||||||
|
console.log('Verified identity repair applied; backup retained')
|
||||||
|
}
|
||||||
|
} finally { await db.$disconnect() }
|
||||||
@ -37,6 +37,8 @@ const TARGET_MIGRATIONS = Object.freeze([
|
|||||||
'20260918_add_performance_conversion_sync',
|
'20260918_add_performance_conversion_sync',
|
||||||
'20260923_add_certificate_report_metadata',
|
'20260923_add_certificate_report_metadata',
|
||||||
'20260924_add_certificate_tested_on',
|
'20260924_add_certificate_tested_on',
|
||||||
|
'20261008_permission_group_department_identity',
|
||||||
|
'20261008_team_function_configs',
|
||||||
])
|
])
|
||||||
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
|
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
|
||||||
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
|
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
|
||||||
@ -349,6 +351,7 @@ async function queryEvidence(prisma, localMigrations) {
|
|||||||
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private', 'report_kind', 'certificate_expires_on', 'certificate_tested_on', 'certificate_number', 'certificate_usage_instructions', 'report_usage_rules'))
|
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell', 'task_detail_demand_type', 'task_detail_private', 'report_kind', 'certificate_expires_on', 'certificate_tested_on', 'certificate_number', 'certificate_usage_instructions', 'report_usage_rules'))
|
||||||
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
|
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
|
||||||
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
|
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
|
||||||
|
OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits')
|
||||||
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
|
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
|
||||||
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
|
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
|
||||||
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
|
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
|
||||||
@ -414,6 +417,8 @@ async function queryEvidence(prisma, localMigrations) {
|
|||||||
'performance_conversion_metric_product_date_idx',
|
'performance_conversion_metric_product_date_idx',
|
||||||
'performance_conversion_metric_date_idx',
|
'performance_conversion_metric_date_idx',
|
||||||
'performance_conversion_sync_states_lease_idx'
|
'performance_conversion_sync_states_lease_idx'
|
||||||
|
,'material_permission_pack_dept_unit_key', 'material_permission_pack_departments_pack_id_idx',
|
||||||
|
'team_function_configs_organization_unit_id_key', 'team_function_config_audits_config_id_created_at_idx'
|
||||||
)
|
)
|
||||||
ORDER BY idx.relname`,
|
ORDER BY idx.relname`,
|
||||||
)
|
)
|
||||||
@ -451,6 +456,9 @@ async function queryEvidence(prisma, localMigrations) {
|
|||||||
'brand_knowledge_likes_user_id_fkey',
|
'brand_knowledge_likes_user_id_fkey',
|
||||||
'brand_knowledge_favorites_knowledge_id_fkey',
|
'brand_knowledge_favorites_knowledge_id_fkey',
|
||||||
'brand_knowledge_favorites_user_id_fkey'
|
'brand_knowledge_favorites_user_id_fkey'
|
||||||
|
,'material_permission_pack_departments_organization_unit_id_fkey',
|
||||||
|
'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey',
|
||||||
|
'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check'
|
||||||
)
|
)
|
||||||
ORDER BY con.conname`,
|
ORDER BY con.conname`,
|
||||||
)
|
)
|
||||||
@ -845,6 +853,42 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
|
|||||||
fail(`Stored brand logo is untrusted: system_settings.${row.id}.loginLogoUrl`)
|
fail(`Stored brand logo is untrusted: system_settings.${row.id}.loginLogoUrl`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
assertOrganizationConfigurationEvidence(evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertOrganizationConfigurationEvidence(evidence) {
|
||||||
|
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
|
||||||
|
for (const [table, column, type, nullable] of [
|
||||||
|
['material_permission_pack_departments', 'organization_unit_id', 'text', 'YES'],
|
||||||
|
...['id', 'organization_unit_id', 'description', 'updated_by_user_id'].map(column => ['team_function_configs', column, 'text', 'NO']),
|
||||||
|
['team_function_configs', 'lead_employee_profile_id', 'text', 'YES'],
|
||||||
|
['team_function_configs', 'branches', 'jsonb', 'NO'],
|
||||||
|
['team_function_configs', 'revision', 'integer', 'NO'],
|
||||||
|
...['created_at', 'updated_at'].map(column => ['team_function_configs', column, 'timestamp without time zone', 'NO']),
|
||||||
|
...['id', 'config_id', 'actor_user_id'].map(column => ['team_function_config_audits', column, 'text', 'NO']),
|
||||||
|
['team_function_config_audits', 'before', 'jsonb', 'YES'],
|
||||||
|
['team_function_config_audits', 'after', 'jsonb', 'NO'],
|
||||||
|
['team_function_config_audits', 'created_at', 'timestamp without time zone', 'NO'],
|
||||||
|
]) {
|
||||||
|
const row = columns.get(`${table}.${column}`)
|
||||||
|
if (!row || row.data_type !== type || row.is_nullable !== nullable) fail(`Database column mismatch: ${table}.${column}`)
|
||||||
|
}
|
||||||
|
for (const [table, name, column] of [
|
||||||
|
['material_permission_pack_departments', 'material_permission_pack_dept_unit_key', 'organization_unit_id'],
|
||||||
|
['team_function_configs', 'team_function_configs_organization_unit_id_key', 'organization_unit_id'],
|
||||||
|
]) assertExactUniqueBtreeIndex(evidence.indexes.find(row => row.indexname === name), table, name, [column])
|
||||||
|
for (const [table, name, columns] of [
|
||||||
|
['material_permission_pack_departments', 'material_permission_pack_departments_pack_id_idx', ['pack_id']],
|
||||||
|
['team_function_config_audits', 'team_function_config_audits_config_id_created_at_idx', ['config_id', 'created_at']],
|
||||||
|
]) assertExactBtreeIndex(evidence.indexes.find(row => row.indexname === name)?.indexdef, table, columns)
|
||||||
|
for (const [table, name, column, referenced] of [
|
||||||
|
['material_permission_pack_departments', 'material_permission_pack_departments_organization_unit_id_fkey', 'organization_unit_id', 'organization_units'],
|
||||||
|
['team_function_configs', 'team_function_configs_organization_unit_id_fkey', 'organization_unit_id', 'organization_units'],
|
||||||
|
['team_function_configs', 'team_function_configs_lead_employee_profile_id_fkey', 'lead_employee_profile_id', 'performance_survey_employee_profiles'],
|
||||||
|
['team_function_config_audits', 'team_function_config_audits_config_id_fkey', 'config_id', 'team_function_configs'],
|
||||||
|
]) assertExactForeignKeyConstraint(evidence.constraints.find(row => row.constraint_name === name), table, name, column, referenced, 'id')
|
||||||
|
assertExactCheckConstraint(evidence.constraints.find(row => row.constraint_name === 'team_function_configs_revision_check'),
|
||||||
|
'team_function_configs', 'team_function_configs_revision_check', ['revision'], 'revision > 0')
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertNoUnexpectedPendingMigrations(localMigrations, evidence) {
|
function assertNoUnexpectedPendingMigrations(localMigrations, evidence) {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
assertExactBtreeIndex,
|
assertExactBtreeIndex,
|
||||||
assertExpectedDatabaseIdentity,
|
assertExpectedDatabaseIdentity,
|
||||||
assertTargetMigrationEvidence,
|
assertTargetMigrationEvidence,
|
||||||
|
assertOrganizationConfigurationEvidence,
|
||||||
databaseFingerprintHash,
|
databaseFingerprintHash,
|
||||||
normalizeColumnDefault,
|
normalizeColumnDefault,
|
||||||
requireExactCommit,
|
requireExactCommit,
|
||||||
@ -20,6 +21,36 @@ import {
|
|||||||
verifiedBackupSummaryHash,
|
verifiedBackupSummaryHash,
|
||||||
} from './safe-production-migrate.mjs'
|
} from './safe-production-migrate.mjs'
|
||||||
|
|
||||||
|
function organizationEvidence() {
|
||||||
|
const column = (table_name, column_name, data_type = 'text', is_nullable = 'NO') => ({ table_name, column_name, data_type, is_nullable })
|
||||||
|
const tables = ['team_function_configs', 'team_function_config_audits']
|
||||||
|
const unique = (tablename, indexname) => ({ tablename, indexname, indisunique: true, indisprimary: false, indisvalid: true, indisready: true, access_method: 'btree', predicate: '', columns: ['organization_unit_id'] })
|
||||||
|
const foreign = (tablename, constraint_name, column, referenced_table) => ({ tablename, constraint_name, contype: 'f', convalidated: true, referenced_table, columns: [column], referenced_columns: ['id'] })
|
||||||
|
return {
|
||||||
|
columns: [column('material_permission_pack_departments', 'organization_unit_id', 'text', 'YES'),
|
||||||
|
...['id', 'organization_unit_id', 'description', 'updated_by_user_id'].map(name => column(tables[0], name)),
|
||||||
|
column(tables[0], 'lead_employee_profile_id', 'text', 'YES'), column(tables[0], 'branches', 'jsonb'), column(tables[0], 'revision', 'integer'),
|
||||||
|
...['created_at', 'updated_at'].map(name => column(tables[0], name, 'timestamp without time zone')),
|
||||||
|
...['id', 'config_id', 'actor_user_id'].map(name => column(tables[1], name)), column(tables[1], 'before', 'jsonb', 'YES'), column(tables[1], 'after', 'jsonb'), column(tables[1], 'created_at', 'timestamp without time zone')],
|
||||||
|
indexes: [unique('material_permission_pack_departments', 'material_permission_pack_dept_unit_key'), unique(tables[0], 'team_function_configs_organization_unit_id_key'),
|
||||||
|
{ indexname: 'material_permission_pack_departments_pack_id_idx', indexdef: 'CREATE INDEX material_permission_pack_departments_pack_id_idx ON material_permission_pack_departments USING btree (pack_id)' },
|
||||||
|
{ indexname: 'team_function_config_audits_config_id_created_at_idx', indexdef: 'CREATE INDEX team_function_config_audits_config_id_created_at_idx ON team_function_config_audits USING btree (config_id, created_at)' }],
|
||||||
|
constraints: [foreign('material_permission_pack_departments', 'material_permission_pack_departments_organization_unit_id_fkey', 'organization_unit_id', 'organization_units'),
|
||||||
|
foreign(tables[0], 'team_function_configs_organization_unit_id_fkey', 'organization_unit_id', 'organization_units'),
|
||||||
|
foreign(tables[0], 'team_function_configs_lead_employee_profile_id_fkey', 'lead_employee_profile_id', 'performance_survey_employee_profiles'),
|
||||||
|
foreign(tables[1], 'team_function_config_audits_config_id_fkey', 'config_id', tables[0]),
|
||||||
|
{ tablename: tables[0], constraint_name: 'team_function_configs_revision_check', contype: 'c', convalidated: true, columns: ['revision'], expression: 'revision > 0' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('organization configuration verification rejects missing identity uniqueness, invalid audit foreign key and nonpositive revision', () => {
|
||||||
|
const evidence = organizationEvidence()
|
||||||
|
assert.doesNotThrow(() => assertOrganizationConfigurationEvidence(evidence))
|
||||||
|
assert.throws(() => assertOrganizationConfigurationEvidence({ ...evidence, indexes: evidence.indexes.filter(row => row.indexname !== 'material_permission_pack_dept_unit_key') }), /unique index mismatch/)
|
||||||
|
assert.throws(() => assertOrganizationConfigurationEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.constraint_name === 'team_function_config_audits_config_id_fkey' ? { ...row, referenced_table: 'users' } : row) }), /foreign key mismatch/)
|
||||||
|
assert.throws(() => assertOrganizationConfigurationEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.constraint_name === 'team_function_configs_revision_check' ? { ...row, expression: 'revision >= 0' } : row) }), /constraint expression mismatch/)
|
||||||
|
})
|
||||||
|
|
||||||
test('accepts only an exact lowercase 40-character commit', () => {
|
test('accepts only an exact lowercase 40-character commit', () => {
|
||||||
const commit = 'a'.repeat(40)
|
const commit = 'a'.repeat(40)
|
||||||
assert.equal(requireExactCommit(`${commit}\n`, 'commit'), commit)
|
assert.equal(requireExactCommit(`${commit}\n`, 'commit'), commit)
|
||||||
@ -353,6 +384,8 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
|||||||
['20260918_add_performance_conversion_sync', { checksum: 'z' }],
|
['20260918_add_performance_conversion_sync', { checksum: 'z' }],
|
||||||
['20260923_add_certificate_report_metadata', { checksum: 'aa' }],
|
['20260923_add_certificate_report_metadata', { checksum: 'aa' }],
|
||||||
['20260924_add_certificate_tested_on', { checksum: 'ab' }],
|
['20260924_add_certificate_tested_on', { checksum: 'ab' }],
|
||||||
|
['20261008_permission_group_department_identity', { checksum: 'ac' }],
|
||||||
|
['20261008_team_function_configs', { checksum: 'ad' }],
|
||||||
])
|
])
|
||||||
const evidence = {
|
const evidence = {
|
||||||
migrationRows: [
|
migrationRows: [
|
||||||
@ -384,6 +417,8 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
|||||||
{ migration_name: '20260918_add_performance_conversion_sync', checksum: 'z', finished_at: 'now', rolled_back_at: null },
|
{ migration_name: '20260918_add_performance_conversion_sync', checksum: 'z', finished_at: 'now', rolled_back_at: null },
|
||||||
{ migration_name: '20260923_add_certificate_report_metadata', checksum: 'aa', finished_at: 'now', rolled_back_at: null },
|
{ migration_name: '20260923_add_certificate_report_metadata', checksum: 'aa', finished_at: 'now', rolled_back_at: null },
|
||||||
{ migration_name: '20260924_add_certificate_tested_on', checksum: 'ab', finished_at: 'now', rolled_back_at: null },
|
{ migration_name: '20260924_add_certificate_tested_on', checksum: 'ab', finished_at: 'now', rolled_back_at: null },
|
||||||
|
{ migration_name: '20261008_permission_group_department_identity', checksum: 'ac', finished_at: 'now', rolled_back_at: null },
|
||||||
|
{ migration_name: '20261008_team_function_configs', checksum: 'ad', finished_at: 'now', rolled_back_at: null },
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
|
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
|
||||||
@ -621,6 +656,8 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
|||||||
{ id: 'custom', headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/header.png', loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/login.png' },
|
{ id: 'custom', headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/header.png', loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/login.png' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
const organization = organizationEvidence()
|
||||||
|
for (const key of ['columns', 'indexes', 'constraints']) evidence[key].push(...organization[key])
|
||||||
assert.doesNotThrow(() => assertTargetMigrationEvidence(migrations, evidence))
|
assert.doesNotThrow(() => assertTargetMigrationEvidence(migrations, evidence))
|
||||||
const nullableLogo = {
|
const nullableLogo = {
|
||||||
...evidence,
|
...evidence,
|
||||||
|
|||||||
31
scripts/seed-team-function-config.ts
Normal file
31
scripts/seed-team-function-config.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { isSuperAdmin } from '../lib/department-permission'
|
||||||
|
import { getTeamFunctions, saveTeamFunction } from '../lib/team-functions'
|
||||||
|
|
||||||
|
// One department per invocation; explicit IDs only, dry run by default, never overwrite an existing config.
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const option = (key: string) => args[args.indexOf(key) + 1]
|
||||||
|
for (const key of ['--department-id', '--template', '--actor-user-id']) {
|
||||||
|
if (!args.includes(key) || !option(key) || option(key).startsWith('--')) throw new Error(`缺少 ${key}`)
|
||||||
|
}
|
||||||
|
const actorUserId = option('--actor-user-id')
|
||||||
|
if (!(await isSuperAdmin(actorUserId))) throw new Error('操作人必须是数据库中的超级管理员。')
|
||||||
|
const { teams } = await getTeamFunctions(true)
|
||||||
|
const team = teams.find(row => row.organizationUnitId === option('--department-id'))
|
||||||
|
if (!team) throw new Error('未找到有效的品牌中心部门 ID。')
|
||||||
|
if (team.revision !== 0) throw new Error('该部门已有配置,拒绝覆盖;请在页面中编辑。')
|
||||||
|
const templates = JSON.parse(await fs.readFile(path.join(process.cwd(), 'data/team-function-initial-content.json'), 'utf8'))
|
||||||
|
const template = templates.find((row: { name: string }) => row.name === option('--template'))
|
||||||
|
if (!template) throw new Error('未找到职责模板。')
|
||||||
|
const leadId = args.includes('--lead-profile-id') ? option('--lead-profile-id') : null
|
||||||
|
if (leadId && !team.candidates.some(person => person.employeeProfileId === leadId)) throw new Error('负责人 ID 不在该部门在职花名册内。')
|
||||||
|
const input = { organizationUnitId: team.organizationUnitId, revision: 0, leadEmployeeProfileId: leadId,
|
||||||
|
description: template.description, branches: template.branches }
|
||||||
|
console.log(JSON.stringify({ apply: args.includes('--apply'), department: { id: team.organizationUnitId, name: team.name, path: team.path },
|
||||||
|
template: template.name, lead: team.candidates.find(person => person.employeeProfileId === leadId) ?? null, content: input }, null, 2))
|
||||||
|
if (args.includes('--apply')) console.log(await saveTeamFunction(input, actorUserId))
|
||||||
|
}
|
||||||
|
main().catch(error => { console.error(error.message); process.exitCode = 1 }).finally(() => prisma.$disconnect())
|
||||||
Loading…
x
Reference in New Issue
Block a user