feat: restrict external users to material library

This commit is contained in:
江林铖 2026-08-20 11:55:18 +08:00
parent 1769c65055
commit da2588051b
16 changed files with 419 additions and 18 deletions

View File

@ -460,11 +460,17 @@
"type": "navigation", "type": "navigation",
"description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。", "description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。",
"projection": "<section class=\"ds-data-source\"><nav class=\"ds-data-source-nav\" aria-label=\"数据口径二级导航\"><a class=\"ds-data-source-item\" href=\"#\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"5\" r=\"2\"/><circle cx=\"5\" cy=\"19\" r=\"2\"/><circle cx=\"19\" cy=\"19\" r=\"2\"/><path d=\"M12 7v5M5 17v-2h14v2\"/></svg><span><b>公司组织架构</b><small>组织树与人员明细</small></span></a></nav><section class=\"ds-data-source-live\" aria-label=\"公司组织架构操作区\"><strong>组织架构</strong><p>沿用真实人员层级、归属明细与管理员权限。</p></section></section><style>.ds-data-source{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:360px;background:#fff;font-family:ui-sans-serif,system-ui}.ds-data-source-nav{border-right:1px solid #e2e8f0;background:#f8fafc;padding:24px 16px}.ds-data-source-item{display:flex;min-height:58px;align-items:center;gap:12px;border-radius:8px;background:#e6f4ff;padding:10px 12px;color:#0050cb;text-decoration:none}.ds-data-source-item svg{width:16px;height:16px;flex:none;fill:none;stroke:#1677ff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-data-source-item b,.ds-data-source-item small{display:block}.ds-data-source-item b{font-size:14px}.ds-data-source-item small{margin-top:2px;color:#315f91;font-size:12px}.ds-data-source-item:focus-visible{outline:2px solid #5a9ce6}.ds-data-source-live{min-width:0;background:#fafafa;padding:24px;color:#101828}.ds-data-source-live strong{font-size:20px}.ds-data-source-live p{margin:8px 0 0;color:#66758b;font-size:14px}@media(max-width:1023px){.ds-data-source{grid-template-columns:1fr}.ds-data-source-nav{border-right:0;border-bottom:1px solid #e2e8f0;padding:16px}.ds-data-source-item small{display:none}}</style>" "projection": "<section class=\"ds-data-source\"><nav class=\"ds-data-source-nav\" aria-label=\"数据口径二级导航\"><a class=\"ds-data-source-item\" href=\"#\" aria-current=\"page\"><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"5\" r=\"2\"/><circle cx=\"5\" cy=\"19\" r=\"2\"/><circle cx=\"19\" cy=\"19\" r=\"2\"/><path d=\"M12 7v5M5 17v-2h14v2\"/></svg><span><b>公司组织架构</b><small>组织树与人员明细</small></span></a></nav><section class=\"ds-data-source-live\" aria-label=\"公司组织架构操作区\"><strong>组织架构</strong><p>沿用真实人员层级、归属明细与管理员权限。</p></section></section><style>.ds-data-source{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:360px;background:#fff;font-family:ui-sans-serif,system-ui}.ds-data-source-nav{border-right:1px solid #e2e8f0;background:#f8fafc;padding:24px 16px}.ds-data-source-item{display:flex;min-height:58px;align-items:center;gap:12px;border-radius:8px;background:#e6f4ff;padding:10px 12px;color:#0050cb;text-decoration:none}.ds-data-source-item svg{width:16px;height:16px;flex:none;fill:none;stroke:#1677ff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-data-source-item b,.ds-data-source-item small{display:block}.ds-data-source-item b{font-size:14px}.ds-data-source-item small{margin-top:2px;color:#315f91;font-size:12px}.ds-data-source-item:focus-visible{outline:2px solid #5a9ce6}.ds-data-source-live{min-width:0;background:#fafafa;padding:24px;color:#101828}.ds-data-source-live strong{font-size:20px}.ds-data-source-live p{margin:8px 0 0;color:#66758b;font-size:14px}@media(max-width:1023px){.ds-data-source{grid-template-columns:1fr}.ds-data-source-nav{border-right:0;border-bottom:1px solid #e2e8f0;padding:16px}.ds-data-source-item small{display:none}}</style>"
},
{
"name": "Standalone Material Library Shell",
"type": "navigation",
"description": "品牌中心范围外普通用户使用的中性素材库顶栏;只保留页面定位和退出登录,不提供品牌中心入口。",
"projection": "<header class=\"ds-material-standalone\" aria-label=\"素材资产库顶栏\"><strong><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M4 5h6l2 2h8v12H4z\"/></svg>素材资产库</strong><button type=\"button\">退出登录</button></header><style>.ds-material-standalone{display:flex;min-height:64px;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e2e8f0;background:#fff;padding:0 24px;color:#101828;font-family:ui-sans-serif,system-ui}.ds-material-standalone strong{display:flex;min-width:0;align-items:center;gap:12px;font-size:16px}.ds-material-standalone svg{width:20px;height:20px;fill:none;stroke:#176fc1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-material-standalone button{min-height:40px;border:0;border-radius:8px;background:transparent;padding:0 12px;color:#4f6077;font:600 14px/1 ui-sans-serif,system-ui}.ds-material-standalone button:hover{background:#eef2f6;color:#101828}.ds-material-standalone button:focus-visible{outline:2px solid #5a9ce6;outline-offset:2px}</style>"
} }
], ],
"narrative": { "narrative": {
"northStar": "深色导航轨数据工作台", "northStar": "深色导航轨数据工作台",
"overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。", "overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。",
"keyCharacteristics": [ "keyCharacteristics": [
"272px 固定深色导航轨,桌面可折叠为 80px。", "272px 固定深色导航轨,桌面可折叠为 80px。",
"72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。", "72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。",
@ -473,7 +479,8 @@
"移动端导航是完整的模态抽屉;内容状态始终诚实标注。", "移动端导航是完整的模态抽屉;内容状态始终诚实标注。",
"自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。", "自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。",
"数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。", "数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。",
"人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。" "人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。",
"素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。"
], ],
"rules": [ "rules": [
{ {
@ -516,6 +523,11 @@
"body": "嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。", "body": "嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。",
"section": "layout" "section": "layout"
}, },
{
"name": "The Organization Gate Rule",
"body": "品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。",
"section": "layout"
},
{ {
"name": "The Border-First Rule", "name": "The Border-First Rule",
"body": "默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。", "body": "默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。",
@ -523,14 +535,15 @@
} }
], ],
"dos": [ "dos": [
"**Do** 让根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。", "**Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。",
"**Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。", "**Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。",
"**Do** 明确区分真实数据、示例值、公式待接入与空数据。", "**Do** 明确区分真实数据、示例值、公式待接入与空数据。",
"**Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。", "**Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。",
"**Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。", "**Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。",
"**Do** 让嵌入业务的根路径返回以 `target=\"_top\"` 跳出 iframe。", "**Do** 让嵌入业务的根路径返回以 `target=\"_top\"` 跳出 iframe。",
"**Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。", "**Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。",
"**Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。" "**Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。",
"**Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。"
], ],
"donts": [ "donts": [
"**Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。", "**Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。",
@ -542,7 +555,8 @@
"**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/*` 直接进入工作台。"
] ]
} }
} }

