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:
parent
9d23656669
commit
f8db4232ce
62
DESIGN.md
Normal file
62
DESIGN.md
Normal 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`)与旧顶栏保持既有视觉不动
|
||||
10
PRODUCT.md
10
PRODUCT.md
@ -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
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
>
|
||||
返回素材库
|
||||
|
||||
@ -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
1589
app/materials/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
531
app/organization/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1989
app/page.tsx
1989
app/page.tsx
File diff suppressed because it is too large
Load Diff
@ -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]"
|
||||
>
|
||||
返回素材库
|
||||
|
||||
@ -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()}
|
||||
|
||||
174
components/board/BoardShell.tsx
Normal file
174
components/board/BoardShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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]"
|
||||
>
|
||||
返回素材库
|
||||
|
||||
@ -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
20
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user