diff --git a/.impeccable/design.json b/.impeccable/design.json
index 7643b97..517b917 100644
--- a/.impeccable/design.json
+++ b/.impeccable/design.json
@@ -460,11 +460,17 @@
"type": "navigation",
"description": "Department Shell 内的 232px 单项二级栏与真实公司组织架构工作面;不渲染数据来源分组或折叠层。",
"projection": ""
+ },
+ {
+ "name": "Standalone Material Library Shell",
+ "type": "navigation",
+ "description": "品牌中心范围外普通用户使用的中性素材库顶栏;只保留页面定位和退出登录,不提供品牌中心入口。",
+ "projection": ""
}
],
"narrative": {
"northStar": "深色导航轨数据工作台",
- "overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。",
+ "overview": "品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。\n\n它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。",
"keyCharacteristics": [
"272px 固定深色导航轨,桌面可折叠为 80px。",
"72px 白色顶栏与 `#f2f4f7` 冷灰画布组成稳定后台骨架;账户入口固定在侧栏底部。",
@@ -473,7 +479,8 @@
"移动端导航是完整的模态抽屉;内容状态始终诚实标注。",
"自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。",
"数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。",
- "人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。"
+ "人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。",
+ "素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。"
],
"rules": [
{
@@ -516,6 +523,11 @@
"body": "嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。",
"section": "layout"
},
+ {
+ "name": "The Organization Gate Rule",
+ "body": "品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。",
+ "section": "layout"
+ },
{
"name": "The Border-First Rule",
"body": "默认先用 `#e2e8f0` 细边框建立结构;不要用超过 20px 的主表面圆角与大阴影制造漂浮岛。",
@@ -523,14 +535,15 @@
}
],
"dos": [
- "**Do** 让根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。",
+ "**Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。",
"**Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。",
"**Do** 明确区分真实数据、示例值、公式待接入与空数据。",
"**Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。",
"**Do** 让自动化工作流的菜单、`?workflow=` 和右侧真实业务操作面始终同步。",
"**Do** 让嵌入业务的根路径返回以 `target=\"_top\"` 跳出 iframe。",
"**Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。",
- "**Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。"
+ "**Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。",
+ "**Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。"
],
"donts": [
"**Don't** 恢复旧版漂浮岛视觉、门户式白色英雄卡或超过 20px 的主表面圆角。",
@@ -542,7 +555,8 @@
"**Don't** 用入口卡片、复制页面或假数据替代 `/performance-survey` 与 `/work-review` 的真实操作面。",
"**Don't** 让 `href=\"/\"` 在 iframe 内打开并嵌套品牌中心首页。",
"**Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。",
- "**Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。"
+ "**Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。",
+ "**Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。"
]
}
}
diff --git a/DESIGN.md b/DESIGN.md
index 8c949f4..3758db1 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -119,7 +119,7 @@ components:
品牌中心是一套固定导航、快速切换与逐层下钻的内部运营界面。深海军蓝导航轨提供稳定坐标,白色操作顶栏承载全局控制,冷灰画布上以矩形白色工作面板组织真实数据、示例状态与待接入框架。
-它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。
+它保留品牌中心自己的五个职责域、22 个小项入口,以及素材库、自动化工作流、人员架构与数据口径四项已接入业务,不复制参考图中的外部品牌、店铺数据或水印。根路径默认进入核心指标,五域入口由固定侧栏承载;各子页直接呈现数据工作面,不再经过部门总览页。品牌中心架构外的普通用户只进入素材资产库,使用无侧栏、无返回品牌中心入口的独立工作壳。
**Key Characteristics:**
@@ -131,6 +131,7 @@ components:
- 自动化工作流在 Department Shell 内使用 232px 二级业务栏与同源真实操作面,移动端改为顶部两列菜单。
- 数据口径在 Department Shell 内使用 232px 二级栏直接显示“公司组织架构”,右侧复用真实组织树和人员明细,不增加折叠层。
- 人员架构在 Department Shell 内直接复用真实组织工作面,并将组织树根节点限定为“品牌中心”。
+- 素材资产库按权威组织归属选择外壳:品牌中心成员保留 Department Shell,外部普通用户只显示 64px 中性顶栏、素材工作面与退出入口。
## Colors
@@ -188,6 +189,8 @@ components:
桌面端采用固定侧栏工作台:导航轨宽 272px,可折叠为 80px;右侧顶栏高 72px;主内容最大宽度 1600px,使用 16/24/32px 响应式内边距。访问根路径时直接进入核心指标;其余职责域及小项全部通过固定侧栏访问,不额外渲染部门总览工作地图。
+品牌中心工作台访问范围以活跃账号绑定的权威 HR 组织路径为准。系统管理员保留维护通行能力;其余人员只有任一在职组织路径包含精确“品牌中心”节点时才能进入 `/` 与 `/workspaces/*`。范围外人员访问这些地址时服务端跳转至 `/assets/material-library`,素材库仅渲染中性独立顶栏、素材内容和退出登录,不显示品牌中心标识、侧栏、移动抽屉或任何返回入口。
+
1024px 以下隐藏固定侧栏,以左侧模态抽屉替代。抽屉宽度为 `min(86vw, 304px)`,打开时锁定页面滚动、底层内容 `inert`、焦点进入并约束在导航内,支持 Escape 关闭并恢复触发器焦点。390px 页面不得产生全局横向溢出。
自动化工作流在 Department Shell 内容区内再分两层:桌面左侧为 232px 白 / 雾灰二级栏,右侧为 `minmax(0, 1fr)` 的同源实际业务操作面;移动端二级栏移动到内容顶部,以两列菜单呈现。二级栏永久不渲染“自动化工作流”标题、接入数量或独立统计区,只显示“绩效考核调研”“作品过稿看板”两个菜单。`?workflow=performance-survey|work-review` 持久化当前选择并与嵌入内容同步。
@@ -206,6 +209,8 @@ components:
**The Top-Level Return Rule.** 嵌入业务的根路径返回必须跳出 iframe 到顶层浏览上下文,禁止在操作面内再次嵌入品牌中心首页。
+**The Organization Gate Rule.** 品牌中心归属由权威人员绑定与精确组织路径决定;范围外普通用户只能进入素材资产库,品牌中心、组织、管理、考核与过稿工作面均须服务端拦截,视觉隐藏与路由门禁必须同时成立。
+
## Elevation & Depth
系统以边框和底色分层为主,阴影只提供轻微离地感。顶栏与静态数据面板无需强阴影;首页职责域、指标组和悬浮菜单可使用低强度环境阴影。
@@ -258,6 +263,7 @@ components:
- 大职责域按钮只展开/收起;小项链接承担导航,并以 `aria-current="page"` 标识当前位置。
- “人员架构”进入 `/workspaces/organization/people-structure`,在 Department Shell 内复用真实组织工作面并聚焦“品牌中心”;公司级 `/organization` 继续保留全量视图。
- “数据口径”使用单项二级栏直接显示“公司组织架构”;有管理员权限时复用真实组织工作面,无权限时显示明确权限提示。
+- 素材资产库根据会话中的品牌中心访问声明选择外壳;范围外普通用户的独立顶栏只显示“素材资产库”和“退出登录”,不得出现品牌中心链接。
- 移动抽屉使用 `role="dialog"`、`aria-modal="true"` 和完整键盘焦点管理。
### 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:
-- **Do** 让根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell。
+- **Do** 让品牌中心成员从根路径直接进入核心指标,并在框架页、指标页、明细页、加载/异常状态和素材库复用同一 Department Shell;范围外普通用户在素材库使用独立工作壳。
- **Do** 保持 5 个职责域、22 个小项、18 个框架入口,以及素材库、自动化工作流、人员架构和数据口径四项已接入业务的产品真相。
- **Do** 明确区分真实数据、示例值、公式待接入与空数据。
- **Do** 在桌面与 390px 移动宽度验证导航、焦点和横向溢出。
@@ -292,6 +305,7 @@ components:
- **Do** 让嵌入业务的根路径返回以 `target="_top"` 跳出 iframe。
- **Do** 让数据口径直接显示“公司组织架构”并沿用主线管理员权限。
- **Do** 让人员架构只显示“品牌中心”节点、下级组织与对应人员,并沿用主线管理员权限。
+- **Do** 让品牌中心归属声明、服务端重定向和素材库外壳保持一致,并在桌面与移动端验证没有返回品牌中心入口。
### Don't:
@@ -305,3 +319,4 @@ components:
- **Don't** 让 `href="/"` 在 iframe 内打开并嵌套品牌中心首页。
- **Don't** 在数据口径二级栏增加“数据来源”标题、折叠层或占位说明卡。
- **Don't** 让人员架构回退到公司全量组织树,或复制一套脱离主线数据源的人员列表。
+- **Don't** 仅隐藏品牌中心侧栏却继续允许范围外用户通过 `/` 或 `/workspaces/*` 直接进入工作台。
diff --git a/PRODUCT.md b/PRODUCT.md
index ad86f25..19a18e9 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -8,7 +8,7 @@ web
## Users
-品牌中心内部成员、部门负责人和管理者。他们需要从统一入口定位业务、资产、组织与治理相关工作,并按自身权限进入具体工具。
+品牌中心内部成员、部门负责人和管理者需要从统一入口定位业务、资产、组织与治理相关工作;品牌中心架构以外的人员只使用素材资产库,并按自身权限浏览、上传、分享或下载素材,品牌中心工作台、组织、管理、考核与过稿工作面统一重定向回素材库。
## Product Purpose
@@ -24,6 +24,7 @@ web
- “由壹点3D素材”作为“资产与AI自动化”下的二级页面保留完整现有能力。
- “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。
- “人员架构”在部门工作台内复用主线 `/organization` 的真实组织树、人员明细与原权限设置,并只聚焦“品牌中心”节点及其下级人员;公司级 `/organization` 全量视图保持不变。
+- 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。
- “数据口径”使用独立二级栏直接进入“公司组织架构”,在品牌中心外壳内复用 `/organization` 的真实组织树、人员明细与管理员权限。
- 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
@@ -45,6 +46,7 @@ web
- 先让入口与职责边界清楚,再接入数据。
- 核心指标作为默认工作面,固定侧栏负责其它能力导航,具体页面负责完成任务。
- 保持线上业务稳定,本地验证充分后再由用户决定是否提交和部署。
+- 组织归属门禁必须服务端收紧,隐藏导航不能替代路由权限校验。
- 对不可用能力诚实标注状态,不用假数据制造完成感。
## Accessibility & Inclusion
diff --git a/app/assets/material-library/layout.tsx b/app/assets/material-library/layout.tsx
index 7fa9c52..d6fbf2e 100644
--- a/app/assets/material-library/layout.tsx
+++ b/app/assets/material-library/layout.tsx
@@ -1,8 +1,8 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
- title: '由壹点3D素材 | 品牌中心部门看板',
- description: '品牌中心资产与AI自动化素材库',
+ title: '素材资产库 | 由壹点3D素材',
+ description: '浏览、上传、分享与下载素材资产',
}
export default function MaterialLibraryLayout({
diff --git a/app/assets/material-library/page.tsx b/app/assets/material-library/page.tsx
index 424c608..5685e86 100644
--- a/app/assets/material-library/page.tsx
+++ b/app/assets/material-library/page.tsx
@@ -6,7 +6,7 @@ import { useRouter, usePathname } from 'next/navigation'
import Link from 'next/link'
import UploadModal from '@/components/UploadModal'
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 VideoPlayerModal from '@/components/VideoPlayerModal'
import ToastBubble from '@/components/ToastBubble'
@@ -1331,8 +1331,10 @@ export default function MaterialLibraryPage() {
return null
}
+ const canAccessBrandCenter = session.user.canAccessBrandCenter === true
+
return (
-
+
{/* 收藏操作提示气泡 */}
@@ -1344,7 +1346,7 @@ export default function MaterialLibraryPage() {
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
-
+
{/* Header Section */}
@@ -1634,6 +1636,6 @@ export default function MaterialLibraryPage() {
{/* AI聊天助手 */}
-
+
)
}
diff --git a/app/page.tsx b/app/page.tsx
index d1c1723..a76d899 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,11 +1,16 @@
import type { Metadata } from 'next'
+import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
+import { authOptions } from '@/lib/auth'
export const metadata: Metadata = {
title: '核心指标 | 品牌中心部门看板',
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')
}
diff --git a/app/workspaces/layout.tsx b/app/workspaces/layout.tsx
new file mode 100644
index 0000000..999b911
--- /dev/null
+++ b/app/workspaces/layout.tsx
@@ -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
+}
diff --git a/components/material-library/MaterialLibraryAccessShell.test.tsx b/components/material-library/MaterialLibraryAccessShell.test.tsx
new file mode 100644
index 0000000..413ef03
--- /dev/null
+++ b/components/material-library/MaterialLibraryAccessShell.test.tsx
@@ -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 }) =>
{children}
,
+}))
+
+describe('MaterialLibraryAccessShell', () => {
+ beforeEach(() => signOut.mockClear())
+
+ test('品牌中心人员保留部门工作台导航', () => {
+ render(素材列表
)
+
+ expect(screen.getByTestId('department-shell')).toBeInTheDocument()
+ expect(screen.queryByRole('banner', { name: '素材资产库顶栏' })).not.toBeInTheDocument()
+ })
+
+ test('品牌中心外人员只看到独立素材库壳且可以退出', async () => {
+ render(素材列表
)
+
+ 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' })
+ })
+})
diff --git a/components/material-library/MaterialLibraryAccessShell.tsx b/components/material-library/MaterialLibraryAccessShell.tsx
new file mode 100644
index 0000000..8266311
--- /dev/null
+++ b/components/material-library/MaterialLibraryAccessShell.tsx
@@ -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 {children}
+ }
+
+ return (
+
+
+
+
+
+
+
+ 素材资产库
+
+
+
+
+ {children}
+
+ )
+}
diff --git a/lib/__tests__/brand-center-access-policy.test.ts b/lib/__tests__/brand-center-access-policy.test.ts
new file mode 100644
index 0000000..c93c32c
--- /dev/null
+++ b/lib/__tests__/brand-center-access-policy.test.ts
@@ -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)
+ })
+})
diff --git a/lib/__tests__/brand-center-access.test.ts b/lib/__tests__/brand-center-access.test.ts
new file mode 100644
index 0000000..f275702
--- /dev/null
+++ b/lib/__tests__/brand-center-access.test.ts
@@ -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)
+ })
+})
diff --git a/lib/auth.ts b/lib/auth.ts
index 60218f9..5156704 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -4,6 +4,7 @@ import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie'
import { authorizeDingtalkH5UserForNextAuth } from '@/lib/dingtalk-login'
+import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
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.accountStatus = 'active';
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) {
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({
where: { id: String(token.id) },
select: {
@@ -173,6 +182,15 @@ export const authOptions: NextAuthOptions = {
token.isSuperAdmin = currentUser.isSuperAdmin
token.department = currentUser.department || ''
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).department = (token as any).department || '';
(session.user as any).managedDepartment = (token as any).managedDepartment || null;
+ (session.user as any).canAccessBrandCenter = (token as any).canAccessBrandCenter === true;
}
return session;
},
diff --git a/lib/brand-center-access-policy.ts b/lib/brand-center-access-policy.ts
new file mode 100644
index 0000000..46f4de7
--- /dev/null
+++ b/lib/brand-center-access-policy.ts
@@ -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)
+ )
+}
diff --git a/lib/brand-center-access.ts b/lib/brand-center-access.ts
new file mode 100644
index 0000000..1784a8c
--- /dev/null
+++ b/lib/brand-center-access.ts
@@ -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 {
+ 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),
+ ],
+ })
+}
diff --git a/middleware.ts b/middleware.ts
index 0b20065..e8d8ce0 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -5,6 +5,7 @@ import {
getAuthSessionCookieCandidates,
resolveSecureAuthCookieFromRequest,
} from '@/lib/auth-cookie'
+import { shouldRedirectToStandaloneMaterialLibrary } from '@/lib/brand-center-access-policy'
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'
@@ -92,7 +93,10 @@ export async function middleware(request: NextRequest) {
isDepartmentPage(pathname) ||
pathname === '/materials' ||
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 cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie)
@@ -121,6 +125,15 @@ export async function middleware(request: NextRequest) {
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')) {
const role = String((token as any)?.role || '')
@@ -207,5 +220,7 @@ export const config = {
'/dingtalk-entry',
'/dingtalk-callback',
'/performance-survey/:path*',
+ '/work-review/:path*',
+ '/teacher/:path*',
],
}
diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts
index f2429d8..694be30 100644
--- a/types/next-auth.d.ts
+++ b/types/next-auth.d.ts
@@ -11,6 +11,7 @@ declare module 'next-auth' {
managedDepartment?: string | null
avatar?: string | null
isSuperAdmin?: boolean
+ canAccessBrandCenter?: boolean
}
}
@@ -23,6 +24,7 @@ declare module 'next-auth' {
managedDepartment?: string | null
avatar?: string | null
isSuperAdmin?: boolean
+ canAccessBrandCenter?: boolean
}
}
@@ -34,6 +36,6 @@ declare module 'next-auth/jwt' {
managedDepartment?: string | null
avatar?: string | null
isSuperAdmin?: boolean
+ canAccessBrandCenter?: boolean
}
}
-