View File

@ -119,7 +119,7 @@ components:
品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。 品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。
它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。 它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。
**Key Characteristics:** **Key Characteristics:**
@ -131,6 +131,7 @@ components:
- 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。 - 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。
- 数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。 - 数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。
- 人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。 - 人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。
- 素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。
## Colors ## Colors
@ -188,6 +189,8 @@ components:
桌面端采用固定侧栏工作台:导航轨宽 272px,可折叠为 80px;右侧顶栏高 72px;主内容最大宽度 1600px,使用 16/24/32px 响应式内边距。访问根路径时直接进入核心指标;其余职责域及小项全部通过固定侧栏访问,不额外渲染部门总览工作地图。 桌面端采用固定侧栏工作台:导航轨宽 272px,可折叠为 80px;右侧顶栏高 72px;主内容最大宽度 1600px,使用 16/24/32px 响应式内边距。访问根路径时直接进入核心指标;其余职责域及小项全部通过固定侧栏访问,不额外渲染部门总览工作地图。
品牌中心工作台访问范围以活跃账号绑定的权威 HR 组织路径为准。系统管理员保留维护通行能力;其余人员只有任一在职组织路径包含精确“品牌中心”节点时才能进入 `/` 与 `/workspaces/*`。范围外人员访问这些地址时服务端跳转至 `/assets/material-library`,素材库仅渲染中性独立顶栏、素材内容和退出登录,不显示品牌中心标识、侧栏、移动抽屉或任何返回入口。
1024px 以下隐藏固定侧栏,以左侧模态抽屉替代。抽屉宽度为 `min(86vw, 304px)`,打开时锁定页面滚动、底层内容 `inert`、焦点进入并约束在导航内,支持 Escape 关闭并恢复触发器焦点。390px 页面不得产生全局横向溢出。 1024px 以下隐藏固定侧栏,以左侧模态抽屉替代。抽屉宽度为 `min(86vw, 304px)`,打开时锁定页面滚动、底层内容 `inert`、焦点进入并约束在导航内,支持 Escape 关闭并恢复触发器焦点。390px 页面不得产生全局横向溢出。
自动化工作流在 Department Shell 内容区内再分两层:桌面左侧为 232px 白 / 雾灰二级栏,右侧为 `minmax(0, 1fr)` 的同源实际业务操作面;移动端二级栏移动到内容顶部,以两列菜单呈现。二级栏永久不渲染“自动化工作流”标题、接入数量或独立统计区,只显示“绩效考核调研”“作品过稿看板”两个菜单。`?workflow=performance-survey|work-review` 持久化当前选择并与嵌入内容同步。 自动化工作流在 Department Shell 内容区内再分两层:桌面左侧为 232px 白 / 雾灰二级栏,右侧为 `minmax(0, 1fr)` 的同源实际业务操作面;移动端二级栏移动到内容顶部,以两列菜单呈现。二级栏永久不渲染“自动化工作流”标题、接入数量或独立统计区,只显示“绩效考核调研”“作品过稿看板”两个菜单。`?workflow=performance-survey|work-review` 持久化当前选择并与嵌入内容同步。
@ -206,6 +209,8 @@ components:
**The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。 **The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。
**The Organization Gate Rule.** 品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。
## Elevation & Depth ## Elevation & Depth
系统以边框和底色分层为主,阴影只提供轻微离地感。顶栏与静态数据面板无需强阴影;首页职责域、指标组和悬浮菜单可使用低强度环境阴影。 系统以边框和底色分层为主,阴影只提供轻微离地感。顶栏与静态数据面板无需强阴影;首页职责域、指标组和悬浮菜单可使用低强度环境阴影。
@ -258,6 +263,7 @@ components:
- 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。 - 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。
- “人员架构”进入 `/workspaces/organization/people-structure`,在 Department Shell 内复用真实组织工作面并聚焦“品牌中心”;公司级 `/organization` 继续保留全量视图。 - “人员架构”进入 `/workspaces/organization/people-structure`,在 Department Shell 内复用真实组织工作面并聚焦“品牌中心”;公司级 `/organization` 继续保留全量视图。
- “数据口径”使用单项二级栏直接显示“公司组织架构”;有管理员权限时复用真实组织工作面,无权限时显示明确权限提示。 - “数据口径”使用单项二级栏直接显示“公司组织架构”;有管理员权限时复用真实组织工作面,无权限时显示明确权限提示。
- 素材资产库根据会话中的品牌中心访问声明选择外壳;范围外普通用户的独立顶栏只显示“素材资产库”和“退出登录”,不得出现品牌中心链接。
- 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。 - 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。
### Automation Workflow Split Workbench ### Automation Workflow Split Workbench
@ -280,11 +286,18 @@ components:
所有子页面从页面标题直接进入核心内容,不渲染独立的“数据接入说明”“数据来源说明”或“计算说明”面板。真实读取、示例值、待接入、读取失败和公式状态由对应卡片或数据模块自身承担;实际业务操作帮助不属于此类,仍应保留在需要的位置。 所有子页面从页面标题直接进入核心内容,不渲染独立的“数据接入说明”“数据来源说明”或“计算说明”面板。真实读取、示例值、待接入、读取失败和公式状态由对应卡片或数据模块自身承担;实际业务操作帮助不属于此类,仍应保留在需要的位置。
### Standalone Material Library Shell
- **Audience:** 品牌中心权威组织范围外的普通用户。
- **Structure:** 64px 白色中性顶栏 + 完整素材资产工作面;不渲染 Department Shell、侧栏、品牌中心副标题或返回图标。
- **Actions:** 顶栏只提供素材资产库定位和退出登录;素材上传、筛选、分享与下载能力仍由原业务权限控制。
- **Security:** `/` 与 `/workspaces/*` 同时经过会话声明、Middleware 和服务端 Layout 门禁,禁止仅靠 CSS 或条件隐藏导航。
## Do's and Don'ts ## Do's and Don'ts
### Do: ### Do:
- **Do** 让根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。 - **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。
- **Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。 - **Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。
- **Do** 明确区分真实数据、示例值、公式待接入与空数据。 - **Do** 明确区分真实数据、示例值、公式待接入与空数据。
- **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。 - **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。
@ -292,6 +305,7 @@ components:
- **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。 - **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。
- **Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。 - **Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。
- **Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。 - **Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。
- **Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。
### Don't: ### Don't:
@ -305,3 +319,4 @@ components:
- **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。 - **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。
- **Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。 - **Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。
- **Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。 - **Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。
- **Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。

