chenpei 6c6c282203 feat: 全站视觉统一白皮书语言 + 个人档案改造 + 顶栏平台化
视觉统一(Zen Minimal × Future Organic 白皮书语言,全站浅色单主题):
- 登录页:品牌→由壹点·品牌中心,白纸底+发丝线+柔光光晕,黑按钮
- 作品过稿:深灰头→白皮书,返回链接→返回创意资产库
- 绩效考核:桌面+移动双头部换白皮书,标题改绩效考核,返回改链接
- 组织架构:BoardShell+页面 76 处调色板全量替换(#eef1f4→#fafafa 等)
- admin 后台:MaterialsHeader(面包屑治理与设置),Tab 栏墨色高亮
- 深色主题废弃:globals.css 移除媒体查询,tailwind darkMode:'class' 不挂 .dark
- 设计文档同步更新:AGENTS.md UI 基线/DESIGN.md/PRODUCT.md/README.md/design-qa.md

个人档案(原个人中心):
- API 合并权威人员档案(getEmployeeByUserId → 花名/工号/部门/职位/M-P等级/主管/状态)
- 页面白皮书化:权威档案卡(只读 HR 字段)+ 账号区(头像上传)+ 素材/收藏

顶栏平台化:
- MaterialsHeader 双模式:首页(五板块导航+全套工具+菜单)/板块(面包屑)
- 菜单重排:我的档案/绩效考核/作品过稿看板‖更新记录/提交反馈‖治理与设置‖退出
- 菜单头身份行:花名+职位·部门(权威档案)
- 首页排序:01 创意资产库(全员)→ 02 组织管理(管理员)→ 03-05 规划中
- 素材库改名创意资产库,全站同步(索引/导航/卡片/按钮/页脚)
- 排行榜换白皮书(白底发丝线+琥珀前三名)

构建修复:
- tsconfig 排除 output/(本地审计临时文件不再卡类型检查)
- 存储边界门禁豁免 output/(与 .dockerignore 对齐)
2026-08-15 14:16:20 +08:00

532 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 方向契约(云采石场 · seed 32cb33b5)
* THESIS: 组织架构页让"人"归位到真实组织树——部门即层叠的石料块,人员是块上的铭牌;
* 拒绝旧后台"平铺列表+堆卡片"的默认布局,用左右结构(左=组织采石剖面,右=人员明细)直接呈现层级与归属。
* OWN-WORLD: 浅色工业风——石材白/灰蓝地面、切割蓝(#5a66b8)唯一强调色、块状层叠卡片(细边框+轻投影)、
* 工程数据用等宽数字、状态用绿/琥珀信号色。
* 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-[#fafafa]" />
}
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-[#1a1c1d] tracking-tight">组织架构</h1>
<p className="text-[13px] text-[#86898e] mt-1">
品牌中心人员全景 · 数据源:HR 花名册(每日 02:00 自动同步)
</p>
</div>
<div className="flex items-center gap-3">
{data && (
<span className="text-[12px] text-[#86898e]">
共 <b className="text-[#1a1c1d]">{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-[#5a66b8] text-white border-[#5a66b8]'
: 'bg-white text-[#5a66b8] border-[#5a66b8] hover:bg-[#eef0fa]'
}`}
>
含离职
</button>
</div>
</div>
{error && (
<div className="bg-[#fdf6e9] 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-[#e7e8ea] shadow-sm p-10 text-center text-[#86898e] 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-[#e7e8ea] 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-[#1a1c1d]">组织架构</span>
<span className="text-[11px] text-[#86898e]">{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-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
}`}
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-[#86898e] 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-[#1a1c1d] font-semibold' : 'text-[#1a1c1d]'
}`}
>
{node.name}
</span>
<span className="ml-auto text-[11px] font-medium text-[#5a66b8] 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-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
}`}
>
<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-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
}`}
>
<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-[#e7e8ea] 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-[#5a66b8] text-white rounded px-2 py-0.5 font-semibold whitespace-nowrap">
L{selectedPath.split('/').length}
</span>
<span className="text-[14px] font-semibold text-[#1a1c1d] truncate">
{selectedPath.split('/').pop()}
</span>
</>
) : (
<span className="text-[14px] font-semibold text-[#1a1c1d]">
{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-[#f4f5f7] focus:outline-none focus:border-[#5a66b8] focus:ring-2 focus:ring-[#eef0fa]"
/>
</div>
</div>
{selectedGroup === 'org' && selectedPath && (
<p className="text-[12px] text-[#86898e] 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-[#f4f5f7]">
<div>
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
{selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length}
</div>
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">在职</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
{leaderCount}
</div>
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">负责人</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
{levelCounts[0]?.[0] ?? '—'}
</div>
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">最常见等级</div>
</div>
<div>
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
{selectedEmployees.filter((e) => e.managerName).length}
</div>
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">有主管记录</div>
</div>
</div>
)}
{/* 人员表 */}
{selectedGroup === 'unbound' ? (
<div className="p-5">
<p className="text-[13px] text-[#86898e] 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-[#f4f5f7]"
>
<div>
<span className="text-[13px] text-[#1a1c1d] font-medium">
{u.realName ?? u.username}
</span>
<span className="text-[12px] text-[#86898e] ml-2">@{u.username}</span>
</div>
<span className="text-[12px] text-[#86898e]">{u.oldDepartment || '无部门'}</span>
</div>
))}
</div>
</div>
) : filteredEmployees.length === 0 ? (
<div className="p-10 text-center text-[#86898e] text-sm">该部门暂无人员</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-[#e7e8ea] bg-[#f4f5f7]">
<th className="px-5 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">人员</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">工号</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">职位</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">等级</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">直属主管</th>
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">状态</th>
</tr>
</thead>
<tbody>
{filteredEmployees.map((e) => (
<tr key={e.userId} className="border-b border-[#eef0f3] hover:bg-[#f4f5f7]">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<span className="w-7 h-7 rounded-full bg-[#eef0fa] text-[#1a1c1d] text-[12px] font-semibold flex items-center justify-center">
{(e.displayName ?? e.realName ?? '?').slice(0, 1)}
</span>
<span className="text-[13px] text-[#1a1c1d] font-medium">
{e.displayName ?? e.realName ?? e.username}
</span>
{e.isLeadership && (
<span className="text-[10px] text-[#1c8a5a] bg-[#e8f5ee] rounded px-1.5 py-0.5">
负责人
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-[12px] text-[#86898e] tabular-nums">{e.jobNumber ?? '—'}</td>
<td className="px-4 py-3 text-[13px] text-[#1a1c1d]">{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-[#fdf6e9] text-[#a0671c]'
: 'bg-[#eef0fa] text-[#1a1c1d]'
}`}
>
{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-[#1a1c1d]">{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-[#86898e] bg-[#eef0f3]'
: e.employmentState === 'UNKNOWN'
? 'text-[#a0671c] bg-[#fdf6e9]'
: 'text-[#1c8a5a] bg-[#e8f5ee]'
}`}
>
{STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
</div>
</BoardShell>
</>
)
}