feat: 第一阶段框架——登录落点切换为品牌中心看板首页

- 看板首页 / :Zen Minimal × Future Organic 融合(用户裁定),编号索引+柔光光晕+GSAP克制动效,白皮书气质
- 素材库首页整体迁移至 /materials(含colocated测试),全站返回素材库链接同步清扫
- 组织架构页上移为顶层 /organization(保留云采石场契约注释),旧路径 /admin/org 服务端跳转;API 迁移 /api/organization(逻辑零改动)
- 后台删除组织架构跳转按钮(白屏问题源头消失);middleware 增 /materials 登录门、/organization 管理员门
- 品牌统一:浅色顶栏用 loginLogoUrl(深色图案版),标题「由壹点·品牌中心」;字体自托管 @fontsource
- 可访问性收尾(评审fix项):全文对比度过WCAG、焦点环、加载骨架防闪灰、移动端状态可见、::selection
- PRODUCT.md/DESIGN.md 记录方向契约;测试基线 1815 通过/6 既有失败(无劣化)
This commit is contained in:
chenpei 2026-08-15 11:01:27 +08:00
parent 9d23656669
commit f8db4232ce
19 changed files with 2838 additions and 2136 deletions

62
DESIGN.md Normal file
View File

@ -0,0 +1,62 @@
# DESIGN.md — 由壹点·品牌中心(看板首页)
<!-- impeccable:design-schema 1 -->
> 记录自建成代码,非先行规范。方向:用户裁定的 Zen Minimal × Future Organic 融合(2026-08-15)。
## World
「白皮书」:整页是一张编辑排印的品牌白皮书——白纸黑字、发丝线、超大留白;五个板块排成作品集式编号索引;唯一的色彩氛围是右上角一枚缓慢呼吸的柔紫光晕。安静、克制、有底气。
## Color
| 令牌 | 值 | 用途 |
|---|---|---|
| paper | `#fafafa` | 页面底 |
| surface | `#ffffff` | 白卡、悬停行底 |
| ink | `#1a1c1d` | 标题、正文主色、主按钮 |
| body | `#6b7075` | 次级正文(≥4.5:1) |
| faint | `#86898e` | 注脚/微标注/规划态文字(≥4.5:1) |
| ghost-title | `#8f9398` | 规划中板块标题(大字 ≥3:1) |
| hairline | `#e7e8ea` | 全部分割线与卡片边 |
| accent | `#6e79c8` / 深链 `#5a66b8` | 柔光光晕 / 页脚链接 |
| accent-wash | `#eef0fa` | 光晕最浅层、::selection |
| signal-live | `#157a4b` | 已上线状态点 |
| signal-building | `#6e79c8` | 建设中状态点 |
| signal-planned | `#9aa0a8` | 规划中状态点 |
无阴影(平面化);层级靠留白、字重与发丝线。**logo 双版本**:浅底表面用 `loginLogoUrl`(深蓝+橙图案),黑底表面用 `headerLogoUrl`(亮色图案)。
## Type
- 中文标题:Noto Sans SC(`font-display`),600 字重、`-0.03em` 紧字距;首页巨标 `clamp(60px, 9vw, 132px)`
- 工程标注/编号/英文:Space Grotesk(`font-en`),11–13px、大写、`0.14em` 字距
- 正文:系统无衬线 13–16px、行高 1.7
- 自托管:`@fontsource/noto-sans-sc`(700/900 备用)、`@fontsource/space-grotesk`(500/600/700)
## Layout
- 顶栏:56px 高,logo + 「由壹点·品牌中心」+ 板块导航 + 用户区;发丝线下边
- 容器:`max-w-[1280px] px-6`;板块间距 64–96px
- 编号索引:五行,网格 `[64px_1fr_160px_120px_32px]`(移动端 `[44px_1fr_auto]`,状态点保留)
- 已上线板块:两张 12px 圆角白卡(`p-8/10`)承载入口按钮
## Components
- 索引行:已上线 = 整行可点 Link,悬停白底+箭头浮现+编号变紫;规划中 = 灰行不可点
- 按钮:主 = 墨色实心 8px 圆角;次 = 1px 墨边白底,hover 反色
- 状态:7px 圆点 + 12px 文字(移动端不隐藏)
- 焦点:`ring-2 ring-[#1a1c1d]/70 ring-offset-2`,全站交互元素必带
## Motion
GSAP(`useGSAP` + `matchMedia('(prefers-reduced-motion: no-preference)')`):巨字升起 → 光晕淡入 → 索引行 stagger 0.08 → 注脚淡入;光晕 5s 纯 opacity 呼吸循环。全部 0.4–1.2s,信息效率优先(用户既定标准:略丰富但克制)。
## Surfaces
::selection = `#eef0fa`;链接 `underline-offset-4`;光晕容器 `overflow-hidden` 防移动端横向溢出。
## Scope Notes
- 组织架构页(`/organization`)仍为旧「云采石场」语言(BoardShell),待后续按本世界对齐
- 素材库(`/materials`)与旧顶栏保持既有视觉不动

View File