View File

@ -8,7 +8,7 @@ web
## Users ## Users
品牌中心内部成员、部门负责人和管理者。他们需要从统一入口定位业务、资产、组织与治理相关工作,并按自身权限进入具体工具。 品牌中心内部成员、部门负责人和管理者需要从统一入口定位业务、资产、组织与治理相关工作;品牌中心架构以外的人员只使用素材资产库,并按自身权限浏览、上传、分享或下载素材,品牌中心工作台、组织、管理、考核与过稿工作面统一重定向回素材库。
## Product Purpose ## Product Purpose
@ -24,6 +24,7 @@ web
- “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。 - “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。
- “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。 - “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。
- “人员架构”在部门工作台内复用主线 `/organization` 的真实组织树、人员明细与原权限设置,并只聚焦“品牌中心”节点及其下级人员;公司级 `/organization` 全量视图保持不变。 - “人员架构”在部门工作台内复用主线 `/organization` 的真实组织树、人员明细与原权限设置,并只聚焦“品牌中心”节点及其下级人员;公司级 `/organization` 全量视图保持不变。
- 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。
- “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。 - “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。
- 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 - 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。 - 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
@ -45,6 +46,7 @@ web
- 先让入口与职责边界清楚,再接入数据。 - 先让入口与职责边界清楚,再接入数据。
- 核心指标作为默认工作面,固定侧栏负责其它能力导航,具体页面负责完成任务。 - 核心指标作为默认工作面,固定侧栏负责其它能力导航,具体页面负责完成任务。
- 保持线上业务稳定,本地验证充分后再由用户决定是否提交和部署。 - 保持线上业务稳定,本地验证充分后再由用户决定是否提交和部署。
- 组织归属门禁必须服务端收紧,隐藏导航不能替代路由权限校验。
- 对不可用能力诚实标注状态,不用假数据制造完成感。 - 对不可用能力诚实标注状态,不用假数据制造完成感。
## Accessibility & Inclusion ## Accessibility & Inclusion

