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 对齐)
This commit is contained in:
parent
f8db4232ce
commit
6c6c282203
13
AGENTS.md
13
AGENTS.md
@ -94,12 +94,14 @@
|
|||||||
- 如果任务准备上线、范围较大、或存在不确定性,再追加:`release_gate`
|
- 如果任务准备上线、范围较大、或存在不确定性,再追加:`release_gate`
|
||||||
- 只要任务落入高风险边界,`orchestrator` 默认采用更保守的判断和验证策略。
|
- 只要任务落入高风险边界,`orchestrator` 默认采用更保守的判断和验证策略。
|
||||||
|
|
||||||
UI 基线规则:
|
UI 基线规则(2026-08-15 更新):
|
||||||
|
|
||||||
- 当前项目的 UI 统一标准默认以首页为基线。
|
- 平台主体已切换为「由壹点·品牌中心」看板,UI 统一标准以**看板首页**为基线(`app/page.tsx`)。
|
||||||
- 首页对应文件:`app/page.tsx`
|
- 基线语言:Zen Minimal × Future Organic 白皮书风(白纸 #fafafa / 墨字 #1a1c1d / 发丝线 / 编号索引 / 柔紫光晕点缀 / 无阴影平面化),完整令牌见 `DESIGN.md`。
|
||||||
- `ui_auditor` 在审查其它页面时,优先对齐首页的视觉语言、主按钮风格、信息层级、间距节奏和状态反馈。
|
- **全站浅色单主题**:深色主题已废弃(`tailwind darkMode: 'class'` 且不挂 `.dark`,`dark:` 变体全部失效);新代码不得再写 `dark:` 类。
|
||||||
- 如果某个后台页面或特殊业务页面需要偏离首页风格,必须说明原因和影响范围。
|
- logo 双版本:浅底表面用 `loginLogoUrl`(深色图案),黑底遗留表面用 `headerLogoUrl`。
|
||||||
|
- `ui_auditor` 在审查其它页面时,优先对齐上述语言;遗留页面(admin/profile/share/edit 与组织架构页旧壳)按「逐页统一」节奏迁移。
|
||||||
|
- 如果某个后台页面或特殊业务页面需要偏离基线风格,必须说明原因和影响范围。
|
||||||
|
|
||||||
## 5. 前端体验与 Impeccable 规则
|
## 5. 前端体验与 Impeccable 规则
|
||||||
|
|
||||||
@ -124,3 +126,4 @@ UI 基线规则:
|
|||||||
|
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `docs/CODEX_MULTI_AGENTS.md`
|
- `docs/CODEX_MULTI_AGENTS.md`
|
||||||
|
- `.private\gongxiangp2-完整敏感接管清单-2026-08-13.md`
|
||||||
|
|||||||
38
CLAUDE.md
38
CLAUDE.md
@ -63,6 +63,35 @@ npm run lint # ESLint
|
|||||||
- `gongxiangpan-redis`(生产 Redis)
|
- `gongxiangpan-redis`(生产 Redis)
|
||||||
- `youyidian-3d-shadow`(影子环境应用)
|
- `youyidian-3d-shadow`(影子环境应用)
|
||||||
|
|
||||||
|
### ⚠️ 生产实际部署形态(2026-08-14 现场核实,与旧文档不一致,以此为准)
|
||||||
|
|
||||||
|
CLAUDE.md 旧版记的容器名/端口映射与线上对不上,实际是**蓝绿部署 + 独立 nginx 反代**:
|
||||||
|
|
||||||
|
- **应用容器采用蓝绿双实例**:`youyidian-3d-material-blue` / `youyidian-3d-material-green`,同一时刻一个接流量、另一个待命回退。容器**不直接映射宿主机端口**(`3000/tcp` 不外露)。
|
||||||
|
- **流量入口是一个独立的 local-proxy nginx**(不是 TrueNAS 系统自带的那个),监听宿主机 `4320`:
|
||||||
|
- 配置路径:`/mnt/PoolOne/apps/youyidian-3d/local-proxy/youyidian-3d-material/nginx.conf`
|
||||||
|
- 通过 `upstream` 里的容器内网 IP(如 `172.19.0.3:3000`)指向当前生效实例
|
||||||
|
- **切换蓝绿 = 改这个 `upstream` IP + `nginx -s reload`**,`current-container` 文件记录当前指向
|
||||||
|
- 应用挂载:`/mnt/PoolOne/apps/youyidian-3d/uploads:/app/uploads`(uploads 实际路径,注意**不是**部署脚本默认的 `/mnt/PoolOne/youyidian-uploads`)
|
||||||
|
- 应用连的数据库是 `192.168.58.20:5432/lobechat`(宿主机 5432 由 `gongxiangpan-postgres-1` 映射)
|
||||||
|
|
||||||
|
### ⚠️ 大文件分片上传 500 的一个基础设施坑(2026-08-14 已修)
|
||||||
|
|
||||||
|
**现象**:视频等大文件分片上传,chunk 0 就返回 500,前端报"上传块 0 失败,已重试多次"或"分片合并文件必须同时提供 uploadId",但**应用容器日志里查不到该 chunk 请求**——因为请求在 nginx 层就被拦下了。
|
||||||
|
|
||||||
|
**根因**:local-proxy nginx(以 `nobody` 运行)和 TrueNAS 系统 nginx(以 `www-data` 运行)**共用同一个临时缓冲目录 `/var/lib/nginx/body`**。当系统 nginx 重建该目录并锁成 `www-data drwx------` 后,`nobody` 无法写入,10MB 的分片缓冲失败,nginx 直接回 500。`nginx -s reload` 或系统 nginx 重启都可能触发。
|
||||||
|
|
||||||
|
**修复**:给 local-proxy 配独立临时目录,不再和系统 nginx 抢。在 `nginx.conf` 的 `http {}` 块内加:
|
||||||
|
```
|
||||||
|
client_body_temp_path <proxy_dir>/tmp/client_body;
|
||||||
|
proxy_temp_path <proxy_dir>/tmp/proxy;
|
||||||
|
fastcgi_temp_path <proxy_dir>/tmp/fastcgi;
|
||||||
|
uwsgi_temp_path <proxy_dir>/tmp/uwsgi;
|
||||||
|
scgi_temp_path <proxy_dir>/tmp/scgi;
|
||||||
|
```
|
||||||
|
并 `mkdir -p` 这些目录、`chown -R nobody:nogroup`、`chmod 700`,再 reload。诊断时先看 `local-proxy/.../error.log` 有没有 `open() "..." failed (13: Permission denied)`。
|
||||||
|
|
||||||
|
|
||||||
### 上游报表库(人事与组织架构数据源)
|
### 上游报表库(人事与组织架构数据源)
|
||||||
|
|
||||||
⚠️ **重要认知**:花名册**不是**从钉钉通讯录来的。钉钉在本项目中只负责登录鉴权与消息推送,从未调用过通讯录接口。人事权威源是公司报表库。
|
⚠️ **重要认知**:花名册**不是**从钉钉通讯录来的。钉钉在本项目中只负责登录鉴权与消息推送,从未调用过通讯录接口。人事权威源是公司报表库。
|
||||||
@ -392,10 +421,12 @@ app/
|
|||||||
│ ├── external/ # 外部 API 集成
|
│ ├── external/ # 外部 API 集成
|
||||||
│ └── user/ # 用户资料操作
|
│ └── user/ # 用户资料操作
|
||||||
├── [id]/share # 公开分享页面
|
├── [id]/share # 公开分享页面
|
||||||
├── admin/ # 管理员后台页面
|
├── admin/ # 管理员后台页面(素材库管理,旧壳待统一)
|
||||||
├── edit/[id]/ # 素材编辑页面
|
├── edit/[id]/ # 素材编辑页面
|
||||||
├── login/ # 登录页面
|
├── login/ # 登录页面
|
||||||
└── page.tsx # 首页(素材网格)
|
├── materials/ # 创意资产库(原素材库首页,2026-08-15 迁入)
|
||||||
|
├── organization/ # 组织管理(人员架构顶层页,2026-08-15 上移)
|
||||||
|
└── page.tsx # 品牌中心看板首页(登录落点,Zen×Organic 白皮书风)
|
||||||
|
|
||||||
components/
|
components/
|
||||||
├── upload/ # 上传组件
|
├── upload/ # 上传组件
|
||||||
@ -408,7 +439,8 @@ components/
|
|||||||
│ ├── RealPhotoUploader.tsx # 实拍图
|
│ ├── RealPhotoUploader.tsx # 实拍图
|
||||||
│ └── SpecialBatchUploadUI.tsx # 批量上传界面(音乐、视频、3D、品牌VI等)
|
│ └── SpecialBatchUploadUI.tsx # 批量上传界面(音乐、视频、3D、品牌VI等)
|
||||||
├── admin/ # 管理后台组件
|
├── admin/ # 管理后台组件
|
||||||
├── home/ # 首页组件
|
├── board/ # 看板壳组件(BoardShell、MaterialsHeader——新视觉语言)
|
||||||
|
├── home/ # 创意资产库(原首页)组件
|
||||||
└── detail/ # 详情页组件
|
└── detail/ # 详情页组件
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
<!-- impeccable:design-schema 1 -->
|
<!-- impeccable:design-schema 1 -->
|
||||||
|
|
||||||
> 记录自建成代码,非先行规范。方向:用户裁定的 Zen Minimal × Future Organic 融合(2026-08-15)。
|
> 记录自建成代码,非先行规范。方向:用户裁定的 Zen Minimal × Future Organic 融合(2026-08-15)。
|
||||||
|
> **全站浅色单主题**:深色主题已废弃(2026-08-15 用户裁定),`darkMode: 'class'` 且不挂 `.dark`,所有 `dark:` 变体失效;新代码禁止再写 `dark:` 类。遗留 `dark:` 类 inert 保留,随逐页统一清理。
|
||||||
|
|
||||||
## World
|
## World
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,9 @@ Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prism
|
|||||||
|
|
||||||
## Capabilities and Constraints
|
## Capabilities and Constraints
|
||||||
|
|
||||||
- 组织架构页:组织树 + 人员列表,展示 M/P 等级、人员归属(部门路径)、直属主管、在职状态。
|
- 看板首页 `/`:五大板块编号索引(01 创意资产库全员可进 / 02 组织管理仅管理员 / 03-05 规划中如实标灰),入场"对焦"动效,悬停物理重量语言。
|
||||||
|
- 创意资产库 `/materials`:原素材库首页,业务功能不动;顶栏已统一为看板语言(MaterialsHeader,功能与旧顶栏等价)。
|
||||||
|
- 组织架构页 `/organization`:组织树 + 人员列表,展示 M/P 等级、人员归属(部门路径)、直属主管、在职状态。
|
||||||
- 纯展示(只读),不改数据。
|
- 纯展示(只读),不改数据。
|
||||||
- 权限:系统管理员看全部;部门负责人看本部门。
|
- 权限:系统管理员看全部;部门负责人看本部门。
|
||||||
- 离职人员默认隐藏,可切换「含离职」。
|
- 离职人员默认隐藏,可切换「含离职」。
|
||||||
@ -44,6 +46,7 @@ Next.js 14 (App Router) + React 18 + TypeScript + TailwindCSS + PostgreSQL(Prism
|
|||||||
- 平台名:由壹点·品牌中心(隶属由壹点软件集团;素材库模块沿用「由壹点3D素材」之名)。
|
- 平台名:由壹点·品牌中心(隶属由壹点软件集团;素材库模块沿用「由壹点3D素材」之名)。
|
||||||
- **首页视觉方向(2026-08-15 用户裁定):Zen Minimal × Future Organic 融合**——白纸(#fafafa)黑字(#1a1c1d)的瑞士编辑排印、发丝线、超大留白、编号索引列表(作品集语言)、右上角唯一柔紫光晕(#6e79c8 系)缓慢呼吸;平面化无阴影,黑实心按钮 8px 圆角。方向源自用户在 Stitch 自生成的候选 3/4 + 作品集文件夹两份 DESIGN.md(Monochrome/Studio Minimalist)。此前的「云采石场」语言仅保留在组织架构页(BoardShell),后续按新方向对齐。
|
- **首页视觉方向(2026-08-15 用户裁定):Zen Minimal × Future Organic 融合**——白纸(#fafafa)黑字(#1a1c1d)的瑞士编辑排印、发丝线、超大留白、编号索引列表(作品集语言)、右上角唯一柔紫光晕(#6e79c8 系)缓慢呼吸;平面化无阴影,黑实心按钮 8px 圆角。方向源自用户在 Stitch 自生成的候选 3/4 + 作品集文件夹两份 DESIGN.md(Monochrome/Studio Minimalist)。此前的「云采石场」语言仅保留在组织架构页(BoardShell),后续按新方向对齐。
|
||||||
- **logo 双版本规则**:黑底表面(素材库旧顶栏)用 `headerLogoUrl`(亮色图案);浅底表面(看板首页、BoardShell)用 `loginLogoUrl`(深蓝+橙图案)。两者内容不同,不可混用。
|
- **logo 双版本规则**:黑底表面(素材库旧顶栏)用 `headerLogoUrl`(亮色图案);浅底表面(看板首页、BoardShell)用 `loginLogoUrl`(深蓝+橙图案)。两者内容不同,不可混用。
|
||||||
|
- **全站浅色单主题(2026-08-15 裁定)**:深色主题废弃,`tailwind darkMode: 'class'` 且不挂 `.dark`;新代码禁止 `dark:` 类。
|
||||||
- 动效标准:略丰富但克制(用户既定),GSAP + matchMedia(prefers-reduced-motion) + fromTo + clearProps,200-500ms,信息效率优先。
|
- 动效标准:略丰富但克制(用户既定),GSAP + matchMedia(prefers-reduced-motion) + fromTo + clearProps,200-500ms,信息效率优先。
|
||||||
|
|
||||||
## Evidence on Hand
|
## Evidence on Hand
|
||||||
|
|||||||
@ -75,13 +75,15 @@
|
|||||||
```text
|
```text
|
||||||
gongxiangp2/
|
gongxiangp2/
|
||||||
├── app/ # 页面、App Router、API 路由
|
├── app/ # 页面、App Router、API 路由
|
||||||
│ ├── admin/ # 管理后台页面
|
│ ├── admin/ # 管理后台页面(素材库管理,旧壳待统一)
|
||||||
│ ├── api/ # 业务 API
|
│ ├── api/ # 业务 API
|
||||||
│ ├── login/ # 登录页
|
│ ├── login/ # 登录页
|
||||||
|
│ ├── materials/ # 创意资产库(原素材库首页)
|
||||||
|
│ ├── organization/ # 组织管理(人员架构顶层页)
|
||||||
│ ├── register/ # 注册页
|
│ ├── register/ # 注册页
|
||||||
│ ├── share/ # 分享页
|
│ ├── share/ # 分享页
|
||||||
│ └── page.tsx # 首页
|
│ └── page.tsx # 品牌中心看板首页(登录落点)
|
||||||
├── components/ # 通用组件、后台组件、上传组件
|
├── components/ # 通用组件、后台组件、上传组件、看板壳(board/)
|
||||||
├── lib/ # 业务工具、上传/下载/转码/权限逻辑
|
├── lib/ # 业务工具、上传/下载/转码/权限逻辑
|
||||||
├── prisma/ # Prisma schema
|
├── prisma/ # Prisma schema
|
||||||
├── scripts/ # 运维/修复/清理脚本
|
├── scripts/ # 运维/修复/清理脚本
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Header from '@/components/Header'
|
import MaterialsHeader from '@/components/board/MaterialsHeader'
|
||||||
import DepartmentPermissionsTab from '@/components/DepartmentPermissionsTab'
|
import DepartmentPermissionsTab from '@/components/DepartmentPermissionsTab'
|
||||||
import SuperAdminsTab from '@/components/SuperAdminsTab'
|
import SuperAdminsTab from '@/components/SuperAdminsTab'
|
||||||
import MaterialManagement from '@/components/admin/MaterialManagement'
|
import MaterialManagement from '@/components/admin/MaterialManagement'
|
||||||
@ -269,7 +269,7 @@ export default function AdminPage() {
|
|||||||
if (status === 'loading' || loading) {
|
if (status === 'loading' || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
|
<div className="text-xl text-[#86898e]">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -279,8 +279,8 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-[#fafafa]">
|
||||||
<Header session={session} />
|
<MaterialsHeader session={session} sectionLabel="治理与设置" />
|
||||||
|
|
||||||
{/* Toast通知 */}
|
{/* Toast通知 */}
|
||||||
{toast && (
|
{toast && (
|
||||||
@ -295,7 +295,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">系统管理</h1>
|
<h1 className="text-2xl font-semibold text-[#1a1c1d]">治理与设置</h1>
|
||||||
{canManageSystemTabs && (
|
{canManageSystemTabs && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -312,14 +312,14 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-4 overflow-x-auto border-b border-gray-200 dark:border-gray-700 mb-8 [&>button]:shrink-0">
|
<div className="flex gap-4 overflow-x-auto border-b border-[#e7e8ea] mb-8 [&>button]:shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab('dataStats')}
|
onClick={() => setActiveTab('dataStats')}
|
||||||
className={`pb-4 px-4 font-medium transition-colors ${
|
className={`pb-4 px-4 font-medium transition-colors ${
|
||||||
activeTab === 'dataStats'
|
activeTab === 'dataStats'
|
||||||
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
|
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
: 'text-[#6b7075] hover:text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
数据统计
|
数据统计
|
||||||
@ -332,8 +332,8 @@ export default function AdminPage() {
|
|||||||
onClick={() => setActiveTab('materials')}
|
onClick={() => setActiveTab('materials')}
|
||||||
className={`pb-4 px-4 font-medium transition-colors ${
|
className={`pb-4 px-4 font-medium transition-colors ${
|
||||||
activeTab === 'materials'
|
activeTab === 'materials'
|
||||||
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
|
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
: 'text-[#6b7075] hover:text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
素材管理
|
素材管理
|
||||||
@ -344,8 +344,8 @@ export default function AdminPage() {
|
|||||||
onClick={() => setActiveTab('categories')}
|
onClick={() => setActiveTab('categories')}
|
||||||
className={`pb-4 px-4 font-medium transition-colors ${
|
className={`pb-4 px-4 font-medium transition-colors ${
|
||||||
activeTab === 'categories'
|
activeTab === 'categories'
|
||||||
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
|
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
: 'text-[#6b7075] hover:text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
分类管理
|
分类管理
|
||||||
@ -358,8 +358,8 @@ export default function AdminPage() {
|
|||||||
onClick={() => setActiveTab('permissions')}
|
onClick={() => setActiveTab('permissions')}
|
||||||
className={`pb-4 px-4 font-medium transition-colors ${
|
className={`pb-4 px-4 font-medium transition-colors ${
|
||||||
activeTab === 'permissions'
|
activeTab === 'permissions'
|
||||||
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
|
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
: 'text-[#6b7075] hover:text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
部门权限配置
|
部门权限配置
|
||||||
@ -370,8 +370,8 @@ export default function AdminPage() {
|
|||||||
onClick={() => setActiveTab('superAdmins')}
|
onClick={() => setActiveTab('superAdmins')}
|
||||||
className={`pb-4 px-4 font-medium transition-colors ${
|
className={`pb-4 px-4 font-medium transition-colors ${
|
||||||
activeTab === 'superAdmins'
|
activeTab === 'superAdmins'
|
||||||
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
|
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
: 'text-[#6b7075] hover:text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
超级管理员
|
超级管理员
|
||||||
@ -382,8 +382,8 @@ export default function AdminPage() {
|
|||||||
onClick={() => setActiveTab('publicTraffic')}
|
onClick={() => setActiveTab('publicTraffic')}
|
||||||
className={`pb-4 px-4 font-medium transition-colors ${
|
className={`pb-4 px-4 font-medium transition-colors ${
|
||||||
activeTab === 'publicTraffic'
|
activeTab === 'publicTraffic'
|
||||||
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
|
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
: 'text-[#6b7075] hover:text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
公网流量
|
公网流量
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from '@/lib/auth';
|
import { authOptions } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getEmployeeByUserId } from '@/lib/employee-directory';
|
||||||
|
|
||||||
// GET - 获取当前用户信息
|
// GET - 获取当前用户信息(账号字段 + 权威人员档案合并)
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@ -20,7 +21,6 @@ export async function GET(request: NextRequest) {
|
|||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
department: true,
|
|
||||||
realName: true,
|
realName: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@ -31,7 +31,27 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(user);
|
// 权威人员档案(钉钉绑定 → HR 花名册视图);未绑定者为 null
|
||||||
|
const employee = await getEmployeeByUserId(user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...user,
|
||||||
|
// 旧 department 字符串已淘汰,不再返回
|
||||||
|
employee: employee
|
||||||
|
? {
|
||||||
|
displayName: employee.displayName,
|
||||||
|
realName: employee.realName,
|
||||||
|
flowerName: employee.flowerName,
|
||||||
|
jobNumber: employee.jobNumber,
|
||||||
|
departmentPath: employee.departmentPath,
|
||||||
|
positionTitle: employee.positionTitle,
|
||||||
|
positionLevel: employee.positionLevel,
|
||||||
|
levelType: employee.levelType,
|
||||||
|
managerName: employee.managerName,
|
||||||
|
employmentStatus: employee.employmentStatus,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[UserProfile] GET error:', error);
|
console.error('[UserProfile] GET error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -2,20 +2,13 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* 全站浅色单主题(2026-08-15 用户裁定):深色媒体查询已移除,dark: 变体已停用(tailwind darkMode:'class' 且不挂 .dark) */
|
||||||
:root {
|
:root {
|
||||||
--foreground-rgb: 0, 0, 0;
|
--foreground-rgb: 0, 0, 0;
|
||||||
--background-start-rgb: 250, 250, 250;
|
--background-start-rgb: 250, 250, 250;
|
||||||
--background-end-rgb: 255, 255, 255;
|
--background-end-rgb: 255, 255, 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--foreground-rgb: 255, 255, 255;
|
|
||||||
--background-start-rgb: 18, 18, 18;
|
|
||||||
--background-end-rgb: 0, 0, 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: rgb(var(--foreground-rgb));
|
color: rgb(var(--foreground-rgb));
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
@ -18,7 +18,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
// 系统设置
|
// 系统设置
|
||||||
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO_URL)
|
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO_URL)
|
||||||
const [siteName, setSiteName] = useState('由壹点3D素材')
|
|
||||||
|
|
||||||
// 钉钉配置(从环境变量或配置文件获取)
|
// 钉钉配置(从环境变量或配置文件获取)
|
||||||
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
|
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
|
||||||
@ -58,7 +57,6 @@ export default function LoginPage() {
|
|||||||
const settings = await response.json()
|
const settings = await response.json()
|
||||||
const logoUrl = settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL
|
const logoUrl = settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL
|
||||||
setLoginLogoUrl(logoUrl)
|
setLoginLogoUrl(logoUrl)
|
||||||
setSiteName(settings.siteName || '由壹点3D素材')
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载系统设置失败:', error)
|
console.error('加载系统设置失败:', error)
|
||||||
@ -274,27 +272,36 @@ export default function LoginPage() {
|
|||||||
// 这样可以避免iframe的cookie限制问题
|
// 这样可以避免iframe的cookie限制问题
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#fafafa] p-4">
|
||||||
<div className="max-w-md w-full">
|
{/* 柔光渐变光晕(平台首页同款氛围) */}
|
||||||
{/* Logo */}
|
<div
|
||||||
<div className="text-center mb-8">
|
aria-hidden="true"
|
||||||
<div className="flex justify-center mb-4">
|
className="pointer-events-none absolute -top-24 right-[-80px] h-[420px] w-[420px] rounded-full md:h-[520px] md:w-[520px]"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 38% 40%, #eef0fa 0%, #dfe3f6 32%, rgba(224,228,248,0.55) 55%, rgba(250,250,250,0) 72%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
{/* 品牌 */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
<BrandLogo
|
<BrandLogo
|
||||||
src={loginLogoUrl}
|
src={loginLogoUrl}
|
||||||
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
|
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
|
||||||
alt=""
|
alt=""
|
||||||
width={80}
|
width={72}
|
||||||
height={80}
|
height={72}
|
||||||
unoptimized
|
unoptimized
|
||||||
className="rounded-apple shadow-apple"
|
className="rounded-xl border border-[#e7e8ea]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{siteName}</h1>
|
<h1 className="text-[28px] font-semibold tracking-tight text-[#1a1c1d]">由壹点·品牌中心</h1>
|
||||||
<p className="text-gray-600 mt-2">欢迎回来,请登录您的账号</p>
|
<p className="mt-2 text-[13px] text-[#6b7075]">欢迎回来,请登录您的账号</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* 登录表单 */}
|
||||||
<div className="card-apple p-8">
|
<div className="rounded-xl border border-[#e7e8ea] bg-white p-8">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
|
||||||
@ -332,7 +339,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full btn-primary"
|
className="min-h-11 w-full rounded-lg bg-[#1a1c1d] text-[14px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? '登录中...' : '登录'}
|
{loading ? '登录中...' : '登录'}
|
||||||
@ -343,10 +350,10 @@ export default function LoginPage() {
|
|||||||
{/* 分隔线 */}
|
{/* 分隔线 */}
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
<div className="w-full border-t border-[#e7e8ea]"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<div className="relative flex justify-center text-sm">
|
||||||
<span className="px-2 bg-white text-gray-500">或</span>
|
<span className="bg-white px-2 text-[13px] text-[#86898e]">或</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -355,7 +362,7 @@ export default function LoginPage() {
|
|||||||
id="dingtalk-login-btn"
|
id="dingtalk-login-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDingtalkLogin()}
|
onClick={() => handleDingtalkLogin()}
|
||||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl shadow-sm bg-white hover:bg-gray-50 transition-colors"
|
className="flex w-full items-center justify-center gap-3 rounded-lg border border-[#e7e8ea] bg-white px-4 py-3 transition-colors hover:bg-[#fafafa]"
|
||||||
>
|
>
|
||||||
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
|
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
|
||||||
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
|
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } f
|
|||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
import UploadModal from '@/components/UploadModal'
|
import UploadModal from '@/components/UploadModal'
|
||||||
import Header from '@/components/Header'
|
import MaterialsHeader from '@/components/board/MaterialsHeader'
|
||||||
import RankingPanel from '@/components/RankingPanel'
|
import RankingPanel from '@/components/RankingPanel'
|
||||||
import AIChatWidget from '@/components/AIChatWidget'
|
import AIChatWidget from '@/components/AIChatWidget'
|
||||||
import VideoPlayerModal from '@/components/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/VideoPlayerModal'
|
||||||
@ -1282,7 +1282,8 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<Header session={session} />
|
{/* 板块顶栏:品牌中心看板语言(含面包屑/淘宝搜索/内外网状态/性能监控/用户菜单),功能与旧顶栏等价 */}
|
||||||
|
<MaterialsHeader session={session} />
|
||||||
{/* 维护通知横幅已在 Header 中显示 */}
|
{/* 维护通知横幅已在 Header 中显示 */}
|
||||||
|
|
||||||
{/* 收藏操作提示气泡 */}
|
{/* 收藏操作提示气泡 */}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* 方向契约(云采石场 · seed 32cb33b5)
|
* 方向契约(云采石场 · seed 32cb33b5)
|
||||||
* THESIS: 组织架构页让"人"归位到真实组织树——部门即层叠的石料块,人员是块上的铭牌;
|
* THESIS: 组织架构页让"人"归位到真实组织树——部门即层叠的石料块,人员是块上的铭牌;
|
||||||
* 拒绝旧后台"平铺列表+堆卡片"的默认布局,用左右结构(左=组织采石剖面,右=人员明细)直接呈现层级与归属。
|
* 拒绝旧后台"平铺列表+堆卡片"的默认布局,用左右结构(左=组织采石剖面,右=人员明细)直接呈现层级与归属。
|
||||||
* OWN-WORLD: 浅色工业风——石材白/灰蓝地面、切割蓝(#4a90d9)唯一强调色、块状层叠卡片(细边框+轻投影)、
|
* OWN-WORLD: 浅色工业风——石材白/灰蓝地面、切割蓝(#5a66b8)唯一强调色、块状层叠卡片(细边框+轻投影)、
|
||||||
* 工程数据用等宽数字、状态用绿/琥珀信号色。
|
* 工程数据用等宽数字、状态用绿/琥珀信号色。
|
||||||
* STORY: 管理员打开页面即看到"公司这座山被切成一层层部门":点哪一层,右侧露出该层成员名单,
|
* STORY: 管理员打开页面即看到"公司这座山被切成一层层部门":点哪一层,右侧露出该层成员名单,
|
||||||
* 等级(M/P)、直属主管、在职状态像工程铭牌一样一目了然;未绑定/未确定人员单独成垛,不混入。
|
* 等级(M/P)、直属主管、在职状态像工程铭牌一样一目了然;未绑定/未确定人员单独成垛,不混入。
|
||||||
@ -213,7 +213,7 @@ export default function OrgPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'loading' || (status === 'authenticated' && !canAccess)) {
|
if (status === 'loading' || (status === 'authenticated' && !canAccess)) {
|
||||||
return <div className="min-h-screen bg-[#eef1f4]" />
|
return <div className="min-h-screen bg-[#fafafa]" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -224,15 +224,15 @@ export default function OrgPage() {
|
|||||||
{/* 页头 */}
|
{/* 页头 */}
|
||||||
<div className="flex items-end justify-between mb-5">
|
<div className="flex items-end justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[26px] font-bold text-[#1c3a5e] tracking-tight">组织架构</h1>
|
<h1 className="text-[26px] font-bold text-[#1a1c1d] tracking-tight">组织架构</h1>
|
||||||
<p className="text-[13px] text-[#8a94a0] mt-1">
|
<p className="text-[13px] text-[#86898e] mt-1">
|
||||||
品牌中心人员全景 · 数据源:HR 花名册(每日 02:00 自动同步)
|
品牌中心人员全景 · 数据源:HR 花名册(每日 02:00 自动同步)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{data && (
|
{data && (
|
||||||
<span className="text-[12px] text-[#8a94a0]">
|
<span className="text-[12px] text-[#86898e]">
|
||||||
共 <b className="text-[#1c3a5e]">{data.summary.visibleCount}</b> 人
|
共 <b className="text-[#1a1c1d]">{data.summary.visibleCount}</b> 人
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
(在职 {data.summary.activeEmployees} / 含离职 {data.summary.inactiveEmployees})
|
(在职 {data.summary.activeEmployees} / 含离职 {data.summary.inactiveEmployees})
|
||||||
@ -245,8 +245,8 @@ export default function OrgPage() {
|
|||||||
onClick={() => setIncludeInactive((v) => !v)}
|
onClick={() => setIncludeInactive((v) => !v)}
|
||||||
className={`text-[12px] px-3 py-1.5 rounded border transition-colors ${
|
className={`text-[12px] px-3 py-1.5 rounded border transition-colors ${
|
||||||
includeInactive
|
includeInactive
|
||||||
? 'bg-[#4a90d9] text-white border-[#4a90d9]'
|
? 'bg-[#5a66b8] text-white border-[#5a66b8]'
|
||||||
: 'bg-white text-[#4a90d9] border-[#4a90d9] hover:bg-[#e3ecf5]'
|
: 'bg-white text-[#5a66b8] border-[#5a66b8] hover:bg-[#eef0fa]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
含离职
|
含离职
|
||||||
@ -255,22 +255,22 @@ export default function OrgPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-[#fdf3e3] border border-[#e8c98a] text-[#a0671c] rounded-lg px-4 py-3 text-[13px] mb-4">
|
<div className="bg-[#fdf6e9] border border-[#e8c98a] text-[#a0671c] rounded-lg px-4 py-3 text-[13px] mb-4">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<div className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm p-10 text-center text-[#8a94a0] text-sm">
|
<div className="bg-white rounded-lg border border-[#e7e8ea] shadow-sm p-10 text-center text-[#86898e] text-sm">
|
||||||
加载中…
|
加载中…
|
||||||
</div>
|
</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-5 items-start">
|
<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">
|
<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">
|
<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-[12px] font-semibold text-[#1a1c1d]">组织架构</span>
|
||||||
<span className="text-[11px] text-[#8a94a0]">{data.tree.length} 顶层节点</span>
|
<span className="text-[11px] text-[#86898e]">{data.tree.length} 顶层节点</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="max-h-[calc(100vh-260px)] overflow-y-auto py-2" aria-label="组织树">
|
<nav className="max-h-[calc(100vh-260px)] overflow-y-auto py-2" aria-label="组织树">
|
||||||
{flatTree.map(({ node, depth }) => {
|
{flatTree.map(({ node, depth }) => {
|
||||||
@ -283,7 +283,7 @@ export default function OrgPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectNode(node)}
|
onClick={() => selectNode(node)}
|
||||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||||
isSelected ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
|
isSelected ? 'bg-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
|
||||||
}`}
|
}`}
|
||||||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||||
>
|
>
|
||||||
@ -299,7 +299,7 @@ export default function OrgPage() {
|
|||||||
className="shrink-0 cursor-pointer"
|
className="shrink-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3 h-3 text-[#8a94a0] transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
className={`w-3 h-3 text-[#86898e] transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -313,12 +313,12 @@ export default function OrgPage() {
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-[13px] truncate ${
|
className={`text-[13px] truncate ${
|
||||||
isSelected ? 'text-[#1c3a5e] font-semibold' : 'text-[#3c434c]'
|
isSelected ? 'text-[#1a1c1d] font-semibold' : 'text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-[11px] font-medium text-[#4a90d9] tabular-nums">
|
<span className="ml-auto text-[11px] font-medium text-[#5a66b8] tabular-nums">
|
||||||
{node.count}
|
{node.count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -334,7 +334,7 @@ export default function OrgPage() {
|
|||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
|
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
|
||||||
selectedGroup === 'unclassified' ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
|
selectedGroup === 'unclassified' ? 'bg-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[13px] text-[#a0671c]">未确定</span>
|
<span className="text-[13px] text-[#a0671c]">未确定</span>
|
||||||
@ -350,7 +350,7 @@ export default function OrgPage() {
|
|||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
|
className={`w-full flex items-center justify-between px-3 py-2 text-left ${
|
||||||
selectedGroup === 'unbound' ? 'bg-[#e3ecf5]' : 'hover:bg-[#f7f8fa]'
|
selectedGroup === 'unbound' ? 'bg-[#eef0fa]' : 'hover:bg-[#f4f5f7]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[13px] text-[#a0671c]">未绑定账号</span>
|
<span className="text-[13px] text-[#a0671c]">未绑定账号</span>
|
||||||
@ -364,21 +364,21 @@ export default function OrgPage() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 右侧:人员明细 */}
|
{/* 右侧:人员明细 */}
|
||||||
<section className="bg-white rounded-lg border border-[#e2e6ea] shadow-sm overflow-hidden">
|
<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="px-5 py-4 border-b border-[#eef0f3]">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{selectedGroup === 'org' && selectedPath ? (
|
{selectedGroup === 'org' && selectedPath ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-[11px] bg-[#4a90d9] text-white rounded px-2 py-0.5 font-semibold whitespace-nowrap">
|
<span className="text-[11px] bg-[#5a66b8] text-white rounded px-2 py-0.5 font-semibold whitespace-nowrap">
|
||||||
L{selectedPath.split('/').length}
|
L{selectedPath.split('/').length}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px] font-semibold text-[#1c3a5e] truncate">
|
<span className="text-[14px] font-semibold text-[#1a1c1d] truncate">
|
||||||
{selectedPath.split('/').pop()}
|
{selectedPath.split('/').pop()}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[14px] font-semibold text-[#1c3a5e]">
|
<span className="text-[14px] font-semibold text-[#1a1c1d]">
|
||||||
{selectedGroup === 'unclassified' ? '未确定部门' : '未绑定账号'}
|
{selectedGroup === 'unclassified' ? '未确定部门' : '未绑定账号'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -388,41 +388,41 @@ export default function OrgPage() {
|
|||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
placeholder="搜索姓名 / 工号 / 职位"
|
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]"
|
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>
|
||||||
</div>
|
</div>
|
||||||
{selectedGroup === 'org' && selectedPath && (
|
{selectedGroup === 'org' && selectedPath && (
|
||||||
<p className="text-[12px] text-[#8a94a0] mt-1.5 truncate">{selectedPath}</p>
|
<p className="text-[12px] text-[#86898e] mt-1.5 truncate">{selectedPath}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计行 */}
|
{/* 统计行 */}
|
||||||
{selectedGroup === 'org' && (
|
{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 className="grid grid-cols-2 md:grid-cols-4 gap-4 px-5 py-4 border-b border-[#eef0f3] bg-[#f4f5f7]">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
|
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||||||
{selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length}
|
{selectedEmployees.filter((e) => e.employmentState !== 'RESIGNED').length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">在职</div>
|
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">在职</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
|
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||||||
{leaderCount}
|
{leaderCount}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">负责人</div>
|
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">负责人</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
|
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||||||
{levelCounts[0]?.[0] ?? '—'}
|
{levelCounts[0]?.[0] ?? '—'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">最常见等级</div>
|
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">最常见等级</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[22px] font-bold text-[#1c3a5e] tabular-nums leading-none">
|
<div className="text-[22px] font-bold text-[#1a1c1d] tabular-nums leading-none">
|
||||||
{selectedEmployees.filter((e) => e.managerName).length}
|
{selectedEmployees.filter((e) => e.managerName).length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-[#8a94a0] mt-1 tracking-wide">有主管记录</div>
|
<div className="text-[10px] text-[#86898e] mt-1 tracking-wide">有主管记录</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -430,68 +430,68 @@ export default function OrgPage() {
|
|||||||
{/* 人员表 */}
|
{/* 人员表 */}
|
||||||
{selectedGroup === 'unbound' ? (
|
{selectedGroup === 'unbound' ? (
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<p className="text-[13px] text-[#8a94a0] mb-3">
|
<p className="text-[13px] text-[#86898e] mb-3">
|
||||||
以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。
|
以下账号没有钉钉 ID,无法自动绑定权威人员档案,需人工核对后手动绑定。
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{data.unbound.map((u) => (
|
{data.unbound.map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.userId}
|
key={u.userId}
|
||||||
className="flex items-center justify-between px-4 py-2.5 rounded border border-[#eef0f3] bg-[#fbfcfd]"
|
className="flex items-center justify-between px-4 py-2.5 rounded border border-[#eef0f3] bg-[#f4f5f7]"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[13px] text-[#3c434c] font-medium">
|
<span className="text-[13px] text-[#1a1c1d] font-medium">
|
||||||
{u.realName ?? u.username}
|
{u.realName ?? u.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] text-[#8a94a0] ml-2">@{u.username}</span>
|
<span className="text-[12px] text-[#86898e] ml-2">@{u.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[12px] text-[#8a94a0]">{u.oldDepartment || '无部门'}</span>
|
<span className="text-[12px] text-[#86898e]">{u.oldDepartment || '无部门'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : filteredEmployees.length === 0 ? (
|
) : filteredEmployees.length === 0 ? (
|
||||||
<div className="p-10 text-center text-[#8a94a0] text-sm">该部门暂无人员</div>
|
<div className="p-10 text-center text-[#86898e] text-sm">该部门暂无人员</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#e2e6ea] bg-[#f7f8fa]">
|
<tr className="border-b border-[#e7e8ea] bg-[#f4f5f7]">
|
||||||
<th className="px-5 py-2.5 text-[10px] font-semibold text-[#8a94a0] tracking-wider">人员</th>
|
<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-[#8a94a0] 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-[#8a94a0] 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-[#8a94a0] 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-[#8a94a0] 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-[#8a94a0] tracking-wider">状态</th>
|
<th className="px-4 py-2.5 text-[10px] font-semibold text-[#86898e] tracking-wider">状态</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredEmployees.map((e) => (
|
{filteredEmployees.map((e) => (
|
||||||
<tr key={e.userId} className="border-b border-[#eef0f3] hover:bg-[#fbfcfd]">
|
<tr key={e.userId} className="border-b border-[#eef0f3] hover:bg-[#f4f5f7]">
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<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">
|
<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)}
|
{(e.displayName ?? e.realName ?? '?').slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[13px] text-[#3c434c] font-medium">
|
<span className="text-[13px] text-[#1a1c1d] font-medium">
|
||||||
{e.displayName ?? e.realName ?? e.username}
|
{e.displayName ?? e.realName ?? e.username}
|
||||||
</span>
|
</span>
|
||||||
{e.isLeadership && (
|
{e.isLeadership && (
|
||||||
<span className="text-[10px] text-[#1c8a5a] bg-[#e3f5ec] rounded px-1.5 py-0.5">
|
<span className="text-[10px] text-[#1c8a5a] bg-[#e8f5ee] rounded px-1.5 py-0.5">
|
||||||
负责人
|
负责人
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[12px] text-[#8a94a0] tabular-nums">{e.jobNumber ?? '—'}</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-[#3c434c]">{e.positionTitle ?? '—'}</td>
|
<td className="px-4 py-3 text-[13px] text-[#1a1c1d]">{e.positionTitle ?? '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{e.positionLevel ? (
|
{e.positionLevel ? (
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${
|
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${
|
||||||
e.levelType === 'M'
|
e.levelType === 'M'
|
||||||
? 'bg-[#fdf3e3] text-[#a0671c]'
|
? 'bg-[#fdf6e9] text-[#a0671c]'
|
||||||
: 'bg-[#e3ecf5] text-[#1c3a5e]'
|
: 'bg-[#eef0fa] text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{e.positionLevel}
|
{e.positionLevel}
|
||||||
@ -501,15 +501,15 @@ export default function OrgPage() {
|
|||||||
<span className="text-[12px] text-[#b0b6be]">未记录</span>
|
<span className="text-[12px] text-[#b0b6be]">未记录</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[13px] text-[#3c434c]">{e.managerName ?? '—'}</td>
|
<td className="px-4 py-3 text-[13px] text-[#1a1c1d]">{e.managerName ?? '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] rounded-full px-2.5 py-0.5 ${
|
className={`text-[11px] rounded-full px-2.5 py-0.5 ${
|
||||||
e.employmentState === 'RESIGNED'
|
e.employmentState === 'RESIGNED'
|
||||||
? 'text-[#8a94a0] bg-[#eef0f3]'
|
? 'text-[#86898e] bg-[#eef0f3]'
|
||||||
: e.employmentState === 'UNKNOWN'
|
: e.employmentState === 'UNKNOWN'
|
||||||
? 'text-[#a0671c] bg-[#fdf3e3]'
|
? 'text-[#a0671c] bg-[#fdf6e9]'
|
||||||
: 'text-[#1c8a5a] bg-[#e3f5ec]'
|
: 'text-[#1c8a5a] bg-[#e8f5ee]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'}
|
{STATE_LABEL[e.employmentState ?? 'UNKNOWN'] ?? '未知'}
|
||||||
|
|||||||
280
app/page.tsx
280
app/page.tsx
@ -18,27 +18,18 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSession, signOut } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { useGSAP } from '@gsap/react'
|
import { useGSAP } from '@gsap/react'
|
||||||
import BrandLogo from '@/components/BrandLogo'
|
import MaterialsHeader from '@/components/board/MaterialsHeader'
|
||||||
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
|
|
||||||
|
|
||||||
gsap.registerPlugin(useGSAP)
|
gsap.registerPlugin(useGSAP)
|
||||||
|
|
||||||
const ADMIN_ROLES = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN']
|
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' }) {
|
function StatusDot({ tone }: { tone: 'live' | 'building' | 'planned' }) {
|
||||||
const tones = { live: 'bg-[#157a4b]', building: 'bg-[#6e79c8]', planned: 'bg-[#9aa0a8]' } as const
|
const tones = { live: 'bg-[#157a4b]', building: 'bg-[#6e79c8]', planned: 'bg-[#9aa0a8]' } as const
|
||||||
return <span aria-hidden="true" className={`inline-block h-[7px] w-[7px] shrink-0 rounded-full ${tones[tone]}`} />
|
return <span aria-hidden="true" className={`inline-block h-[7px] w-[7px] shrink-0 rounded-full ${tones[tone]}`} />
|
||||||
@ -70,29 +61,8 @@ export default function BoardHomePage() {
|
|||||||
const pageRef = useRef<HTMLDivElement>(null)
|
const pageRef = useRef<HTMLDivElement>(null)
|
||||||
const sessionLoading = status === 'loading'
|
const sessionLoading = status === 'loading'
|
||||||
|
|
||||||
const sessionUser = session?.user as
|
const sessionUser = session?.user as { role?: string } | undefined
|
||||||
| { role?: string; name?: string | null; realName?: string | null; username?: string | null }
|
|
||||||
| undefined
|
|
||||||
const isAdmin = ADMIN_ROLES.includes(sessionUser?.role || '')
|
const isAdmin = ADMIN_ROLES.includes(sessionUser?.role || '')
|
||||||
const displayName = sessionUser?.realName || sessionUser?.name || sessionUser?.username || '成员'
|
|
||||||
|
|
||||||
// 品牌设置(logo):本页顶栏为浅色,用浅底适配版 logo(loginLogoUrl),
|
|
||||||
// 深底版 headerLogoUrl 仅用于素材库黑色顶栏
|
|
||||||
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()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
@ -100,19 +70,19 @@ export default function BoardHomePage() {
|
|||||||
}
|
}
|
||||||
}, [status, router])
|
}, [status, router])
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
|
||||||
try {
|
|
||||||
await signOut({ redirect: false })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
} finally {
|
|
||||||
router.push('/login')
|
|
||||||
router.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexRows = [
|
const indexRows = [
|
||||||
{
|
{
|
||||||
no: '01',
|
no: '01',
|
||||||
|
title: '创意资产库',
|
||||||
|
en: 'Creative Assets',
|
||||||
|
tone: 'live' as const,
|
||||||
|
status: '运行中',
|
||||||
|
live: true,
|
||||||
|
href: '/materials',
|
||||||
|
desc: '素材浏览、上传、分享与下载——品牌中心的创意资产都在这里,全部功能照常使用。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: '02',
|
||||||
title: '组织管理',
|
title: '组织管理',
|
||||||
en: 'People & Org',
|
en: 'People & Org',
|
||||||
tone: 'live' as const,
|
tone: 'live' as const,
|
||||||
@ -121,16 +91,6 @@ export default function BoardHomePage() {
|
|||||||
href: '/organization',
|
href: '/organization',
|
||||||
desc: '人员主档以钉钉唯一值绑定 HR 花名册,组织树与在职状态每日 02:00 自动同步。',
|
desc: '人员主档以钉钉唯一值绑定 HR 花名册,组织树与在职状态每日 02:00 自动同步。',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
no: '02',
|
|
||||||
title: '资产与AI自动化',
|
|
||||||
en: 'Assets & AI',
|
|
||||||
tone: 'live' as const,
|
|
||||||
status: '运行中',
|
|
||||||
live: true,
|
|
||||||
href: '/materials',
|
|
||||||
desc: '素材浏览、上传、分享与下载——现有素材库入住本板块,全部功能照常使用。',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
no: '03',
|
no: '03',
|
||||||
title: '中心仪表盘',
|
title: '中心仪表盘',
|
||||||
@ -165,29 +125,67 @@ export default function BoardHomePage() {
|
|||||||
|
|
||||||
const motion = gsap.matchMedia()
|
const motion = gsap.matchMedia()
|
||||||
motion.add('(prefers-reduced-motion: no-preference)', () => {
|
motion.add('(prefers-reduced-motion: no-preference)', () => {
|
||||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
|
const tl = gsap.timeline({ defaults: { ease: 'expo.out' } })
|
||||||
tl.fromTo(
|
tl
|
||||||
'[data-hero]',
|
// 主标题:对焦揭幕——虚焦+宽字距 → 聚焦收拢(重、慢,配 quint 级缓动)
|
||||||
{ y: 30, autoAlpha: 0 },
|
.fromTo(
|
||||||
{ y: 0, autoAlpha: 1, duration: 0.6, clearProps: 'opacity,visibility,transform' },
|
'[data-hero-title]',
|
||||||
|
{ filter: 'blur(16px)', opacity: 0, letterSpacing: '0.16em' },
|
||||||
|
{
|
||||||
|
filter: 'blur(0px)',
|
||||||
|
opacity: 1,
|
||||||
|
letterSpacing: '-0.03em',
|
||||||
|
duration: 1.15,
|
||||||
|
ease: 'power4.out',
|
||||||
|
clearProps: 'filter,opacity,letterSpacing',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
// 柔光:同语言聚焦绽放
|
||||||
.fromTo(
|
.fromTo(
|
||||||
'[data-orb]',
|
'[data-orb]',
|
||||||
{ autoAlpha: 0, scale: 0.92 },
|
{ autoAlpha: 0, scale: 0.9, filter: 'blur(12px)' },
|
||||||
{ autoAlpha: 1, scale: 1, duration: 1.2, ease: 'power2.out' },
|
{ autoAlpha: 1, scale: 1, filter: 'blur(0px)', duration: 1.3, clearProps: 'filter' },
|
||||||
'-=0.3',
|
'-=0.8',
|
||||||
|
)
|
||||||
|
// 定位语
|
||||||
|
.fromTo(
|
||||||
|
'[data-hero]',
|
||||||
|
{ y: 12, autoAlpha: 0 },
|
||||||
|
{ y: 0, autoAlpha: 1, duration: 0.55, clearProps: 'opacity,visibility,transform' },
|
||||||
|
'-=0.85',
|
||||||
|
)
|
||||||
|
// 图纸先行:每行的发丝线依次画出,内容再落位
|
||||||
|
.fromTo(
|
||||||
|
'[data-row-line]',
|
||||||
|
{ scaleX: 0 },
|
||||||
|
{ scaleX: 1, duration: 0.6, stagger: 0.07 },
|
||||||
|
'-=0.55',
|
||||||
)
|
)
|
||||||
.fromTo(
|
.fromTo(
|
||||||
'[data-row]',
|
'[data-row]',
|
||||||
{ y: 16, autoAlpha: 0 },
|
{ y: 14, autoAlpha: 0 },
|
||||||
{ y: 0, autoAlpha: 1, duration: 0.45, stagger: 0.08, clearProps: 'opacity,visibility,transform' },
|
{ y: 0, autoAlpha: 1, duration: 0.55, stagger: 0.07, clearProps: 'opacity,visibility,transform' },
|
||||||
'-=0.35',
|
'-=0.5',
|
||||||
|
)
|
||||||
|
// 白卡:虚焦落位(与标题同语言)
|
||||||
|
.fromTo(
|
||||||
|
'[data-card]',
|
||||||
|
{ y: 20, autoAlpha: 0, filter: 'blur(8px)' },
|
||||||
|
{
|
||||||
|
y: 0,
|
||||||
|
autoAlpha: 1,
|
||||||
|
filter: 'blur(0px)',
|
||||||
|
duration: 0.75,
|
||||||
|
stagger: 0.12,
|
||||||
|
clearProps: 'opacity,visibility,transform,filter',
|
||||||
|
},
|
||||||
|
'-=0.4',
|
||||||
)
|
)
|
||||||
.fromTo(
|
.fromTo(
|
||||||
'[data-foot]',
|
'[data-foot]',
|
||||||
{ autoAlpha: 0 },
|
{ autoAlpha: 0 },
|
||||||
{ autoAlpha: 1, duration: 0.5, clearProps: 'opacity,visibility' },
|
{ autoAlpha: 1, duration: 0.6, clearProps: 'opacity,visibility' },
|
||||||
'-=0.2',
|
'-=0.35',
|
||||||
)
|
)
|
||||||
// 柔光光晕的呼吸(极慢、纯 opacity)
|
// 柔光光晕的呼吸(极慢、纯 opacity)
|
||||||
gsap.to('[data-orb]', { opacity: 0.75, duration: 5, yoyo: true, repeat: -1, ease: 'sine.inOut' })
|
gsap.to('[data-orb]', { opacity: 0.75, duration: 5, yoyo: true, repeat: -1, ease: 'sine.inOut' })
|
||||||
@ -201,56 +199,8 @@ export default function BoardHomePage() {
|
|||||||
<div ref={pageRef} className="board-home min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
<div ref={pageRef} className="board-home min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||||||
<style>{`.board-home ::selection { background: #eef0fa; color: #1a1c1d; }`}</style>
|
<style>{`.board-home ::selection { background: #eef0fa; color: #1a1c1d; }`}</style>
|
||||||
|
|
||||||
{/* ===== 极简顶栏 ===== */}
|
{/* ===== 平台顶栏(首页模式:品牌 + 五大板块导航 + 工具 + 用户菜单) ===== */}
|
||||||
<header className="sticky top-0 z-40 border-b border-[#e7e8ea] bg-[#fafafa]">
|
<MaterialsHeader session={session} />
|
||||||
<div className="mx-auto flex h-14 max-w-[1280px] items-center gap-8 px-6">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className={`flex min-w-0 items-center gap-2.5 rounded-sm text-[14px] font-semibold tracking-tight ${focusRing}`}
|
|
||||||
>
|
|
||||||
<BrandLogo
|
|
||||||
src={brandLogoUrl}
|
|
||||||
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
|
|
||||||
alt=""
|
|
||||||
width={28}
|
|
||||||
height={28}
|
|
||||||
unoptimized
|
|
||||||
className="h-7 w-7 shrink-0 rounded-md object-cover"
|
|
||||||
/>
|
|
||||||
<span>由壹点·品牌中心</span>
|
|
||||||
</Link>
|
|
||||||
<nav className="ml-auto hidden items-center gap-6 md:flex" aria-label="看板板块导航">
|
|
||||||
{NAV_ITEMS.filter((i) => !i.adminOnly || isAdmin).map((item) =>
|
|
||||||
item.planned ? (
|
|
||||||
<span key={item.key} className="text-[13px] text-[#86898e]" title="规划中,尚未开放">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
key={item.key}
|
|
||||||
href={item.href ?? '/'}
|
|
||||||
aria-current={item.key === 'dashboard' ? 'page' : undefined}
|
|
||||||
className={`rounded-sm text-[13px] transition-opacity hover:opacity-60 focus-visible:opacity-60 ${focusRing} ${
|
|
||||||
item.key === 'dashboard' ? 'font-semibold' : 'text-[#6b7075]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
<div className="ml-auto flex items-center gap-4 md:ml-0">
|
|
||||||
<span className="hidden text-[13px] text-[#6b7075] sm:inline">{displayName}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleSignOut()}
|
|
||||||
className={`rounded-sm text-[13px] text-[#6b7075] transition-opacity hover:opacity-60 ${focusRing}`}
|
|
||||||
>
|
|
||||||
退出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-[1280px] px-6">
|
<main className="mx-auto max-w-[1280px] px-6">
|
||||||
{/* ===== 编辑页首:巨字 + 柔光 ===== */}
|
{/* ===== 编辑页首:巨字 + 柔光 ===== */}
|
||||||
@ -265,17 +215,17 @@ export default function BoardHomePage() {
|
|||||||
'radial-gradient(circle at 38% 40%, #eef0fa 0%, #dfe3f6 32%, rgba(224,228,248,0.55) 55%, rgba(250,250,250,0) 72%)',
|
'radial-gradient(circle at 38% 40%, #eef0fa 0%, #dfe3f6 32%, rgba(224,228,248,0.55) 55%, rgba(250,250,250,0) 72%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div data-hero className="relative">
|
<div className="relative">
|
||||||
<p className={labelCls}>由壹点软件集团 · 品牌中心 · 阶段一:地基与骨架</p>
|
{/* 巨型标题:入场对焦揭幕(GSAP blur+tracking),悬停字距呼吸(CSS) */}
|
||||||
<h1 className="mt-5 text-[clamp(60px,9vw,132px)] font-semibold leading-[1.04] tracking-[-0.03em]">
|
<h1
|
||||||
|
data-hero-title
|
||||||
|
className="group cursor-default text-[clamp(60px,9vw,132px)] font-semibold leading-[1.04] tracking-[-0.03em] transition-[letter-spacing] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:tracking-[0.035em]"
|
||||||
|
>
|
||||||
品牌中心
|
品牌中心
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 max-w-xl text-[15px] leading-relaxed text-[#6b7075] md:text-[16px]">
|
<p data-hero className="mt-6 max-w-xl text-[15px] leading-relaxed text-[#6b7075] md:text-[16px]">
|
||||||
品牌的矿脉,从人到资产到经营,统一开采。
|
品牌的矿脉,从人到资产到经营,统一开采。
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 max-w-xl text-[13px] leading-relaxed text-[#6b7075]">
|
|
||||||
人员与组织主档已落成,素材资产在运行,其余板块待启程。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -290,16 +240,31 @@ export default function BoardHomePage() {
|
|||||||
key={row.no}
|
key={row.no}
|
||||||
data-row
|
data-row
|
||||||
href={row.href ?? '#'}
|
href={row.href ?? '#'}
|
||||||
className={`group grid grid-cols-[44px_1fr_auto] items-baseline gap-4 rounded-sm border-b border-[#e7e8ea] py-7 transition-colors hover:bg-white md:grid-cols-[64px_1fr_160px_120px_32px] md:gap-8 md:py-9 ${focusRing}`}
|
className={`group relative grid grid-cols-[44px_1fr_auto] items-baseline gap-4 rounded-sm py-7 transition-colors duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:bg-white md:grid-cols-[64px_1fr_160px_120px_32px] md:gap-8 md:py-9 ${focusRing}`}
|
||||||
>
|
>
|
||||||
<span className="font-en text-[13px] tabular-nums text-[#86898e] transition-colors group-hover:text-[#5a66b8]">
|
{/* 基线发丝线:入场画出,悬停加深 */}
|
||||||
|
<span
|
||||||
|
data-row-line
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 bottom-0 h-px origin-left bg-[#e7e8ea] transition-colors duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:bg-[#d9dcdf]"
|
||||||
|
/>
|
||||||
|
{/* 悬停定位线:从左划出 */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 bottom-[-1px] h-[2px] origin-left scale-x-0 bg-[#1a1c1d] transition-transform duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-x-100"
|
||||||
|
/>
|
||||||
|
<span className="font-en text-[13px] tabular-nums text-[#86898e] transition-all duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-1 group-hover:text-[#5a66b8]">
|
||||||
{row.no}
|
{row.no}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-[24px] font-semibold tracking-tight md:text-[30px]">{row.title}</h2>
|
<h2 className="text-[24px] font-semibold tracking-tight transition-transform duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-1.5 md:text-[30px]">
|
||||||
<p className="mt-1.5 text-[13px] leading-relaxed text-[#6b7075]">{row.desc}</p>
|
{row.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1.5 text-[13px] leading-relaxed text-[#6b7075] transition-all duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-1 group-hover:text-[#4a4f55]">
|
||||||
|
{row.desc}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`font-en hidden text-[11px] uppercase tracking-[0.14em] text-[#86898e] md:inline`}>
|
<span className={`font-en hidden text-[11px] uppercase tracking-[0.14em] text-[#86898e] transition-colors duration-[350ms] group-hover:text-[#6b7075] md:inline`}>
|
||||||
{row.en}
|
{row.en}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@ -308,7 +273,7 @@ export default function BoardHomePage() {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="hidden text-[18px] text-[#1a1c1d] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 md:inline"
|
className="hidden -translate-x-2 text-[18px] text-[#1a1c1d] opacity-0 transition-all duration-[350ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:translate-x-0 group-focus-visible:opacity-100 md:inline"
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
@ -318,8 +283,13 @@ export default function BoardHomePage() {
|
|||||||
key={row.no}
|
key={row.no}
|
||||||
data-row
|
data-row
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
className="grid grid-cols-[44px_1fr_auto] items-baseline gap-4 border-b border-[#e7e8ea] py-7 md:grid-cols-[64px_1fr_160px_120px_32px] md:gap-8 md:py-9"
|
className="relative grid grid-cols-[44px_1fr_auto] items-baseline gap-4 py-7 md:grid-cols-[64px_1fr_160px_120px_32px] md:gap-8 md:py-9"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
data-row-line
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 bottom-0 h-px origin-left bg-[#e7e8ea]"
|
||||||
|
/>
|
||||||
<span className="font-en text-[13px] tabular-nums text-[#86898e]">{row.no}</span>
|
<span className="font-en text-[13px] tabular-nums text-[#86898e]">{row.no}</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-[24px] font-medium tracking-tight text-[#8f9398] md:text-[30px]">
|
<h2 className="text-[24px] font-medium tracking-tight text-[#8f9398] md:text-[30px]">
|
||||||
@ -352,11 +322,30 @@ export default function BoardHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<section className="grid gap-6 py-16 md:grid-cols-2 md:gap-8 md:py-24">
|
<section className="grid gap-6 py-16 md:grid-cols-2 md:gap-8 md:py-24">
|
||||||
<div data-row className="rounded-xl border border-[#e7e8ea] bg-white p-8 md:p-10">
|
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:p-10">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<StatusDot tone="live" />
|
||||||
|
<span className="text-[12px] text-[#6b7075]">运行中</span>
|
||||||
|
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>01 / Assets</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">创意资产库</h3>
|
||||||
|
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
|
||||||
|
素材浏览、上传、分享与下载——创意资产的全部日常都在这里。
|
||||||
|
</p>
|
||||||
|
<div className="mt-7">
|
||||||
|
<Link
|
||||||
|
href="/materials"
|
||||||
|
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
|
||||||
|
>
|
||||||
|
进入创意资产库
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:p-10">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<StatusDot tone="live" />
|
<StatusDot tone="live" />
|
||||||
<span className="text-[12px] text-[#6b7075]">已上线</span>
|
<span className="text-[12px] text-[#6b7075]">已上线</span>
|
||||||
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>01 / People</span>
|
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>02 / People</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">组织管理</h3>
|
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">组织管理</h3>
|
||||||
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
|
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
|
||||||
@ -366,7 +355,7 @@ export default function BoardHomePage() {
|
|||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<Link
|
<Link
|
||||||
href="/organization"
|
href="/organization"
|
||||||
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-opacity hover:opacity-80 ${focusRing}`}
|
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
|
||||||
>
|
>
|
||||||
进入人员架构
|
进入人员架构
|
||||||
</Link>
|
</Link>
|
||||||
@ -377,31 +366,12 @@ export default function BoardHomePage() {
|
|||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/performance-survey"
|
href="/performance-survey"
|
||||||
className={`inline-flex min-h-11 items-center rounded-lg border border-[#1a1c1d] bg-white px-6 text-[13px] font-medium transition-colors hover:bg-[#1a1c1d] hover:text-white ${focusRing}`}
|
className={`inline-flex min-h-11 items-center rounded-lg border border-[#1a1c1d] bg-white px-6 text-[13px] font-medium transition-all duration-200 hover:bg-[#1a1c1d] hover:text-white active:scale-[0.98] ${focusRing}`}
|
||||||
>
|
>
|
||||||
绩效考核
|
绩效考核
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-row className="rounded-xl border border-[#e7e8ea] bg-white p-8 md:p-10">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<StatusDot tone="live" />
|
|
||||||
<span className="text-[12px] text-[#6b7075]">运行中</span>
|
|
||||||
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>02 / Assets</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">资产与AI自动化</h3>
|
|
||||||
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
|
|
||||||
素材浏览、上传、分享与下载——素材库的全部日常都在这里。
|
|
||||||
</p>
|
|
||||||
<div className="mt-7">
|
|
||||||
<Link
|
|
||||||
href="/materials"
|
|
||||||
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-opacity hover:opacity-80 ${focusRing}`}
|
|
||||||
>
|
|
||||||
进入素材库
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -415,7 +385,7 @@ export default function BoardHomePage() {
|
|||||||
href="/materials"
|
href="/materials"
|
||||||
className={`rounded-sm text-[12px] font-medium text-[#5a66b8] underline-offset-4 hover:underline ${focusRing}`}
|
className={`rounded-sm text-[12px] font-medium text-[#5a66b8] underline-offset-4 hover:underline ${focusRing}`}
|
||||||
>
|
>
|
||||||
直达素材库 →
|
直达创意资产库 →
|
||||||
</Link>
|
</Link>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -704,10 +704,10 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 border-b border-[#eeeeee] bg-[#2f3131] text-white">
|
<header className="sticky top-0 z-30 border-b border-[#e7e8ea] bg-[#fafafa] text-[#1a1c1d]">
|
||||||
<div className="mx-auto flex h-14 w-full max-w-[1440px] items-center justify-between gap-4 px-4 sm:px-6">
|
<div className="mx-auto flex h-14 w-full max-w-[1440px] items-center justify-between gap-4 px-4 sm:px-6">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-[#f4f4f4] text-[12px] font-semibold text-[#0050cb]">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[12px] font-semibold text-[#1a1c1d]">
|
||||||
{avatar ? (
|
{avatar ? (
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={avatar}
|
||||||
@ -721,14 +721,14 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
|
|||||||
getSurveyInitials(name)
|
getSurveyInitials(name)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-[16px] font-bold tracking-tight">考核调研</div>
|
<div className="truncate text-[16px] font-bold tracking-tight">绩效考核</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/materials"
|
href="/materials"
|
||||||
className="shrink-0 text-[12px] font-semibold text-white/85 transition-colors hover:text-white"
|
className="shrink-0 text-[12px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d]"
|
||||||
aria-label="返回素材库首页"
|
aria-label="返回创意资产库"
|
||||||
>
|
>
|
||||||
由壹点「素材库」
|
返回创意资产库
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -915,10 +915,10 @@ function PerformanceSurveyDashboardHeader({
|
|||||||
tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }>
|
tabItems: Array<{ id: PerformanceSurveyDashboardTab; label: string }>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-[#1f2937] text-white shadow-sm">
|
<header className="border-b border-[#e7e8ea] bg-[#fafafa] text-[#1a1c1d]">
|
||||||
<div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(220px,1fr)_auto_minmax(220px,1fr)] sm:px-4">
|
<div className="container mx-auto grid min-h-[60px] max-w-7xl grid-cols-[minmax(0,1fr)_auto] items-center gap-4 px-3 py-3 sm:grid-cols-[minmax(220px,1fr)_auto_minmax(220px,1fr)] sm:px-4">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-white">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[#1a1c1d]">
|
||||||
{avatar ? (
|
{avatar ? (
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={avatar}
|
||||||
@ -933,8 +933,8 @@ function PerformanceSurveyDashboardHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-[16px] font-bold">考核调研</div>
|
<div className="truncate text-[16px] font-bold">绩效考核</div>
|
||||||
<div className="truncate text-[11px] font-medium text-white/70" title={displayName}>{displayName}</div>
|
<div className="truncate text-[11px] font-medium text-[#6b7075]" title={displayName}>{displayName}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="hidden h-full items-center justify-center gap-9 sm:flex">
|
<nav className="hidden h-full items-center justify-center gap-9 sm:flex">
|
||||||
@ -948,8 +948,8 @@ function PerformanceSurveyDashboardHeader({
|
|||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={`h-full border-b-2 px-1 text-[13px] font-bold transition-colors ${
|
className={`h-full border-b-2 px-1 text-[13px] font-bold transition-colors ${
|
||||||
active
|
active
|
||||||
? 'border-white text-white'
|
? 'border-[#1a1c1d] text-[#1a1c1d]'
|
||||||
: 'border-transparent text-[#b3c5ff] hover:text-white'
|
: 'border-transparent text-[#6b7075] hover:text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@ -959,17 +959,17 @@ function PerformanceSurveyDashboardHeader({
|
|||||||
</nav>
|
</nav>
|
||||||
<Link
|
<Link
|
||||||
href="/materials"
|
href="/materials"
|
||||||
className="hidden justify-self-end text-[13px] font-semibold text-white/90 transition-colors hover:text-white sm:block"
|
className="hidden justify-self-end text-[13px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d] sm:block"
|
||||||
aria-label="返回素材库首页"
|
aria-label="返回创意资产库"
|
||||||
>
|
>
|
||||||
由壹点「素材库」
|
返回创意资产库
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/materials"
|
href="/materials"
|
||||||
className="justify-self-end text-[12px] font-semibold text-[#b3c5ff] transition-colors hover:text-white sm:hidden"
|
className="justify-self-end text-[12px] font-semibold text-[#6b7075] transition-colors hover:text-[#1a1c1d] sm:hidden"
|
||||||
aria-label="返回素材库首页"
|
aria-label="返回创意资产库"
|
||||||
>
|
>
|
||||||
由壹点
|
返回
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 个人中心页面
|
* 个人档案页(原个人中心 · 2026-08-15 改造)
|
||||||
*
|
*
|
||||||
* 参考:flnxi_sucai/app/profile/page.tsx
|
* 定位:从"账号资料页"升级为「我的档案」——
|
||||||
* 包含"我的素材"和"我的收藏"两个标签页
|
* 权威人员档案(HR 花名册:花名/工号/部门路径/职位/M-P 等级/直属主管/在职状态,只读)
|
||||||
|
* + 账号区(头像上传/用户名/邮箱/注册时间)
|
||||||
|
* + 我的素材/我的收藏(保留原有功能,视觉统一白皮书语言)。
|
||||||
|
* 权威数据经 /api/user/profile 内部调用 lib/employee-directory 的 getEmployeeByUserId 合并返回。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
@ -11,27 +14,32 @@ import { useSession } from 'next-auth/react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Header from '@/components/Header'
|
import MaterialsHeader from '@/components/board/MaterialsHeader'
|
||||||
import VideoPlayerModal from '@/components/VideoPlayerModal'
|
|
||||||
import { useToast } from '@/hooks/useToast'
|
import { useToast } from '@/hooks/useToast'
|
||||||
import ToastBubble from '@/components/ToastBubble'
|
import ToastBubble from '@/components/ToastBubble'
|
||||||
import {
|
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||||
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
|
|
||||||
DOUYIN_QIANCHUAN_VIDEO_TYPE,
|
interface EmployeeRecord {
|
||||||
INSTALL_VIDEO_TYPE,
|
displayName: string
|
||||||
TAOXI_GUANGGUANG_VIDEO_TYPE,
|
realName: string | null
|
||||||
TAOXI_MAIN_VIDEO_TYPE,
|
flowerName: string | null
|
||||||
isVideoMaterialType,
|
jobNumber: string | null
|
||||||
} from '@/lib/video-material-types'
|
departmentPath: string | null
|
||||||
|
positionTitle: string | null
|
||||||
|
positionLevel: string | null
|
||||||
|
levelType: string | null
|
||||||
|
managerName: string | null
|
||||||
|
employmentStatus: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
avatar: string | null
|
avatar: string | null
|
||||||
department?: string
|
|
||||||
realName?: string | null
|
realName?: string | null
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
employee: EmployeeRecord | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Material {
|
interface Material {
|
||||||
@ -39,27 +47,36 @@ interface Material {
|
|||||||
userId: string
|
userId: string
|
||||||
productModel: string
|
productModel: string
|
||||||
previewUrl: string
|
previewUrl: string
|
||||||
displayImages?: string[]
|
|
||||||
materialType?: string
|
materialType?: string
|
||||||
categoryName?: string
|
|
||||||
sceneName?: string
|
|
||||||
renderer?: string
|
|
||||||
authorName?: string
|
|
||||||
downloadCount: number
|
downloadCount: number
|
||||||
likeCount: number
|
likeCount: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
isFavorited?: boolean
|
|
||||||
isLiked?: boolean
|
|
||||||
zipFileName?: string
|
|
||||||
zipFileSize?: string | number
|
|
||||||
videoCoverUrl?: string | null
|
videoCoverUrl?: string | null
|
||||||
videoPreview540Url?: string | null
|
videoPreview540Url?: string | null
|
||||||
videoPreviewClipUrl?: string | null
|
videoPreviewClipUrl?: string | null
|
||||||
videoSourceUrl?: string | null
|
|
||||||
videoProcessingStatus?: string | null
|
|
||||||
videoDuration?: number | null
|
videoDuration?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LEVEL_TYPE_LABEL: Record<string, string> = { M: '管理', P: '专业', S: '支持' }
|
||||||
|
|
||||||
|
function StatusDot({ active }: { active: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`inline-block h-[7px] w-[7px] shrink-0 rounded-full ${active ? 'bg-[#157a4b]' : 'bg-[#9aa0a8]'}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.14em] text-[#86898e]">{label}</p>
|
||||||
|
<p className="mt-1.5 text-[15px] leading-relaxed text-[#1a1c1d]">{children}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { data: session, status, update } = useSession()
|
const { data: session, status, update } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -67,9 +84,6 @@ export default function ProfilePage() {
|
|||||||
const [materials, setMaterials] = useState<Material[]>([])
|
const [materials, setMaterials] = useState<Material[]>([])
|
||||||
const [favorites, setFavorites] = useState<Material[]>([])
|
const [favorites, setFavorites] = useState<Material[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [previewImage, setPreviewImage] = useState<{ url: string; title: string } | null>(null)
|
|
||||||
const [showVideoModal, setShowVideoModal] = useState(false)
|
|
||||||
const [videoSrc, setVideoSrc] = useState<string | null>(null)
|
|
||||||
const [showAvatarModal, setShowAvatarModal] = useState(false)
|
const [showAvatarModal, setShowAvatarModal] = useState(false)
|
||||||
const { toast, showToast } = useToast()
|
const { toast, showToast } = useToast()
|
||||||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||||
@ -127,46 +141,12 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否为视频素材
|
|
||||||
const isVideoMaterial = (materialType?: string) => {
|
|
||||||
return isVideoMaterialType(materialType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时长
|
|
||||||
const formatDuration = (seconds: number) => {
|
const formatDuration = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
const secs = seconds % 60
|
const secs = seconds % 60
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取素材类型标签样式
|
|
||||||
const getMaterialTypeBadgeClass = (materialType?: string) => {
|
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
'3D场景': 'bg-blue-500',
|
|
||||||
'详情页': 'bg-purple-500',
|
|
||||||
'详情模板': 'bg-purple-500',
|
|
||||||
'矢量文件': 'bg-green-500',
|
|
||||||
'C4D模型': 'bg-orange-500',
|
|
||||||
'视频': 'bg-red-500',
|
|
||||||
[TAOXI_MAIN_VIDEO_TYPE]: 'bg-red-500',
|
|
||||||
'主图视频': 'bg-red-500',
|
|
||||||
[INSTALL_VIDEO_TYPE]: 'bg-red-500',
|
|
||||||
[TAOXI_GUANGGUANG_VIDEO_TYPE]: 'bg-red-500',
|
|
||||||
'种草视频': 'bg-red-500',
|
|
||||||
[DOUYIN_QIANCHUAN_VIDEO_TYPE]: 'bg-red-500',
|
|
||||||
[DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE]: 'bg-red-500',
|
|
||||||
'模特': 'bg-pink-500',
|
|
||||||
'主图模版': 'bg-indigo-500',
|
|
||||||
'SKU模版': 'bg-teal-500',
|
|
||||||
'音乐': 'bg-yellow-500',
|
|
||||||
'标准饰品': 'bg-cyan-500',
|
|
||||||
'品牌VI灵感': 'bg-rose-500',
|
|
||||||
'CAD': 'bg-gray-600',
|
|
||||||
'标准模型': 'bg-violet-500',
|
|
||||||
}
|
|
||||||
return typeMap[materialType || ''] || 'bg-gray-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
@ -195,17 +175,15 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
console.log('[Profile] Avatar uploaded successfully:', data.avatarUrl)
|
|
||||||
|
|
||||||
// 关键:立即更新本地状态,显示新头像
|
setProfile(prev => (prev ? { ...prev, avatar: data.avatarUrl } : null))
|
||||||
setProfile(prev => prev ? { ...prev, avatar: data.avatarUrl } : null)
|
|
||||||
|
|
||||||
setShowAvatarModal(false)
|
setShowAvatarModal(false)
|
||||||
setAvatarFile(null)
|
setAvatarFile(null)
|
||||||
setAvatarPreview('')
|
setAvatarPreview('')
|
||||||
|
|
||||||
// 立即更新 NextAuth 会话,触发 Header 中头像同步更新
|
|
||||||
await update({ avatar: data.avatarUrl })
|
await update({ avatar: data.avatarUrl })
|
||||||
|
showToast('头像已更新', { variant: 'success' })
|
||||||
} else {
|
} else {
|
||||||
const { parseFetchError } = await import('@/lib/error-handler')
|
const { parseFetchError } = await import('@/lib/error-handler')
|
||||||
const parsedError = await parseFetchError(res)
|
const parsedError = await parseFetchError(res)
|
||||||
@ -222,8 +200,8 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
if (status === 'loading' || loading) {
|
if (status === 'loading' || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
<div className="flex min-h-screen items-center justify-center bg-[#fafafa]">
|
||||||
<p className="text-gray-500 dark:text-gray-400">加载中...</p>
|
<p className="text-[13px] text-[#86898e]">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -232,112 +210,27 @@ export default function ProfilePage() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const employee = profile?.employee ?? null
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
const displayName = employee?.displayName || profile?.username || session.user?.name || '成员'
|
||||||
<Header session={session} />
|
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
/** 素材卡片(我的素材/我的收藏共用) */
|
||||||
{/* 用户信息卡片 */}
|
const renderMaterialCard = (material: Material, favorited = false) => {
|
||||||
<div className="card-apple mb-6 p-6 dark:bg-gray-800 dark:border-gray-700">
|
const isVideo = isVideoMaterialType(material.materialType)
|
||||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
|
||||||
{/* 头像 */}
|
|
||||||
<div className="relative">
|
|
||||||
{profile?.avatar ? (
|
|
||||||
<div className="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
|
|
||||||
<Image
|
|
||||||
src={profile.avatar}
|
|
||||||
alt="头像"
|
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
|
||||||
{(profile?.username || session.user?.name || session.user?.email || 'U').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<label className="absolute bottom-0 right-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors shadow-lg">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleAvatarSelect}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户信息 */}
|
|
||||||
<div className="flex-1 text-center sm:text-left">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
{profile?.username || session.user?.name || session.user?.email || '用户'}
|
|
||||||
</h1>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 space-y-1 mb-4">
|
|
||||||
<p>@{(profile?.email || session.user?.email || 'user').split('@')[0]}</p>
|
|
||||||
<p>{profile?.email || session.user?.email}</p>
|
|
||||||
{profile?.department ? (
|
|
||||||
<p>部门:{profile.department}</p>
|
|
||||||
) : null}
|
|
||||||
{profile?.createdAt ? (
|
|
||||||
<p>注册时间:{new Date(profile.createdAt as any).toLocaleDateString()}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">我的素材:</span>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300 font-semibold">{materials.length}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">我的收藏:</span>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300 font-semibold">{favorites.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 我的素材卡片(上) */}
|
|
||||||
<div className="card-apple dark:bg-gray-800 dark:border-gray-700">
|
|
||||||
<div className="flex items-center justify-between px-4 pt-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">我的素材</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">已上传 {materials.length} 个</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{materials.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">还没有上传过素材</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
|
|
||||||
{materials.map((material) => {
|
|
||||||
const isVideo = isVideoMaterial(material.materialType)
|
|
||||||
const clipUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
const clipUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
||||||
const poster = material.videoCoverUrl || material.previewUrl
|
const poster = material.videoCoverUrl || material.previewUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={material.id}
|
key={material.id}
|
||||||
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer group bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-shadow"
|
className="group relative aspect-[3/4] cursor-pointer overflow-hidden rounded-xl border border-[#e7e8ea] bg-white transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-0.5"
|
||||||
onClick={() => {
|
onClick={() => router.push(`/share/${material.id}`)}
|
||||||
// 所有素材类型统一跳转到详情页面
|
|
||||||
router.push(`/share/${material.id}`)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* 预览图/视频 */}
|
<div className="relative h-full w-full overflow-hidden bg-[#f0f1f3]">
|
||||||
<div className="relative w-full h-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
||||||
{isVideo && clipUrl ? (
|
{isVideo && clipUrl ? (
|
||||||
<video
|
<video
|
||||||
src={clipUrl}
|
src={clipUrl}
|
||||||
poster={poster || undefined}
|
poster={poster || undefined}
|
||||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
@ -353,120 +246,32 @@ export default function ProfilePage() {
|
|||||||
sizes="(max-width: 768px) 100vw, 300px"
|
sizes="(max-width: 768px) 100vw, 300px"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600">
|
<div className="flex h-full w-full items-center justify-center bg-[#f0f1f3]">
|
||||||
<span className="text-gray-500 dark:text-gray-400">暂无预览图</span>
|
<span className="text-[12px] text-[#86898e]">暂无预览图</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SPU标签 */}
|
<div className="absolute left-2 top-2 rounded-sm bg-black/70 px-2 py-1 text-xs font-medium text-white">
|
||||||
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
|
|
||||||
{material.productModel}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 视频时长角标 */}
|
|
||||||
{isVideo && material.videoDuration && (
|
|
||||||
<div className="absolute bottom-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
|
|
||||||
{formatDuration(material.videoDuration)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 素材信息 */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
|
||||||
<p className="text-sm font-medium text-white truncate mb-1">
|
|
||||||
{material.productModel}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between text-xs text-white/80">
|
|
||||||
<span>下载 {material.downloadCount}</span>
|
|
||||||
<span>点赞 {material.likeCount}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 我的收藏卡片(下) */}
|
|
||||||
<div className="card-apple dark:bg-gray-800 dark:border-gray-700">
|
|
||||||
<div className="flex items-center justify-between px-4 pt-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">我的收藏</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">已收藏 {favorites.length} 个</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{favorites.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">还没有收藏素材</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4">
|
|
||||||
{favorites.map((material) => {
|
|
||||||
const isVideo = isVideoMaterial(material.materialType)
|
|
||||||
const clipUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
|
||||||
const poster = material.videoCoverUrl || material.previewUrl
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={material.id}
|
|
||||||
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer group bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-shadow"
|
|
||||||
onClick={() => {
|
|
||||||
// 所有素材类型统一跳转到详情页面
|
|
||||||
router.push(`/share/${material.id}`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative w-full h-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
||||||
{isVideo && clipUrl ? (
|
|
||||||
<video
|
|
||||||
src={clipUrl}
|
|
||||||
poster={poster || undefined}
|
|
||||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
|
||||||
) : material.previewUrl ? (
|
|
||||||
<Image
|
|
||||||
src={material.previewUrl}
|
|
||||||
alt={material.productModel}
|
|
||||||
fill
|
|
||||||
unoptimized
|
|
||||||
className="object-cover transition-transform group-hover:scale-105"
|
|
||||||
sizes="(max-width: 768px) 100vw, 300px"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gray-300 dark:bg-gray-600">
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">暂无预览图</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
|
|
||||||
{material.productModel}
|
{material.productModel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isVideo && material.videoDuration && (
|
{isVideo && material.videoDuration && (
|
||||||
<div className="absolute bottom-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium">
|
<div className="absolute bottom-2 left-2 rounded-sm bg-black/70 px-2 py-1 text-xs font-medium text-white">
|
||||||
{formatDuration(material.videoDuration)}
|
{formatDuration(material.videoDuration)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 收藏角标 */}
|
{favorited && (
|
||||||
<div className="absolute bottom-2 right-2 bg-red-500 text-white p-1.5 rounded-full">
|
<div className="absolute bottom-2 right-2 rounded-full bg-[#c0483c] p-1.5 text-white">
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||||||
<p className="text-sm font-medium text-white truncate mb-1">
|
<p className="mb-1 truncate text-sm font-medium text-white">{material.productModel}</p>
|
||||||
{material.productModel}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between text-xs text-white/80">
|
<div className="flex items-center justify-between text-xs text-white/80">
|
||||||
<span>下载 {material.downloadCount}</span>
|
<span>下载 {material.downloadCount}</span>
|
||||||
<span>点赞 {material.likeCount}</span>
|
<span>点赞 {material.likeCount}</span>
|
||||||
@ -474,65 +279,178 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||||||
|
<MaterialsHeader session={session} sectionLabel="个人档案" />
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-[1280px] px-6 pb-16">
|
||||||
|
{/* ===== 页头 ===== */}
|
||||||
|
<section className="pb-10 pt-14 md:pt-20">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.14em] text-[#86898e]">Employee Record · 我的档案</p>
|
||||||
|
<h1 className="mt-4 text-[clamp(40px,6vw,72px)] font-semibold leading-[1.08] tracking-[-0.03em]">
|
||||||
|
{displayName}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-xl text-[14px] text-[#6b7075]">
|
||||||
|
{employee
|
||||||
|
? `${employee.positionTitle ?? '职位未记录'}${employee.departmentPath ? ` · ${employee.departmentPath}` : ''}`
|
||||||
|
: '账号未绑定权威人员档案'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ===== 档案卡 ===== */}
|
||||||
|
<section className="rounded-xl border border-[#e7e8ea] bg-white p-8 md:p-10">
|
||||||
|
<div className="grid gap-10 md:grid-cols-[1fr_300px] md:gap-14">
|
||||||
|
{/* 权威档案区(只读,来自 HR 花名册) */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<h2 className="text-[16px] font-semibold">人员档案</h2>
|
||||||
|
<span className="rounded-sm bg-[#eef0f3] px-2 py-0.5 text-[11px] text-[#6d7075]">HR 花名册 · 只读</span>
|
||||||
|
</div>
|
||||||
|
{employee ? (
|
||||||
|
<div className="grid grid-cols-1 gap-x-10 gap-y-6 sm:grid-cols-2">
|
||||||
|
<Field label="花名 / 真名">
|
||||||
|
{employee.displayName}
|
||||||
|
{employee.realName && employee.realName !== employee.displayName && (
|
||||||
|
<span className="text-[#6b7075]">({employee.realName})</span>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="工号">{employee.jobNumber ?? '未记录'}</Field>
|
||||||
|
<Field label="部门路径">{employee.departmentPath ?? '未确定'}</Field>
|
||||||
|
<Field label="职位">{employee.positionTitle ?? '未记录'}</Field>
|
||||||
|
<Field label="等级">
|
||||||
|
{employee.positionLevel ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="rounded-sm bg-[#e3ecf5] px-2 py-0.5 text-[13px] font-semibold text-[#3a7dc4]">
|
||||||
|
{employee.positionLevel}
|
||||||
|
{employee.levelType ? ` · ${LEVEL_TYPE_LABEL[employee.levelType] ?? employee.levelType}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'未记录'
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="直属主管">{employee.managerName ?? '未记录'}</Field>
|
||||||
|
<Field label="在职状态">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<StatusDot active={employee.employmentStatus !== 'RESIGNED'} />
|
||||||
|
{employee.employmentStatus === 'RESIGNED' ? '已离职' : '在职'}
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-[#c9ccd1] px-5 py-8 text-[13px] leading-relaxed text-[#6b7075]">
|
||||||
|
该账号尚未绑定权威人员档案(需要钉钉唯一值绑定)。<br />
|
||||||
|
绑定后此处将展示花名、工号、部门、职位、等级与直属主管。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图片预览模态框 */}
|
{/* 账号区 */}
|
||||||
{previewImage && (
|
<div className="border-t border-[#e7e8ea] pt-8 md:border-l md:border-t-0 md:pl-10 md:pt-0">
|
||||||
<div
|
<h2 className="mb-6 text-[16px] font-semibold">账号</h2>
|
||||||
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
|
<div className="flex items-center gap-5">
|
||||||
onClick={() => setPreviewImage(null)}
|
<div className="relative">
|
||||||
>
|
{profile?.avatar ? (
|
||||||
<div className="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
|
<div className="h-16 w-16 overflow-hidden rounded-full border border-[#e7e8ea]">
|
||||||
<Image
|
<Image
|
||||||
src={previewImage.url}
|
src={profile.avatar}
|
||||||
alt={previewImage.title}
|
alt="头像"
|
||||||
fill
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
className="object-contain"
|
|
||||||
sizes="90vw"
|
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
onClick={() => setPreviewImage(null)}
|
) : (
|
||||||
className="absolute top-4 right-4 text-white bg-black/50 hover:bg-black/70 rounded-full p-2 transition-all"
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[#1a1c1d] text-xl font-semibold text-white">
|
||||||
>
|
{displayName.charAt(0).toUpperCase()}
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</div>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
)}
|
||||||
|
<label className="absolute bottom-0 right-0 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border border-[#e7e8ea] bg-white text-[#6b7075] transition-colors hover:text-[#1a1c1d]">
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
<input type="file" accept="image/*" onChange={handleAvatarSelect} className="hidden" />
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-[14px] font-medium">{profile?.username ?? '—'}</p>
|
||||||
|
<p className="mt-0.5 truncate text-[12px] text-[#6b7075]">{profile?.email ?? session.user?.email}</p>
|
||||||
|
{profile?.createdAt && (
|
||||||
|
<p className="mt-0.5 text-[12px] text-[#86898e]">
|
||||||
|
注册于 {new Date(profile.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-[28px] font-semibold tabular-nums leading-none">{materials.length}</p>
|
||||||
|
<p className="mt-1.5 text-[12px] text-[#86898e]">我的素材</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[28px] font-semibold tabular-nums leading-none">{favorites.length}</p>
|
||||||
|
<p className="mt-1.5 text-[12px] text-[#86898e]">我的收藏</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Video Player Modal(参考首页实现) */}
|
{/* ===== 我的素材 ===== */}
|
||||||
{showVideoModal && videoSrc && (
|
<section className="mt-14">
|
||||||
<VideoPlayerModal
|
<div className="mb-6 flex items-baseline justify-between border-b border-[#e7e8ea] pb-4">
|
||||||
videoSrc={videoSrc}
|
<h2 className="text-[22px] font-semibold tracking-tight">我的素材</h2>
|
||||||
onClose={() => {
|
<span className="text-[12px] tabular-nums text-[#86898e]">{materials.length} 个</span>
|
||||||
setShowVideoModal(false)
|
</div>
|
||||||
setVideoSrc(null)
|
{materials.length === 0 ? (
|
||||||
}}
|
<p className="py-10 text-center text-[13px] text-[#86898e]">还没有上传过素材</p>
|
||||||
/>
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{materials.map((m) => renderMaterialCard(m))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ===== 我的收藏 ===== */}
|
||||||
|
<section className="mt-14">
|
||||||
|
<div className="mb-6 flex items-baseline justify-between border-b border-[#e7e8ea] pb-4">
|
||||||
|
<h2 className="text-[22px] font-semibold tracking-tight">我的收藏</h2>
|
||||||
|
<span className="text-[12px] tabular-nums text-[#86898e]">{favorites.length} 个</span>
|
||||||
|
</div>
|
||||||
|
{favorites.length === 0 ? (
|
||||||
|
<p className="py-10 text-center text-[13px] text-[#86898e]">还没有收藏素材</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{favorites.map((m) => renderMaterialCard(m, true))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* 头像上传模态框 */}
|
{/* 头像上传模态框 */}
|
||||||
{showAvatarModal && (
|
{showAvatarModal && (
|
||||||
<div className="fixed inset-0 z-[100] bg-black/50 flex items-center justify-center p-4" onClick={() => setShowAvatarModal(false)}>
|
<div
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full" onClick={(e) => e.stopPropagation()}>
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">上传头像</h3>
|
onClick={() => setShowAvatarModal(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-xl border border-[#e7e8ea] bg-white p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="mb-5 text-[16px] font-semibold">上传头像</h3>
|
||||||
{avatarPreview && (
|
{avatarPreview && (
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="mb-5 flex justify-center">
|
||||||
<div className="w-32 h-32 rounded-full overflow-hidden border-2 border-gray-300 dark:border-gray-600">
|
<div className="h-32 w-32 overflow-hidden rounded-full border border-[#e7e8ea]">
|
||||||
<Image
|
<Image
|
||||||
src={avatarPreview}
|
src={avatarPreview}
|
||||||
alt="预览"
|
alt="预览"
|
||||||
width={128}
|
width={128}
|
||||||
height={128}
|
height={128}
|
||||||
className="w-full h-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -545,14 +463,14 @@ export default function ProfilePage() {
|
|||||||
setAvatarFile(null)
|
setAvatarFile(null)
|
||||||
setAvatarPreview('')
|
setAvatarPreview('')
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
className="min-h-11 flex-1 rounded-lg border border-[#1a1c1d] bg-white px-4 text-[13px] font-medium transition-colors hover:bg-[#1a1c1d] hover:text-white"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAvatarUpload}
|
onClick={handleAvatarUpload}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="min-h-11 flex-1 rounded-lg bg-[#1a1c1d] px-4 text-[13px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.98] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{uploading ? '上传中...' : '确认上传'}
|
{uploading ? '上传中...' : '确认上传'}
|
||||||
</button>
|
</button>
|
||||||
@ -561,10 +479,8 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 统一的Toast提示 */}
|
{/* Toast */}
|
||||||
{toast && toast.show && (
|
{toast && toast.show && <ToastBubble message={toast.message} variant={toast.variant} />}
|
||||||
<ToastBubble message={toast.message} variant={toast.variant} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -528,10 +528,10 @@ export default function Header({ session }: HeaderProps) {
|
|||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>个人中心</span>
|
<span>我的档案</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 考核调研 */}
|
{/* 绩效考核 */}
|
||||||
<Link
|
<Link
|
||||||
href="/performance-survey"
|
href="/performance-survey"
|
||||||
onClick={() => setShowDropdown(false)}
|
onClick={() => setShowDropdown(false)}
|
||||||
@ -609,7 +609,7 @@ export default function Header({ session }: HeaderProps) {
|
|||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>系统设置</span>
|
<span>治理与设置</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -284,7 +284,7 @@ export default function RankingPanel() {
|
|||||||
clickStartRef.current = null
|
clickStartRef.current = null
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="fixed z-40 w-12 h-12 bg-gray-900 dark:bg-gray-950 rounded-full shadow-2xl border border-gray-700 dark:border-gray-800 flex items-center justify-center hover:scale-110 transition-transform"
|
className="fixed z-40 flex h-12 w-12 items-center justify-center rounded-full border border-[#e7e8ea] bg-white shadow-[0_12px_32px_-12px_rgba(26,28,29,0.28)] transition-transform hover:scale-110"
|
||||||
style={{ left: `${buttonPosition.x}px`, top: `${buttonPosition.y}px`, cursor: isButtonDragging ? 'grabbing' : 'grab' }}
|
style={{ left: `${buttonPosition.x}px`, top: `${buttonPosition.y}px`, cursor: isButtonDragging ? 'grabbing' : 'grab' }}
|
||||||
title="打开榜单面板(可拖动)"
|
title="打开榜单面板(可拖动)"
|
||||||
>
|
>
|
||||||
@ -298,15 +298,15 @@ export default function RankingPanel() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="fixed z-40 w-[150px] h-[400px] bg-gray-900 dark:bg-gray-950 rounded-lg shadow-2xl border border-gray-700 dark:border-gray-800 overflow-hidden backdrop-blur-sm"
|
className="fixed z-40 h-[400px] w-[150px] overflow-hidden rounded-xl border border-[#e7e8ea] bg-white shadow-[0_24px_60px_-24px_rgba(26,28,29,0.3)]"
|
||||||
style={{ left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }}
|
style={{ left: `${position.x}px`, top: `${position.y}px`, cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||||
>
|
>
|
||||||
{/* 拖拽区域 */}
|
{/* 拖拽区域 */}
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className="bg-gradient-to-r from-gray-800 to-gray-900 px-3 py-3 text-center border-b border-gray-700 cursor-grab active:cursor-grabbing"
|
className="cursor-grab border-b border-[#e7e8ea] bg-[#fafafa] px-3 py-3 text-center active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-bold text-white leading-tight">
|
<h3 className="text-sm font-semibold leading-tight text-[#1a1c1d]">
|
||||||
{getTabTitle()}
|
{getTabTitle()}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@ -314,32 +314,32 @@ export default function RankingPanel() {
|
|||||||
{/* 关闭按钮 */}
|
{/* 关闭按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="absolute top-1.5 right-1.5 w-3 h-3 flex items-center justify-center bg-gray-800 hover:bg-gray-700 rounded-full transition-colors z-10"
|
className="absolute top-1.5 right-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full border border-[#e7e8ea] bg-white transition-colors hover:bg-[#e7e8ea]"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
>
|
>
|
||||||
<svg className="w-2 h-2 text-gray-400 hover:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-2 w-2 text-[#6b7075]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 指示器 */}
|
{/* 指示器 */}
|
||||||
<div className="flex justify-center gap-1 py-2 bg-gray-800 border-b border-gray-700">
|
<div className="flex justify-center gap-1 border-b border-[#e7e8ea] bg-[#fafafa] py-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTab('contributors')}
|
onClick={() => setCurrentTab('contributors')}
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
className={`h-1.5 w-1.5 rounded-full transition-all ${
|
||||||
currentTab === 'contributors' ? 'bg-white w-4' : 'bg-gray-600'
|
currentTab === 'contributors' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTab('downloads')}
|
onClick={() => setCurrentTab('downloads')}
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
className={`h-1.5 w-1.5 rounded-full transition-all ${
|
||||||
currentTab === 'downloads' ? 'bg-white w-4' : 'bg-gray-600'
|
currentTab === 'downloads' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentTab('likes')}
|
onClick={() => setCurrentTab('likes')}
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
className={`h-1.5 w-1.5 rounded-full transition-all ${
|
||||||
currentTab === 'likes' ? 'bg-white w-4' : 'bg-gray-600'
|
currentTab === 'likes' ? 'w-4 bg-[#1a1c1d]' : 'bg-[#d4d6da]'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -348,28 +348,28 @@ export default function RankingPanel() {
|
|||||||
<div className="p-3 overflow-y-auto" style={{ height: 'calc(100% - 80px)' }}>
|
<div className="p-3 overflow-y-auto" style={{ height: 'calc(100% - 80px)' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-xs text-gray-400">加载中...</div>
|
<div className="text-xs text-[#86898e]">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
) : getCurrentData().length === 0 ? (
|
) : getCurrentData().length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-xs text-gray-500 text-center">暂无数据</div>
|
<div className="text-xs text-center text-[#86898e]">暂无数据</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{getCurrentData().map((user, index) => (
|
{getCurrentData().map((user, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`flex items-center gap-2 p-2 rounded-lg transition-all ${
|
className={`flex items-center gap-2 rounded-lg border p-2 transition-all ${
|
||||||
index < 3
|
index < 3
|
||||||
? 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/30'
|
? 'border-[#f0dfb8] bg-[#fdf6e9]'
|
||||||
: 'bg-gray-800/50 border border-gray-700'
|
: 'border-[#e7e8ea] bg-[#fafafa]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-6 flex items-center justify-center">
|
<div className="flex-shrink-0 w-6 flex items-center justify-center">
|
||||||
{index < 3 ? (
|
{index < 3 ? (
|
||||||
getMedalIcon(index)
|
getMedalIcon(index)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-medium text-gray-400">
|
<span className="text-xs font-medium text-[#86898e]">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -377,7 +377,7 @@ export default function RankingPanel() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`text-xs font-medium truncate ${
|
className={`text-xs font-medium truncate ${
|
||||||
index < 3 ? 'text-yellow-400' : 'text-gray-200'
|
index < 3 ? 'text-[#a0671c]' : 'text-[#1a1c1d]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.username}
|
{user.username}
|
||||||
@ -385,7 +385,7 @@ export default function RankingPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<span className={`text-xs font-bold ${
|
<span className={`text-xs font-bold ${
|
||||||
index < 3 ? 'text-yellow-400' : 'text-gray-400'
|
index < 3 ? 'text-[#a0671c]' : 'text-[#6b7075]'
|
||||||
}`}>
|
}`}>
|
||||||
{user.count}
|
{user.count}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -34,8 +34,8 @@ const NAV_ITEMS: Array<{
|
|||||||
adminOnly?: boolean
|
adminOnly?: boolean
|
||||||
}> = [
|
}> = [
|
||||||
{ key: 'dashboard', label: '中心仪表盘', href: '/' },
|
{ key: 'dashboard', label: '中心仪表盘', href: '/' },
|
||||||
|
{ key: 'assets', label: '创意资产库', href: '/materials' },
|
||||||
{ key: 'organization', label: '组织管理', href: '/organization', adminOnly: true },
|
{ key: 'organization', label: '组织管理', href: '/organization', adminOnly: true },
|
||||||
{ key: 'assets', label: '资产与AI自动化', href: '/materials' },
|
|
||||||
{ key: 'business', label: '业务经营', planned: true },
|
{ key: 'business', label: '业务经营', planned: true },
|
||||||
{ key: 'governance', label: '治理与设置', planned: true },
|
{ key: 'governance', label: '治理与设置', planned: true },
|
||||||
]
|
]
|
||||||
@ -89,7 +89,7 @@ export default function BoardShell({ session, active, children }: BoardShellProp
|
|||||||
key={item.key}
|
key={item.key}
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
title="规划中,尚未开放"
|
title="规划中,尚未开放"
|
||||||
className={`flex cursor-not-allowed items-center gap-1.5 text-[#6d7683] ${
|
className={`flex cursor-not-allowed items-center gap-1.5 text-[#6b7075] ${
|
||||||
compact ? 'px-2.5 py-1.5 text-[12px]' : 'px-3 py-2 text-[13px]'
|
compact ? 'px-2.5 py-1.5 text-[12px]' : 'px-3 py-2 text-[13px]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -106,8 +106,8 @@ export default function BoardShell({ session, active, children }: BoardShellProp
|
|||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={`transition-colors ${compact ? 'px-2.5 py-1.5 text-[12px]' : 'px-3 py-2 text-[13px]'} ${
|
className={`transition-colors ${compact ? 'px-2.5 py-1.5 text-[12px]' : 'px-3 py-2 text-[13px]'} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[#e3ecf5] font-semibold text-[#1c3a5e] shadow-[inset_0_-2px_0_#4a90d9]'
|
? 'bg-[#eef0fa] font-semibold text-[#1a1c1d] shadow-[inset_0_-2px_0_#5a66b8]'
|
||||||
: 'text-[#3c434c] hover:bg-[#f3f5f7]'
|
: 'text-[#1a1c1d] hover:bg-[#f3f5f7]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@ -116,7 +116,7 @@ export default function BoardShell({ session, active, children }: BoardShellProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#eef1f4] text-[#2c3038]">
|
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||||||
<header className="sticky top-0 z-40 border-b border-[#dfe4e9] bg-white">
|
<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">
|
<div className="mx-auto flex h-[60px] max-w-[1400px] items-center gap-4 px-5">
|
||||||
<Link
|
<Link
|
||||||
@ -133,7 +133,7 @@ export default function BoardShell({ session, active, children }: BoardShellProp
|
|||||||
unoptimized
|
unoptimized
|
||||||
className="h-8 w-8 shrink-0 rounded-md object-cover"
|
className="h-8 w-8 shrink-0 rounded-md object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-[16px] font-bold tracking-tight text-[#1c3a5e]">
|
<span className="truncate text-[16px] font-bold tracking-tight text-[#1a1c1d]">
|
||||||
由壹点·品牌中心
|
由壹点·品牌中心
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@ -147,13 +147,13 @@ export default function BoardShell({ session, active, children }: BoardShellProp
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2.5 md:ml-0">
|
<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">
|
<span className="hidden max-w-[9rem] truncate text-[12px] text-[#1a1c1d] sm:inline">
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleSignOut()}
|
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"
|
className="border border-[#d5dae0] px-2.5 py-1.5 text-[12px] text-[#1a1c1d] transition-colors hover:border-[#5a66b8] hover:text-[#5a66b8] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#5a66b8]/40"
|
||||||
>
|
>
|
||||||
退出
|
退出
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
628
components/board/MaterialsHeader.tsx
Normal file
628
components/board/MaterialsHeader.tsx
Normal file
@ -0,0 +1,628 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Session } from 'next-auth'
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network'
|
||||||
|
import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal'
|
||||||
|
import MaintenanceBanner from '@/components/MaintenanceBanner'
|
||||||
|
import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch'
|
||||||
|
import BrandLogo from '@/components/BrandLogo'
|
||||||
|
import { formatHumanSpeed, formatTransferProgressSummary } from '@/lib/human-readable-transfer'
|
||||||
|
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* MaterialsHeader —— 创意资产库板块顶栏(看板语言 · Zen Minimal)
|
||||||
|
* 移植自 components/Header.tsx 的全部功能(淘宝搜索/内外网状态/管理员性能监控/用户菜单/更新日志),
|
||||||
|
* 视觉统一到品牌中心白皮书语言:浅底、发丝线、墨字;深色模式跟随素材页既有 dark: 支持。
|
||||||
|
* 旧 Header 仍服务 admin/profile/share/edit 等页面,待后续逐页统一。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface MaterialsHeaderProps {
|
||||||
|
session: Session | null
|
||||||
|
/** 板块模式:传入板块名显示面包屑;不传则首页模式(显示五大板块导航) */
|
||||||
|
sectionLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOARD_NAV: Array<{ key: string; label: string; href?: string; planned?: boolean; adminOnly?: boolean }> = [
|
||||||
|
{ key: 'dashboard', label: '中心仪表盘', href: '/' },
|
||||||
|
{ key: 'assets', label: '创意资产库', href: '/materials' },
|
||||||
|
{ key: 'organization', label: '组织管理', href: '/organization', adminOnly: true },
|
||||||
|
{ key: 'business', label: '业务经营', planned: true },
|
||||||
|
{ key: 'governance', label: '治理与设置', planned: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MaterialsHeader({ session, sectionLabel }: MaterialsHeaderProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const role = session ? String((session.user as any)?.role || '') : ''
|
||||||
|
const isAdmin = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)
|
||||||
|
const canViewPerformance = ['ADMIN', 'SYSTEM_ADMIN'].includes(role) || Boolean((session?.user as any)?.isSuperAdmin)
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
|
||||||
|
const [networkChecked, setNetworkChecked] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 性能监控悬停卡片状态
|
||||||
|
const [showPerfCard, setShowPerfCard] = useState(false)
|
||||||
|
const [perfLoading, setPerfLoading] = useState(false)
|
||||||
|
const [perfError, setPerfError] = useState<string | null>(null)
|
||||||
|
const [perfData, setPerfData] = useState<{
|
||||||
|
cpu?: { usage: number; loadAverage: number[] }
|
||||||
|
memory?: { used: number; total: number; percentage: number }
|
||||||
|
network?: { uploadSpeed: number; downloadSpeed: number }
|
||||||
|
uploadQueue?: { total: number; processing: any[]; waiting: any[] }
|
||||||
|
transcodeQueue?: { total: number; maxConcurrent: number; processing: any[]; waiting: any[] }
|
||||||
|
} | null>(null)
|
||||||
|
const perfHoverTimerRef = useRef<number | null>(null)
|
||||||
|
const perfHideTimerRef = useRef<number | null>(null)
|
||||||
|
const perfIntervalRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const { showModal: showUpdateLogModal, currentLog, showAllHistory, handleClose: handleUpdateLogClose } = useUpdateLog()
|
||||||
|
|
||||||
|
// 品牌设置(logo):浅底表面用浅底适配版
|
||||||
|
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()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 更新浏览器标题(板块身份)
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = sectionLabel ? `由壹点·品牌中心 · ${sectionLabel}` : '由壹点·品牌中心'
|
||||||
|
}, [sectionLabel])
|
||||||
|
|
||||||
|
// 权威档案身份行(菜单头:花名下的职位·部门;无绑定时回退邮箱)
|
||||||
|
const [employeeLine, setEmployeeLine] = useState<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/user/profile')
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const employee = data?.employee
|
||||||
|
if (employee) {
|
||||||
|
const dept = employee.departmentPath ? employee.departmentPath.split('/').filter(Boolean).slice(-2).join(' / ') : ''
|
||||||
|
const line = [employee.positionTitle, dept].filter(Boolean).join(' · ')
|
||||||
|
setEmployeeLine(line || employee.jobNumber || null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 档案行加载失败时回退邮箱展示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 检测网络状态
|
||||||
|
useEffect(() => {
|
||||||
|
const detectNetwork = async () => {
|
||||||
|
try {
|
||||||
|
const networkStatus = await initializeNetwork()
|
||||||
|
setIsInternalNetwork(networkStatus.isInternal)
|
||||||
|
setNetworkChecked(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('网络检测失败:', error)
|
||||||
|
setIsInternalNetwork(false)
|
||||||
|
setNetworkChecked(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detectNetwork()
|
||||||
|
setupNetworkChangeDetection()
|
||||||
|
const unsubscribe = addNetworkChangeListener((status) => {
|
||||||
|
setIsInternalNetwork(status.isInternal)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 点击外部关闭下拉菜单
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setShowDropdown(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 性能监控:根据 showPerfCard 控制轮询(2 秒)
|
||||||
|
useEffect(() => {
|
||||||
|
const clearIntervalSafe = () => {
|
||||||
|
if (perfIntervalRef.current !== null) {
|
||||||
|
window.clearInterval(perfIntervalRef.current)
|
||||||
|
perfIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!canViewPerformance || !showPerfCard) {
|
||||||
|
clearIntervalSafe()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fetchPerf = async () => {
|
||||||
|
try {
|
||||||
|
setPerfLoading(true)
|
||||||
|
setPerfError(null)
|
||||||
|
const res = await fetch('/api/performance/quick')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setPerfData(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载性能数据失败:', err)
|
||||||
|
setPerfError('加载失败')
|
||||||
|
} finally {
|
||||||
|
setPerfLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPerf()
|
||||||
|
perfIntervalRef.current = window.setInterval(fetchPerf, 2000)
|
||||||
|
return () => {
|
||||||
|
clearIntervalSafe()
|
||||||
|
}
|
||||||
|
}, [canViewPerformance, showPerfCard])
|
||||||
|
|
||||||
|
// 悬停事件处理:延迟显示 / 隐藏性能卡片
|
||||||
|
const handlePerfMouseEnter = () => {
|
||||||
|
if (!canViewPerformance) return
|
||||||
|
if (perfHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(perfHideTimerRef.current)
|
||||||
|
perfHideTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (perfHoverTimerRef.current === null) {
|
||||||
|
perfHoverTimerRef.current = window.setTimeout(() => {
|
||||||
|
setShowPerfCard(true)
|
||||||
|
perfHoverTimerRef.current = null
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePerfMouseLeave = () => {
|
||||||
|
if (!canViewPerformance) return
|
||||||
|
if (perfHoverTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(perfHoverTimerRef.current)
|
||||||
|
perfHoverTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (perfHideTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(perfHideTimerRef.current)
|
||||||
|
perfHideTimerRef.current = null
|
||||||
|
}
|
||||||
|
perfHideTimerRef.current = window.setTimeout(() => {
|
||||||
|
setShowPerfCard(false)
|
||||||
|
perfHideTimerRef.current = null
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAgo = (ms: number): string => {
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return '--'
|
||||||
|
const sec = Math.floor(ms / 1000)
|
||||||
|
if (sec < 60) return `${sec}s`
|
||||||
|
const min = Math.floor(sec / 60)
|
||||||
|
if (min < 60) return `${min}min`
|
||||||
|
const hr = Math.floor(min / 60)
|
||||||
|
return `${hr}h${min % 60}min`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name?.charAt(0).toUpperCase() || 'U'
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItemCls =
|
||||||
|
'w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MaintenanceBanner />
|
||||||
|
|
||||||
|
<header className="sticky top-0 z-50 border-b border-[#e7e8ea] bg-[#fafafa] dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div className="mx-auto flex h-14 max-w-[1280px] items-center gap-3 px-4 sm:px-6">
|
||||||
|
{/* 品牌 */}
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex min-w-0 items-center gap-2.5 rounded-sm text-[14px] font-semibold tracking-tight text-[#1a1c1d] transition-opacity hover:opacity-70 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<BrandLogo
|
||||||
|
src={brandLogoUrl}
|
||||||
|
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
|
||||||
|
alt=""
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
unoptimized
|
||||||
|
className="h-7 w-7 shrink-0 rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
<span className="hidden sm:inline">由壹点·品牌中心</span>
|
||||||
|
<span className="sr-only sm:hidden">由壹点·品牌中心</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{sectionLabel ? (
|
||||||
|
/* 板块模式:面包屑 */
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true" className="text-[12px] text-[#d4d6da] dark:text-gray-600">/</span>
|
||||||
|
<span className="text-[13px] font-medium text-[#6b7075] dark:text-gray-300">{sectionLabel}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* 首页模式:五大板块导航 */
|
||||||
|
<nav className="ml-auto hidden items-center gap-6 md:flex" aria-label="看板板块导航">
|
||||||
|
{BOARD_NAV.filter((i) => !i.adminOnly || isAdmin).map((item) =>
|
||||||
|
item.planned ? (
|
||||||
|
<span key={item.key} className="text-[13px] text-[#86898e]" title="规划中,尚未开放">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={item.href ?? '/'}
|
||||||
|
aria-current={item.key === 'dashboard' ? 'page' : undefined}
|
||||||
|
className={`rounded-sm text-[13px] transition-opacity hover:opacity-60 ${
|
||||||
|
item.key === 'dashboard' ? 'font-semibold text-[#1a1c1d]' : 'text-[#6b7075]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav className={`flex items-center gap-3 sm:gap-5 ${sectionLabel ? 'ml-auto' : 'md:ml-0'}`} aria-label="页面导航">
|
||||||
|
{session && (
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<HeaderTaobaoSearch />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 网络状态 + 性能监控悬停卡片 */}
|
||||||
|
{networkChecked && (
|
||||||
|
<div
|
||||||
|
className="relative hidden sm:block"
|
||||||
|
onMouseEnter={canViewPerformance ? handlePerfMouseEnter : undefined}
|
||||||
|
onMouseLeave={canViewPerformance ? handlePerfMouseLeave : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
isInternalNetwork
|
||||||
|
? 'border-emerald-600/20 bg-emerald-600/10 text-emerald-700 dark:text-emerald-400'
|
||||||
|
: 'border-sky-600/20 bg-sky-600/10 text-sky-700 dark:text-sky-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<circle cx="10" cy="10" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span>{isInternalNetwork ? '内网' : '公网'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canViewPerformance && showPerfCard && (
|
||||||
|
<div className="absolute right-0 z-50 mt-2 w-96 rounded-lg border border-gray-700 bg-gray-900 p-4 text-gray-100 shadow-xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-gray-300">系统性能</span>
|
||||||
|
{perfLoading && <span className="text-xs text-gray-400">加载中...</span>}
|
||||||
|
{perfError && !perfLoading && <span className="text-xs text-red-400">{perfError}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-sm text-gray-300">
|
||||||
|
<span>CPU</span>
|
||||||
|
<span>{perfData?.cpu?.usage ?? '--'}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-800">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-emerald-500"
|
||||||
|
style={{ width: `${Math.min(Math.max(perfData?.cpu?.usage ?? 0, 0), 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex justify-between text-xs text-gray-500">
|
||||||
|
<span>1/5/15 分钟负载</span>
|
||||||
|
<span>{(perfData?.cpu?.loadAverage || []).map((v) => v.toFixed(2)).join(' / ') || '--'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-sm text-gray-300">
|
||||||
|
<span>内存</span>
|
||||||
|
<span>
|
||||||
|
{perfData?.memory
|
||||||
|
? `${perfData.memory.used} / ${perfData.memory.total} MB (${perfData.memory.percentage}%)`
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-800">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-sky-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(Math.max(perfData?.memory?.percentage ?? 0, 0), 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-sm text-gray-300">
|
||||||
|
<span>服务器网络</span>
|
||||||
|
<span className="text-xs text-gray-500">全站实时</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>入站</span>
|
||||||
|
<span>{perfData?.network?.uploadSpeed ? formatHumanSpeed(perfData.network.uploadSpeed) : '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400">
|
||||||
|
<span>出站</span>
|
||||||
|
<span>{perfData?.network?.downloadSpeed ? formatHumanSpeed(perfData.network.downloadSpeed) : '--'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500">服务器整体流量,非当前浏览器测速</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-3 border-t border-gray-800 pt-3">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex justify-between text-sm text-gray-300">
|
||||||
|
<span>上传队列</span>
|
||||||
|
<span>
|
||||||
|
{perfData?.uploadQueue
|
||||||
|
? `处理中 ${perfData.uploadQueue.processing?.length || 0} · 等待 ${perfData.uploadQueue.waiting?.length || 0}`
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{perfData?.uploadQueue?.processing && perfData.uploadQueue.processing.length > 0 && (
|
||||||
|
<div className="max-h-32 space-y-1.5 overflow-y-auto">
|
||||||
|
{perfData.uploadQueue.processing.map((task: any, idx: number) => {
|
||||||
|
const uploadedBytes =
|
||||||
|
task.uploadedBytes || (task.fileSize || 0) * ((task.progress || 0) / 100)
|
||||||
|
const speedText = task.currentSpeed
|
||||||
|
? formatTransferProgressSummary({
|
||||||
|
speedBytesPerSecond: task.currentSpeed,
|
||||||
|
loadedBytes: uploadedBytes,
|
||||||
|
totalBytes: task.fileSize,
|
||||||
|
})
|
||||||
|
: '速度: --'
|
||||||
|
return (
|
||||||
|
<div key={task.uploadId || idx} className="rounded bg-gray-800/50 px-2.5 py-1.5 text-xs text-gray-400">
|
||||||
|
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||||||
|
<div className="mt-1 flex justify-between text-xs">
|
||||||
|
<span>{task.username || '未知用户'}</span>
|
||||||
|
<span>{task.materialType || '未知类型'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
<div>进度: {task.progress ?? 0}%</div>
|
||||||
|
<div>{speedText}</div>
|
||||||
|
</div>
|
||||||
|
{task.isActive === false && (
|
||||||
|
<div className="mt-1 text-xs text-amber-400">无活动: {formatAgo(task.inactiveMs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{perfData?.uploadQueue?.waiting && perfData.uploadQueue.waiting.length > 0 && (
|
||||||
|
<div className="mt-2 max-h-32 space-y-1.5 overflow-y-auto">
|
||||||
|
{perfData.uploadQueue.waiting.map((task: any, idx: number) => (
|
||||||
|
<div key={task.uploadId || idx} className="rounded bg-gray-800/30 px-2.5 py-1.5 text-xs text-gray-500">
|
||||||
|
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||||||
|
<div className="mt-1 flex justify-between text-xs">
|
||||||
|
<span>{task.username || '未知用户'}</span>
|
||||||
|
<span>{task.materialType || '未知类型'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">等待中</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex justify-between text-sm text-gray-300">
|
||||||
|
<span>转码队列</span>
|
||||||
|
<span>
|
||||||
|
{perfData?.transcodeQueue
|
||||||
|
? `处理中 ${perfData.transcodeQueue.processing?.length || 0} / ${perfData.transcodeQueue.maxConcurrent || 0} · 等待 ${perfData.transcodeQueue.waiting?.length || 0}`
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{perfData?.transcodeQueue?.processing && perfData.transcodeQueue.processing.length > 0 && (
|
||||||
|
<div className="max-h-32 space-y-1.5 overflow-y-auto">
|
||||||
|
{perfData.transcodeQueue.processing.map((task: any, idx: number) => {
|
||||||
|
const stageMap: Record<string, string> = {
|
||||||
|
preview: '预览视频',
|
||||||
|
clip: '片段生成',
|
||||||
|
cover: '封面提取',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={task.materialId || idx} className="rounded bg-gray-800/50 px-2.5 py-1.5 text-xs text-gray-400">
|
||||||
|
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||||||
|
<div className="mt-1 flex justify-between text-xs">
|
||||||
|
<span>
|
||||||
|
{task.materialType || '未知类型'} · {task.stage ? stageMap[task.stage] || task.stage : '处理中'}
|
||||||
|
</span>
|
||||||
|
<span>{task.progress ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">上传人: {task.authorName || '未知用户'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{perfData?.transcodeQueue?.waiting && perfData.transcodeQueue.waiting.length > 0 && (
|
||||||
|
<div className="mt-2 max-h-32 space-y-1.5 overflow-y-auto">
|
||||||
|
{perfData.transcodeQueue.waiting.map((task: any, idx: number) => (
|
||||||
|
<div key={task.materialId || idx} className="rounded bg-gray-800/30 px-2.5 py-1.5 text-xs text-gray-500">
|
||||||
|
<div className="truncate font-medium">{task.fileName || '未知文件'}</div>
|
||||||
|
<div className="mt-1 flex justify-between text-xs">
|
||||||
|
<span>{task.materialType || '未知类型'}</span>
|
||||||
|
<span>队列 #{task.queuePosition || idx + 1}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">上传人: {task.authorName || '未知用户'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户菜单 */}
|
||||||
|
{session && (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
|
aria-label="打开用户菜单"
|
||||||
|
aria-expanded={showDropdown}
|
||||||
|
className="flex items-center rounded-lg p-1.5 transition-all hover:bg-gray-200/60 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{(session.user as any)?.avatar ? (
|
||||||
|
<Image
|
||||||
|
src={(session.user as any).avatar}
|
||||||
|
alt={session.user?.name || '用户'}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="h-8 w-8 rounded-full border border-[#e7e8ea] object-cover transition-all hover:border-[#c9ccd1] dark:border-gray-600"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#1a1c1d] text-base font-semibold text-white dark:bg-gray-200 dark:text-gray-900">
|
||||||
|
{getInitials(session.user?.name || '')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="absolute right-0 z-50 mt-2 w-48 rounded-lg border border-gray-200 bg-white py-2 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div className="border-b border-gray-100 px-4 py-2 dark:border-gray-700">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{session.user?.name}</p>
|
||||||
|
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{employeeLine ?? session.user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 档案与板块 */}
|
||||||
|
<Link href="/profile" onClick={() => setShowDropdown(false)} className={menuItemCls}>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span>我的档案</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/performance-survey" onClick={() => setShowDropdown(false)} className={menuItemCls}>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-6m4 6V7m4 10v-3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>绩效考核</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/work-review" onClick={() => setShowDropdown(false)} className={menuItemCls}>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm4 4h8M8 13h5m-5 4h3" />
|
||||||
|
</svg>
|
||||||
|
<span>作品过稿看板</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div aria-hidden="true" className="my-1.5 border-t border-gray-100 dark:border-gray-700" />
|
||||||
|
|
||||||
|
{/* 工具 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDropdown(false)
|
||||||
|
showUpdateLog('all')
|
||||||
|
}}
|
||||||
|
className={menuItemCls}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<span>更新记录</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://alidocs.dingtalk.com/i/nodes/NZQYprEoWoeEabj3srv6RwDkJ1waOeDk?utm_scene=person_space"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setShowDropdown(false)}
|
||||||
|
className={menuItemCls}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
<span>提交反馈</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div aria-hidden="true" className="my-1.5 border-t border-gray-100 dark:border-gray-700" />
|
||||||
|
<Link href="/admin" onClick={() => setShowDropdown(false)} className={menuItemCls}>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
/>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>治理与设置</span>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div aria-hidden="true" className="my-1.5 border-t border-gray-100 dark:border-gray-700" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setShowDropdown(false)
|
||||||
|
try {
|
||||||
|
await signOut({ redirect: false })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
router.push('/login')
|
||||||
|
router.refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('退出登录失败:', error)
|
||||||
|
router.push('/login')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-all flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<UpdateLogModal
|
||||||
|
isOpen={showUpdateLogModal}
|
||||||
|
onClose={handleUpdateLogClose}
|
||||||
|
updateLog={currentLog || undefined}
|
||||||
|
showAllHistory={showAllHistory}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -27,10 +27,10 @@ export function WorkReviewHeader({
|
|||||||
const initials = name.trim().slice(0, 2) || '我'
|
const initials = name.trim().slice(0, 2) || '我'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 bg-[#1f2937] text-white shadow-sm">
|
<header className="sticky top-0 z-30 border-b border-[#e7e8ea] bg-[#fafafa]">
|
||||||
<div className={`mx-auto flex min-h-[64px] w-full flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap sm:px-6 ${wide ? 'max-w-[1600px]' : 'max-w-[1280px]'}`}>
|
<div className={`mx-auto flex min-h-[64px] w-full flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap sm:px-6 ${wide ? 'max-w-[1600px]' : 'max-w-[1280px]'}`}>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-[12px] font-bold">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[#e7e8ea] bg-[#e9ebef] text-[12px] font-bold text-[#1a1c1d]">
|
||||||
{avatar ? (
|
{avatar ? (
|
||||||
<Image
|
<Image
|
||||||
src={avatar}
|
src={avatar}
|
||||||
@ -43,8 +43,8 @@ export function WorkReviewHeader({
|
|||||||
) : initials}
|
) : initials}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-[16px] font-bold">作品过稿看板</div>
|
<div className="truncate text-[16px] font-bold text-[#1a1c1d]">作品过稿看板</div>
|
||||||
<div className="truncate text-[11px] font-medium text-white/65">{name}</div>
|
<div className="truncate text-[11px] font-medium text-[#6b7075]">{name}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ export function WorkReviewHeader({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={secondaryAction.onClick}
|
onClick={secondaryAction.onClick}
|
||||||
className="inline-flex min-h-10 flex-1 items-center justify-center rounded bg-[#0050cb] px-3 text-[12px] font-bold text-white transition-colors hover:bg-[#003fa3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1f2937] sm:min-h-9 sm:flex-none sm:px-4 sm:text-[13px]"
|
className="inline-flex min-h-10 flex-1 items-center justify-center rounded-lg bg-[#1a1c1d] px-3 text-[12px] font-bold text-white transition-all hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] sm:min-h-9 sm:flex-none sm:px-4 sm:text-[13px]"
|
||||||
>
|
>
|
||||||
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
|
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
|
||||||
{secondaryAction.icon === 'add' ? '+' : '←'}
|
{secondaryAction.icon === 'add' ? '+' : '←'}
|
||||||
@ -65,10 +65,10 @@ export function WorkReviewHeader({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={primaryAction.onClick}
|
onClick={primaryAction.onClick}
|
||||||
className={`inline-flex items-center justify-center rounded px-3 text-[12px] font-bold text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1f2937] sm:min-h-9 sm:px-4 sm:text-[13px] ${
|
className={`inline-flex items-center justify-center rounded-lg px-3 text-[12px] font-bold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] sm:min-h-9 sm:px-4 sm:text-[13px] ${
|
||||||
secondaryAction
|
secondaryAction
|
||||||
? 'min-h-10 flex-1 border border-white/25 bg-white/10 hover:bg-white/15 sm:flex-none'
|
? 'min-h-10 flex-1 border border-[#1a1c1d] bg-white text-[#1a1c1d] hover:bg-[#1a1c1d] hover:text-white sm:flex-none'
|
||||||
: 'min-h-9 bg-[#0050cb] hover:bg-[#003fa3]'
|
: 'min-h-9 bg-[#1a1c1d] text-white hover:opacity-80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
|
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
|
||||||
@ -79,9 +79,9 @@ export function WorkReviewHeader({
|
|||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/materials"
|
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]"
|
className="inline-flex min-h-10 items-center justify-center rounded-lg px-2 text-[12px] font-semibold text-[#6b7075] transition-colors hover:bg-[#e9ebef] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/60 sm:min-h-9 sm:px-3 sm:text-[13px]"
|
||||||
>
|
>
|
||||||
返回素材库
|
返回创意资产库
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
# 设计 QA 存档(历史记录)
|
||||||
|
|
||||||
|
> **已被取代(2026-08-15)**:本文件是考核调研页 Stitch 对齐轮的 QA 产物,仅作历史留档。
|
||||||
|
> 当前全站视觉规范以 `DESIGN.md`(Zen Minimal × Future Organic 白皮书语言,全站浅色单主题)为准。
|
||||||
|
|
||||||
**Findings**
|
**Findings**
|
||||||
- No actionable P0/P1/P2 issues remain for the current Stitch alignment pass.
|
- No actionable P0/P1/P2 issues remain for the current Stitch alignment pass.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
> **更新(2026-08-15)**:深色主题已全站废弃(详见 DESIGN.md「全站浅色单主题」),本清单中"支持深浅色模式切换"的描述不再适用;
|
||||||
|
> 素材页顶栏已换成 `components/board/MaterialsHeader.tsx`(Header.tsx 仍服务其它遗留页面)。商品搜索功能本身不受影响。
|
||||||
|
|
||||||
# 前端 UI 改动清单
|
# 前端 UI 改动清单
|
||||||
|
|
||||||
## 当前进行中
|
## 当前进行中
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
> **视觉基线更新(2026-08-15)**:本提案落地时,图表与配色须遵循 `DESIGN.md` 的白皮书语言(全站浅色单主题),不得引入深色样式。
|
||||||
|
|
||||||
# 数据统计页 · 趋势可视化升级 设计提案
|
# 数据统计页 · 趋势可视化升级 设计提案
|
||||||
|
|
||||||
> 创建时间:2026-06-27
|
> 创建时间:2026-06-27
|
||||||
|
|||||||
@ -15,7 +15,8 @@ const ALWAYS_SKIPPED_DIRECTORIES = new Set([
|
|||||||
'.git', '.next', 'node_modules', 'coverage',
|
'.git', '.next', 'node_modules', 'coverage',
|
||||||
'.build-cache', '.docker-cache',
|
'.build-cache', '.docker-cache',
|
||||||
])
|
])
|
||||||
const ROOT_DATA_DIRECTORIES = new Set(['uploads', 'backups'])
|
// 根级本地数据目录:不进构建上下文(.dockerignore 已排除),扫描时同样豁免
|
||||||
|
const ROOT_DATA_DIRECTORIES = new Set(['uploads', 'backups', 'output'])
|
||||||
const LEGACY_READ_ONLY_MARKER = 'LEGACY_READ_ONLY'
|
const LEGACY_READ_ONLY_MARKER = 'LEGACY_READ_ONLY'
|
||||||
const LEGACY_DATA_REMEDIATION_MARKER = 'LEGACY_DATA_REMEDIATION'
|
const LEGACY_DATA_REMEDIATION_MARKER = 'LEGACY_DATA_REMEDIATION'
|
||||||
const DEFAULT_COMPOSE_FILES = new Set([
|
const DEFAULT_COMPOSE_FILES = new Set([
|
||||||
|
|||||||
@ -6,7 +6,7 @@ const config: Config = {
|
|||||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
],
|
],
|
||||||
darkMode: 'media', // 自动跟随系统黑暗模式设置
|
darkMode: 'class', // 深色主题已废弃(2026-08-15 用户裁定:全站统一浅色新视觉);class 策略且项目不挂 .dark 类 => dark: 变体全部失效
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@ -24,5 +24,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "types/**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "types/**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules", "flnxi_sucai", "gongxiangp2", "scripts"]
|
"exclude": ["node_modules", "flnxi_sucai", "gongxiangp2", "scripts", "output"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user