@ -18,11 +18,11 @@ Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prism
## Product Purpose
把「由壹点3D素材库」逐步改造为「品牌中心部门看板」。当前阶段是人员架构地基的界面收尾:让后台界面真实展示 HR 花名册的组织结构(此前界面仍显示旧的 28 个手工部门,与权威组织树不一致)。
把「由壹点3D素材库」逐步改造为「品牌中心部门看板」(对内品牌:**由壹点·品牌中心**,隶属由壹点软件集团)。第一阶段(2026-08-15)已完成:登录落点切换为看板首页 `/`,素材库整体迁至 `/materials`,组织架构上移为顶层 `/organization`(旧路径 `/admin/org` 服务端跳转)。
## Positioning
以钉钉唯一值为唯一人员身份判定依据,统一接入公司 HR 报表库(非钉钉通讯录),人员/组织数据全平台一致;考核调研与素材库共用同一份权威人员主档,业务特殊数据隔离在各自专属表。
以钉钉唯一值为唯一人员身份判定依据,统一接入公司 HR 报表库(非钉钉通讯录),人员/组织数据全平台一致;考核调研与素材库共用同一份权威人员主档,业务特殊数据隔离在各自专属表。素材库为在产系统,改造期间上传/下载/分享/权限链路不动。
## Operating Context
@ -41,8 +41,10 @@ Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prism
## Brand Commitments
- 平台名:由壹点3D素材库(改造中为品牌中心部门看板)。
- **视觉方向(2026-08-14 由用户从概念骰子中选定):「云采石场」**——浅色工业风,蓝白主调、石材切割纹理、工程数据卡片、层叠结构。首个应用面:组织架构页;确认后逐步推广到全平台。用户选择替代脚本分配的"深海潜水"方向。
- 平台名:由壹点·品牌中心(隶属由壹点软件集团;素材库模块沿用「由壹点3D素材」之名)。
- **首页视觉方向(2026-08-15 用户裁定):Zen Minimal × Future Organic 融合**——白纸(#fafafa)黑字(#1a1c1d)的瑞士编辑排印、发丝线、超大留白、编号索引列表(作品集语言)、右上角唯一柔紫光晕(#6e79c8 系)缓慢呼吸;平面化无阴影,黑实心按钮 8px 圆角。方向源自用户在 Stitch 自生成的候选 3/4 + 作品集文件夹两份 DESIGN.md(Monochrome/Studio Minimalist)。此前的「云采石场」语言仅保留在组织架构页(BoardShell),后续按新方向对齐。
- **logo 双版本规则**:黑底表面(素材库旧顶栏)用 `headerLogoUrl`(亮色图案);浅底表面(看板首页、BoardShell)用 `loginLogoUrl`(深蓝+橙图案)。两者内容不同,不可混用。
- 动效标准:略丰富但克制(用户既定),GSAP + matchMedia(prefers-reduced-motion) + fromTo + clearProps,200-500ms,信息效率优先。
## Evidence on Hand

View File

@ -1,532 +1,7 @@
/*
* 方向契约(云采石场 · seed 32cb33b5)
* THESIS: 组织架构页让"人"归位到真实组织树——部门即层叠的石料块,人员是块上的铭牌;
* 拒绝旧后台"平铺列表+堆卡片"的默认布局,用左右结构(左=组织采石剖面,右=人员明细)直接呈现层级与归属。
* OWN-WORLD: 浅色工业风——石材白/灰蓝地面、切割蓝(#4a90d9)唯一强调色、块状层叠卡片(细边框+轻投影)、
* 工程数据用等宽数字、状态用绿/琥珀信号色。
* STORY: 管理员打开页面即看到"公司这座山被切成一层层部门":点哪一层,右侧露出该层成员名单,
* 等级(M/P)、直属主管、在职状态像工程铭牌一样一目了然;未绑定/未确定人员单独成垛,不混入。
* FIRST VIEWPORT: 左 300px 组织树(层叠石料块,高亮当前层,节点带人数);右主区顶部=当前层路径+四格工程统计,
* 下方=人员明细表(花名/工号/职位/等级/主管/状态)。
* FORM: 云采石场方向(用户从概念骰子选定,替代脚本分配的深海潜水)。
* FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
*/
import { redirect } from 'next/navigation'
'use client'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
// ---------- 类型 ----------
interface OrgNode {
id: string
name: string
path: string
level: number
parentId: string | null
count: number
children: OrgNode[]
}
interface EmployeeRow {
userId: string
username: string | null
realName: string | null
displayName: string | null
jobNumber: string | null
departmentPath: string | null
departmentName: string | null
positionTitle: string | null
positionLevel: string | null
levelType: string | null
managerName: string | null
isLeadership: boolean
employmentState: string | null
isActive: boolean
}
interface UnboundUser {
userId: string
username: string
realName: string | null
oldDepartment: string | null
}
interface OrgResponse {
tree: OrgNode[]
employees: EmployeeRow[]
unclassified: EmployeeRow[]
unbound: UnboundUser[]
summary: {
totalEmployees: number
activeEmployees: number
inactiveEmployees: number
unboundCount: number
unclassifiedCount: number
visibleCount: number
scope: string
}
}
const LEVEL_TYPE_LABEL: Record<string, string> = {
M: '管理',
P: '专业',
S: '支持',
}
const STATE_LABEL: Record<string, string> = {
ACTIVE: '在职',
RESIGNED: '离职',
UNKNOWN: '未知',
}
// ---------- 组件 ----------
export default function OrgPage() {
const { data: session, status } = useSession()
const router = useRouter()
const sessionUser = session?.user as
| { role?: string; isSuperAdmin?: boolean; managedDepartment?: string | null }
| undefined
const canAccess = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(sessionUser?.role || '')
const isSuperAdmin = Boolean(sessionUser?.isSuperAdmin)
const [data, setData] = useState<OrgResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [includeInactive, setIncludeInactive] = useState(false)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [selectedGroup, setSelectedGroup] = useState<'org' | 'unclassified' | 'unbound'>('org')
const [keyword, setKeyword] = useState('')
// 权限与重定向
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
if (status === 'authenticated' && !canAccess) {
router.push('/')
}
}, [status, canAccess, router])
// 加载数据
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/admin/org?includeInactive=${includeInactive}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.error || '加载失败')
}
const json = (await res.json()) as OrgResponse
setData(json)
// 默认展开第一层,选中第一个有人的顶层节点
const firstTop = json.tree.find((n) => n.count > 0)
if (firstTop) {
setExpanded(new Set([firstTop.id]))
setSelectedPath(firstTop.path)
}
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [includeInactive])
useEffect(() => {
if (status === 'authenticated' && canAccess) {
void fetchData()
}
}, [status, canAccess, fetchData])
// 展平树为可渲染的行(带缩进深度)
const flatTree = useMemo(() => {
if (!data) return []
const rows: Array<{ node: OrgNode; depth: number }> = []
const walk = (nodes: OrgNode[], depth: number) => {
for (const node of nodes) {
rows.push({ node, depth })
if (expanded.has(node.id) && node.children.length > 0) {
walk(node.children, depth + 1)
}
}
}
walk(data.tree, 0)
return rows
}, [data, expanded])
// 当前选中部门的人员
const selectedEmployees = useMemo(() => {
if (!data) return []
if (selectedGroup === 'unclassified') return data.unclassified
if (selectedGroup === 'unbound') return []
if (!selectedPath) return []
return data.employees.filter((e) => e.departmentPath === selectedPath)
}, [data, selectedPath, selectedGroup])
// 部门负责人计数(当前选中列表)
const leaderCount = selectedEmployees.filter((e) => e.isLeadership).length
// 等级分布(当前选中列表)
const levelCounts = useMemo(() => {
const map = new Map<string, number>()
for (const e of selectedEmployees) {
if (e.positionLevel) map.set(e.positionLevel, (map.get(e.positionLevel) ?? 0) + 1)
}
return Array.from(map.entries()).sort((a, b) => b[1] - a[1])
}, [selectedEmployees])
// 搜索过滤
const filteredEmployees = useMemo(() => {
if (!keyword.trim()) return selectedEmployees
const k = keyword.trim().toLowerCase()
return selectedEmployees.filter(
(e) =>
(e.displayName ?? '').toLowerCase().includes(k) ||
(e.realName ?? '').toLowerCase().includes(k) ||
(e.jobNumber ?? '').toLowerCase().includes(k) ||
(e.positionTitle ?? '').toLowerCase().includes(k),
)
}, [selectedEmployees, keyword])
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const selectNode = (node: OrgNode) => {
setSelectedPath(node.path)
setSelectedGroup('org')
if (!expanded.has(node.id) && node.children.length > 0) {
setExpanded((prev) => new Set(prev).add(node.id))
}
}
if (status === 'loading' || (status === 'authenticated' && !canAccess)) {
return <div className="min-h-screen bg-[#eef1f4]" />
}
return (
<>
{/* direction: cloud-quarry (seed 32cb33b5) — THESIS: 部门即层叠石料块,人员是块上铭牌 */}
<div className="min-h-screen bg-[#eef1f4] text-[#2c3038]">
<Header session={session} />
<div className="max-w-[1400px] mx-auto px-5 py-6">
{/* 页头 */}
<div className="flex items-end justify-between mb-5">
<div>
<h1 className="text-[26px] font-bold text-[#1c3a5e] tracking-tight">组织架构</h1>
<p className="text-[13px] text-[#8a94a0] mt-1">
品牌中心人员全景 · 数据源:HR 花名册(每日 02:00 自动同步)
</p>
</div>
<div className="flex items-center gap-3">
{data && (
<span className="text-[12px] text-[#8a94a0]">
共 <b className="text-[#1c3a5e]">{data.summary.visibleCount}</b> 人
{isSuperAdmin && (
<span className="ml-2">
(在职 {data.summary.activeEmployees} / 含离职 {data.summary.inactiveEmployees})
</span>
)}
</span>
)}
<button
type="button"
onClick={() => setIncludeInactive((v) => !v)}
className={`text-[12px] px-3 py-1.5 rounded border transition-colors ${
includeInactive
? 'bg-[#4a90d9] text-white border-[#4a90d9]'
: 'bg-white text-[#4a90d9] border-[#4a90d9] hover:bg-[#e3ecf5]'
}`}
>
含离职
</button>
</div>
</div>
{error && (
<div className="bg-[#fdf3e3] border border-[#e8c98a] text-[#a0671c] rounded-lg px-4 py-3 text-[13px] mb-4">
{error}
</div>
)}
{loading && !data ? (
<div className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm p-10 text-center text-[#8a94a0] text-sm">
加载中…
</div>
) : data ? (
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-5 items-start">
{/* 左侧:组织树 */}
<aside className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-[#eef0f3] flex items-center justify-between">
<span className="text-[12px] font-semibold text-[#1c3a5e]">组织架构</span>
<span className="text-[11px] text-[#8a94a0]">{data.tree.length} 顶层节点</span>
</div>
<nav className="max-h-[calc(100vh-260px)] overflow-y-auto py-2" aria-label="组织树">
{flatTree.map(({ node, depth }) => {
const hasChildren = node.children.length > 0
const isExpanded = expanded.has(node.id)
const isSelected = selectedGroup === 'org' && selectedPath === node.path
return (
<div key={node.id}>
<button
type="button"
onClick={() => selectNode(node)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
isSelected ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
}`}
style={{ paddingLeft: `${12 + depth * 16}px` }}
>
{hasChildren ? (
<span
role="button"
tabIndex={-1}
aria-label={isExpanded ? '收起' : '展开'}
onClick={(e) => {
e.stopPropagation()
toggleExpand(node.id)
}}
className="shrink-0 cursor-pointer"
>
<svg
className={`w-3 h-3 text-[#8a94a0] transition-transform ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</span>
) : (
<span className="w-3 inline-block" aria-hidden="true" />
)}
<span
className={`text-[13px] truncate ${
isSelected ? 'text-[#1c3a5e] font-semibold' : 'text-[#3c434c]'
}`}
>
{node.name}
</span>
<span className="ml-auto text-[11px] font-medium text-[#4a90d9] tabular-nums">
{node.count}
</span>
</button>
</div>
)
})}
{/* 特殊组 */}
<div className="border-t border-[#eef0f3] mt-2 pt-1">
<button
type="button"
onClick={() => {
setSelectedGroup('unclassified')
setSelectedPath(null)
}}
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
selectedGroup === 'unclassified' ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
}`}
>
<span className="text-[13px] text-[#a0671c]">未确定</span>
<span className="text-[11px] font-medium text-[#a0671c] tabular-nums">
{data.unclassified.length}
</span>
</button>
{isSuperAdmin && (
<button
type="button"
onClick={() => {
setSelectedGroup('unbound')
setSelectedPath(null)
}}
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
selectedGroup === 'unbound' ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
}`}
>
<span className="text-[13px] text-[#a0671c]">未绑定账号</span>
<span className="text-[11px] font-medium text-[#a0671c] tabular-nums">
{data.unbound.length}
</span>
</button>
)}
</div>
</nav>
</aside>
{/* 右侧:人员明细 */}
<section className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-[#eef0f3]">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
{selectedGroup === 'org' && selectedPath ? (
<>
<span className="text-[11px] bg-[#4a90d9] text-white rounded px-2 py-0.5 font-semibold whitespace-nowrap">
L{selectedPath.split('/').length}
</span>
<span className="text-[14px] font-semibold text-[#1c3a5e] truncate">
{selectedPath.split('/').pop()}
</span>
</>
) : (
<span className="text-[14px] font-semibold text-[#1c3a5e]">
{selectedGroup === 'unclassified' ? '未确定部门' : '未绑定账号'}
</span>
)}
</div>
<div className="relative shrink-0">
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索姓名 / 工号 / 职位"
className="w-40 md:w-52 text-[13px] px-3 py-1.5 rounded border border-[#d5dae0] bg-[#fbfcfd] focus:outline-none focus:border-[#4a90d9] focus:ring-2 focus:ring-[#e3ecf5]"
/>
</div>
</div>
{selectedGroup === 'org' && selectedPath && (
<p className="text-[12px] text-[#8a94a0] mt-1.5 truncate">{selectedPath}</p>
)}
</div>
{/* 统计行 */}
{selectedGroup === 'org' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 px-5 py-4 border-b border-[#eef0f3] bg-[#fbfcfd]">
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">在职</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{leaderCount}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">负责人</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{levelCounts[0]?.[0] ?? '—'}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">最常见等级</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{selectedEmployees.filter((e) => e.managerName).length}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">有主管记录</div>
</div>
</div>
)}
{/* 人员表 */}
{selectedGroup === 'unbound' ? (
<div className="p-5">
<p className="text-[13px] text-[#8a94a0] mb-3">
以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。
</p>
<div className="grid gap-2">
{data.unbound.map((u) => (
<div
key={u.userId}
className="flex items-center justify-between px-4 py-2.5 rounded border border-[#eef0f3] bg-[#fbfcfd]"
>
<div>
<span className="text-[13px] text-[#3c434c] font-medium">
{u.realName ?? u.username}
</span>
<span className="text-[12px] text-[#8a94a0] ml-2">@{u.username}</span>
</div>
<span className="text-[12px] text-[#8a94a0]">{u.oldDepartment || '无部门'}</span>
</div>
))}
</div>
</div>
) : filteredEmployees.length === 0 ? (
<div className="p-10 text-center text-[#8a94a0] text-sm">该部门暂无人员</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-[#e2e6ea] bg-[#f7f8fa]">
<th className="px-5 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">人员</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">工号</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">职位</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">等级</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">直属主管</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">状态</th>
</tr>
</thead>
<tbody>
{filteredEmployees.map((e) => (
<tr key={e.userId} className="border-b border-[#eef0f3] hover:bg-[#fbfcfd]">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<span className="w-7 h-7 rounded-full bg-[#e3ecf5] text-[#1c3a5e] text-[12px] font-semibold flex items-center justify-center">
{(e.displayName ?? e.realName ?? '?').slice(0, 1)}
</span>
<span className="text-[13px] text-[#3c434c] font-medium">
{e.displayName ?? e.realName ?? e.username}
</span>
{e.isLeadership && (
<span className="text-[10px] text-[#1c8a5a] bg-[#e3f5ec] rounded px-1.5 py-0.5">
负责人
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-[12px] text-[#8a94a0] tabular-nums">{e.jobNumber ?? '—'}</td>
<td className="px-4 py-3 text-[13px] text-[#3c434c]">{e.positionTitle ?? '—'}</td>
<td className="px-4 py-3">
{e.positionLevel ? (
<span
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${
e.levelType === 'M'
? 'bg-[#fdf3e3] text-[#a0671c]'
: 'bg-[#e3ecf5] text-[#1c3a5e]'
}`}
>
{e.positionLevel}
{e.levelType ? ` · ${LEVEL_TYPE_LABEL[e.levelType] ?? e.levelType}` : ''}
</span>
) : (
<span className="text-[12px] text-[#b0b6be]">未记录</span>
)}
</td>
<td className="px-4 py-3 text-[13px] text-[#3c434c]">{e.managerName ?? '—'}</td>
<td className="px-4 py-3">
<span
className={`text-[11px] rounded-full px-2.5 py-0.5 ${
e.employmentState === 'RESIGNED'
? 'text-[#8a94a0] bg-[#eef0f3]'
: e.employmentState === 'UNKNOWN'
? 'text-[#a0671c] bg-[#fdf3e3]'
: 'text-[#1c8a5a] bg-[#e3f5ec]'
}`}
>
{STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
</div>
</div>
</>
)
// 组织架构页已于 2026-08-14 上移为全平台顶层入口 /organization(品牌中心看板·组织管理板块)。
// 旧路径保留服务端跳转,保护已有收藏与历史链接。
export default function AdminOrgRedirectPage() {
redirect('/organization')
}

View File

@ -325,14 +325,6 @@ export default function AdminPage() {
数据统计
</button>
<button
type="button"
onClick={() => router.push('/admin/org')}
className="pb-4 px-4 font-medium transition-colors text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
组织架构
</button>
{canManageSystemTabs && (
<>
<button

View File

@ -562,7 +562,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setInitialFormSignature(currentFormSignature)
setSuccess('素材信息更新成功')
setTimeout(() => {
router.push('/')
router.push('/materials')
}, 1200)
} catch (submitError: any) {
console.error('更新失败:', submitError)
@ -611,7 +611,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => router.push('/')}
onClick={() => router.push('/materials')}
className="min-h-11 rounded-apple border border-gray-300 px-5 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
返回素材库

View File

@ -1,4 +1,9 @@
import type { Metadata, Viewport } from 'next'
import '@fontsource/noto-sans-sc/700.css'
import '@fontsource/noto-sans-sc/900.css'
import '@fontsource/space-grotesk/500.css'
import '@fontsource/space-grotesk/600.css'
import '@fontsource/space-grotesk/700.css'
import './globals.css'
import Providers from './providers'
import { prisma } from '@/lib/prisma'

1589
app/materials/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,9 @@
import { readFileSync } from 'fs'
import path from 'path'
describe('首页视频转码状态轮询边界', () => {
describe('素材库首页视频转码状态轮询边界', () => {
test('轮询只读状态,不得静默触发强制重试写请求', () => {
const source = readFileSync(path.join(process.cwd(), 'app/page.tsx'), 'utf8')
const source = readFileSync(path.join(process.cwd(), 'app/materials/page.tsx'), 'utf8')
const pollBody = source.split('const poll = async () => {')[1]?.split('// 标记为正在轮询')[0]
expect(pollBody).toBeTruthy()
@ -12,7 +12,7 @@ describe('首页视频转码状态轮询边界', () => {
})
test('不能用固定30分钟上限停止仍在排队或处理中的视频', () => {
const source = readFileSync(path.join(process.cwd(), 'app/page.tsx'), 'utf8')
const source = readFileSync(path.join(process.cwd(), 'app/materials/page.tsx'), 'utf8')
expect(source).not.toContain('30 * 60 * 1000')
expect(source).not.toContain('transcodeStartTimes')

531
app/organization/page.tsx Normal file
View File

@ -0,0 +1,531 @@
/*
* 方向契约(云采石场 · seed 32cb33b5)
* THESIS: 组织架构页让"人"归位到真实组织树——部门即层叠的石料块,人员是块上的铭牌;
* 拒绝旧后台"平铺列表+堆卡片"的默认布局,用左右结构(左=组织采石剖面,右=人员明细)直接呈现层级与归属。
* OWN-WORLD: 浅色工业风——石材白/灰蓝地面、切割蓝(#4a90d9)唯一强调色、块状层叠卡片(细边框+轻投影)、
* 工程数据用等宽数字、状态用绿/琥珀信号色。
* STORY: 管理员打开页面即看到"公司这座山被切成一层层部门":点哪一层,右侧露出该层成员名单,
* 等级(M/P)、直属主管、在职状态像工程铭牌一样一目了然;未绑定/未确定人员单独成垛,不混入。
* FIRST VIEWPORT: 左 300px 组织树(层叠石料块,高亮当前层,节点带人数);右主区顶部=当前层路径+四格工程统计,
* 下方=人员明细表(花名/工号/职位/等级/主管/状态)。
* FORM: 云采石场方向(用户从概念骰子选定,替代脚本分配的深海潜水)。
* FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
*/
'use client'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import BoardShell from '@/components/board/BoardShell'
// ---------- 类型 ----------
interface OrgNode {
id: string
name: string
path: string
level: number
parentId: string | null
count: number
children: OrgNode[]
}
interface EmployeeRow {
userId: string
username: string | null
realName: string | null
displayName: string | null
jobNumber: string | null
departmentPath: string | null
departmentName: string | null
positionTitle: string | null
positionLevel: string | null
levelType: string | null
managerName: string | null
isLeadership: boolean
employmentState: string | null
isActive: boolean
}
interface UnboundUser {
userId: string
username: string
realName: string | null
oldDepartment: string | null
}
interface OrgResponse {
tree: OrgNode[]
employees: EmployeeRow[]
unclassified: EmployeeRow[]
unbound: UnboundUser[]
summary: {
totalEmployees: number
activeEmployees: number
inactiveEmployees: number
unboundCount: number
unclassifiedCount: number
visibleCount: number
scope: string
}
}
const LEVEL_TYPE_LABEL: Record<string, string> = {
M: '管理',
P: '专业',
S: '支持',
}
const STATE_LABEL: Record<string, string> = {
ACTIVE: '在职',
RESIGNED: '离职',
UNKNOWN: '未知',
}
// ---------- 组件 ----------
export default function OrgPage() {
const { data: session, status } = useSession()
const router = useRouter()
const sessionUser = session?.user as
| { role?: string; isSuperAdmin?: boolean; managedDepartment?: string | null }
| undefined
const canAccess = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(sessionUser?.role || '')
const isSuperAdmin = Boolean(sessionUser?.isSuperAdmin)
const [data, setData] = useState<OrgResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [includeInactive, setIncludeInactive] = useState(false)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [selectedGroup, setSelectedGroup] = useState<'org' | 'unclassified' | 'unbound'>('org')
const [keyword, setKeyword] = useState('')
// 权限与重定向
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
if (status === 'authenticated' && !canAccess) {
router.push('/')
}
}, [status, canAccess, router])
// 加载数据
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/organization?includeInactive=${includeInactive}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.error || '加载失败')
}
const json = (await res.json()) as OrgResponse
setData(json)
// 默认展开第一层,选中第一个有人的顶层节点
const firstTop = json.tree.find((n) => n.count > 0)
if (firstTop) {
setExpanded(new Set([firstTop.id]))
setSelectedPath(firstTop.path)
}
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [includeInactive])
useEffect(() => {
if (status === 'authenticated' && canAccess) {
void fetchData()
}
}, [status, canAccess, fetchData])
// 展平树为可渲染的行(带缩进深度)
const flatTree = useMemo(() => {
if (!data) return []
const rows: Array<{ node: OrgNode; depth: number }> = []
const walk = (nodes: OrgNode[], depth: number) => {
for (const node of nodes) {
rows.push({ node, depth })
if (expanded.has(node.id) && node.children.length > 0) {
walk(node.children, depth + 1)
}
}
}
walk(data.tree, 0)
return rows
}, [data, expanded])
// 当前选中部门的人员
const selectedEmployees = useMemo(() => {
if (!data) return []
if (selectedGroup === 'unclassified') return data.unclassified
if (selectedGroup === 'unbound') return []
if (!selectedPath) return []
return data.employees.filter((e) => e.departmentPath === selectedPath)
}, [data, selectedPath, selectedGroup])
// 部门负责人计数(当前选中列表)
const leaderCount = selectedEmployees.filter((e) => e.isLeadership).length
// 等级分布(当前选中列表)
const levelCounts = useMemo(() => {
const map = new Map<string, number>()
for (const e of selectedEmployees) {
if (e.positionLevel) map.set(e.positionLevel, (map.get(e.positionLevel) ?? 0) + 1)
}
return Array.from(map.entries()).sort((a, b) => b[1] - a[1])
}, [selectedEmployees])
// 搜索过滤
const filteredEmployees = useMemo(() => {
if (!keyword.trim()) return selectedEmployees
const k = keyword.trim().toLowerCase()
return selectedEmployees.filter(
(e) =>
(e.displayName ?? '').toLowerCase().includes(k) ||
(e.realName ?? '').toLowerCase().includes(k) ||
(e.jobNumber ?? '').toLowerCase().includes(k) ||
(e.positionTitle ?? '').toLowerCase().includes(k),
)
}, [selectedEmployees, keyword])
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const selectNode = (node: OrgNode) => {
setSelectedPath(node.path)
setSelectedGroup('org')
if (!expanded.has(node.id) && node.children.length > 0) {
setExpanded((prev) => new Set(prev).add(node.id))
}
}
if (status === 'loading' || (status === 'authenticated' && !canAccess)) {
return <div className="min-h-screen bg-[#eef1f4]" />
}
return (
<>
{/* direction: cloud-quarry (seed 32cb33b5) — THESIS: 部门即层叠石料块,人员是块上铭牌 */}
<BoardShell session={session} active="organization">
<div className="max-w-[1400px] mx-auto px-5 py-6">
{/* 页头 */}
<div className="flex items-end justify-between mb-5">
<div>
<h1 className="text-[26px] font-bold text-[#1c3a5e] tracking-tight">组织架构</h1>
<p className="text-[13px] text-[#8a94a0] mt-1">
品牌中心人员全景 · 数据源:HR 花名册(每日 02:00 自动同步)
</p>
</div>
<div className="flex items-center gap-3">
{data && (
<span className="text-[12px] text-[#8a94a0]">
共 <b className="text-[#1c3a5e]">{data.summary.visibleCount}</b> 人
{isSuperAdmin && (
<span className="ml-2">
(在职 {data.summary.activeEmployees} / 含离职 {data.summary.inactiveEmployees})
</span>
)}
</span>
)}
<button
type="button"
onClick={() => setIncludeInactive((v) => !v)}
className={`text-[12px] px-3 py-1.5 rounded border transition-colors ${
includeInactive
? 'bg-[#4a90d9] text-white border-[#4a90d9]'
: 'bg-white text-[#4a90d9] border-[#4a90d9] hover:bg-[#e3ecf5]'
}`}
>
含离职
</button>
</div>
</div>
{error && (
<div className="bg-[#fdf3e3] border border-[#e8c98a] text-[#a0671c] rounded-lg px-4 py-3 text-[13px] mb-4">
{error}
</div>
)}
{loading && !data ? (
<div className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm p-10 text-center text-[#8a94a0] text-sm">
加载中…
</div>
) : data ? (
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-5 items-start">
{/* 左侧:组织树 */}
<aside className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-[#eef0f3] flex items-center justify-between">
<span className="text-[12px] font-semibold text-[#1c3a5e]">组织架构</span>
<span className="text-[11px] text-[#8a94a0]">{data.tree.length} 顶层节点</span>
</div>
<nav className="max-h-[calc(100vh-260px)] overflow-y-auto py-2" aria-label="组织树">
{flatTree.map(({ node, depth }) => {
const hasChildren = node.children.length > 0
const isExpanded = expanded.has(node.id)
const isSelected = selectedGroup === 'org' && selectedPath === node.path
return (
<div key={node.id}>
<button
type="button"
onClick={() => selectNode(node)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
isSelected ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
}`}
style={{ paddingLeft: `${12 + depth * 16}px` }}
>
{hasChildren ? (
<span
role="button"
tabIndex={-1}
aria-label={isExpanded ? '收起' : '展开'}
onClick={(e) => {
e.stopPropagation()
toggleExpand(node.id)
}}
className="shrink-0 cursor-pointer"
>
<svg
className={`w-3 h-3 text-[#8a94a0] transition-transform ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</span>
) : (
<span className="w-3 inline-block" aria-hidden="true" />
)}
<span
className={`text-[13px] truncate ${
isSelected ? 'text-[#1c3a5e] font-semibold' : 'text-[#3c434c]'
}`}
>
{node.name}
</span>
<span className="ml-auto text-[11px] font-medium text-[#4a90d9] tabular-nums">
{node.count}
</span>
</button>
</div>
)
})}
{/* 特殊组 */}
<div className="border-t border-[#eef0f3] mt-2 pt-1">
<button
type="button"
onClick={() => {
setSelectedGroup('unclassified')
setSelectedPath(null)
}}
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
selectedGroup === 'unclassified' ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
}`}
>
<span className="text-[13px] text-[#a0671c]">未确定</span>
<span className="text-[11px] font-medium text-[#a0671c] tabular-nums">
{data.unclassified.length}
</span>
</button>
{isSuperAdmin && (
<button
type="button"
onClick={() => {
setSelectedGroup('unbound')
setSelectedPath(null)
}}
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
selectedGroup === 'unbound' ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
}`}
>
<span className="text-[13px] text-[#a0671c]">未绑定账号</span>
<span className="text-[11px] font-medium text-[#a0671c] tabular-nums">
{data.unbound.length}
</span>
</button>
)}
</div>
</nav>
</aside>
{/* 右侧:人员明细 */}
<section className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-[#eef0f3]">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
{selectedGroup === 'org' && selectedPath ? (
<>
<span className="text-[11px] bg-[#4a90d9] text-white rounded px-2 py-0.5 font-semibold whitespace-nowrap">
L{selectedPath.split('/').length}
</span>
<span className="text-[14px] font-semibold text-[#1c3a5e] truncate">
{selectedPath.split('/').pop()}
</span>
</>
) : (
<span className="text-[14px] font-semibold text-[#1c3a5e]">
{selectedGroup === 'unclassified' ? '未确定部门' : '未绑定账号'}
</span>
)}
</div>
<div className="relative shrink-0">
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索姓名 / 工号 / 职位"
className="w-40 md:w-52 text-[13px] px-3 py-1.5 rounded border border-[#d5dae0] bg-[#fbfcfd] focus:outline-none focus:border-[#4a90d9] focus:ring-2 focus:ring-[#e3ecf5]"
/>
</div>
</div>
{selectedGroup === 'org' && selectedPath && (
<p className="text-[12px] text-[#8a94a0] mt-1.5 truncate">{selectedPath}</p>
)}
</div>
{/* 统计行 */}
{selectedGroup === 'org' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 px-5 py-4 border-b border-[#eef0f3] bg-[#fbfcfd]">
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">在职</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{leaderCount}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">负责人</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{levelCounts[0]?.[0] ?? '—'}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">最常见等级</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
{selectedEmployees.filter((e) => e.managerName).length}
</div>
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">有主管记录</div>
</div>
</div>
)}
{/* 人员表 */}
{selectedGroup === 'unbound' ? (
<div className="p-5">
<p className="text-[13px] text-[#8a94a0] mb-3">
以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。
</p>
<div className="grid gap-2">
{data.unbound.map((u) => (
<div
key={u.userId}
className="flex items-center justify-between px-4 py-2.5 rounded border border-[#eef0f3] bg-[#fbfcfd]"
>
<div>
<span className="text-[13px] text-[#3c434c] font-medium">
{u.realName ?? u.username}
</span>
<span className="text-[12px] text-[#8a94a0] ml-2">@{u.username}</span>
</div>
<span className="text-[12px] text-[#8a94a0]">{u.oldDepartment || '无部门'}</span>
</div>
))}
</div>
</div>
) : filteredEmployees.length === 0 ? (
<div className="p-10 text-center text-[#8a94a0] text-sm">该部门暂无人员</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-[#e2e6ea] bg-[#f7f8fa]">
<th className="px-5 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">人员</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">工号</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">职位</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">等级</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">直属主管</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">状态</th>
</tr>
</thead>
<tbody>
{filteredEmployees.map((e) => (
<tr key={e.userId} className="border-b border-[#eef0f3] hover:bg-[#fbfcfd]">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<span className="w-7 h-7 rounded-full bg-[#e3ecf5] text-[#1c3a5e] text-[12px] font-semibold flex items-center justify-center">
{(e.displayName ?? e.realName ?? '?').slice(0, 1)}
</span>
<span className="text-[13px] text-[#3c434c] font-medium">
{e.displayName ?? e.realName ?? e.username}
</span>
{e.isLeadership && (
<span className="text-[10px] text-[#1c8a5a] bg-[#e3f5ec] rounded px-1.5 py-0.5">
负责人
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-[12px] text-[#8a94a0] tabular-nums">{e.jobNumber ?? '—'}</td>
<td className="px-4 py-3 text-[13px] text-[#3c434c]">{e.positionTitle ?? '—'}</td>
<td className="px-4 py-3">
{e.positionLevel ? (
<span
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${
e.levelType === 'M'
? 'bg-[#fdf3e3] text-[#a0671c]'
: 'bg-[#e3ecf5] text-[#1c3a5e]'
}`}
>
{e.positionLevel}
{e.levelType ? ` · ${LEVEL_TYPE_LABEL[e.levelType] ?? e.levelType}` : ''}
</span>
) : (
<span className="text-[12px] text-[#b0b6be]">未记录</span>
)}
</td>
<td className="px-4 py-3 text-[13px] text-[#3c434c]">{e.managerName ?? '—'}</td>
<td className="px-4 py-3">
<span
className={`text-[11px] rounded-full px-2.5 py-0.5 ${
e.employmentState === 'RESIGNED'
? 'text-[#8a94a0] bg-[#eef0f3]'
: e.employmentState === 'UNKNOWN'
? 'text-[#a0671c] bg-[#fdf3e3]'
: 'text-[#1c8a5a] bg-[#e3f5ec]'
}`}
>
{STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
</div>
</BoardShell>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -724,7 +724,7 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
<div className="truncate text-[16px] font-bold tracking-tight">考核调研</div>
</div>
<Link
href="/"
href="/materials"
className="shrink-0 text-[12px] font-semibold text-white/85 transition-colors hover:text-white"
aria-label="返回素材库首页"
>
@ -958,14 +958,14 @@ function PerformanceSurveyDashboardHeader({
})}
</nav>
<Link
href="/"
href="/materials"
className="hidden justify-self-end text-[13px] font-semibold text-white/90 transition-colors hover:text-white sm:block"
aria-label="返回素材库首页"
>
由壹点「素材库」
</Link>
<Link
href="/"
href="/materials"
className="justify-self-end text-[12px] font-semibold text-[#b3c5ff] transition-colors hover:text-white sm:hidden"
aria-label="返回素材库首页"
>
@ -5806,7 +5806,7 @@ function VisualCaseSurvey({
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href="/"
href="/materials"
className="rounded-[12px] border border-[#dbe5f2] bg-white px-3 py-2.5 text-[13px] font-semibold text-[#101828]"
>
素材库
@ -6918,7 +6918,7 @@ function SurveyUnavailablePanel({
返回个人看板
</Link>
<Link
href="/"
href="/materials"
className="inline-flex min-h-11 items-center justify-center rounded-md border border-[#dbe5f2] bg-white px-5 text-sm font-semibold text-[#101828]"
>
返回素材库

View File

@ -514,13 +514,13 @@ export default function SharePage() {
? parseApiError(errorPayload)
: await parseFetchError(response)
showToast(error.message, { variant: 'error' })
router.push('/')
router.push('/materials')
}
} catch (error) {
console.error('Error fetching material:', error)
const { message } = parseApiError(error)
showToast(message, { variant: 'error' })
router.push('/')
router.push('/materials')
} finally {
setLoading(false)
}
@ -871,7 +871,7 @@ export default function SharePage() {
sessionStorage.removeItem('home_materials_cache')
showToast('素材已移至回收站', { variant: 'success' })
window.setTimeout(() => router.replace('/'), 500)
window.setTimeout(() => router.replace('/materials'), 500)
} finally {
setDetailActionBusy(null)
}
@ -954,7 +954,7 @@ export default function SharePage() {
router.back()
return
}
router.replace('/')
router.replace('/materials')
}}
onFavorite={() => void handleFavoriteMaterial()}
onShare={() => void handleShareMaterial()}

View File

@ -0,0 +1,174 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { signOut } from 'next-auth/react'
import type { Session } from 'next-auth'
import type { ReactNode } from 'react'
import BrandLogo from '@/components/BrandLogo'
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
/*
* BoardShell —— 品牌中心看板顶层骨架(云采石场 · seed 32cb33b5 世界内扩展)
* 顶栏=白色石材地面上的切割导航:当前板块以切割蓝垫层高亮,规划中板块保持"未开采"不可点状态;
* 供看板骨架首页(/)与组织管理(/organization)共用,后续各板块入住时沿用同一顶栏。
*/
export type BoardSection = 'dashboard' | 'organization' | 'assets' | 'business' | 'governance'
interface BoardShellProps {
session?: Session | null
active: BoardSection
children: ReactNode
}
const ADMIN_ROLES = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN']
const NAV_ITEMS: Array<{
key: BoardSection
label: string
href?: string
planned?: boolean
plannedLabel?: string
adminOnly?: boolean
}> = [
{ key: 'dashboard', label: '中心仪表盘', href: '/' },
{ key: 'organization', label: '组织管理', href: '/organization', adminOnly: true },
{ key: 'assets', label: '资产与AI自动化', href: '/materials' },
{ key: 'business', label: '业务经营', planned: true },
{ key: 'governance', label: '治理与设置', planned: true },
]
export default function BoardShell({ session, active, children }: BoardShellProps) {
const router = useRouter()
// 本壳顶栏为浅色,用浅底适配版 logo(loginLogoUrl);深底版仅用于素材库黑色顶栏
const [brandLogoUrl, setBrandLogoUrl] = useState(DEFAULT_LOGIN_LOGO_URL)
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
setBrandLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL)
}
} catch {
// 设置加载失败时保留默认 logo
}
}
void loadSettings()
}, [])
const sessionUser = session?.user as
| {
role?: string
name?: string | null
realName?: string | null
username?: string | null
}
| undefined
const isAdmin = ADMIN_ROLES.includes(sessionUser?.role || '')
const displayName = sessionUser?.realName || sessionUser?.name || sessionUser?.username || '成员'
const handleSignOut = async () => {
try {
await signOut({ redirect: false })
await new Promise((resolve) => setTimeout(resolve, 100))
} finally {
router.push('/login')
router.refresh()
}
}
const visibleItems = NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin)
const renderNavItem = (item: (typeof visibleItems)[number], compact = false) => {
if (item.planned) {
return (
<span
key={item.key}
aria-disabled="true"
title="规划中,尚未开放"
className={`flex cursor-not-allowed items-center gap-1.5 text-[#6d7683] ${
compact ? 'px-2.5 py-1.5 text-[12px]' : 'px-3 py-2 text-[13px]'
}`}
>
{item.label}
<span className="bg-[#eef0f3] px-1.5 py-0.5 text-[10px]">{item.plannedLabel ?? '规划中'}</span>
</span>
)
}
const isActive = active === item.key
return (
<Link
key={item.key}
href={item.href ?? '/'}
aria-current={isActive ? 'page' : undefined}
className={`transition-colors ${compact ? 'px-2.5 py-1.5 text-[12px]' : 'px-3 py-2 text-[13px]'} ${
isActive
? 'bg-[#e3ecf5] font-semibold text-[#1c3a5e] shadow-[inset_0_-2px_0_#4a90d9]'
: 'text-[#3c434c] hover:bg-[#f3f5f7]'
}`}
>
{item.label}
</Link>
)
}
return (
<div className="min-h-screen bg-[#eef1f4] text-[#2c3038]">
<header className="sticky top-0 z-40 border-b border-[#dfe4e9] bg-white">
<div className="mx-auto flex h-[60px] max-w-[1400px] items-center gap-4 px-5">
<Link
href="/"
className="flex min-w-0 items-center gap-2.5"
aria-label="回到品牌中心看板"
>
<BrandLogo
src={brandLogoUrl}
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
alt=""
width={32}
height={32}
unoptimized
className="h-8 w-8 shrink-0 rounded-md object-cover"
/>
<span className="truncate text-[16px] font-bold tracking-tight text-[#1c3a5e]">
由壹点·品牌中心
</span>
</Link>
{/* 桌面端板块导航 */}
<nav
className="ml-auto hidden items-center gap-1 md:flex"
aria-label="看板板块导航"
>
{visibleItems.map((item) => renderNavItem(item))}
</nav>
<div className="ml-auto flex items-center gap-2.5 md:ml-0">
<span className="hidden max-w-[9rem] truncate text-[12px] text-[#3c434c] sm:inline">
{displayName}
</span>
<button
type="button"
onClick={() => void handleSignOut()}
className="border border-[#d5dae0] px-2.5 py-1.5 text-[12px] text-[#3c434c] transition-colors hover:border-[#4a90d9] hover:text-[#4a90d9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#4a90d9]/40"
>
退出
</button>
</div>
</div>
{/* 移动端板块导航:横向滚动 */}
<nav
className="flex items-center gap-1 overflow-x-auto border-t border-[#eef0f3] px-4 py-1.5 md:hidden"
aria-label="看板板块导航"
>
{visibleItems.map((item) => renderNavItem(item, true))}
</nav>
</header>
<main>{children}</main>
</div>
)
}

View File

@ -78,7 +78,7 @@ export function WorkReviewHeader({
</button>
)}
<Link
href="/"
href="/materials"
className="inline-flex min-h-10 items-center justify-center rounded px-2 text-[12px] font-semibold text-white/80 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white sm:min-h-9 sm:px-3 sm:text-[13px]"
>
返回素材库

View File

@ -77,7 +77,14 @@ export async function middleware(request: NextRequest) {
}
// 处理需要认证的页面
if (pathname === '/' || pathname.startsWith('/admin')) {
// /materials(素材库)与 /organization(组织管理)与首页同级,需登录;
// /organization 沿用管理员角色门(与 /admin 一致)
if (
pathname === '/' ||
pathname === '/materials' ||
pathname.startsWith('/admin') ||
pathname.startsWith('/organization')
) {
const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request)
const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie)
let token = null
@ -106,7 +113,7 @@ export async function middleware(request: NextRequest) {
}
// 管理员页面需要管理员权限
if (pathname.startsWith('/admin')) {
if (pathname.startsWith('/admin') || pathname.startsWith('/organization')) {
const role = String((token as any)?.role || '')
if (!['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)) {
const url = new URL('/', request.url)
@ -145,7 +152,9 @@ export async function middleware(request: NextRequest) {
// 对于 HTML 页面,根据是否有新版本决定缓存策略
if (
pathname === '/' ||
pathname === '/materials' ||
pathname.startsWith('/admin') ||
pathname.startsWith('/organization') ||
pathname.startsWith('/share') ||
pathname.startsWith('/edit') ||
isAuthEntryPage ||
@ -180,6 +189,8 @@ export const config = {
matcher: [
'/',
'/login',
'/materials',
'/organization/:path*',
'/admin/:path*',
'/api/:path*',
'/dingtalk-entry',

20
package-lock.json generated
View File

@ -13,6 +13,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/noto-sans-sc": "^5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
"@gsap/react": "^2.1.2",
"@prisma/client": "^5.22.0",
"@prisma/engines": "^6.18.0",
@ -3477,6 +3479,24 @@
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@fontsource/noto-sans-sc": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz",
"integrity": "sha512-HeqIlGm0+ohOKxZLuHj1qW6r6avHH0OWdKERAcSDI0RQ+MXrteuLKA+M+5eOA8rYy0MFvOR5AT0fQo2rUkye0Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/space-grotesk": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.3.0.tgz",
"integrity": "sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@gsap/react": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@gsap/react/-/react-2.1.2.tgz",

View File

@ -31,6 +31,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/noto-sans-sc": "^5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
"@gsap/react": "^2.1.2",
"@prisma/client": "^5.22.0",
"@prisma/engines": "^6.18.0",

View File

@ -23,6 +23,10 @@ const config: Config = {
900: '#0c4a6e',
},
},
fontFamily: {
display: ['"Noto Sans SC"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
en: ['"Space Grotesk"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
borderRadius: {
'apple': '12px',
},