View File

@ -1,8 +1,8 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
export const metadata: Metadata = { export const metadata: Metadata = {
title: '由壹点3D素材 | 品牌中心部门看板', title: '素材资产库 | 由壹点3D素材',
description: '品牌中心资产与AI自动化素材库', description: '浏览、上传、分享与下载素材资产',
} }
export default function MaterialLibraryLayout({ export default function MaterialLibraryLayout({

View File

@ -6,7 +6,7 @@ import { useRouter, usePathname } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import UploadModal from '@/components/UploadModal' import UploadModal from '@/components/UploadModal'
import RankingPanel from '@/components/RankingPanel' import RankingPanel from '@/components/RankingPanel'
import DepartmentShell from '@/components/department-dashboard/DepartmentShell' import MaterialLibraryAccessShell from '@/components/material-library/MaterialLibraryAccessShell'
import AIChatWidget from '@/components/AIChatWidget' import AIChatWidget from '@/components/AIChatWidget'
import VideoPlayerModal from '@/components/VideoPlayerModal' import VideoPlayerModal from '@/components/VideoPlayerModal'
import ToastBubble from '@/components/ToastBubble' import ToastBubble from '@/components/ToastBubble'
@ -1331,8 +1331,10 @@ export default function MaterialLibraryPage() {
return null return null
} }
const canAccessBrandCenter = session.user.canAccessBrandCenter === true
return ( return (
<DepartmentShell> <MaterialLibraryAccessShell canAccessBrandCenter={canAccessBrandCenter}>
<div className="department-material-light min-h-[calc(100vh-72px)] bg-[#f2f4f7] text-[#101828]"> <div className="department-material-light min-h-[calc(100vh-72px)] bg-[#f2f4f7] text-[#101828]">
{/* 收藏操作提示气泡 */} {/* 收藏操作提示气泡 */}
@ -1344,7 +1346,7 @@ export default function MaterialLibraryPage() {
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */} {/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
<RankingPanel appearance="light" safeLeft={272} dockRight /> <RankingPanel appearance="light" safeLeft={canAccessBrandCenter ? 272 : 0} dockRight />
<main className="container mx-auto max-w-7xl px-4 py-6"> <main className="container mx-auto max-w-7xl px-4 py-6">
{/* Header Section */} {/* Header Section */}
@ -1634,6 +1636,6 @@ export default function MaterialLibraryPage() {
{/* AI聊天助手 */} {/* AI聊天助手 */}
<AIChatWidget /> <AIChatWidget />
</div> </div>
</DepartmentShell> </MaterialLibraryAccessShell>
) )
} }

View File

@ -1,11 +1,16 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
export const metadata: Metadata = { export const metadata: Metadata = {
title: '核心指标 | 品牌中心部门看板', title: '核心指标 | 品牌中心部门看板',
description: '品牌中心项目进度、交付质量、团队负荷与风险状态', description: '品牌中心项目进度、交付质量、团队负荷与风险状态',
} }
export default function BrandCenterDashboardPage() { export default async function BrandCenterDashboardPage() {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/login')
if (session.user.canAccessBrandCenter !== true) redirect('/assets/material-library')
redirect('/workspaces/dashboard/core-metrics') redirect('/workspaces/dashboard/core-metrics')
} }

10
app/workspaces/layout.tsx Normal file
View File

@ -0,0 +1,10 @@
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
export default async function WorkspacesLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await getServerSession(authOptions)
if (!session?.user) redirect('/login')
if (session.user.canAccessBrandCenter !== true) redirect('/assets/material-library')
return children
}

View File

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MaterialLibraryAccessShell from './MaterialLibraryAccessShell'
const signOut = jest.fn()
jest.mock('next-auth/react', () => ({
signOut: (...args: unknown[]) => signOut(...args),
}))
jest.mock('@/components/department-dashboard/DepartmentShell', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div data-testid="department-shell">{children}</div>,
}))
describe('MaterialLibraryAccessShell', () => {
beforeEach(() => signOut.mockClear())
test('品牌中心人员保留部门工作台导航', () => {
render(<MaterialLibraryAccessShell canAccessBrandCenter><p>素材列表</p></MaterialLibraryAccessShell>)
expect(screen.getByTestId('department-shell')).toBeInTheDocument()
expect(screen.queryByRole('banner', { name: '素材资产库顶栏' })).not.toBeInTheDocument()
})
test('品牌中心外人员只看到独立素材库壳且可以退出', async () => {
render(<MaterialLibraryAccessShell canAccessBrandCenter={false}><p>素材列表</p></MaterialLibraryAccessShell>)
expect(screen.queryByTestId('department-shell')).not.toBeInTheDocument()
expect(screen.getByRole('banner', { name: '素材资产库顶栏' })).toBeInTheDocument()
expect(screen.getByText('素材资产库')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '退出登录' }))
expect(signOut).toHaveBeenCalledWith({ callbackUrl: '/login' })
})
})

