From f8db4232ce3529b90233a8057427e8de2f7b4339 Mon Sep 17 00:00:00 2001 From: chenpei Date: Sat, 15 Aug 2026 11:01:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AC=AC=E4=B8=80=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E2=80=94=E2=80=94=E7=99=BB=E5=BD=95=E8=90=BD?= =?UTF-8?q?=E7=82=B9=E5=88=87=E6=8D=A2=E4=B8=BA=E5=93=81=E7=89=8C=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E7=9C=8B=E6=9D=BF=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 看板首页 / :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 既有失败(无劣化) --- DESIGN.md | 62 + PRODUCT.md | 10 +- app/admin/org/page.tsx | 535 +---- app/admin/page.tsx | 8 - app/api/{admin/org => organization}/route.ts | 0 app/edit/[id]/page.tsx | 4 +- app/layout.tsx | 5 + app/materials/page.tsx | 1589 +++++++++++++ .../page.video-transcode-poll.test.ts | 6 +- app/organization/page.tsx | 531 +++++ app/page.tsx | 1989 ++++------------- app/performance-survey/[[...path]]/page.tsx | 10 +- app/share/[id]/page.tsx | 8 +- components/board/BoardShell.tsx | 174 ++ components/work-review/WorkReviewHeader.tsx | 2 +- middleware.ts | 15 +- package-lock.json | 20 + package.json | 2 + tailwind.config.ts | 4 + 19 files changed, 2838 insertions(+), 2136 deletions(-) create mode 100644 DESIGN.md rename app/api/{admin/org => organization}/route.ts (100%) create mode 100644 app/materials/page.tsx rename app/{ => materials}/page.video-transcode-poll.test.ts (72%) create mode 100644 app/organization/page.tsx create mode 100644 components/board/BoardShell.tsx diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..fc8c08c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,62 @@ +# DESIGN.md — 由壹点·品牌中心(看板首页) + + + +> 记录自建成代码,非先行规范。方向:用户裁定的 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`)与旧顶栏保持既有视觉不动 diff --git a/PRODUCT.md b/PRODUCT.md index 260490a..fe695d3 100644 --- a/PRODUCT.md +++ b/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 diff --git a/app/admin/org/page.tsx b/app/admin/org/page.tsx index 756f09a..a41b03d 100644 --- a/app/admin/org/page.tsx +++ b/app/admin/org/page.tsx @@ -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 = { - M: '管理', - P: '专业', - S: '支持', -} - -const STATE_LABEL: Record = { - 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(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [includeInactive, setIncludeInactive] = useState(false) - const [expanded, setExpanded] = useState>(new Set()) - const [selectedPath, setSelectedPath] = useState(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() - 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
- } - - return ( - <> - {/* direction: cloud-quarry (seed 32cb33b5) — THESIS: 部门即层叠石料块,人员是块上铭牌 */} -
-
-
- {/* 页头 */} -
-
-

组织架构

-

- 品牌中心人员全景 · 数据源:HR 花名册(每日 02:00 自动同步) -

-
-
- {data && ( - - 共 {data.summary.visibleCount} 人 - {isSuperAdmin && ( - - (在职 {data.summary.activeEmployees} / 含离职 {data.summary.inactiveEmployees}) - - )} - - )} - -
-
- - {error && ( -
- {error} -
- )} - - {loading && !data ? ( -
- 加载中… -
- ) : data ? ( -
- {/* 左侧:组织树 */} - - - {/* 右侧:人员明细 */} -
-
-
-
- {selectedGroup === 'org' && selectedPath ? ( - <> - - L{selectedPath.split('/').length} - - - {selectedPath.split('/').pop()} - - - ) : ( - - {selectedGroup === 'unclassified' ? '未确定部门' : '未绑定账号'} - - )} -
-
- 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]" - /> -
-
- {selectedGroup === 'org' && selectedPath && ( -

{selectedPath}

- )} -
- - {/* 统计行 */} - {selectedGroup === 'org' && ( -
-
-
- {selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length} -
-
在职
-
-
-
- {leaderCount} -
-
负责人
-
-
-
- {levelCounts[0]?.[0] ?? '—'} -
-
最常见等级
-
-
-
- {selectedEmployees.filter((e) => e.managerName).length} -
-
有主管记录
-
-
- )} - - {/* 人员表 */} - {selectedGroup === 'unbound' ? ( -
-

- 以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。 -

-
- {data.unbound.map((u) => ( -
-
- - {u.realName ?? u.username} - - @{u.username} -
- {u.oldDepartment || '无部门'} -
- ))} -
-
- ) : filteredEmployees.length === 0 ? ( -
该部门暂无人员
- ) : ( -
- - - - - - - - - - - - - {filteredEmployees.map((e) => ( - - - - - - - - - ))} - -
人员工号职位等级直属主管状态
-
- - {(e.displayName ?? e.realName ?? '?').slice(0, 1)} - - - {e.displayName ?? e.realName ?? e.username} - - {e.isLeadership && ( - - 负责人 - - )} -
-
{e.jobNumber ?? '—'}{e.positionTitle ?? '—'} - {e.positionLevel ? ( - - {e.positionLevel} - {e.levelType ? ` · ${LEVEL_TYPE_LABEL[e.levelType] ?? e.levelType}` : ''} - - ) : ( - 未记录 - )} - {e.managerName ?? '—'} - - {STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'} - -
-
- )} -
-
- ) : null} -
-
- - ) +// 组织架构页已于 2026-08-14 上移为全平台顶层入口 /organization(品牌中心看板·组织管理板块)。 +// 旧路径保留服务端跳转,保护已有收藏与历史链接。 +export default function AdminOrgRedirectPage() { + redirect('/organization') } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c30cc2d..faa13cc 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -325,14 +325,6 @@ export default function AdminPage() { 数据统计 - - {canManageSystemTabs && ( <> +
+ + {/* Search Bar - 实时搜索 */} + + + {/* 筛选面板组件 */} + + + {/* Active Filters Display */} + {(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && ( +
+
+ 当前筛选: + {searchKeyword && ( + + 搜索: {searchKeyword} + + + )} + {selectedMaterialType && ( + + 素材类型: {selectedMaterialType} + + + )} + {selectedCategory && ( + + 店铺: {selectedCategory} + + + )} + {selectedScene && ( + + 场景类别: {selectedScene} + + + )} + {selectedPlatform && ( + + 平台分类: {selectedPlatform} + + + )} + {selectedProductCategory && ( + + 产品类别: {selectedProductCategory} + + + )} + {selectedMonth && ( + + 月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth} + + + )} + +
+
+ )} + + + {/* Materials Grid - 使用 MaterialGrid 组件 */} + + + {/* 分页+懒加载 */} + {materials.length > 0 && !loading && ( + + )} + + + {/* Upload Modal */} + {showUploadModal && ( + setShowUploadModal(false)} + onSuccess={() => { + setShowUploadModal(false) + // 延迟刷新列表,确保数据库事务全部提交完成(修复批量上传后素材显示不全的问题) + // 延迟1秒,给数据库足够的时间完成所有事务提交 + setTimeout(() => { + fetchMaterials() + }, 1000) + fetchFacets() + }} + /> + )} + + {/* Image Preview Modal */} + {previewImage && ( +
setPreviewImage(null)} + > +
+ +
+ {previewImage.title} e.stopPropagation()} + /> +

{previewImage.title}

+
+
+
+ )} + + {/* Share Notification */} + {showShareNotification && ( + + )} + + {/* 统一的Toast提示 */} + {toast && toast.show && ( + + )} + + {/* Share Modal */} + {showShareModal && shareUrl && ( +
setShowShareModal(false)} + > +
e.stopPropagation()} + > +