View File

@ -0,0 +1,44 @@
'use client'
import { LibraryBig, LogOut } from 'lucide-react'
import { signOut } from 'next-auth/react'
import type { ReactNode } from 'react'
import DepartmentShell from '@/components/department-dashboard/DepartmentShell'
export default function MaterialLibraryAccessShell({
canAccessBrandCenter,
children,
}: {
canAccessBrandCenter: boolean
children: ReactNode
}) {
if (canAccessBrandCenter) {
return <DepartmentShell>{children}</DepartmentShell>
}
return (
<div className="min-h-screen bg-[#f2f4f7] text-[#101828]">
<header className="sticky top-0 z-40 border-b border-[#e2e8f0] bg-white" aria-label="素材资产库顶栏">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#edf5ff] text-[#176fc1]">
<LibraryBig className="h-5 w-5" aria-hidden="true" />
</span>
<span className="truncate text-base font-bold tracking-[-0.02em]">素材资产库</span>
</div>
<button
type="button"
aria-label="退出登录"
onClick={() => signOut({ callbackUrl: '/login' })}
className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-semibold text-[#4f6077] transition-colors hover:bg-[#eef2f6] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] focus-visible:ring-offset-2"
>
<LogOut className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">退出登录</span>
<span className="sr-only sm:hidden">退出登录</span>
</button>
</div>
</header>
{children}
</div>
)
}

View File

@ -0,0 +1,52 @@
import {
hasBrandCenterAccess,
isBrandCenterOrganizationPath,
isBrandCenterRestrictedPath,
shouldRedirectToStandaloneMaterialLibrary,
} from '@/lib/brand-center-access-policy'
describe('brand center access policy', () => {
test('只把路径中的精确品牌中心节点视为品牌中心范围', () => {
expect(isBrandCenterOrganizationPath('江苏简尚品牌管理有限公司/品牌中心/品牌创意A部')).toBe(true)
expect(isBrandCenterOrganizationPath('江苏简尚品牌管理有限公司/龙晨品牌中心')).toBe(false)
expect(isBrandCenterOrganizationPath('江苏简尚品牌管理有限公司/运营中心A')).toBe(false)
})
test('品牌中心任一任职路径可进入,明确离职则不可进入', () => {
expect(hasBrandCenterAccess({
role: 'USER',
departmentPaths: ['集团/摄影中心', '集团/品牌中心/综合支持部'],
})).toBe(true)
expect(hasBrandCenterAccess({
role: 'USER',
employmentState: 'RESIGNED',
departmentPaths: ['集团/品牌中心'],
})).toBe(false)
})
test('系统管理员保留维护通行,外部门管理员不自动获得品牌中心权限', () => {
expect(hasBrandCenterAccess({ role: 'SYSTEM_ADMIN', departmentPaths: [] })).toBe(true)
expect(hasBrandCenterAccess({ role: 'USER', isSuperAdmin: true, departmentPaths: [] })).toBe(true)
expect(hasBrandCenterAccess({ role: 'DEPT_ADMIN', departmentPaths: ['集团/运营中心A'] })).toBe(false)
})
test('品牌中心与管理工作面统一受组织范围门禁,素材工作流保持可用', () => {
expect(isBrandCenterRestrictedPath('/')).toBe(true)
expect(isBrandCenterRestrictedPath('/workspaces/dashboard/core-metrics')).toBe(true)
expect(isBrandCenterRestrictedPath('/admin')).toBe(true)
expect(isBrandCenterRestrictedPath('/organization')).toBe(true)
expect(isBrandCenterRestrictedPath('/performance-survey/monthly')).toBe(true)
expect(isBrandCenterRestrictedPath('/work-review')).toBe(true)
expect(isBrandCenterRestrictedPath('/teacher/templates')).toBe(true)
expect(isBrandCenterRestrictedPath('/assets/material-library')).toBe(false)
expect(isBrandCenterRestrictedPath('/materials')).toBe(false)
expect(isBrandCenterRestrictedPath('/profile')).toBe(false)
})
test('外部人员访问旧素材库或品牌中心工作面时统一进入正式素材库', () => {
expect(shouldRedirectToStandaloneMaterialLibrary('/materials', false)).toBe(true)
expect(shouldRedirectToStandaloneMaterialLibrary('/workspaces/dashboard/core-metrics', false)).toBe(true)
expect(shouldRedirectToStandaloneMaterialLibrary('/assets/material-library', false)).toBe(false)
expect(shouldRedirectToStandaloneMaterialLibrary('/materials', true)).toBe(false)
})
})

View File

@ -0,0 +1,69 @@
import { prisma } from '@/lib/prisma'
import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: { findUnique: jest.fn() },
performanceSurveyEmployeeProfile: { findUnique: jest.fn() },
organizationUnit: { findMany: jest.fn() },
},
}))
const mockedPrisma = prisma as unknown as {
user: { findUnique: jest.Mock }
performanceSurveyEmployeeProfile: { findUnique: jest.Mock }
organizationUnit: { findMany: jest.Mock }
}
describe('resolveBrandCenterAccessForUser', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedPrisma.organizationUnit.findMany.mockResolvedValue([])
mockedPrisma.performanceSurveyEmployeeProfile.findUnique.mockResolvedValue(null)
})
test('系统管理员不依赖人员绑定即可进入', async () => {
await expect(resolveBrandCenterAccessForUser('admin', { role: 'SYSTEM_ADMIN' })).resolves.toBe(true)
expect(mockedPrisma.user.findUnique).not.toHaveBeenCalled()
})
test('读取活跃绑定的多部门任职路径', async () => {
mockedPrisma.user.findUnique.mockResolvedValue({
dingtalkUserId: 'ding-1',
employeeBindings: [{
employeeProfile: {
departmentPath: '集团/摄影中心',
employmentState: 'ACTIVE',
departmentMemberships: [{
departmentPath: '集团/品牌中心/综合支持部',
sourceDeptId: '20',
}],
},
}],
})
await expect(resolveBrandCenterAccessForUser('user-1', { role: 'USER' })).resolves.toBe(true)
})
test('部门路径缺失时可按任职部门 ID 解析真实组织路径', async () => {
mockedPrisma.user.findUnique.mockResolvedValue({
dingtalkUserId: null,
employeeBindings: [{
employeeProfile: {
departmentPath: null,
employmentState: 'ACTIVE',
departmentMemberships: [{ departmentPath: null, sourceDeptId: '20' }],
},
}],
})
mockedPrisma.organizationUnit.findMany.mockResolvedValue([{ path: '集团/品牌中心' }])
await expect(resolveBrandCenterAccessForUser('user-2', { role: 'USER' })).resolves.toBe(true)
})
test('未绑定且不在品牌中心时按无权限处理', async () => {
mockedPrisma.user.findUnique.mockResolvedValue({ dingtalkUserId: null, employeeBindings: [] })
await expect(resolveBrandCenterAccessForUser('user-3', { role: 'USER' })).resolves.toBe(false)
})
})

View File

@ -4,6 +4,7 @@ import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie' import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie'
import { authorizeDingtalkH5UserForNextAuth } from '@/lib/dingtalk-login' import { authorizeDingtalkH5UserForNextAuth } from '@/lib/dingtalk-login'
import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
const LEGACY_DINGTALK_AUTO_LOGIN_PASSWORD = 'dingtalk_auto_login' const LEGACY_DINGTALK_AUTO_LOGIN_PASSWORD = 'dingtalk_auto_login'
@ -146,9 +147,17 @@ export const authOptions: NextAuthOptions = {
token.managedDepartment = (user as any).managedDepartment || null; token.managedDepartment = (user as any).managedDepartment || null;
token.accountStatus = 'active'; token.accountStatus = 'active';
token.authCheckedAt = Date.now(); token.authCheckedAt = Date.now();
token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(user.id, {
role: (user as any).role,
isSuperAdmin: (user as any).isSuperAdmin,
}).catch((error) => {
console.error('读取品牌中心访问范围失败,已按无权限处理:', error)
return false
})
} else if (token.id) { } else if (token.id) {
const lastCheckedAt = Number((token as any).authCheckedAt || 0) const lastCheckedAt = Number((token as any).authCheckedAt || 0)
if (Date.now() - lastCheckedAt >= 30_000) { const accessClaimMissing = typeof (token as any).canAccessBrandCenter !== 'boolean'
if (accessClaimMissing || Date.now() - lastCheckedAt >= 30_000) {
const currentUser = await prisma.user.findUnique({ const currentUser = await prisma.user.findUnique({
where: { id: String(token.id) }, where: { id: String(token.id) },
select: { select: {
@ -173,6 +182,15 @@ export const authOptions: NextAuthOptions = {
token.isSuperAdmin = currentUser.isSuperAdmin token.isSuperAdmin = currentUser.isSuperAdmin
token.department = currentUser.department || '' token.department = currentUser.department || ''
token.managedDepartment = currentUser.managedDepartment || null token.managedDepartment = currentUser.managedDepartment || null
token.canAccessBrandCenter = await resolveBrandCenterAccessForUser(String(token.id), {
role: currentUser.role,
isSuperAdmin: currentUser.isSuperAdmin,
}).catch((error) => {
console.error('刷新品牌中心访问范围失败,已按无权限处理:', error)
return false
})
} else {
token.canAccessBrandCenter = false
} }
} }
} }
@ -190,6 +208,7 @@ export const authOptions: NextAuthOptions = {
(session.user as any).isSuperAdmin = token.isSuperAdmin || false; // 添加超级管理员标识到session (session.user as any).isSuperAdmin = token.isSuperAdmin || false; // 添加超级管理员标识到session
(session.user as any).department = (token as any).department || ''; (session.user as any).department = (token as any).department || '';
(session.user as any).managedDepartment = (token as any).managedDepartment || null; (session.user as any).managedDepartment = (token as any).managedDepartment || null;
(session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true;
} }
return session; return session;
}, },

View File

@ -0,0 +1,47 @@
const BRAND_CENTER_SEGMENT = '品牌中心'
const BRAND_CENTER_ADMIN_ROLES = new Set(['ADMIN', 'SYSTEM_ADMIN'])
export function isBrandCenterOrganizationPath(value: unknown): boolean {
return String(value ?? '')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.includes(BRAND_CENTER_SEGMENT)
}
export function hasBrandCenterAccess(input: {
role?: unknown
isSuperAdmin?: unknown
employmentState?: unknown
departmentPaths?: unknown[]
}): boolean {
const role = String(input.role ?? '')
if (input.isSuperAdmin === true || BRAND_CENTER_ADMIN_ROLES.has(role)) return true
if (input.employmentState === 'RESIGNED') return false
return (input.departmentPaths ?? []).some(isBrandCenterOrganizationPath)
}
const BRAND_CENTER_RESTRICTED_PREFIXES = [
'/workspaces',
'/admin',
'/organization',
'/performance-survey',
'/work-review',
'/teacher',
]
export function isBrandCenterRestrictedPath(pathname: string): boolean {
return pathname === '/' || BRAND_CENTER_RESTRICTED_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
)
}
export function shouldRedirectToStandaloneMaterialLibrary(
pathname: string,
canAccessBrandCenter: unknown,
): boolean {
return canAccessBrandCenter !== true && (
pathname === '/materials' || isBrandCenterRestrictedPath(pathname)
)
}

View File

@ -0,0 +1,68 @@
import { prisma } from '@/lib/prisma'
import { hasBrandCenterAccess } from '@/lib/brand-center-access-policy'
type BrandCenterAccessIdentity = {
role?: unknown
isSuperAdmin?: unknown
}
const EMPLOYEE_ACCESS_SELECT = {
departmentPath: true,
employmentState: true,
departmentMemberships: {
select: {
departmentPath: true,
sourceDeptId: true,
},
},
} as const
export async function resolveBrandCenterAccessForUser(
userId: string | null | undefined,
identity: BrandCenterAccessIdentity = {},
): Promise<boolean> {
if (hasBrandCenterAccess(identity)) return true
if (!userId) return false
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
dingtalkUserId: true,
employeeBindings: {
where: { status: 'ACTIVE' },
take: 1,
select: {
employeeProfile: { select: EMPLOYEE_ACCESS_SELECT },
},
},
},
})
let employee = user?.employeeBindings[0]?.employeeProfile ?? null
if (!employee && user?.dingtalkUserId) {
employee = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { dingtalkUserId: user.dingtalkUserId },
select: EMPLOYEE_ACCESS_SELECT,
})
}
if (!employee) return false
const sourceDeptIds = employee.departmentMemberships
.map((membership) => Number(membership.sourceDeptId))
.filter((value) => Number.isInteger(value))
const organizationUnits = sourceDeptIds.length > 0
? await prisma.organizationUnit.findMany({
where: { sourceDeptId: { in: sourceDeptIds }, isActive: true },
select: { path: true },
})
: []
return hasBrandCenterAccess({
...identity,
employmentState: employee.employmentState,
departmentPaths: [
employee.departmentPath,
...employee.departmentMemberships.map((membership) => membership.departmentPath),
...organizationUnits.map((unit) => unit.path),
],
})
}