分享链接

+
+ {shareUrl} +
+
+ + +
+
+
+ )} + + {/* AI聊天助手 */} + + + ) +} diff --git a/app/page.video-transcode-poll.test.ts b/app/materials/page.video-transcode-poll.test.ts similarity index 72% rename from app/page.video-transcode-poll.test.ts rename to app/materials/page.video-transcode-poll.test.ts index 4f3a23e..c8b09b0 100644 --- a/app/page.video-transcode-poll.test.ts +++ b/app/materials/page.video-transcode-poll.test.ts @@ -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') diff --git a/app/organization/page.tsx b/app/organization/page.tsx new file mode 100644 index 0000000..2b2aaa3 --- /dev/null +++ b/app/organization/page.tsx @@ -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 = { + M: '管理', + P: '专业', + S: '支持', +} + +const STATE_LABEL: Record = { + 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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [includeInactive, setIncludeInactive] = useState(false) + const [expanded, setExpanded] = useState>(new Set()) + const [selectedPath, setSelectedPath] = useState(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() + 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
+ } + + return ( + <> + {/* direction: cloud-quarry (seed 32cb33b5) — THESIS: 部门即层叠石料块,人员是块上铭牌 */} + +
+ {/* 页头 */} +
+
+

组织架构

+

+ 品牌中心人员全景 · 数据源:HR 花名册(每日 02:00 自动同步) +

+
+
+ {data && ( + + 共 {data.summary.visibleCount} 人 + {isSuperAdmin && ( + + (在职 {data.summary.activeEmployees} / 含离职 {data.summary.inactiveEmployees}) + + )} + + )} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {loading && !data ? ( +
+ 加载中… +
+ ) : data ? ( +
+ {/* 左侧:组织树 */} + + + {/* 右侧:人员明细 */} +
+
+
+
+ {selectedGroup === 'org' && selectedPath ? ( + <> + + L{selectedPath.split('/').length} + + + {selectedPath.split('/').pop()} + + + ) : ( + + {selectedGroup === 'unclassified' ? '未确定部门' : '未绑定账号'} + + )} +
+
+ 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]" + /> +
+
+ {selectedGroup === 'org' && selectedPath && ( +

{selectedPath}

+ )} +
+ + {/* 统计行 */} + {selectedGroup === 'org' && ( +
+
+
+ {selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length} +
+
在职
+
+
+
+ {leaderCount} +
+
负责人
+
+
+
+ {levelCounts[0]?.[0] ?? '—'} +
+
最常见等级
+
+
+
+ {selectedEmployees.filter((e) => e.managerName).length} +
+
有主管记录
+
+
+ )} + + {/* 人员表 */} + {selectedGroup === 'unbound' ? ( +
+

+ 以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。 +

+
+ {data.unbound.map((u) => ( +
+
+ + {u.realName ?? u.username} + + @{u.username} +
+ {u.oldDepartment || '无部门'} +
+ ))} +
+
+ ) : filteredEmployees.length === 0 ? ( +
该部门暂无人员
+ ) : ( +
+ + + + + + + + + + + + + {filteredEmployees.map((e) => ( + + + + + + + + + ))} + +
人员工号职位等级直属主管状态
+
+ + {(e.displayName ?? e.realName ?? '?').slice(0, 1)} + + + {e.displayName ?? e.realName ?? e.username} + + {e.isLeadership && ( + + 负责人 + + )} +
+
{e.jobNumber ?? '—'}{e.positionTitle ?? '—'} + {e.positionLevel ? ( + + {e.positionLevel} + {e.levelType ? ` · ${LEVEL_TYPE_LABEL[e.levelType] ?? e.levelType}` : ''} + + ) : ( + 未记录 + )} + {e.managerName ?? '—'} + + {STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'} + +
+
+ )} +
+
+ ) : null} +
+
+ + ) +} diff --git a/app/page.tsx b/app/page.tsx index e3d3f6e..f206a2e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,1589 +1,424 @@ +/* + * 方向契约(用户裁定方向:Zen Minimal × Future Organic 融合 · 2026-08-15) + * THESIS: 看板首页是一张"编辑页"——白纸、黑字、发丝线、超大留白;五个板块排成一列 + * 编号索引(作品集列表语言),悬停整行浮现;唯一的柔光来自一枚有机渐变光晕。 + * 拒绝卡片阵列、拒绝重色块、拒绝装饰;安静得像一本品牌中心的白皮书。 + * OWN-WORLD: 白 #fafafa/#fff、墨 #1a1c1d、正文灰 #6b7075、注脚灰 #86898e、发丝线 #e7e8ea、 + * 柔光紫 #6e79c8 系(唯一彩色氛围)+ 浅晕 #eef0fa、微型大写字距标注、 + * 黑实心按钮 8px 圆角、无阴影平面化;全部文字对比过 WCAG(≥4.5:1 / 大字 ≥3:1)。 + * STORY: 打开即读:巨字「品牌中心」+ 一句矿脉定位;视线沿编号索引下移—— + * 01 组织管理、02 资产是活的(可点,行变黑);03 仪表盘在长;04/05 是待启程的灰行。 + * 两块已上线板块各有一张白卡给入口;页脚是跟踪字距的微型注脚。 + * FIRST VIEWPORT: 极简顶栏(logo+由壹点·品牌中心)→ 超大 600 字重「品牌中心」+ 定位语 → + * 右侧一枚柔光渐变光晕压角 → 下方五条发丝线分隔的编号索引行。 + * FORM: 用户在 Stitch 自生成的候选 3(Zen Minimal)与 4(Future Organic)融合, + * 参考作品集文件夹两份 DESIGN.md(Monochrome/Studio Minimalist)裁定。 + * FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md + */ + 'use client' -import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react' -import { useSession } from 'next-auth/react' -import { useRouter, usePathname } from 'next/navigation' -import UploadModal from '@/components/UploadModal' -import Header from '@/components/Header' -import RankingPanel from '@/components/RankingPanel' -import AIChatWidget from '@/components/AIChatWidget' -import VideoPlayerModal from '@/components/VideoPlayerModal' -import ToastBubble from '@/components/ToastBubble' -import { useToast } from '@/hooks/useToast' -import { parseDownloadError } from '@/lib/download-errors' -import { validateAndTriggerDownload } from '@/lib/client-download' -import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' -import { - normalizeVideoMaterialType, - isVideoMaterialType as isVideoMaterialTypeValue, - shouldPollVideoTranscode, -} from '@/lib/video-material-types' -import { buildPublicMaterialShareUrl } from '@/lib/public-app-url' -import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/material-preview-url' -// 首页拆分组件 -import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' +import { useEffect, useRef, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useSession, signOut } from 'next-auth/react' +import gsap from 'gsap' +import { useGSAP } from '@gsap/react' +import BrandLogo from '@/components/BrandLogo' +import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets' -interface Material { - id: string - userId: string - previewUrl: string - productModel: string - categoryName: string - sceneName: string - platformName?: string // 平台分类 - productCategoryName?: string // 品类分类 - materialType: string - isAiGenerated?: boolean - taskDetailSourceType?: string | null - renderer: string - authorName: string - zipFileName: string - zipFileSize: string | number - downloadCount: number - likeCount: number - isLiked: boolean - isFavorited: boolean // 收藏状态 - createdAt: string - displayImages?: string[] // 展示图片数组(详情页/模特类型) - videoSourceUrl?: string | null // 原始视频URL - videoPreview540Url?: string | null // 720p预览视频URL(字段名保持540Url以兼容数据库) - videoPreviewClipUrl?: string | null // 视频片段URL - videoCoverUrl?: string | null // 视频封面URL - videoProcessingStatus?: string | null // 视频处理状态 - videoPreviewBlocked?: boolean - videoPreviewUnavailableMessage?: string | null - videoPreviewRemainingDays?: number | null - videoDuration?: number | null // 视频时长(秒) - transcodeLastError?: string | null // 转码错误信息 - transcodeAttempts?: number | null // 转码尝试次数 - transcodeProgress?: number | null // 转码进度百分比 - transcodeStage?: string | null // 转码阶段:preview/clip/cover - transcodeQueueWaitingIndex?: number | null // 队列等待索引 - transcodeQueueWaitingTotal?: number | null // 队列等待总数 - transcodeQueueProcessingIds?: string[] // 正在处理的ID列表 - transcodeQueueMaxConcurrent?: number | null // 最大并发数 - user: { - username: string - avatar?: string | null - } +gsap.registerPlugin(useGSAP) + +const ADMIN_ROLES = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'] + +const NAV_ITEMS: Array<{ key: string; label: string; href?: string; planned?: boolean; 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 }, +] + +function StatusDot({ tone }: { tone: 'live' | 'building' | 'planned' }) { + const tones = { live: 'bg-[#157a4b]', building: 'bg-[#6e79c8]', planned: 'bg-[#9aa0a8]' } as const + return