View File

@ -5,6 +5,7 @@ import {
getAuthSessionCookieCandidates, getAuthSessionCookieCandidates,
resolveSecureAuthCookieFromRequest, resolveSecureAuthCookieFromRequest,
} from '@/lib/auth-cookie' } from '@/lib/auth-cookie'
import { shouldRedirectToStandaloneMaterialLibrary } from '@/lib/brand-center-access-policy'
const API_CORS_ALLOWED_HEADERS = const API_CORS_ALLOWED_HEADERS =
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie' 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie'
@ -92,7 +93,10 @@ export async function middleware(request: NextRequest) {
isDepartmentPage(pathname) || isDepartmentPage(pathname) ||
pathname === '/materials' || pathname === '/materials' ||
pathname.startsWith('/admin') || pathname.startsWith('/admin') ||
pathname.startsWith('/organization') pathname.startsWith('/organization') ||
pathname.startsWith('/performance-survey') ||
pathname.startsWith('/work-review') ||
pathname.startsWith('/teacher')
) { ) {
const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request)
const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie) const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie)
@ -121,6 +125,15 @@ export async function middleware(request: NextRequest) {
return redirectResponse return redirectResponse
} }
if (shouldRedirectToStandaloneMaterialLibrary(pathname, (token as any).canAccessBrandCenter)) {
const url = new URL('/assets/material-library', request.url)
const redirectResponse = NextResponse.redirect(url)
redirectResponse.headers.set('Cache-Control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0')
redirectResponse.headers.set('Pragma', 'no-cache')
redirectResponse.headers.set('Expires', '0')
return redirectResponse
}
// 管理员页面需要管理员权限 // 管理员页面需要管理员权限
if (pathname.startsWith('/admin') || pathname.startsWith('/organization')) { if (pathname.startsWith('/admin') || pathname.startsWith('/organization')) {
const role = String((token as any)?.role || '') const role = String((token as any)?.role || '')
@ -207,5 +220,7 @@ export const config = {
'/dingtalk-entry', '/dingtalk-entry',
'/dingtalk-callback', '/dingtalk-callback',
'/performance-survey/:path*', '/performance-survey/:path*',
'/work-review/:path*',
'/teacher/:path*',
], ],
} }

View File

@ -11,6 +11,7 @@ declare module 'next-auth' {
managedDepartment?: string | null managedDepartment?: string | null
avatar?: string | null avatar?: string | null
isSuperAdmin?: boolean isSuperAdmin?: boolean
canAccessBrandCenter?: boolean
} }
} }
@ -23,6 +24,7 @@ declare module 'next-auth' {
managedDepartment?: string | null managedDepartment?: string | null
avatar?: string | null avatar?: string | null
isSuperAdmin?: boolean isSuperAdmin?: boolean
canAccessBrandCenter?: boolean
} }
} }
@ -34,6 +36,6 @@ declare module 'next-auth/jwt' {
managedDepartment?: string | null managedDepartment?: string | null
avatar?: string | null avatar?: string | null
isSuperAdmin?: boolean isSuperAdmin?: boolean
canAccessBrandCenter?: boolean
} }
} }