feat: align admin workspaces with white obsidian

This commit is contained in:
WuYongJie 2026-04-28 18:47:54 +08:00
parent 1740036d24
commit ab3d2d77d2
53 changed files with 2196 additions and 1407 deletions

View File

@ -1,14 +1,17 @@
# AGENTS.md
适用范围:`D:\AI Code\3danzhuang` 全仓库。
适用范围:`D:\AI Code\3danzhuang-wuyongjie-mainline` 全仓库。
未单独说明时,先遵循全局规则:`C:\Users\54972\.codex\AGENTS.md`。
这份文件只补充本仓库特有规则,不重复全局通用要求。
重要边界:`D:\AI Code\3danzhuang` 是历史旧现场;当前正式工作目录以 `D:\AI Code\3danzhuang-wuyongjie-mainline` 为准,除非用户明确要求,不要混用旧目录。
## 1. 默认执行口径
- 用户未指定目录时,默认在 `furniture-3d-system/` 执行。
- 当任务发生在 `furniture-3d-system/` 时,默认按当前会话直接开发。
- 未经用户明确要求,不主动 `commit / push`。
- 常规任务分支在完成必要验证、确认范围和目标后,可以按团队协作标准流程主动 `commit / push`。
- `main`、生产发布、破坏性操作、删除数据、改远端历史等高风险动作仍必须由用户明确授权。
- 禁止破坏性操作,除非用户明确授权。
- 没有“规范审查结论”,任务不算完成。
@ -34,22 +37,47 @@
## 4. 协作与分支口径
- `main` 只作为 `WuYongJie` 的主线收口分支,其他协作者默认不能直接推主线。
- `xuxiake` 的长期包装分支固定为 `codex/xuxiake-packaging`。
- `xuxiake` 的临时任务分支固定为 `codex/xuxiake-packaging-<任务简称>`。
- `main` 是最终发布主线,不能随便直接推;生产发布必须另行确认。
- `origin/codex/xuxiake-mainline` 是当前共享开发主线和默认同步基线。
- 新功能、优化和修复默认从最新 `origin/codex/xuxiake-mainline` 开任务分支,再按协作流程回流。
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`。
- 不再使用 `feature/packaging`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名。
- 长期包装分支同步 `main` 时固定使用 `merge main`。
- 临时个人任务分支同步 `main` 时才使用 `rebase main`。
- 长期共享分支同步当前共享开发主线时固定使用 `merge`。
- 临时个人任务分支只有在确认无人共用时才使用 `rebase`。
## 5. Gemini 前端协作口径
## 5. 白曜石工坊视觉口径
- 如项目已指定 `Gemini(前端专家)` 负责前端,则前端代码、UI 视觉、排版动效和 `3D(立体)` 表现层默认由 Gemini 主导。
- 我不主动改 Gemini 已定稿的前端样式;我默认负责底层逻辑、接口、数据、数据库、部署和非前端部分。
- 与 Gemini 联调时,必须优先遵守 Gemini 已确定的数据结构和类型口径,避免因为字段或格式不一致导致前端渲染异常。
- `docs/前端UI改动清单.md` 只作为当前轮次的轻量对接板使用,不作为长期规范来源、历史总账或唯一协作入口。
- `furniture-3d-system/docs/设计规范/白曜石工坊统一视觉标准.md` 是当前新平台第一视觉标准。
- 新平台主链页面,以及 `/admin`、`/users` 等后台管理页面,默认都要向白曜石工坊口径统一。
- 白曜石工坊默认是浅色工作台;不再提供深浅色主题切换入口,也不要把后台页面继续做成默认黑底。
- 页面应保持工作台感、低噪音、克制、层级清楚,不能退回普通 SaaS 卡片墙或营销式大卡片布局。
- `Inspector(检视器)`、数据列表、表格、长文本和表单密集区必须优先保证可读性,不能用毛玻璃承载正文。
- 毛玻璃只允许用于顶栏、HUD、浮动工具条和轻量状态浮层。
- 板材、五金、组件、包装等资产应按 `Meta Component(元组件)` 表达,而不是普通图片卡片。
- 如果苹果风格视觉标准与白曜石工坊标准冲突,新平台主链和后台管理页面优先执行白曜石工坊标准;苹果风格标准只作为基础组件、交互细节和可访问性补充参考。
## 6. 规范审查
## 6. 前端责任口径
- 未明确指定其他协作者时,前端、视觉、交互、`3D(立体)` 表现层和系统联调默认由当前 Codex 全权负责。
- 前端实现必须遵守白曜石工坊视觉标准、模块边界和最小验证要求。
- 处理前端页面时,优先用当前代码、实际页面表现、截图结论和设计规范判断,不再套用旧模型时期的保守分工。
- 复杂任务可以主动拆计划、并行排查和验证,但不能因为模型更强就扩大改动范围或跳过规范审查。
## 7. 截图和上下文续接
- 工程对话可以正常使用截图,但截图只用于当前判断。
- 压缩上下文、写会话恢复摘要或给新对话续接时,只保留文字结论、关键决策、当前状态和下一步。
- 不继承、不复述 `data:image base64(图片编码文本)`,也不要把图片原始内容写进摘要。
- 如果同一问题需要连续大量截图,优先开短图像对话处理,最后只把文字结论合回主工程对话。
## 8. 敏感信息和交接材料
- 账号、密码、服务器地址、访问密钥、数据库连接、S3/MinIO 密钥等敏感信息,只能放在本地续接材料里。
- 含敏感信息的摘要、日志或交接文件不能提交 Git,不能发给同事,不能写进共享文档。
- 仓库内协作文档只写非敏感入口、流程和职责;凭据类信息必须用 `[REDACTED_SECRET]` 或本地路径说明替代。
- 发现摘要文件里混有明文凭据时,默认先提示风险,再决定是否拆成本地敏感版和仓库协作版。
## 9. 规范审查
规范审查固定只查三件事:
@ -66,10 +94,13 @@
只要架构违规、视觉违规,或明显是补丁式修复,就必须 `rejected`。
如果这次不是独立复审,必须明确写出“这是自审,不是独立复审”。
统一清单:`docs/workflow/规范审查清单.md`
统一清单:`furniture-3d-system/docs/workflow/规范审查清单.md`
## 7. 标准来源
## 10. 标准来源
- `furniture-3d-system/docs/设计规范/白曜石工坊统一视觉标准.md`
- `furniture-3d-system/docs/workflow/规范审查清单.md`
补充参考:
- `furniture-3d-system/.cursor/rules/*.mdc`
- `furniture-3d-system/docs/设计规范/苹果风格视觉标准.md`
- `docs/workflow/规范审查清单.md`

View File

@ -1,18 +1,19 @@
# 主线同步操作手册
适用对象:会用 Codex 协作、但不一定熟悉 Git 的同事。
目标:当 `main` 持续更新时,你仍然能把自己的分支保持在最新状态,不把代码做旧,不把冲突拖到最后一天。
目标:当共享开发主线持续更新时,你仍然能把自己的分支保持在最新状态,不把代码做旧,不把冲突拖到最后一天。
---
## 1. 先记住一句话
你不要直接改 `main`。
你只在自己的分支里开发,然后经常把最新的 `main` 同步进来。
你只在自己的分支里开发,然后经常把最新的 `origin/codex/xuxiake-mainline` 同步进来。
当前推荐分工:
- `WuYongJie` 负责主线 `main`
- `WuYongJie` 负责最终发布主线 `main`
- 日常开发默认以 `origin/codex/xuxiake-mainline` 为共享开发主线
- 其他协作者在自己的长期共享分支或临时任务分支开发
分支命名固定规则:
@ -53,25 +54,24 @@ git status --short
- 要保留并提交:先提交到你当前分支
- 只是临时改动:先暂存(stash)再继续
不要在“有一堆未提交改动”时直接 `merge main` 或 `rebase main`。
不要在“有一堆未提交改动”时直接 `merge origin/codex/xuxiake-mainline` 或 `rebase origin/codex/xuxiake-mainline`。
---
如果你现在在长期共享分支,就把下面这 5 行命令原样交给 Codex 执行:
```bash
git checkout main
git pull origin main
git fetch origin
git checkout <你的长期共享分支>
git merge main
git merge origin/codex/xuxiake-mainline
git push origin <你的长期共享分支>
```
这组命令的白话意思:
1. 先把本地 `main` 拉到最新
1. 先刷新远端分支信息
2. 切回你的长期共享分支
3. 把最新主线合进这个共享分支
3. 把最新共享开发主线合进这个共享分支
4. 再把共享分支推上去
---
@ -81,10 +81,9 @@ git push origin <你的长期共享分支>
只有在你从长期共享分支再开出个人任务分支时,才用下面这套:
```bash
git checkout main
git pull origin main
git fetch origin
git checkout <你的分支名>
git rebase main
git rebase origin/codex/xuxiake-mainline
git push --force-with-lease origin <你的分支名>
```
@ -125,7 +124,7 @@ git push --force-with-lease origin <你的分支名>
可以直接把下面这段话发给 Codex:
```text
我在同步 main 到我的分支时遇到冲突了。
我在同步 `origin/codex/xuxiake-mainline` 到我的分支时遇到冲突了。
请只帮我处理这几个冲突文件,不要扩范围,不要改无关模块。
优先保留主线最新结构,再把我这个分支的目标改动接进去。
处理后请告诉我还需要执行什么命令。
@ -139,7 +138,7 @@ git push --force-with-lease origin <你的分支名>
- 不要长时间不同步主线
- 不要等“全部做完了”才第一次同步
- 不要把 `dev` 当万能长期分支名
- 不要对长期共享分支执行 `git rebase main`
- 不要对长期共享分支执行 `git rebase origin/codex/xuxiake-mainline`
- 不要看到冲突就随手全选自己的版本
- 不要在没看懂的情况下执行 `git reset --hard`
@ -150,7 +149,7 @@ git push --force-with-lease origin <你的分支名>
当同事分支已经推上来后,`WuYongJie` 固定按下面顺序做:
1. 先看交接说明,确认本次只做什么、没做什么、验证跑了什么
2. 看分支有没有同步最新 `main`
2. 看分支有没有同步最新 `origin/codex/xuxiake-mainline`
3. 看改动有没有超出任务范围,或者碰到共享接缝
4. 再决定这次是 `merge`、`cherry-pick`,还是手工整合
@ -174,7 +173,7 @@ git push --force-with-lease origin <你的分支名>
如果你懒得记,只记这一段:
```text
每天开工前,先把 main 拉最新,再把 main merge 进你的长期共享分支。
每天开工前,先 fetch 远端,再把 `origin/codex/xuxiake-mainline` merge 进你的长期共享分支。
只有你临时开的个人任务分支才用 rebase。
如果冲突了,不要硬处理,先让 Codex 只处理冲突文件。
```

View File

@ -20,10 +20,10 @@
```text
任务名称:<名称>
目标分支:<固定写你要推到哪个分支,例如 codex/xuxiake-packaging 或 codex/xuxiake-packaging-<任务简称>>
目标分支:<固定写你要推到哪个分支,例如 codex/<负责人>-<工作域> 或 codex/<负责人>-<工作域>-<任务简称>>
当前状态:<已完成到哪里>
你接手的部分:<只写这次要做的内容>
分支名称:<默认写 codex/xuxiake-packaging;如果是临时小任务,再写 codex/xuxiake-packaging-<任务简称>>
分支名称:<默认写 codex/<负责人>-<工作域>;如果是临时小任务,再写 codex/<负责人>-<工作域>-<任务简称>>
本次只改:<一句话说清本次改动范围>
本次明确没改:<一句话写清这次没碰什么>
是否碰共享接缝:<没有 / 有,改了哪些,为什么非改不可>
@ -89,17 +89,15 @@
- 按需跑相关测试
- 能跑就跑 `npm run build`
如果你当前在长期包装分支上,请先同步主线再继续开发:
- `git checkout main`
- `git pull origin main`
- `git checkout codex/xuxiake-packaging`
- `git merge main`
如果你当前在长期共享分支上,请先同步共享开发主线再继续开发:
- `git fetch origin`
- `git checkout <你的长期共享分支>`
- `git merge origin/codex/xuxiake-mainline`
如果你当前在自己的临时任务分支上,再用:
- `git checkout main`
- `git pull origin main`
- `git fetch origin`
- `git checkout <你的分支>`
- `git rebase main`
- `git rebase origin/codex/xuxiake-mainline`
完成后请按下面格式回报:
1. 现在你要做什么
@ -120,5 +118,5 @@
- 没写“本次明确没改什么”
- 没写有没有碰共享接缝
- 没写建议主线怎么收这次分支
- 没提醒对方先同步最新 `main`
- 没提醒对方先同步最新 `origin/codex/xuxiake-mainline`
- 没同步文档,导致下一个人继续靠猜

View File

@ -15,9 +15,9 @@
- 仓库里的 `AGENTS.md` 继续保留为团队共享规则
- 你同事在自己电脑上,再单独放一份“本地工作区级”的 `AGENTS.md`
当前这个项目里,真正的团队共享规则入口是上一层工作区的:
当前这个项目里,真正的团队共享规则入口是当前正式仓库根目录的:
- `D:\AI Code\3danzhuang\AGENTS.md`
- `D:\AI Code\3danzhuang-wuyongjie-mainline\AGENTS.md`
它已经明确要求:
@ -129,7 +129,7 @@ furniture-3d-system-codex-workspace/
- 先看哪些文档
- 这位同事当前只负责哪些模块
- 哪些目录绝对不要碰
- 开工前先同步 `main`
- 开工前先同步 `origin/codex/xuxiake-mainline`
- 结果汇报格式要简单
不适合放进去的内容:

View File

@ -61,7 +61,7 @@
## 5. 开发前的固定动作
- 开工前先同步最新 `main`
- 开工前先同步最新 `origin/codex/xuxiake-mainline`
- 主负责人刚推了重要改动后,再同步一次
- 自己准备提交前,再同步一次
@ -72,27 +72,25 @@
固定分支命名:
- 统一用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`
- 如果你是 `xuxiake`,长期分支固定使用 `codex/xuxiake-packaging`
- 只有临时小任务才使用 `codex/xuxiake-packaging-<任务简称>`
- 长期分支使用 `codex/<负责人>-<工作域>`
- 临时小任务使用 `codex/<负责人>-<工作域>-<任务简称>`
- 不要使用 `dev` 这种看不出职责的泛分支名
默认同步步骤:
```bash
git checkout main
git pull origin main
git checkout codex/xuxiake-packaging
git merge main
git push origin codex/xuxiake-packaging
git fetch origin
git checkout <你的长期共享分支>
git merge origin/codex/xuxiake-mainline
git push origin <你的长期共享分支>
```
如果你临时开的只是个人任务分支,再改用:
```bash
git checkout main
git pull origin main
git fetch origin
git checkout <你的分支名>
git rebase main
git rebase origin/codex/xuxiake-mainline
git push --force-with-lease origin <你的分支名>
```

View File

@ -29,7 +29,7 @@
### 先固定两件事
- `main` 继续只由 `WuYongJie` 直接 push 和收口。
- `main` 是最终发布主线,由 `WuYongJie` 收口;日常开发默认同步 `origin/codex/xuxiake-mainline`。
- 这份文档现在只固定 Git 协作方式、主线收口规则和共享接缝处理原则,不再把当前模块职责长期写死在这里。
### 当前主线入口
@ -69,9 +69,9 @@
### 主分支
- `main` 继续保持受保护状态,且当前只允许 `WuYongJie` 直接 push 和收口。
- `main` 继续保持受保护状态,只作为最终发布主线收口。
- 其他协作者默认不能直接 push `main`,只能走自己的功能分支。
- 这样做的目的不是放开主线,而是保证主负责人可以快速收口,同事不会误把半成品直接推上主线。
- 日常开发基线是 `origin/codex/xuxiake-mainline`,不要把半成品直接推上最终发布主线。
### 开发分支
@ -92,13 +92,13 @@
- `codex/wuyongjie-mainline`
- `codex/wuyongjie-mainline-asset-center`
- `codex/xuxiake-packaging`
- `codex/xuxiake-intake-warehouse-clean-20260415`
- `codex/xuxiake-costing`
- `codex/xuxiake-intake-clean`
- `codex/zhangsan-cad-compat`
### 合并方式
- 主负责人可以直接在 `main` 收口、修主线、推主线。
- 主负责人负责把稳定内容从共享开发主线收口到 `main`。
- 其他协作者默认先推自己的功能分支。
- 同事的改动进入主线前,默认先由主负责人检查和收口。
- 协作者不要自己合并到 `main`,也不要自己改远端主线历史。
@ -106,7 +106,7 @@
当前固定流程:
1. 协作者先从最新 `main` 同步到自己的长期分支或任务分支。
1. 协作者先从最新 `origin/codex/xuxiake-mainline` 同步到自己的长期分支或任务分支。
2. 协作者只在自己负责的工作域内开发。
3. 协作者推送自己的分支,并附上交接说明。
4. `WuYongJie` 负责检查范围、决定 merge / cherry-pick / 手工收口。
@ -116,7 +116,7 @@
`WuYongJie` 收同事分支前,固定先看这 6 项:
1. 这个分支是不是已经同步过最新 `main`
1. 这个分支是不是已经同步过最新 `origin/codex/xuxiake-mainline`
2. 这次改动是不是只落在任务约定范围内
3. 有没有碰共享接缝;如果碰了,交接里有没有写清原因
4. 有没有最小验证结果,而不是只说“应该可以”
@ -131,7 +131,7 @@
- 直接 `merge`
- 适用:改动范围干净,只在同事负责域内,没有混入共享接缝或无关改动
- 前提:已经同步最新 `main`,并且附了最小验证和交接说明
- 前提:已经同步最新 `origin/codex/xuxiake-mainline`,并且附了最小验证和交接说明
- 优先 `cherry-pick`
- 适用:同一个分支里既有可收的目标提交,也混入了暂时不该进主线的提交
@ -142,16 +142,16 @@
- 常见场景:同一个文件同时碰到主线重构区和同事任务区
- 暂不收口
- 适用:没同步最新 `main`、没交接说明、没最小验证、范围失控、直接改了敏感共享接缝但没写原因
- 适用:没同步最新 `origin/codex/xuxiake-mainline`、没交接说明、没最小验证、范围失控、直接改了敏感共享接缝但没写原因
- 处理方式:先退回补说明、补同步、补验证,再继续看
### 同事和主线怎么保持同步
当前项目默认采用这个口径:
- `WuYongJie` 负责持续更新和收口 `main`
- `WuYongJie` 负责维护共享开发主线,并把稳定内容收口到 `main`
- 协作者长期在自己的长期共享分支或当前任务分支开发
- 同事不能等做完了再一次性同步,而是要高频同步 `main`
- 同事不能等做完了再一次性同步,而是要高频同步 `origin/codex/xuxiake-mainline`
默认同步时机:
@ -161,33 +161,31 @@
默认推荐方式:
- 长期协作分支固定优先使用 `merge main`
- 只有临时个人任务分支才优先使用 `rebase main`
- 长期协作分支固定优先使用 `merge origin/codex/xuxiake-mainline`
- 只有临时个人任务分支才优先使用 `rebase origin/codex/xuxiake-mainline`
- 但不要一边长期不同步,一边继续堆改动
白话规则:
- 长期共享分支保留真实协作历史,所以固定用 `merge main`
- 临时个人任务分支只服务当前任务,为了保持提交线清楚,才用 `rebase main`
- 长期共享分支保留真实协作历史,所以固定用 `merge origin/codex/xuxiake-mainline`
- 临时个人任务分支只服务当前任务,为了保持提交线清楚,才用 `rebase origin/codex/xuxiake-mainline`
- 如果你拿不准自己现在是不是长期共享分支,就先按长期共享分支处理,不要擅自改写共享历史
长期共享分支日常同步固定步骤:
```bash
git checkout main
git pull origin main
git fetch origin
git checkout <你的长期共享分支>
git merge main
git merge origin/codex/xuxiake-mainline
git push origin <你的长期共享分支>
```
如果你临时开了个人任务分支,再用:
```bash
git checkout main
git pull origin main
git fetch origin
git checkout <你的临时任务分支>
git rebase main
git rebase origin/codex/xuxiake-mainline
git push --force-with-lease origin <你的临时任务分支>
```

View File

@ -5,7 +5,7 @@
配套入口:
- 先读 `docs/workflow/团队协作模式.md`,确认你属于哪个工作域、该用什么分支名
- 再读 `docs/workflow/主线同步操作手册.md`,确认你当前分支该用 `merge main` 还是 `rebase main`
- 再读 `docs/workflow/主线同步操作手册.md`,确认你当前分支该用 `merge origin/codex/xuxiake-mainline` 还是 `rebase origin/codex/xuxiake-mainline`
- 这份文档只负责讲边界,不额外创造新的角色体系或新的协作入口
- 这份文档只固定系统边界,不固定当前负责人;具体任务归属以当前任务单、交接摘要和已确认协作信息为准

View File

@ -12,6 +12,8 @@
### 视觉有没有跑偏
- 有没有明显偏离当前统一视觉标准
- 新平台主链页面、后台管理页面是否优先符合 `白曜石工坊统一视觉标准.md`
- `苹果风格视觉标准.md` 是否只作为基础组件、交互细节和可访问性补充参考
- 有没有临时拼凑感很强的新界面
- 新页面和现有页面的层级、间距、交互有没有失控

View File

@ -19,9 +19,9 @@
## 2. 五条铁律
### 铁律 1:开工先同步主线
### 铁律 1:开工先同步共享开发主线
- 每天开始开发前,先同步一次最新 `main`
- 每天开始开发前,先同步一次最新 `origin/codex/xuxiake-mainline`
- 主负责人刚推了重要改动后,再同步一次
- 自己准备提交前,再同步一次
@ -47,7 +47,7 @@
### 铁律 4:冲突先停,不硬解
- 同步 `main` 出现冲突时,先判断是不是自己负责的模块
- 同步 `origin/codex/xuxiake-mainline` 出现冲突时,先判断是不是自己负责的模块
- 不是自己负责的模块,先问主负责人
- 是自己负责的模块,再让 Codex 只处理冲突文件
@ -72,7 +72,7 @@
```md
## 高频协作五条铁律
- 开工前、刚有人更新主线后、准备提交前,先同步最新 `main`
- 开工前、刚有人更新共享开发主线后、准备提交前,先同步最新 `origin/codex/xuxiake-mainline`
- 每次任务先写清楚:这次只改什么、不改什么、哪些目录不能碰
- 公共模块和主链骨架优先由我收口,不让协作者顺手改成多份真相
- 遇到冲突先停下来判断边界,不要硬解
@ -88,7 +88,7 @@
```md
## 高频协作五条铁律
- 开工前、主负责人刚更新主线后、准备提交前,先同步最新 `main`
- 开工前、主负责人刚更新共享开发主线后、准备提交前,先同步最新 `origin/codex/xuxiake-mainline`
- 每次任务先说清楚:这次只改什么、不改什么、哪些目录不能碰
- 主导航、主流程骨架、公共数据结构,不要擅自大改
- 同步主线遇到冲突时,先只处理自己负责的文件,不要硬改别人的模块

View File

@ -26,8 +26,9 @@
固定协作分支口径:
- `xuxiake` 默认长期只在 `codex/xuxiake-packaging` 上继续开发
- 只有临时小任务才从它分出 `codex/xuxiake-packaging-<任务简称>`
- 日常开发默认先同步 `origin/codex/xuxiake-mainline`
- 长期包装分支使用 `codex/<负责人>-packaging`
- 临时小任务使用 `codex/<负责人>-packaging-<任务简称>`
- 不要使用 `dev` 这种泛分支名
- 不要直接 push `main`
@ -356,8 +357,8 @@
请接手当前仓库里的“新平台包装子工作区”任务,不要扩范围。
当前固定工作分支:
- 长期分支:`codex/xuxiake-packaging`
- 临时任务分支:`codex/xuxiake-packaging-<任务简称>`
- 长期分支:`codex/<负责人>-packaging`
- 临时任务分支:`codex/<负责人>-packaging-<任务简称>`
- 不要继续使用 `codex/xuxiake-dev`
- 不要使用 `feature/packaging`

View File

@ -56,7 +56,7 @@ const blueColor = appleColorTokens.system.blue.light
**最佳实践:**
- 优先使用语义化颜色(如 `apple-bg-primary`)
- 深色模式会自动切换颜色
- 当前产品固定浅色主题,不再提供深浅色切换
- 使用系统颜色保持一致性
## 基础组件

View File

@ -1,16 +1,16 @@
# Obsidian Atelier 统一视觉标准
# White Obsidian Atelier(白曜石工坊)统一视觉标准
适用范围:资产中心、辅助装配、草稿组、正式入库、轻仓库等新平台主链页面。
目标:统一“黑曜石 / Obsidian Atelier”视觉方向,避免把资产中心做成普通 SaaS 卡片列表,也避免把毛玻璃效果滥用成低可读性的装饰。
目标:统一“白曜石工坊 / White Obsidian Atelier”视觉方向。默认基调是浅色工作台,不再做深浅色主题切换;同时避免把资产中心做成普通 SaaS 卡片列表,也避免把毛玻璃效果滥用成低可读性的装饰。
---
## 1. 总体视觉方向
Obsidian Atelier 的关键词是:深色精密、克制通透、资产工作台、可检视的工业元件。
White Obsidian Atelier 的关键词是:浅色精密、克制通透、资产工作台、可检视的工业元件。
- `Obsidian(黑曜石)`:整体基调偏深、安静、低干扰,用深灰、炭黑、冷白、金属中性色建立专业感。
- `White Obsidian(白曜石)`:整体基调偏浅、安静、低干扰,用冷白、浅灰、石墨灰、金属中性色建立专业感。
- `Atelier(工坊)`:页面不是营销展示页,而是给人长期处理资产、装配、审核、入库的工作空间。
- `Unified(统一)`:资产中心、辅助装配、草稿组、正式入库、轻仓库要像同一套产品,不允许每个页面各自发明一套视觉语言。
- `3D Workbench(3D 工作台)`:新平台的第一感知应是“可操作的资产工作台”,而不是“管理后台卡片墙”。
@ -19,14 +19,15 @@ Obsidian Atelier 的关键词是:深色精密、克制通透、资产工作台
- 信息层级清楚,主操作区域足够安静。
- 强调 3D、材质、规格、状态和审核进度,而不是堆叠大面积营销式卡片。
- 避免一屏全是同色深灰,也避免大面积高饱和渐变。
- 避免一屏全是同色白灰,也避免大面积高饱和渐变。
- 不再提供深浅色主题切换;旧的深色实现只能作为历史代码兼容,不作为新设计依据。
- 装饰服从功能,任何质感效果都必须服务于空间层级、焦点和可读性。
---
## 2. 毛玻璃使用边界
毛玻璃是 Obsidian Atelier 中允许且需要的视觉语言,但它只能用于“浮在工作台上、需要轻量透出背景关系”的界面层。
毛玻璃是 White Obsidian Atelier 中允许且需要的视觉语言,但它只能用于“浮在工作台上、需要轻量透出背景关系”的界面层。
允许使用毛玻璃的区域:
@ -109,12 +110,6 @@ Obsidian Atelier 的关键词是:深色精密、克制通透、资产工作台
className="bg-white/72 backdrop-blur-xl ring-1 ring-black/5 shadow-sm"
```
深色场景可用:
```tsx
className="bg-zinc-950/64 backdrop-blur-2xl ring-1 ring-white/10 shadow-sm"
```
顶栏可用:
```tsx
@ -133,24 +128,12 @@ className="bg-white/68 backdrop-blur-2xl ring-1 ring-white/40 shadow-sm"
className="bg-white border-l border-zinc-200"
```
深色 Inspector 主面板:
```tsx
className="bg-zinc-950 border-l border-white/10"
```
数据列表主体应使用稳定、可读的实体层:
```tsx
className="bg-white divide-y divide-zinc-100"
```
深色数据列表主体:
```tsx
className="bg-zinc-950 divide-y divide-white/10"
```
使用限制:
- 不要把 `backdrop-blur-xl` 或 `backdrop-blur-2xl` 放进大面积主内容面板。
@ -158,14 +141,17 @@ className="bg-zinc-950 divide-y divide-white/10"
- 不要为了质感叠加多层 `shadow-xl`、高饱和渐变或大面积发光。
- 同一屏毛玻璃层级最多保留在顶栏、HUD、浮动工具条和少量状态浮层中。
- 如果文字可读性下降,以可读性为准,立即改回实体背景。
- 不新增深色入口,不在导航、登录、注册、后台页保留深浅色切换按钮。
---
## 6. 协作验收口径
做 Obsidian Atelier 相关页面时,至少按下面几条自查:
做 White Obsidian Atelier 相关页面时,至少按下面几条自查:
- 第一屏是否仍是工作台体验,而不是 SaaS 卡片墙。
- 页面默认是否为浅色工作台,而不是黑底后台。
- 是否已经移除深浅色主题切换入口。
- 右侧 Inspector 是否清晰、稳定、可长期阅读。
- 毛玻璃是否只出现在顶栏、HUD、浮动工具条和轻量状态浮层。
- 板材、五金、组件、包装是否按元组件表达,而不是普通展示卡片。

View File

@ -1,7 +1,8 @@
# 苹果风格视觉设计标准 (Apple-Style Visual Design Standards)
> **版本**: 2.0 (2026-01-29)
> **状态**: 现行标准 (Single Source of Truth)
> **状态**: 补充参考标准;当前新平台第一视觉标准以 `白曜石工坊统一视觉标准.md` 为准
> **优先级**: 如本文件与白曜石工坊标准冲突,优先执行白曜石工坊标准
> **目标**: 打造优雅、统一、沉浸式的用户体验,完全去除"模板化"和"AI味",接近苹果官网的视觉水准。
---
@ -26,7 +27,7 @@
### 1.1 内容至上 (Content is King)
* **原则**:界面不仅是容器,更是内容的舞台。UI 应该"退后",让 3D 模型、产品图片和关键数据"向前"。
* **执行**:
* 背景色主要使用白色或极淡的灰色(Light Mode),深色模式下使用纯黑。
* 背景色主要使用白色或极淡的灰色;当前产品不再提供深浅色主题切换。
* **拒绝**无意义的装饰性边框、分割线和阴影。只有在区分层级必要时才使用。
* **拒绝**均等分割的网格布局("AI味"典型特征)。使用非对称布局(如:左侧 2/3 展示 3D 模型,右侧 1/3 展示控制面板)。
@ -149,7 +150,7 @@
```
src/
├── lib/
│ └── design-tokens/ # 核心视觉配置 (Single Source of Truth)
│ └── design-tokens/ # 核心视觉配置
│ ├── typography.ts # 字体定义
│ ├── colors.ts # 颜色定义
│ ├── spacing.ts # 间距定义

View File

@ -150,7 +150,7 @@ MINIO_DATA_DIR=/path/to/minio-data ./scripts/backup-files.sh
**当前版本特性:**
- 顶部导航:返回首页、主题切换
- 顶部导航:返回首页、固定浅色工作台
- 检查项:
- 设计令牌系统
- 字体系统

View File

@ -5,11 +5,6 @@ import { prisma } from '@/lib/prisma'
import { canViewProduct } from '@/lib/permissions'
import { buildManualAssemblyWorkspace } from '@/lib/open-product/assembly-workspace'
jest.mock('@/components/ui/ThemeToggle', () => ({
__esModule: true,
default: () => <div>ThemeToggle</div>,
}))
jest.mock('@/components/ui/UserMenu', () => ({
__esModule: true,
default: () => <div>UserMenu</div>,

View File

@ -18,9 +18,11 @@ jest.mock('next/link', () => ({
),
}))
jest.mock('@/components/ui/ThemeToggle', () => ({
__esModule: true,
default: () => <div>ThemeToggle</div>,
jest.mock('next/navigation', () => ({
useRouter: () => ({
back: jest.fn(),
push: jest.fn(),
}),
}))
jest.mock('@/components/ui/UserMenu', () => ({

View File

@ -24,10 +24,6 @@ jest.mock('next/link', () => ({
jest.mock('@/components/products/UploadProductModal', () => () => null)
jest.mock('@/components/products/AddSKUModal', () => () => null)
jest.mock('@/components/ui/ThemeToggle', () => ({
__esModule: true,
default: () => <div>ThemeToggle</div>,
}))
jest.mock('@/components/ui/UserMenu', () => ({
__esModule: true,
default: () => <div>UserMenu</div>,

View File

@ -18,11 +18,6 @@ jest.mock('next/link', () => ({
),
}))
jest.mock('@/components/ui/ThemeToggle', () => ({
__esModule: true,
default: () => <div>ThemeToggle</div>,
}))
jest.mock('@/components/ui/UserMenu', () => ({
__esModule: true,
default: () => <div>UserMenu</div>,
@ -80,7 +75,16 @@ jest.mock('@/server/services', () => ({
seriesTemplateName: '单板桌',
},
]),
getById: jest.fn(),
getById: jest.fn().mockResolvedValue({
draftGroup: {
id: 'draft-1',
code: 'DG-001',
name: '包装中草稿',
status: 'PACKAGING',
seriesTemplateName: '单板桌',
},
preSkus: [],
}),
},
intakeService: {
recommendExistingProducts: jest.fn().mockResolvedValue([]),
@ -88,6 +92,24 @@ jest.mock('@/server/services', () => ({
warehouseService: {
listLocations: jest.fn().mockResolvedValue([]),
},
assetCenterService: {
listSubstrates: jest.fn().mockResolvedValue([]),
getSubstrateDictionaryBundle: jest.fn().mockResolvedValue({
categories: [],
substrateTypes: [],
processTypes: [],
}),
},
materialService: {
findAll: jest.fn().mockResolvedValue({ items: [] }),
findAllCategories: jest.fn().mockResolvedValue([]),
},
categoryService: {
listActive: jest.fn().mockResolvedValue([]),
},
intakeNonIntegratedProductService: {
list: jest.fn().mockResolvedValue([]),
},
}))
jest.mock('../intake/IntakeClient', () => ({

View File

@ -26,11 +26,6 @@ jest.mock('../HomeClient', () => ({
default: (props: unknown) => mockedHomeClient(props),
}))
jest.mock('@/components/ui/ThemeToggle', () => ({
__esModule: true,
default: () => <div>ThemeToggle</div>,
}))
jest.mock('@/components/ui/UserMenu', () => ({
__esModule: true,
default: () => <div>UserMenu</div>,

View File

@ -0,0 +1,119 @@
'use client'
import Link from 'next/link'
import {
Grid,
Palette,
ShieldCheck,
SwatchBook,
Users,
} from 'lucide-react'
import {
ObsidianControlShell,
ObsidianDataRow,
ObsidianInspectorSection,
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
interface AdminPortalClientProps {
role: string
}
const roleLabels: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
}
const adminModules = [
{
title: '用户权限中心',
subtitle: '权限',
href: '/users',
icon: Users,
metric: '组织 / 角色',
},
{
title: '主题视觉管理',
subtitle: '主题',
href: '/admin/themes',
icon: Palette,
metric: '配置',
},
{
title: '材质物理库',
subtitle: '材质',
href: '/asset-center/material-library?from=/admin&fromLabel=后台设置',
icon: SwatchBook,
metric: 'PBR',
},
{
title: '分类体系定义',
subtitle: '分类',
href: '/admin/categories',
icon: Grid,
metric: '排序',
},
]
export function AdminPortalClient({ role }: AdminPortalClientProps) {
const roleLabel = roleLabels[role] ?? role
return (
<ObsidianControlShell
eyebrow="后台"
title="系统总控制台"
icon={ShieldCheck}
backHref="/"
backLabel="工作台"
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="控制模块">
{adminModules.map((module) => (
<ObsidianRailButton key={module.href} href={module.href} icon={module.icon} label={module.title} meta={module.subtitle} />
))}
</ObsidianRailGroup>
<div className="obsidian-control-rail-card mt-auto rounded-md border p-4">
<p className="obsidian-control-rail-card-muted text-[10px] font-black uppercase tracking-[0.22em]">操作员</p>
<p className="mt-2 text-sm font-black">{roleLabel}</p>
</div>
</div>
}
inspector={
<div className="flex h-full flex-col bg-[#111719]">
<ObsidianInspectorSection title="当前账号">
<ObsidianDataRow label="角色" value={roleLabel} />
<ObsidianDataRow label="入口权限" value="已授权" tone="success" />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="模块数量">
<ObsidianDataRow label="可进入" value={`${adminModules.length}`} />
<ObsidianDataRow label="材质入口" value="资产中心" />
</ObsidianInspectorSection>
</div>
}
>
<div className="h-full bg-[#0f1416] p-5">
<div className="overflow-hidden rounded-md border border-white/10 bg-[#111719]">
{adminModules.map((module) => (
<Link
key={module.href}
href={module.href}
className="grid items-center gap-4 border-b border-white/10 px-5 py-4 transition last:border-b-0 hover:bg-white/[0.04] md:grid-cols-[40px_minmax(0,1fr)_96px]"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-white/10 bg-[#202a2d] text-[#aeb8bb]">
<module.icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<h3 className="truncate text-sm font-black text-[#edf4f2]">{module.title}</h3>
<p className="mt-1 text-xs text-[#8f9c9d]">{module.subtitle}</p>
</div>
<span className="rounded-md border border-white/10 bg-[#151d1f] px-3 py-2 text-center text-xs font-black text-[#cbd8d5]">
{module.metric}
</span>
</Link>
))}
</div>
</div>
</ObsidianControlShell>
)
}

View File

@ -7,14 +7,20 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { Grid, Layers, Plus, Pencil, Trash2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import Modal from '@/components/ui/Modal'
import Card from '@/components/ui/Card'
import { Title2, Body, Caption1 } from '@/components/ui/Typography'
import { Body, Caption1 } from '@/components/ui/Typography'
import { useToast, ToastContainer } from '@/components/ui/Toast'
import {
ObsidianControlShell,
ObsidianDataRow,
ObsidianInspectorSection,
ObsidianPanel,
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
import { getApiErrorMessage } from '@/lib/utils'
interface Category {
@ -27,7 +33,6 @@ interface Category {
}
export default function CategoriesPage() {
const router = useRouter()
const toast = useToast()
const toastError = toast.error
@ -192,99 +197,90 @@ export default function CategoriesPage() {
}
return (
<div className="min-h-screen bg-apple-bg-secondary">
{/* 顶部导航 */}
<header className="sticky top-0 z-50 h-14 bg-white/80 backdrop-blur-xl border-b border-apple-separator">
<nav className="h-full max-w-6xl mx-auto px-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="gray"
size="small"
onClick={() => router.back()}
>
返回
</Button>
<Title2 className="text-apple-label-primary">分类管理</Title2>
</div>
<Button
variant="filled"
size="small"
onClick={handleOpenAddModal}
className="gap-1.5"
>
<Plus className="w-4 h-4" />
添加分类
</Button>
</nav>
</header>
{/* 主内容 */}
<main className="max-w-4xl mx-auto px-6 py-8">
<Card variant="standard" className="overflow-hidden">
<div className="px-6 py-4 border-b border-apple-separator">
<Body className="text-apple-label-primary font-apple-medium">
产品分类列表
</Body>
</div>
{loading ? (
<div className="px-6 py-12 text-center">
<Caption1 className="text-apple-label-tertiary">加载中...</Caption1>
</div>
) : categories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Caption1 className="text-apple-label-tertiary">暂无分类,点击右上角添加</Caption1>
</div>
) : (
<div className="divide-y divide-apple-separator">
{categories.map((category) => (
<div
key={category.id}
className="px-6 py-4 flex items-center justify-between hover:bg-apple-bg-secondary/30 transition-colors"
>
<div className="flex items-center gap-4">
<div className="w-8 h-8 rounded-apple-md bg-apple-blue/10 flex items-center justify-center text-apple-blue">
<ObsidianControlShell
eyebrow="分类"
title="分类体系定义"
icon={Grid}
backHref="/admin"
backLabel="后台设置"
actions={
<Button variant="plain" size="small" onClick={handleOpenAddModal} className="obsidian-control-action rounded-md border">
<Plus className="h-4 w-4" />
添加分类
</Button>
}
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="分类视图">
<ObsidianRailButton active icon={Grid} label="全部分类" meta={`${categories.length}`} />
<ObsidianRailButton icon={Layers} label="已绑定产品" meta={`${categories.filter((category) => category.productCount > 0).length}`} />
</ObsidianRailGroup>
</div>
}
inspector={
<div className="flex h-full flex-col bg-[#111719]">
<ObsidianInspectorSection title="分类统计">
<ObsidianDataRow label="分类总数" value={`${categories.length}`} />
<ObsidianDataRow label="已绑定产品" value={`${categories.reduce((sum, category) => sum + category.productCount, 0)}`} />
<ObsidianDataRow label="空分类" value={`${categories.filter((category) => category.productCount === 0).length}`} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="规则">
<ObsidianDataRow label="删除保护" value="有产品不可删" tone="warning" />
<ObsidianDataRow label="排序字段" value="排序值" />
</ObsidianInspectorSection>
</div>
}
>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#0f1416]">
<header className="border-b border-white/10 bg-[#101719] px-5 py-4">
<h2 className="text-base font-black tracking-tight text-[#edf4f2]">产品分类</h2>
</header>
<div className="min-h-0 overflow-y-auto p-5">
<ObsidianPanel>
{loading ? (
<div className="px-6 py-12 text-center text-sm font-bold text-[#8f9c9d]">加载中...</div>
) : categories.length === 0 ? (
<div className="px-6 py-12 text-center text-sm font-bold text-[#8f9c9d]">暂无分类,点击右上角添加</div>
) : (
<div className="divide-y divide-white/10">
{categories.map((category) => (
<div
key={category.id}
className="grid gap-4 px-5 py-4 transition hover:bg-white/[0.04] md:grid-cols-[40px_minmax(0,1fr)_120px_auto]"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-white/10 bg-[#202a2d] text-sm font-black text-[#aeb8bb]">
{category.icon || category.name.charAt(0)}
</div>
<div>
<Body className="text-apple-label-primary font-apple-medium">
{category.name}
</Body>
{category.description && (
<Caption1 className="text-apple-label-tertiary">
{category.description}
</Caption1>
)}
<div className="min-w-0">
<p className="truncate text-sm font-black text-[#edf4f2]">{category.name}</p>
{category.description ? <p className="mt-1 text-xs leading-5 text-[#8f9c9d]">{category.description}</p> : null}
</div>
<div className="flex items-center">
<span className="rounded-md border border-white/10 bg-[#151d1f] px-3 py-2 text-xs font-black text-[#cbd8d5]">
{category.productCount} 个产品
</span>
</div>
<div className="flex items-center gap-2 md:justify-end">
<Button variant="plain" size="small" onClick={() => handleOpenEditModal(category)} className="rounded-md border border-white/10 text-[#cbd8d5] hover:bg-white/[0.06]">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="plain"
size="small"
onClick={() => setDeletingCategory(category)}
className="rounded-md border border-red-400/25 text-red-200 hover:bg-red-400/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Caption1 className="text-apple-label-tertiary ml-4">
{category.productCount} 个产品
</Caption1>
</div>
<div className="flex items-center gap-2">
<Button
variant="plain"
size="small"
onClick={() => handleOpenEditModal(category)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="plain"
size="small"
onClick={() => setDeletingCategory(category)}
className="text-apple-red hover:bg-apple-red/10"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</Card>
</main>
))}
</div>
)}
</ObsidianPanel>
</div>
</div>
{/* 添加/编辑弹窗 */}
<Modal
@ -350,7 +346,7 @@ export default function CategoriesPage() {
size="small"
>
<div className="space-y-4">
<Body className="text-apple-label-primary">
<Body className="text-[#cbd8d5]">
确定要删除分类 &quot;{deletingCategory?.name}&quot; 吗?
</Body>
{deletingCategory && deletingCategory.productCount > 0 && (
@ -381,6 +377,6 @@ export default function CategoriesPage() {
</Modal>
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} position="top" />
</div>
</ObsidianControlShell>
)
}

View File

@ -1,60 +1,10 @@
import Link from 'next/link'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import {
Users, Palette, SwatchBook, Grid, ArrowLeft, ArrowRight,
ShieldCheck, Activity, Database, Zap, Clock,
Cpu, Terminal, ShieldAlert, BarChart3
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { verifyToken } from '@/lib/jwt'
import Button from '@/components/ui/Button'
const adminModules = [
{
title: '用户权限中心',
subtitle: 'IDENTITY & ACCESS',
description: '管理系统访问权限、组织架构及账号生命周期,确保数据访问的安全合规。',
href: '/users',
icon: Users,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
stats: '1,248 Users Active',
},
{
title: '主题视觉管理',
subtitle: 'THEME ENGINE',
description: '配置系统全局视觉变量、品牌色、间距及 UI 调性,实现动态化品牌表达。',
href: '/admin/themes',
icon: Palette,
color: 'text-indigo-600',
bgColor: 'bg-indigo-50',
stats: '12 Active Themes',
},
{
title: '材质物理库',
subtitle: 'PHYSICAL MATERIALS',
description: '维护 3D 渲染核心材质球、PBR 物理参数及纹理资产,打造极致视觉还原。',
href: '/admin/materials',
icon: SwatchBook,
color: 'text-emerald-600',
bgColor: 'bg-emerald-50',
stats: '450+ Materials',
},
{
title: '分类体系定义',
subtitle: 'TAXONOMY SYSTEM',
description: '定义产品资产的标准化层级、标签规则与归类逻辑,支撑多维检索需求。',
href: '/admin/categories',
icon: Grid,
color: 'text-amber-600',
bgColor: 'bg-amber-50',
stats: '32 Categories',
},
]
import { AdminPortalClient } from './AdminPortalClient'
export const metadata = {
title: '系统总控制台 | Precision Atelier',
title: '系统总控制台 | White Obsidian Atelier',
}
export default async function AdminPortalPage() {
@ -68,192 +18,5 @@ export default async function AdminPortalPage() {
const allowedRoles = ['SUPER_ADMIN', 'ADMIN']
if (!allowedRoles.includes(payload.role)) redirect('/?error=unauthorized')
return (
<div className="relative min-h-screen overflow-hidden bg-[#f4f7f9] font-apple-system text-apple-label-primary">
{/* 沉浸式背景背景 */}
<div className="pointer-events-none fixed inset-0 z-0">
<div className="absolute left-[-10%] top-[-10%] h-[50%] w-[50%] rounded-full bg-blue-500/5 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] h-[50%] w-[50%] rounded-full bg-indigo-500/5 blur-[120px]" />
<div className="absolute inset-0 opacity-[0.02] mix-blend-overlay" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
</div>
<main className="relative z-10 mx-auto max-w-[1400px] px-10 py-16">
{/* Header Section */}
<header className="mb-16 flex items-end justify-between animate-in fade-in slide-in-from-top-4 duration-700">
<div className="flex items-center gap-8">
<div className="flex items-start gap-4">
<Link href="/">
<Button
variant="plain"
size="small"
className="mt-1 h-10 min-w-0 rounded-xl border border-apple-separator/10 bg-white/50 px-3 shadow-apple-xs transition-all hover:bg-white hover:shadow-apple-sm active:scale-95"
>
<ArrowLeft className="h-5 w-5 text-apple-label-tertiary" />
</Button>
</Link>
<div className="relative flex h-24 w-24 items-center justify-center rounded-[2.5rem] bg-black shadow-apple-2xl ring-1 ring-white/20">
<ShieldCheck className="h-12 w-12 text-white" />
<div className="absolute -bottom-1 -right-1 h-8 w-8 rounded-full bg-emerald-500 ring-4 ring-white flex items-center justify-center">
<Zap size={14} className="text-white" />
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-3">
<span className="text-[11px] font-apple-black uppercase tracking-[0.5em] text-apple-label-tertiary">
Precision Master Console
</span>
<div className="h-[1px] w-12 bg-apple-separator/20" />
</div>
<h1 className="text-4xl font-apple-black tracking-tighter uppercase">
系统<span className="text-apple-blue">总控制台</span>
</h1>
<p className="mt-2 text-sm font-apple-medium text-apple-label-tertiary">
集成式基础设施管理中心 · 实时监控系统运行状态与核心资产分发
</p>
</div>
</div>
<div className="hidden md:flex gap-4">
<QuickMetric icon={Cpu} label="CPU 负载" value="12.4%" />
<QuickMetric icon={Database} label="存储余量" value="840 GB" />
<QuickMetric icon={Activity} label="节点状态" value="ACTIVE" color="text-emerald-500" />
</div>
</header>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-12">
{/* Left: Core Modules (8 Columns) */}
<div className="lg:col-span-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{adminModules.map((module, idx) => (
<Link
key={module.href}
href={module.href}
className="group relative flex flex-col overflow-hidden rounded-[2.5rem] border border-white/80 bg-white/40 p-10 shadow-apple-sm backdrop-blur-xl transition-all duration-500 hover:-translate-y-2 hover:bg-white/80 hover:shadow-apple-2xl active:scale-[0.98]"
>
<div className="mb-10 flex items-start justify-between">
<div className={cn("flex h-16 w-16 items-center justify-center rounded-2xl shadow-apple-xs transition-all duration-500 group-hover:scale-110", module.bgColor, module.color)}>
<module.icon className="h-8 w-8" />
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-apple-separator/5 transition-all duration-500 group-hover:bg-apple-blue group-hover:text-white">
<ArrowRight className="h-5 w-5" />
</div>
</div>
<div className="relative">
<span className={cn("mb-2 block text-[10px] font-apple-black uppercase tracking-[0.3em]", module.color)}>
{module.subtitle}
</span>
<h2 className="mb-4 text-apple-xl font-apple-black tracking-tight text-apple-label-primary">
{module.title}
</h2>
<p className="text-apple-xs font-apple-medium leading-relaxed text-apple-label-tertiary">
{module.description}
</p>
</div>
{/* Footer Stats inside card */}
<div className="mt-8 flex items-center justify-between border-t border-apple-separator/5 pt-6 opacity-40 transition-opacity group-hover:opacity-100">
<span className="text-[9px] font-apple-black uppercase tracking-widest">{module.stats}</span>
<span className="text-[10px] font-apple-bold text-apple-blue">ENTER CENTER</span>
</div>
<span className="pointer-events-none absolute -bottom-6 -right-4 select-none text-[100px] font-apple-black uppercase tracking-tighter opacity-[0.02] transition-all duration-700 group-hover:opacity-[0.05]">
{module.href.split('/').pop()}
</span>
</Link>
))}
</div>
</div>
{/* Right: System Health & Logs (4 Columns) */}
<div className="lg:col-span-4 space-y-6">
<div className="rounded-[2.5rem] border border-white/80 bg-black shadow-apple-2xl p-8 text-white">
<div className="mb-8 flex items-center justify-between">
<h3 className="text-xs font-apple-black uppercase tracking-[0.3em] opacity-60">System Health</h3>
<BarChart3 size={16} className="text-emerald-500" />
</div>
<div className="space-y-6">
<HealthBar label="Database Cluster" progress={92} />
<HealthBar label="S3 Storage Proxy" progress={65} />
<HealthBar label="Auth Engine" progress={100} />
<HealthBar label="Render Farm" progress={42} color="bg-amber-500" />
</div>
<div className="mt-12 rounded-2xl bg-white/5 p-4 ring-1 ring-white/10">
<div className="flex items-center gap-3">
<ShieldAlert className="h-5 w-5 text-amber-500" />
<div className="flex flex-col">
<span className="text-[10px] font-apple-black uppercase">安全提示</span>
<span className="text-[11px] text-white/40">发现 2 个未挂载的材质分区</span>
</div>
</div>
</div>
</div>
<div className="rounded-[2.5rem] border border-white bg-white/50 shadow-apple-sm p-8 backdrop-blur-md">
<div className="mb-6 flex items-center justify-between">
<h3 className="text-xs font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">Live Logs</h3>
<Terminal size={14} className="text-apple-label-tertiary opacity-40" />
</div>
<div className="space-y-4">
<LogItem time="12:44" user="Gemini" action="重写了基础资料库 UI" />
<LogItem time="11:30" user="System" action="同步了 24 个新花色贴图" />
<LogItem time="09:15" user="Codex" action="更新了辅助装配逻辑" />
<LogItem time="08:22" user="Admin" action="锁定了 2 个离职账号" />
</div>
</div>
</div>
</div>
<footer className="mt-24 border-t border-apple-separator/10 pt-12 text-center">
<p className="text-[10px] font-apple-black uppercase tracking-[0.5em] text-apple-label-tertiary opacity-30">
Precision Atelier · Built for Next Generation Design
</p>
</footer>
</main>
</div>
)
}
function QuickMetric({ icon: Icon, label, value, color }: { icon: any, label: string, value: string, color?: string }) {
return (
<div className="flex items-center gap-4 rounded-3xl bg-white/60 p-5 shadow-apple-xs ring-1 ring-apple-separator/5 backdrop-blur-md">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-apple-xs">
<Icon className="h-5 w-5 text-apple-label-tertiary" />
</div>
<div className="flex flex-col">
<span className="text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary opacity-60">{label}</span>
<span className={cn("text-sm font-apple-black uppercase tracking-tight", color || "text-apple-label-primary")}>{value}</span>
</div>
</div>
)
}
function HealthBar({ label, progress, color = "bg-emerald-500" }: { label: string, progress: number, color?: string }) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-[10px] font-apple-bold uppercase tracking-widest">
<span className="opacity-60">{label}</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
<div className={cn("h-full transition-all duration-1000", color)} style={{ width: `${progress}%` }} />
</div>
</div>
)
}
function LogItem({ time, user, action }: { time: string, user: string, action: string }) {
return (
<div className="flex items-start gap-3">
<span className="text-[9px] font-apple-black text-apple-blue opacity-60 pt-0.5">{time}</span>
<div className="flex flex-col">
<span className="text-[10px] font-apple-black text-apple-label-secondary">{user}</span>
<span className="text-[11px] text-apple-label-tertiary leading-tight">{action}</span>
</div>
</div>
)
return <AdminPortalClient role={payload.role} />
}

View File

@ -9,14 +9,20 @@
*/
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { Input } from '@/components/ui/Input'
import Modal from '@/components/ui/Modal'
import { Badge } from '@/components/ui/Badge'
import { useToast } from '@/components/ui/Toast'
import { useAuthStore } from '@/stores/auth.store'
import { useAuth } from '@/hooks/useAuth'
import {
ObsidianControlShell,
ObsidianDataRow,
ObsidianInspectorSection,
ObsidianPanel,
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
import {
Plus,
Edit2,
@ -51,29 +57,26 @@ const initialFormData: ThemeFormData = {
loginPanel: null,
}
const roleLabels: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
}
// ============ 主组件 ============
export default function ThemeManagementClient() {
const router = useRouter()
const toast = useToast()
const { user } = useAuthStore()
const { user } = useAuth()
const canManageThemes = user?.role === 'SUPER_ADMIN'
// 状态
const [themes, setThemes] = useState<ThemeListItemDTO[]>([])
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingTheme, setEditingTheme] = useState<ThemeDetailDTO | null>(null)
const [formData, setFormData] = useState<ThemeFormData>(initialFormData)
const [submitting, setSubmitting] = useState(false)
// 权限检查
useEffect(() => {
if (user && user.role !== 'SUPER_ADMIN') {
toast.error('只有超级管理员可以访问此页面')
router.push('/')
}
}, [user, router, toast])
const fetchThemes = useCallback(async () => {
try {
setLoading(true)
@ -94,8 +97,10 @@ export default function ThemeManagementClient() {
// 加载主题列表
useEffect(() => {
fetchThemes()
}, [fetchThemes])
if (canManageThemes) {
fetchThemes()
}
}, [canManageThemes, fetchThemes])
// 打开创建/编辑模态框
const openModal = async (themeId?: string) => {
@ -246,133 +251,155 @@ export default function ThemeManagementClient() {
// ============ 渲染 ============
if (user?.role !== 'SUPER_ADMIN') {
return null
if (!canManageThemes) {
return (
<ObsidianControlShell
eyebrow="主题"
title="主题视觉管理"
icon={Palette}
backHref="/admin"
backLabel="后台设置"
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="主题状态">
<ObsidianRailButton active icon={Palette} label="权限受限" />
</ObsidianRailGroup>
</div>
}
inspector={
<div className="flex h-full flex-col bg-[#111719]">
<ObsidianInspectorSection title="权限">
<ObsidianDataRow label="当前角色" value={user ? roleLabels[user.role] ?? user.role : '-'} tone="warning" />
<ObsidianDataRow label="所需角色" value="超级管理员" />
<ObsidianDataRow label="保存接口" value="未开放" tone="warning" />
</ObsidianInspectorSection>
</div>
}
>
<div className="flex h-full items-center justify-center bg-[#0f1416] px-6">
<ObsidianPanel title="主题管理权限">
<div className="p-6">
<h2 className="text-lg font-black tracking-tight text-[#edf4f2]">当前账号不能管理主题</h2>
<p className="mt-2 text-sm leading-6 text-[#8f9c9d]">请使用超级管理员账号操作主题。</p>
</div>
</ObsidianPanel>
</div>
</ObsidianControlShell>
)
}
return (
<div className="min-h-screen bg-apple-bg-secondary">
{/* 顶部导航 */}
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur-xl border-b border-apple-separator">
<nav className="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="plain"
size="small"
onClick={() => router.push('/')}
>
← 返回首页
</Button>
<h1 className="text-apple-lg font-apple-system font-apple-semibold text-apple-label-primary">
主题管理
</h1>
</div>
<Button variant="filled" onClick={() => openModal()}>
<Plus className="w-4 h-4 mr-2" />
新建主题
</Button>
</nav>
</header>
<ObsidianControlShell
eyebrow="主题"
title="主题视觉管理"
icon={Palette}
backHref="/admin"
backLabel="后台设置"
actions={
<Button variant="plain" size="small" onClick={() => openModal()} className="obsidian-control-action rounded-md border">
<Plus className="h-4 w-4" />
新建主题
</Button>
}
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="主题状态">
<ObsidianRailButton active icon={Palette} label="全部主题" meta={`${themes.length}`} />
<ObsidianRailButton icon={Check} label="激活主题" meta={`${themes.filter((theme) => theme.isActive).length}`} />
<ObsidianRailButton icon={Star} label="默认主题" meta={`${themes.filter((theme) => theme.isDefault).length}`} />
</ObsidianRailGroup>
</div>
}
inspector={
<div className="flex h-full flex-col bg-[#111719]">
<ObsidianInspectorSection title="主题统计">
<ObsidianDataRow label="主题总数" value={`${themes.length}`} />
<ObsidianDataRow label="激活主题" value={`${themes.filter((theme) => theme.isActive).length}`} tone="success" />
<ObsidianDataRow label="默认主题" value={`${themes.filter((theme) => theme.isDefault).length}`} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="配置">
<ObsidianDataRow label="首页背景" value="可配置" />
<ObsidianDataRow label="登录面板" value="可配置" />
<ObsidianDataRow label="权限要求" value="超级管理员" />
</ObsidianInspectorSection>
</div>
}
>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#0f1416]">
<header className="border-b border-white/10 bg-[#101719] px-5 py-4">
<h2 className="text-base font-black tracking-tight text-[#edf4f2]">主题配置</h2>
</header>
{/* 主内容 */}
<main className="max-w-6xl mx-auto px-6 py-8">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-apple-label-secondary">加载中...</div>
</div>
) : themes.length === 0 ? (
<Card className="p-12 text-center">
<Palette className="w-12 h-12 mx-auto text-apple-label-tertiary mb-4" />
<p className="text-apple-label-secondary mb-4">暂无主题配置</p>
<Button variant="filled" onClick={() => openModal()}>
创建第一个主题
</Button>
</Card>
) : (
<div className="grid gap-4">
{themes.map((theme) => (
<Card key={theme.id} className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-apple-lg font-apple-system font-apple-semibold text-apple-label-primary">
{theme.name}
</h3>
{theme.isDefault && (
<Badge variant="subtle" size="small">
<Star className="w-3 h-3 mr-1" />
默认
</Badge>
)}
{theme.isActive && (
<Badge variant="filled" size="small">
<Check className="w-3 h-3 mr-1" />
激活
</Badge>
)}
{theme.startDate && theme.endDate && (
<Badge variant="outlined" size="small">
<Clock className="w-3 h-3 mr-1" />
定时
</Badge>
<div className="min-h-0 overflow-y-auto p-5">
<ObsidianPanel>
{loading ? (
<div className="flex h-64 items-center justify-center text-sm font-bold text-[#8f9c9d]">加载中...</div>
) : themes.length === 0 ? (
<div className="px-6 py-14 text-center">
<Palette className="mx-auto h-10 w-10 text-[#7f8d8e]" />
<p className="mt-4 text-sm font-bold text-[#8f9c9d]">暂无主题配置</p>
<Button variant="plain" onClick={() => openModal()} className="obsidian-control-action mt-5 rounded-md border">
创建第一个主题
</Button>
</div>
) : (
<div className="divide-y divide-white/10">
{themes.map((theme) => (
<div key={theme.id} className="grid gap-4 px-5 py-4 transition hover:bg-white/[0.04] md:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 className="truncate text-base font-black text-[#edf4f2]">{theme.name}</h3>
{theme.isDefault && (
<Badge variant="subtle" size="small" className="bg-white/[0.06] text-[#cbd8d5]">
<Star className="mr-1 h-3 w-3" />
默认
</Badge>
)}
{theme.isActive && (
<Badge variant="filled" size="small" className="bg-emerald-400/15 text-emerald-200">
<Check className="mr-1 h-3 w-3" />
激活
</Badge>
)}
{theme.startDate && theme.endDate && (
<Badge variant="outlined" size="small" className="border-white/10 text-[#aeb8bb]">
<Clock className="mr-1 h-3 w-3" />
定时
</Badge>
)}
</div>
{theme.description ? <p className="text-sm leading-6 text-[#8f9c9d]">{theme.description}</p> : null}
{(theme.startDate || theme.endDate) && (
<p className="mt-1 text-xs text-[#7f8d8e]">
{theme.startDate && `开始: ${new Date(theme.startDate).toLocaleString('zh-CN')}`}
{theme.startDate && theme.endDate && ' | '}
{theme.endDate && `结束: ${new Date(theme.endDate).toLocaleString('zh-CN')}`}
</p>
)}
</div>
{theme.description && (
<p className="text-apple-sm text-apple-label-secondary mb-2">
{theme.description}
</p>
)}
{(theme.startDate || theme.endDate) && (
<p className="text-apple-xs text-apple-label-tertiary">
{theme.startDate && `开始: ${new Date(theme.startDate).toLocaleString('zh-CN')}`}
{theme.startDate && theme.endDate && ' | '}
{theme.endDate && `结束: ${new Date(theme.endDate).toLocaleString('zh-CN')}`}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="gray"
size="small"
onClick={() => toggleActivate(theme.id, theme.isActive)}
>
{theme.isActive ? (
<>
<X className="w-4 h-4 mr-1" />
停用
</>
) : (
<>
<Check className="w-4 h-4 mr-1" />
激活
</>
)}
</Button>
<Button
variant="gray"
size="small"
onClick={() => openModal(theme.id)}
>
<Edit2 className="w-4 h-4 mr-1" />
编辑
</Button>
{!theme.isDefault && (
<Button
variant="gray"
size="small"
onClick={() => deleteTheme(theme.id)}
>
<Trash2 className="w-4 h-4 mr-1" />
删除
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<Button variant="plain" size="small" onClick={() => toggleActivate(theme.id, theme.isActive)} className="rounded-md border border-white/10 text-[#cbd8d5] hover:bg-white/[0.06]">
{theme.isActive ? <X className="mr-1 h-4 w-4" /> : <Check className="mr-1 h-4 w-4" />}
{theme.isActive ? '停用' : '激活'}
</Button>
)}
<Button variant="plain" size="small" onClick={() => openModal(theme.id)} className="rounded-md border border-white/10 text-[#cbd8d5] hover:bg-white/[0.06]">
<Edit2 className="mr-1 h-4 w-4" />
编辑
</Button>
{!theme.isDefault && (
<Button variant="plain" size="small" onClick={() => deleteTheme(theme.id)} className="rounded-md border border-red-400/25 text-red-200 hover:bg-red-400/10">
<Trash2 className="mr-1 h-4 w-4" />
删除
</Button>
)}
</div>
</div>
</div>
</Card>
))}
</div>
)}
</main>
))}
</div>
)}
</ObsidianPanel>
</div>
</div>
{/* 编辑模态框 */}
<Modal
@ -400,7 +427,7 @@ export default function ThemeManagementClient() {
{/* 定时发布 */}
<div className="space-y-4">
<h4 className="text-apple-sm font-apple-system font-apple-medium text-apple-label-primary">
<h4 className="text-apple-sm font-apple-system font-apple-medium text-[#aeb8bb]">
定时发布(可选)
</h4>
<div className="grid grid-cols-2 gap-4">
@ -421,7 +448,7 @@ export default function ThemeManagementClient() {
{/* 首页背景 */}
<div className="space-y-4">
<h4 className="text-apple-sm font-apple-system font-apple-medium text-apple-label-primary flex items-center gap-2">
<h4 className="flex items-center gap-2 text-apple-sm font-apple-system font-apple-medium text-[#aeb8bb]">
<ImageIcon className="w-4 h-4" />
首页背景
</h4>
@ -457,7 +484,7 @@ export default function ThemeManagementClient() {
{/* 登录页面板 */}
<div className="space-y-4">
<h4 className="text-apple-sm font-apple-system font-apple-medium text-apple-label-primary flex items-center gap-2">
<h4 className="flex items-center gap-2 text-apple-sm font-apple-system font-apple-medium text-[#aeb8bb]">
<Video className="w-4 h-4" />
登录页右侧面板
</h4>
@ -488,7 +515,7 @@ export default function ThemeManagementClient() {
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-4 border-t border-apple-separator">
<div className="flex justify-end gap-3 border-t border-white/10 pt-4">
<Button variant="gray" onClick={closeModal} disabled={submitting}>
取消
</Button>
@ -498,6 +525,6 @@ export default function ThemeManagementClient() {
</div>
</div>
</Modal>
</div>
</ObsidianControlShell>
)
}

View File

@ -7,13 +7,21 @@
*/
import { Metadata } from 'next'
import { AuthProvider } from '@/hooks/useAuth'
import { RequireAuth } from '@/components/auth/RequireAuth'
import ThemeManagementClient from './ThemeManagementClient'
export const metadata: Metadata = {
title: '主题管理 - 3D 家具系统',
title: '主题视觉管理 | White Obsidian Atelier',
description: '管理系统主题和背景配置',
}
export default function ThemeManagementPage() {
return <ThemeManagementClient />
return (
<AuthProvider>
<RequireAuth>
<ThemeManagementClient />
</RequireAuth>
</AuthProvider>
)
}

View File

@ -2,7 +2,7 @@
/**
* 基础资料库 (Material Library)
* Obsidian Atelier - 元组件资料工作台
* White Obsidian Atelier - 元组件资料工作台
* 布局标准:资料索引 - 材质/基材工作台 - 实体 Inspector
*/

View File

@ -2,7 +2,6 @@
import { useEffect, useState } from 'react'
import Button from '@/components/ui/Button'
import ThemeToggle from '@/components/ui/ThemeToggle'
export default function DebugThemePage() {
const [themeInfo, setThemeInfo] = useState({
@ -45,12 +44,8 @@ export default function DebugThemePage() {
attributeFilter: ['data-theme', 'class'],
})
// 监听自定义事件
window.addEventListener('theme-change', updateInfo)
return () => {
observer.disconnect()
window.removeEventListener('theme-change', updateInfo)
}
}, [])
@ -59,9 +54,8 @@ export default function DebugThemePage() {
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-label-primary)' }}>
主题系统调试页面
浅色主题调试页面
</h1>
<ThemeToggle />
</div>
{/* 调试信息卡片 */}
@ -174,8 +168,8 @@ export default function DebugThemePage() {
<div style={{ color: 'var(--color-label-secondary)' }} className="space-y-2">
<p>1. 打开浏览器开发者工具(F12)</p>
<p>2. 查看 Console 标签页,应该能看到详细的主题信息</p>
<p>3. 切换主题后,信息会自动更新</p>
<p>4. 检查 Elements 标签页,查看 &lt;html&gt; 标签的 data-theme 属性</p>
<p>3. 当前系统固定为浅色主题,不再提供深浅色切换</p>
<p>4. 检查 Elements 标签页,查看 &lt;html&gt; 标签的 data-theme 属性应为 light</p>
</div>
</div>
</div>

View File

@ -514,8 +514,8 @@
}
}
/* ===== Obsidian Material Library Theme Bridge =====
基础资料库使用黑曜石工作台结构,但必须跟随全局 light/dark 主题。 */
/* ===== White Obsidian Material Library Theme Bridge =====
基础资料库使用白曜石工作台结构;全局产品主题固定浅色。 */
@layer components {
:root,
[data-theme='light'] {
@ -630,6 +630,288 @@
border-color: var(--ml-border) !important;
}
:root,
[data-theme='light'] {
--obs-shell-bg: #eef2f3;
--obs-shell-text: #263033;
--obs-shell-panel-bg: #ffffff;
--obs-shell-content-bg: #f4f7f7;
--obs-shell-header-bg: #f8faf9;
--obs-shell-surface-bg: #ffffff;
--obs-shell-muted-bg: #edf2f2;
--obs-shell-rail-bg: #e5ebeb;
--obs-shell-border: rgba(17, 23, 25, 0.12);
--obs-shell-title: #101719;
--obs-shell-muted: #5f6d70;
--obs-shell-subtle: #7a878a;
--obs-shell-hover-bg: rgba(17, 23, 25, 0.045);
--obs-shell-control-bg: rgba(17, 23, 25, 0.045);
--obs-shell-control-hover-bg: rgba(17, 23, 25, 0.08);
--obs-shell-control-text: #4f5c5f;
--obs-shell-control-hover-text: #101719;
--obs-shell-icon-bg: rgba(17, 23, 25, 0.06);
--obs-shell-icon-text: #2d393c;
--obs-shell-rail-title: #687477;
--obs-shell-rail-text: #3f4a4d;
--obs-shell-rail-hover-bg: rgba(17, 23, 25, 0.06);
--obs-shell-rail-hover-text: #101719;
--obs-shell-rail-active-bg: #101719;
--obs-shell-rail-active-text: #f7f9f9;
--obs-shell-status-text: #566265;
--obs-shell-status-muted: #6d787b;
--obs-shell-status-strong: #101719;
--obs-shell-status-pill-bg: rgba(17, 23, 25, 0.055);
--obs-shell-action-bg: #101719;
--obs-shell-action-text: #f7f9f9;
--obs-shell-action-border: rgba(17, 23, 25, 0.16);
--obs-shell-action-hover-bg: #263033;
--obs-shell-rail-card-bg: rgba(17, 23, 25, 0.045);
--obs-shell-rail-card-text: #101719;
--obs-shell-rail-card-muted: #687477;
--obs-shell-success: #1f7a56;
--obs-shell-warning: #9a6a16;
--obs-shell-danger: #b3453d;
}
[data-theme='dark'] {
--obs-shell-bg: #111719;
--obs-shell-text: #e8edef;
--obs-shell-panel-bg: #111719;
--obs-shell-content-bg: #0f1416;
--obs-shell-header-bg: #101719;
--obs-shell-surface-bg: #151d1f;
--obs-shell-muted-bg: #202a2d;
--obs-shell-rail-bg: #111719;
--obs-shell-border: rgba(255, 255, 255, 0.10);
--obs-shell-title: #f4f7f6;
--obs-shell-muted: #aeb8bb;
--obs-shell-subtle: #8f9a9d;
--obs-shell-hover-bg: rgba(255, 255, 255, 0.055);
--obs-shell-control-bg: rgba(255, 255, 255, 0.04);
--obs-shell-control-hover-bg: rgba(255, 255, 255, 0.08);
--obs-shell-control-text: #aeb8bb;
--obs-shell-control-hover-text: #ffffff;
--obs-shell-icon-bg: rgba(255, 255, 255, 0.06);
--obs-shell-icon-text: #dfe6e8;
--obs-shell-rail-bg: #171d1f;
--obs-shell-rail-title: #748083;
--obs-shell-rail-text: #c4cccf;
--obs-shell-rail-hover-bg: rgba(255, 255, 255, 0.06);
--obs-shell-rail-hover-text: #ffffff;
--obs-shell-rail-active-bg: #ffffff;
--obs-shell-rail-active-text: #111719;
--obs-shell-status-text: #aeb8bb;
--obs-shell-status-muted: #748083;
--obs-shell-status-strong: #ffffff;
--obs-shell-status-pill-bg: rgba(255, 255, 255, 0.04);
--obs-shell-action-bg: #ffffff;
--obs-shell-action-text: #111719;
--obs-shell-action-border: rgba(255, 255, 255, 0.10);
--obs-shell-action-hover-bg: #e8edef;
--obs-shell-rail-card-bg: rgba(255, 255, 255, 0.04);
--obs-shell-rail-card-text: #ffffff;
--obs-shell-rail-card-muted: #748083;
--obs-shell-success: #9bdcc5;
--obs-shell-warning: #e5bd72;
--obs-shell-danger: #f3a29c;
}
.obsidian-control-grid {
background-image: var(--obs-shell-grid);
background-size: 28px 28px;
opacity: var(--obs-shell-grid-opacity);
}
.obsidian-control-vignette {
background: var(--obs-shell-vignette);
}
.obsidian-control-action {
border-color: var(--obs-shell-action-border) !important;
background: var(--obs-shell-action-bg) !important;
color: var(--obs-shell-action-text) !important;
}
.obsidian-control-action:hover {
background: var(--obs-shell-action-hover-bg) !important;
}
.obsidian-control-rail-card {
border-color: var(--obs-shell-border);
background: var(--obs-shell-rail-card-bg);
color: var(--obs-shell-rail-card-text);
}
.obsidian-control-rail-card-muted {
color: var(--obs-shell-rail-card-muted);
}
.obsidian-control-shell {
background: var(--obs-shell-bg) !important;
color: var(--obs-shell-text) !important;
}
.obsidian-control-shell > div > aside:first-of-type {
background: var(--obs-shell-rail-bg) !important;
border-color: var(--obs-shell-border) !important;
}
.obsidian-control-shell > div > section {
background: var(--obs-shell-panel-bg) !important;
border-color: var(--obs-shell-border) !important;
}
.obsidian-control-shell > div > aside:last-child {
background: var(--obs-shell-panel-bg) !important;
border-color: var(--obs-shell-border) !important;
}
.obsidian-control-shell > div > section > header,
.obsidian-control-shell > div > aside > div:first-child,
.obsidian-control-shell [class*="bg-[#101719]"] {
background: var(--obs-shell-header-bg) !important;
border-color: var(--obs-shell-border) !important;
}
.obsidian-control-shell [class*="bg-[#0d1113]"],
.obsidian-control-shell [class*="bg-[#0f1416]"],
.obsidian-control-shell [class*="bg-[#101517]"] {
background-color: var(--obs-shell-content-bg) !important;
}
.obsidian-control-shell [class*="bg-[#111719]"] {
background-color: var(--obs-shell-panel-bg) !important;
}
.obsidian-control-shell [class*="bg-[#151d1f]"] {
background-color: var(--obs-shell-surface-bg) !important;
}
.obsidian-control-shell [class*="bg-[#202a2d]"],
.obsidian-control-shell [class*="bg-white/[0.03]"],
.obsidian-control-shell [class*="bg-white/[0.04]"],
.obsidian-control-shell [class*="bg-white/[0.06]"] {
background-color: var(--obs-shell-muted-bg) !important;
}
.obsidian-control-shell [class*="bg-[#e7eeec]"] {
background-color: var(--obs-shell-rail-active-bg) !important;
}
.obsidian-control-shell [class*="border-white/10"],
.obsidian-control-shell [class*="border-red-400/25"],
.obsidian-control-shell [class*="border-amber-400/30"] {
border-color: var(--obs-shell-border) !important;
}
.obsidian-control-shell [class*="divide-white/10"] > :not([hidden]) ~ :not([hidden]) {
border-color: var(--obs-shell-border) !important;
}
.obsidian-control-shell [class*="text-[#f4f7f6]"],
.obsidian-control-shell [class*="text-[#edf4f2]"],
.obsidian-control-shell [class*="text-[#e7eeec]"],
.obsidian-control-shell [class*="text-[#dce7e4]"],
.obsidian-control-shell [class*="text-[#cbd8d5]"] {
color: var(--obs-shell-title) !important;
}
.obsidian-control-shell [class*="text-[#aeb8bb]"],
.obsidian-control-shell [class*="text-[#c0ccca]"],
.obsidian-control-shell [class*="text-[#9ba8a8]"],
.obsidian-control-shell [class*="text-[#8f9c9d]"],
.obsidian-control-shell [class*="text-[#7f8d8e]"],
.obsidian-control-shell [class*="text-[#788586]"],
.obsidian-control-shell [class*="text-[#748083]"] {
color: var(--obs-shell-muted) !important;
}
.obsidian-control-shell [class*="text-[#101517]"],
.obsidian-control-shell [class*="text-[#101719]"] {
color: var(--obs-shell-rail-active-text) !important;
}
.obsidian-control-shell [class*="text-white"] {
color: var(--obs-shell-title) !important;
}
.obsidian-control-shell [class*="hover:text-white"]:hover {
color: var(--obs-shell-rail-hover-text) !important;
}
.obsidian-control-shell [class*="text-emerald-200"],
.obsidian-control-shell [class*="text-[#9bdcc5]"] {
color: var(--obs-shell-success) !important;
}
.obsidian-control-shell [class*="text-amber-200"],
.obsidian-control-shell [class*="text-[#e5bd72]"] {
color: var(--obs-shell-warning) !important;
}
.obsidian-control-shell [class*="text-red-200"] {
color: var(--obs-shell-danger) !important;
}
.obsidian-control-shell [class*="hover:bg-white/[0.04]"]:hover,
.obsidian-control-shell [class*="hover:bg-white/[0.06]"]:hover,
.obsidian-control-shell [class*="hover:bg-red-400/10"]:hover,
.obsidian-control-shell [class*="hover:bg-amber-400/10"]:hover {
background-color: var(--obs-shell-hover-bg) !important;
}
.obsidian-modal-panel label,
.obsidian-modal-panel .text-apple-label-primary,
.obsidian-modal-panel .text-apple-label-secondary,
.obsidian-modal-panel .text-apple-label-tertiary {
color: var(--obs-shell-muted) !important;
}
.obsidian-modal-panel input,
.obsidian-modal-panel textarea,
.obsidian-modal-panel select {
border-color: var(--obs-shell-border) !important;
background: var(--obs-shell-surface-bg) !important;
color: var(--obs-shell-title) !important;
}
.obsidian-modal-panel input:focus,
.obsidian-modal-panel textarea:focus,
.obsidian-modal-panel select:focus {
border-color: var(--obs-shell-title) !important;
background: var(--obs-shell-surface-bg) !important;
}
.obsidian-modal-panel input::placeholder,
.obsidian-modal-panel textarea::placeholder {
color: var(--obs-shell-subtle) !important;
}
.obsidian-modal-panel [class*="bg-white"],
.obsidian-modal-panel [class*="bg-[#fbfcfc]"],
.obsidian-modal-panel [class*="bg-[#f7f8f8]"],
.obsidian-modal-panel [class*="bg-apple-bg-secondary"],
.obsidian-modal-panel [class*="bg-apple-bg-tertiary"] {
background-color: var(--obs-shell-surface-bg) !important;
}
.obsidian-modal-panel [class*="border-[#dde3e5]"],
.obsidian-modal-panel [class*="border-apple-separator"] {
border-color: var(--obs-shell-border) !important;
}
.obsidian-modal-panel [class*="text-[#2d3335]"],
.obsidian-modal-panel [class*="text-apple-label-primary"] {
color: var(--obs-shell-title) !important;
}
.obsidian-modal-panel [class*="text-[#6b7679]"],
.obsidian-modal-panel [class*="text-[#7a8588]"],
.obsidian-modal-panel [class*="text-apple-label-secondary"],
.obsidian-modal-panel [class*="text-apple-label-tertiary"] {
color: var(--obs-shell-muted) !important;
}
.material-library-obsidian [class*="ring-white/10"],
.material-library-obsidian [class*="ring-[#8fb6b0]"],
.material-library-obsidian [class*="ring-[#48686b]"] {

View File

@ -46,16 +46,10 @@ export default function RootLayout({
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
{/* 防止主题闪烁 - 必须在页面渲染前执行 */}
{/* 固定浅色主题,避免旧本地偏好把工作台切回深色 */}
<Script id="theme-init" strategy="beforeInteractive">
{`(function() {
try {
var savedTheme = localStorage.getItem('apple-ui-theme');
var theme = savedTheme || 'light';
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
document.documentElement.setAttribute('data-theme', 'light');
})();`}
</Script>
@ -64,7 +58,7 @@ export default function RootLayout({
<link rel="apple-touch-icon" href={APP_BRAND_LOGO_SRC} />
{/* 移动端优化 */}
<meta name="theme-color" content="#546067" />
<meta name="theme-color" content="#f6f8f9" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
</head>

View File

@ -17,7 +17,6 @@ import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LargeTitle, Body, Footnote, Caption2 } from '@/components/ui/Typography';
import ThemeToggle from '@/components/ui/ThemeToggle';
import { useToast, ToastContainer } from '@/components/ui/Toast';
import { getApiErrorMessage } from '@/lib/utils';
import type { ApiResponse } from '@/lib/api';
@ -177,11 +176,6 @@ export default function LoginPage() {
{/* Toast 容器 - 居中显示,类似系统通知 */}
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} position="top" />
{/* 主题切换按钮 - 移到右侧面板内或悬浮在顶层 */}
<div className="absolute top-6 right-6 z-50">
<ThemeToggle />
</div>
{/* 左侧:极简居中品牌卡片 */}
<div className="hidden lg:flex flex-1 relative items-center justify-center" style={{ backgroundColor: 'var(--color-surface-container-low)' }}>
{/* 居中容器 - 响应式宽度 */}

View File

@ -0,0 +1,178 @@
'use client'
import Link from 'next/link'
import { Activity, Building2, CalendarClock, Fingerprint, KeyRound, LogOut, ShieldCheck, UserRound, Users } from 'lucide-react'
import { AuthProvider, useAuth } from '@/hooks/useAuth'
import { RequireAuth } from '@/components/auth/RequireAuth'
import Button from '@/components/ui/Button'
import {
ObsidianControlShell,
ObsidianDataRow,
ObsidianInspectorSection,
ObsidianPanel,
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
const roleLabels: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
SUPERVISOR_DEPARTMENT: '部门主管',
SUPERVISOR_TEAM: '小组主管',
DESIGNER: '设计师',
USER: '普通用户',
}
const statusLabels: Record<string, string> = {
ACTIVE: '运行中',
PENDING: '待激活',
INACTIVE: '已离职',
SUSPENDED: '已锁定',
}
export default function ProfilePage() {
return (
<AuthProvider>
<RequireAuth>
<ProfileContent />
</RequireAuth>
</AuthProvider>
)
}
function ProfileContent() {
const { user, logout } = useAuth()
const canManageUsers = Boolean(user && ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM'].includes(user.role))
const canAccessAdmin = Boolean(user && ['SUPER_ADMIN', 'ADMIN'].includes(user.role))
const roleLabel = user ? roleLabels[user.role] ?? user.role : '-'
const statusLabel = user ? statusLabels[user.status] ?? user.status : '-'
const lastLoginText = user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '暂无记录'
return (
<ObsidianControlShell
eyebrow="账号"
title="个人中心"
icon={UserRound}
backHref="/"
backLabel="工作台"
actions={
<Button
variant="plain"
size="small"
onClick={() => void logout()}
className="obsidian-control-action rounded-md border px-4"
>
<LogOut className="h-4 w-4" />
退出登录
</Button>
}
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="身份入口">
<ObsidianRailButton active icon={Fingerprint} label="我的身份" meta={statusLabel} />
{canManageUsers ? <ObsidianRailButton href="/users" icon={Users} label="用户权限管理" /> : null}
{canAccessAdmin ? <ObsidianRailButton href="/admin" icon={KeyRound} label="后台设置" /> : null}
</ObsidianRailGroup>
<div className="obsidian-control-rail-card mt-auto rounded-md border p-4">
<p className="obsidian-control-rail-card-muted text-[10px] font-black uppercase tracking-[0.22em]">会话</p>
<p className="mt-2 text-sm font-black">{statusLabel}</p>
</div>
</div>
}
inspector={
<div className="flex h-full flex-col bg-[#111719]">
<ObsidianInspectorSection title="身份摘要">
<ObsidianDataRow label="账号状态" value={statusLabel} tone={user?.status === 'ACTIVE' ? 'success' : 'warning'} />
<ObsidianDataRow label="权限角色" value={roleLabel} />
<ObsidianDataRow label="登录来源" value={user?.loginSource || '系统账号'} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="组织归属">
<ObsidianDataRow label="部门" value={user?.department?.name || '未归属'} />
<ObsidianDataRow label="小组" value={user?.team?.name || '未归属'} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="安全记录">
<ObsidianDataRow label="最后登录" value={lastLoginText} />
<ObsidianDataRow label="首次登录" value={user?.isFirstLogin ? '是' : '否'} />
</ObsidianInspectorSection>
</div>
}
>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#0f1416]">
<header className="border-b border-white/10 bg-[#101719] px-5 py-4">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-xl font-black tracking-tight text-[#edf4f2]">{user?.name || '当前用户'}</h2>
<p className="mt-1 text-sm font-semibold text-[#8f9c9d]">{user?.email}</p>
</div>
<div className="rounded-md border border-white/10 bg-[#151d1f] px-4 py-3">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7f8d8e]">角色</p>
<p className="mt-1 text-sm font-black text-[#edf4f2]">{roleLabel}</p>
</div>
</div>
</header>
<div className="min-h-0 overflow-y-auto p-5">
<div className="grid gap-4 xl:grid-cols-3">
<ObsidianPanel title="身份令牌" className="xl:col-span-2">
<div className="grid gap-4 p-5 md:grid-cols-3">
<ProfileMetric icon={Fingerprint} label="账号 ID" value={user?.id.slice(0, 8) ?? '-'} />
<ProfileMetric icon={ShieldCheck} label="权限角色" value={roleLabel} />
<ProfileMetric icon={Activity} label="状态" value={statusLabel} />
</div>
</ObsidianPanel>
<ObsidianPanel title="组织坐标">
<div className="space-y-3 p-5">
<ProfileLine icon={Building2} label="部门" value={user?.department?.name || '未归属'} />
<ProfileLine icon={Users} label="小组" value={user?.team?.name || '未归属'} />
<ProfileLine icon={CalendarClock} label="最后登录" value={lastLoginText} />
</div>
</ObsidianPanel>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-3">
{canManageUsers ? (
<ProfileAction href="/users" title="用户权限管理" />
) : null}
{canAccessAdmin ? (
<ProfileAction href="/admin" title="后台设置" />
) : null}
<ProfileAction href="/" title="返回工作台" />
</div>
</div>
</div>
</ObsidianControlShell>
)
}
function ProfileMetric({ icon: Icon, label, value }: { icon: typeof Fingerprint; label: string; value: string }) {
return (
<div className="rounded-md border border-white/10 bg-[#151d1f] p-4">
<Icon className="h-5 w-5 text-[#aeb8bb]" />
<p className="mt-4 text-[10px] font-black uppercase tracking-[0.2em] text-[#7f8d8e]">{label}</p>
<p className="mt-1 truncate text-lg font-black text-[#edf4f2]">{value}</p>
</div>
)
}
function ProfileLine({ icon: Icon, label, value }: { icon: typeof Building2; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-md border border-white/10 bg-[#151d1f] px-3 py-3">
<Icon className="h-4 w-4 text-[#7f8d8e]" />
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#7f8d8e]">{label}</p>
<p className="truncate text-sm font-bold text-[#edf4f2]">{value}</p>
</div>
</div>
)
}
function ProfileAction({ href, title }: { href: string; title: string }) {
return (
<Link href={href} className="block rounded-md border border-white/10 bg-[#111719] p-4 transition hover:border-white/20 hover:bg-[#151d1f]">
<p className="text-sm font-black text-[#edf4f2]">{title}</p>
</Link>
)
}

View File

@ -6,7 +6,6 @@ import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LargeTitle, Body, Footnote, Caption2 } from '@/components/ui/Typography';
import ThemeToggle from '@/components/ui/ThemeToggle';
import { useToast, ToastContainer } from '@/components/ui/Toast';
import { getApiErrorMessage } from '@/lib/utils';
import type { ApiResponse } from '@/lib/api';
@ -92,10 +91,6 @@ export default function RegisterPage() {
<div className="min-h-screen w-full flex bg-(--color-background-secondary) transition-colors duration-300 relative overflow-hidden">
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} position="top" />
<div className="absolute top-6 right-6 z-50">
<ThemeToggle />
</div>
<div className="hidden lg:flex flex-1 relative items-center justify-center bg-[#f3f4f6]">
<div className="flex flex-col items-center justify-center text-center w-[420px] xl:w-[520px] min-h-[300px] xl:min-h-[360px]">
<svg className="w-12 xl:w-14 h-12 xl:h-14" viewBox="0 0 24 24" fill="none" stroke="#2b2f33" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function SettingsRouteAlias() {
redirect('/admin')
}

View File

@ -3,7 +3,7 @@
*
* 验证所有设计系统组件和功能的正常运行
*
* @updated 2025-12-12 - 适配深浅色主题
* @updated 2026-04-28 - 固定浅色主题
*/
'use client'
@ -16,7 +16,6 @@ import Card from '@/components/ui/Card'
import Badge, { type BadgeType } from '@/components/ui/Badge'
import ProgressBar from '@/components/ui/ProgressBar'
import { useToast, ToastContainer } from '@/components/ui/Toast'
import ThemeToggle from '@/components/ui/ThemeToggle'
import Link from 'next/link'
interface HealthCheck {
@ -63,7 +62,7 @@ export default function SystemHealthPage() {
{ name: '动画系统', status: 'loading', message: '检查中...' },
{ name: '可访问性支持', status: 'loading', message: '检查中...' },
{ name: '组件性能', status: 'loading', message: '检查中...' },
{ name: '主题系统', status: 'loading', message: '检查中...' },
{ name: '浅色主题', status: 'loading', message: '检查中...' },
])
const [overallProgress, setOverallProgress] = React.useState(0)
@ -159,14 +158,13 @@ export default function SystemHealthPage() {
details: ['React.memo ✓', 'useMemo ✓', 'useCallback ✓', '虚拟滚动 ✓']
},
{
name: '主题系统',
name: '浅色主题',
test: () => {
const supportsColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
return supportsColorScheme ? 'success' : 'warning'
return document.documentElement.getAttribute('data-theme') === 'light' ? 'success' : 'error'
},
successMessage: '主题系统完全支持',
warningMessage: '主题检测功能受限',
details: ['浅色主题 ✓', '深色主题 ✓', '系统检测 ✓', '平滑过渡 ✓']
successMessage: '浅色主题已固定启用',
errorMessage: '浅色主题未正确写入',
details: ['data-theme=light ✓', '深浅色切换已移除 ✓', '白曜石工作台基调 ✓']
}
]
let successCount = 0
@ -308,7 +306,6 @@ export default function SystemHealthPage() {
>
重新检查
</Button>
<ThemeToggle />
</div>
</div>
</div>

View File

@ -5,7 +5,7 @@
* 用于测试和验证设计系统的实现
*
* @created 2025-12-11
* @updated 2025-12-12 - 完善深浅色主题适配
* @updated 2026-04-28 - 固定浅色主题
*/
'use client'
@ -42,7 +42,6 @@ import Spinner from '@/components/ui/Spinner'
import { SlideInUp } from '@/components/ui/AnimatedEntry'
import { SuccessAlert, ErrorAlert, WarningAlert, InfoAlert } from '@/components/ui/Alert'
import { AccessibilityPanel, PreferencesProvider } from '@/lib/preferences'
import ThemeToggle from '@/components/ui/ThemeToggle'
export default function UIPreviewPage() {
const [progress, setProgress] = React.useState(45)
@ -77,7 +76,6 @@ export default function UIPreviewPage() {
基于苹果 Human Interface Guidelines 2025 的设计系统实现
</Body>
</div>
<ThemeToggle />
</div>
</SlideInUp>

View File

@ -1,49 +1,73 @@
'use client';
'use client'
/**
* 身份控制中心 (Identity Control Center)
* Precision Atelier - 三栏式管理工作台重构版
*/
import { useCallback, useState, useMemo } from 'react';
import type { Role } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import {
Search, ShieldCheck, UserPlus,
Fingerprint, Mail, Phone, Briefcase, Calendar, Shield
} from 'lucide-react';
import { AuthProvider, useAuth } from '@/hooks/useAuth';
import { RequireAuth } from '@/components/auth/RequireAuth';
import { PermissionGuard } from '@/components/auth/PermissionGuard';
import Button from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Alert } from '@/components/ui/Alert';
import { ActivateUserModal } from '@/components/users/ActivateUserModal';
import { CreateUserModal } from '@/components/users/CreateUserModal';
import { EditUserModal } from '@/components/users/EditUserModal';
import { HandoverModal } from '@/components/users/HandoverModal';
import { DepartmentAccessTree, type UserTreeItem } from '@/components/users/DepartmentAccessTree';
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout';
import { canAssignRoleByHierarchy, getAssignableRolesForRole } from '@/lib/role-scope';
import { cn } from '@/lib/utils';
import { useDebounce } from '@/hooks/usePerformance';
import { useCallback, useMemo, useState } from 'react'
import type { Role } from '@prisma/client'
import type { LucideIcon } from 'lucide-react'
import {
Activity,
Briefcase,
Fingerprint,
KeyRound,
Mail,
Phone,
Search,
ShieldCheck,
UserPlus,
Users,
} from 'lucide-react'
import { AuthProvider, useAuth } from '@/hooks/useAuth'
import { RequireAuth } from '@/components/auth/RequireAuth'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import Button from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { ActivateUserModal } from '@/components/users/ActivateUserModal'
import { CreateUserModal } from '@/components/users/CreateUserModal'
import { EditUserModal } from '@/components/users/EditUserModal'
import { HandoverModal } from '@/components/users/HandoverModal'
import { DepartmentAccessTree, type UserTreeItem } from '@/components/users/DepartmentAccessTree'
import {
ObsidianControlShell,
ObsidianDataRow,
ObsidianInspectorSection,
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
import { canAssignRoleByHierarchy, getAssignableRolesForRole } from '@/lib/role-scope'
import { useDebounce } from '@/hooks/usePerformance'
const ROLE_OPTIONS = [
{ value: '', label: '所有权限角色' },
{ value: 'SUPER_ADMIN', label: '超级管理员' },
{ value: 'ADMIN', label: '管理员' },
{ value: 'SUPERVISOR', label: '主管' },
{ value: 'DESIGNER', label: '设计师' },
{ value: 'USER', label: '普通用户' },
];
{ value: '', label: '所有权限角色', icon: Users },
{ value: 'SUPER_ADMIN', label: '超级管理员', icon: KeyRound },
{ value: 'ADMIN', label: '管理员', icon: ShieldCheck },
{ value: 'SUPERVISOR', label: '主管', icon: Briefcase },
{ value: 'DESIGNER', label: '设计师', icon: Fingerprint },
{ value: 'USER', label: '普通用户', icon: Users },
] as const
const STATUS_OPTIONS = [
{ value: '', label: '全状态' },
{ value: 'ACTIVE', label: '运行中 / ACTIVE' },
{ value: 'PENDING', label: '待激活 / PENDING' },
{ value: 'INACTIVE', label: '已离职 / INACTIVE' },
{ value: 'SUSPENDED', label: '已锁定 / LOCKED' },
];
{ value: 'ACTIVE', label: '运行中' },
{ value: 'PENDING', label: '待激活' },
{ value: 'INACTIVE', label: '已离职' },
{ value: 'SUSPENDED', label: '已锁定' },
] as const
const roleLabels: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
SUPERVISOR_DEPARTMENT: '部门主管',
SUPERVISOR_TEAM: '小组主管',
DESIGNER: '设计师',
USER: '普通用户',
}
const statusLabels: Record<string, string> = {
ACTIVE: '运行中',
PENDING: '待激活',
INACTIVE: '已离职',
SUSPENDED: '已锁定',
}
export default function UsersPage() {
return (
@ -54,283 +78,259 @@ export default function UsersPage() {
</PermissionGuard>
</RequireAuth>
</AuthProvider>
);
)
}
function UsersPageContent() {
const { user: currentUser } = useAuth();
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [refreshToken, setRefreshToken] = useState(0);
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserTreeItem | null>(null);
const [activationUser, setActivationUser] = useState<UserTreeItem | null>(null);
const [handoverUser, setHandoverUser] = useState<UserTreeItem | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const { user: currentUser } = useAuth()
const [searchQuery, setSearchQuery] = useState('')
const [roleFilter, setRoleFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [refreshToken, setRefreshToken] = useState(0)
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserTreeItem | null>(null)
const [editingUser, setEditingUser] = useState<UserTreeItem | null>(null)
const [activationUser, setActivationUser] = useState<UserTreeItem | null>(null)
const [handoverUser, setHandoverUser] = useState<UserTreeItem | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const assignableRoles = useMemo<Role[]>(
() => (currentUser ? (getAssignableRolesForRole(currentUser.role) as Role[]) : []),
[currentUser]
);
[currentUser],
)
const canEditManagedUser = useCallback(
(user: UserTreeItem) => {
if (!currentUser) return false;
if (user.id === currentUser.id) return true;
if (currentUser.role === 'SUPER_ADMIN') return true;
return canAssignRoleByHierarchy(currentUser.role, user.role as Role);
if (!currentUser) return false
if (user.id === currentUser.id) return true
if (currentUser.role === 'SUPER_ADMIN') return true
return canAssignRoleByHierarchy(currentUser.role, user.role as Role)
},
[currentUser]
);
[currentUser],
)
const canActivatePendingUser = useCallback(
(user: UserTreeItem) => {
if (!currentUser) return false;
if (user.id === currentUser.id) return false;
if (!['SUPERVISOR', 'ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) return false;
if (currentUser.role === 'SUPER_ADMIN') return true;
return canAssignRoleByHierarchy(currentUser.role, user.role as Role);
if (!currentUser) return false
if (user.id === currentUser.id) return false
if (!['SUPERVISOR', 'ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) return false
if (currentUser.role === 'SUPER_ADMIN') return true
return canAssignRoleByHierarchy(currentUser.role, user.role as Role)
},
[currentUser]
);
[currentUser],
)
const triggerRefresh = useCallback(() => {
setRefreshToken((current) => current + 1);
}, []);
setRefreshToken((current) => current + 1)
}, [])
const closeCreateModal = useCallback(() => setShowCreateModal(false), []);
const closeEditModal = useCallback(() => setSelectedUser(null), []);
const closeActivateModal = useCallback(() => setActivationUser(null), []);
const closeHandoverModal = useCallback(() => setHandoverUser(null), []);
const closeCreateModal = useCallback(() => setShowCreateModal(false), [])
const closeEditModal = useCallback(() => setEditingUser(null), [])
const closeActivateModal = useCallback(() => setActivationUser(null), [])
const closeHandoverModal = useCallback(() => setHandoverUser(null), [])
const handleCreateSuccess = useCallback(() => {
closeCreateModal();
triggerRefresh();
}, [closeCreateModal, triggerRefresh]);
closeCreateModal()
triggerRefresh()
}, [closeCreateModal, triggerRefresh])
const handleEditSuccess = useCallback(() => {
closeEditModal();
triggerRefresh();
}, [closeEditModal, triggerRefresh]);
closeEditModal()
triggerRefresh()
}, [closeEditModal, triggerRefresh])
const handleActivateSuccess = useCallback(() => {
closeActivateModal();
triggerRefresh();
}, [closeActivateModal, triggerRefresh]);
closeActivateModal()
triggerRefresh()
}, [closeActivateModal, triggerRefresh])
const handleHandoverSuccess = useCallback(() => {
closeHandoverModal();
triggerRefresh();
}, [closeHandoverModal, triggerRefresh]);
closeHandoverModal()
triggerRefresh()
}, [closeHandoverModal, triggerRefresh])
const handleEditUser = useCallback((user: UserTreeItem) => {
if (!canEditManagedUser(user)) {
setActionError('当前账号不能编辑同级或更高权限用户');
return;
}
setActionError(null);
setSelectedUser(user);
}, [canEditManagedUser]);
const handleDeleteUser = useCallback(async (userId: string) => {
try {
setActionError(null);
if (!window.confirm('确定要移除此访问权限吗?此操作不可撤销。')) return;
const response = await fetch(`/api/users/${userId}`, { method: 'DELETE', credentials: 'include' });
if (!response.ok) throw new Error('删除用户失败');
triggerRefresh();
} catch (error) {
setActionError(error instanceof Error ? error.message : '删除失败');
}
}, [triggerRefresh]);
const handleActivateUser = useCallback((user: UserTreeItem) => {
if (!canActivatePendingUser(user)) {
setActionError('当前账号不能激活该用户');
return;
}
setActionError(null);
setActivationUser(user);
}, [canActivatePendingUser]);
const selectedUserAllowedRoles = useMemo<Role[]>(
() => {
if (!selectedUser || !currentUser) return [];
if (selectedUser.id === currentUser.id) {
return [selectedUser.role as Role];
const handleEditUser = useCallback(
(user: UserTreeItem) => {
if (!canEditManagedUser(user)) {
setActionError('当前账号不能编辑同级或更高权限用户')
return
}
return assignableRoles;
setActionError(null)
setSelectedUser(user)
setEditingUser(user)
},
[assignableRoles, currentUser, selectedUser]
);
[canEditManagedUser],
)
const handleSelectUser = useCallback((user: UserTreeItem) => {
setActionError(null)
setSelectedUser(user)
}, [])
const handleDeleteUser = useCallback(
async (userId: string) => {
try {
setActionError(null)
if (!window.confirm('确定要移除此访问权限吗?此操作不可撤销。')) return
const response = await fetch(`/api/users/${userId}`, { method: 'DELETE', credentials: 'include' })
if (!response.ok) throw new Error('删除用户失败')
if (selectedUser?.id === userId) setSelectedUser(null)
if (editingUser?.id === userId) setEditingUser(null)
triggerRefresh()
} catch (error) {
setActionError(error instanceof Error ? error.message : '删除失败')
}
},
[editingUser?.id, selectedUser?.id, triggerRefresh],
)
const handleActivateUser = useCallback(
(user: UserTreeItem) => {
if (!canActivatePendingUser(user)) {
setActionError('当前账号不能激活该用户')
return
}
setActionError(null)
setActivationUser(user)
},
[canActivatePendingUser],
)
const selectedUserAllowedRoles = useMemo<Role[]>(() => {
if (!editingUser || !currentUser) return []
if (editingUser.id === currentUser.id) {
return [editingUser.role as Role]
}
return assignableRoles
}, [assignableRoles, currentUser, editingUser])
return (
<div className="relative min-h-screen overflow-hidden bg-[#f4f7f9] font-apple-system text-apple-label-primary">
<PlatformSplitLayout
title="身份控制中心"
description="定义组织架构、配置原子化权限策略及账号生命周期管理。"
backHref="/admin"
backLabel="管理中心"
rightPanelClassName="w-[480px] max-w-[40vw]"
leftContent={
<div className="flex h-full flex-col gap-8 py-4">
<div className="space-y-1.5">
<p className="px-3 text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary opacity-50">Quick Filters</p>
<div className="flex flex-col gap-1">
{ROLE_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setRoleFilter(opt.value)}
className={cn(
"flex items-center justify-between rounded-2xl px-4 py-3 transition-all",
roleFilter === opt.value ? "bg-white text-apple-blue shadow-apple-sm" : "text-apple-label-secondary hover:bg-white/50"
)}
>
<span className="text-apple-xs font-apple-bold truncate">{opt.label}</span>
{roleFilter === opt.value && <div className="h-1.5 w-1.5 rounded-full bg-apple-blue" />}
</button>
))}
<ObsidianControlShell
eyebrow="权限"
title="用户权限管理"
icon={ShieldCheck}
backHref="/admin"
backLabel="后台设置"
actions={
<Button
variant="plain"
size="small"
onClick={() => setShowCreateModal(true)}
className="obsidian-control-action rounded-md border"
>
<UserPlus className="h-4 w-4" />
开设账号
</Button>
}
rail={
<div className="flex h-full flex-col gap-6">
<ObsidianRailGroup title="权限角色">
{ROLE_OPTIONS.map((option) => (
<ObsidianRailButton
key={option.value}
active={roleFilter === option.value}
icon={option.icon}
label={option.label}
onClick={() => setRoleFilter(option.value)}
/>
))}
</ObsidianRailGroup>
<ObsidianRailGroup title="账号状态">
{STATUS_OPTIONS.map((option) => (
<ObsidianRailButton
key={option.value}
active={statusFilter === option.value}
icon={Activity}
label={option.label}
onClick={() => setStatusFilter(option.value)}
/>
))}
</ObsidianRailGroup>
</div>
}
inspector={
<div className="flex h-full flex-col bg-white text-[#152024]">
{selectedUser ? (
<>
<div className="border-b border-[#dce3e4] bg-[#f8faf9] px-5 py-5">
<div className="flex h-12 w-12 items-center justify-center rounded-[6px] border border-[#d8e0e1] bg-white text-[#405054]">
<Fingerprint className="h-6 w-6" />
</div>
</div>
<div className="mt-auto rounded-3xl bg-black/[0.03] p-6">
<div className="flex items-center gap-3 mb-4">
<ShieldCheck className="h-5 w-5 text-apple-blue" />
<span className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">Security Status</span>
</div>
<div className="space-y-3">
<SecurityMetric label="已激活账号" value="124" />
<SecurityMetric label="待审核请求" value="3" color="text-amber-500" />
<SecurityMetric label="异常登录" value="0" />
</div>
</div>
</div>
}
mainContent={
<div className="flex h-full flex-col">
{/* 精密搜索栏 */}
<div className="mb-8 flex items-center justify-between rounded-[2rem] border border-white/60 bg-white/40 p-3 shadow-apple-sm backdrop-blur-xl">
<div className="relative flex-1">
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 text-apple-label-tertiary" />
<input
type="text"
placeholder="搜索姓名、邮箱或 UID 检索用户..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-12 w-full rounded-2xl border-none bg-white/80 pl-14 pr-6 text-apple-sm font-apple-medium text-apple-label-primary outline-none focus:bg-white focus:ring-4 focus:ring-apple-blue/5"
/>
</div>
<div className="ml-3 flex items-center gap-3">
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
options={STATUS_OPTIONS}
className="h-12 min-w-[140px] rounded-2xl border-none bg-white/80 shadow-apple-xs"
/>
<Button variant="filled" size="small" onClick={() => setShowCreateModal(true)} className="h-12 rounded-2xl px-8 text-[10px] font-apple-black uppercase tracking-widest shadow-apple-blue/20">
<UserPlus className="mr-2 h-4 w-4" /> 开设账号
</Button>
</div>
</div>
{actionError && <Alert type="error" variant="subtle" title="Security Alert" description={actionError} className="mb-8 rounded-3xl" />}
{/* 资源树容器 */}
<div className="rounded-[2.5rem] border border-white/60 bg-white/30 p-2 shadow-apple-xl backdrop-blur-sm overflow-hidden flex-1">
<div className="h-full overflow-y-auto rounded-[2rem] bg-white/80 shadow-apple-xs ring-1 ring-apple-separator/5">
<DepartmentAccessTree
searchQuery={debouncedSearchQuery}
roleFilter={roleFilter}
statusFilter={statusFilter}
refreshToken={refreshToken}
currentUserId={currentUser?.id}
onEdit={handleEditUser}
onDelete={handleDeleteUser}
onActivate={handleActivateUser}
onHandover={setHandoverUser}
canEditUser={canEditManagedUser}
canActivateUser={canActivatePendingUser}
/>
</div>
</div>
</div>
}
rightContent={
selectedUser ? (
<div className="flex h-full flex-col overflow-hidden bg-white/40 backdrop-blur-3xl">
{/* User Header Profile */}
<div className="relative h-60 w-full overflow-hidden border-b border-apple-separator/5 bg-black p-8">
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '24px 24px' }} />
<div className="relative z-10 flex flex-col items-center text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-[2rem] bg-white/10 ring-1 ring-white/20">
<Fingerprint className="h-10 w-10 text-white" />
</div>
<h2 className="text-xl font-apple-black text-white uppercase tracking-tight">{selectedUser.name}</h2>
<p className="text-[10px] font-apple-black text-apple-blue uppercase tracking-[0.3em] mt-2">{selectedUser.role}</p>
</div>
</div>
{/* Details Inspector */}
<div className="flex-1 overflow-y-auto p-8">
<div className="flex items-center justify-between mb-8">
<h3 className="text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">Identity Inspector</h3>
<div className="flex gap-2">
<Button variant="plain" size="small" onClick={() => setSelectedUser(null)}>关闭</Button>
</div>
</div>
<div className="space-y-8">
<InspectorGroup title="基础通讯 Base Contact">
<InspectorItem icon={Mail} label="工作邮箱" value={selectedUser.email} />
<InspectorItem icon={Phone} label="联系电话" value="未配置" />
</InspectorGroup>
<InspectorGroup title="组织架构 Architecture">
<InspectorItem icon={Briefcase} label="所属部门" value="研发中心 / 3D 实验室" />
<InspectorItem icon={Calendar} label="入职日期" value="2025-04-12" />
</InspectorGroup>
<InspectorGroup title="安全审计 Security Audit">
<div className="rounded-2xl bg-black/[0.02] p-4 ring-1 ring-apple-separator/5">
<div className="flex items-center gap-3">
<Shield className="h-4 w-4 text-emerald-500" />
<span className="text-[11px] font-apple-bold text-apple-label-secondary">权限状态:已授权</span>
</div>
<p className="mt-2 text-[10px] text-apple-label-tertiary leading-relaxed">
该账户已绑定硬件指纹令牌,最近一次成功登录发生在 2 小时前。
</p>
</div>
</InspectorGroup>
</div>
</div>
<div className="p-8 border-t border-apple-separator/5 bg-white/20">
<Button variant="filled" className="w-full h-12 rounded-2xl bg-black text-[10px] font-apple-black uppercase tracking-widest text-white shadow-apple-2xl" onClick={() => triggerRefresh()}>
刷新同步状态
</Button>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center p-12 text-center">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-[2rem] bg-apple-bg-secondary shadow-apple-sm">
<Fingerprint className="h-10 w-10 text-apple-label-tertiary opacity-30" />
</div>
<p className="text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">Identity Inspector</p>
<h3 className="mt-4 text-apple-lg font-apple-bold text-apple-label-primary">等待选择用户</h3>
<p className="mt-3 max-w-[240px] text-apple-xs leading-relaxed text-apple-label-tertiary">
从左侧列表中选择一名员工,即可在此处查看详细的权限矩阵与安全审计报告。
<h2 className="mt-4 truncate text-lg font-black text-[#152024]">{selectedUser.name}</h2>
<p className="mt-1 text-[11px] font-bold text-[#607073]">
{roleLabels[selectedUser.role] ?? selectedUser.role}
</p>
</div>
)
}
/>
</div>
<ObsidianInspectorSection title="基础通讯">
<InspectorItem icon={Mail} label="工作邮箱" value={selectedUser.email} />
<InspectorItem icon={Phone} label="联系电话" value={selectedUser.phone || '未配置'} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="组织架构">
<InspectorItem icon={Briefcase} label="所属部门" value={selectedUser.department?.name || '未归属'} />
<InspectorItem icon={Users} label="所属小组" value={selectedUser.team?.name || '未归属'} />
</ObsidianInspectorSection>
<ObsidianInspectorSection title="安全审计">
<ObsidianDataRow label="账号状态" value={statusLabels[selectedUser.status] ?? selectedUser.status} tone={selectedUser.status === 'ACTIVE' ? 'success' : 'warning'} />
<ObsidianDataRow label="登录来源" value={selectedUser.loginSource || '系统账号'} />
<ObsidianDataRow label="创建时间" value={formatDate(selectedUser.createdAt)} />
<ObsidianDataRow label="最后登录" value={selectedUser.lastLoginAt ? formatDate(selectedUser.lastLoginAt) : '暂无记录'} />
</ObsidianInspectorSection>
</>
) : (
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
<Fingerprint className="h-7 w-7 text-[#6b777a]" />
<h3 className="mt-4 text-sm font-black text-[#152024]">未选择账号</h3>
<p className="mt-2 text-xs font-medium text-[#607073]">点击中间列表中的人员后查看权限和组织信息</p>
</div>
)}
</div>
}
>
<div className="grid h-full grid-rows-[auto_minmax(0,1fr)] bg-[#f4f7f7]">
<header className="border-b border-[#dce3e4] bg-[#f8faf9] px-4 py-3">
<div className="relative w-full max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6b777a]" />
<input
type="text"
placeholder="搜索姓名、邮箱或 UID"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="h-10 w-full rounded-[6px] border border-[#d6dee0] bg-white pl-10 pr-4 text-sm font-semibold text-[#152024] outline-none transition placeholder:text-[#7a878a] focus:border-[#152024] focus:bg-white"
/>
</div>
</header>
{showCreateModal && <CreateUserModal isOpen={showCreateModal} allowedRoles={assignableRoles} onClose={closeCreateModal} onSuccess={handleCreateSuccess} />}
{activationUser && (
<div className="min-h-0 overflow-y-auto p-3">
{actionError ? (
<Alert type="error" variant="subtle" title="权限操作被拦截" description={actionError} className="mb-4 rounded-md" />
) : null}
<div className="rounded-[6px] border border-[#d8e0e1] bg-white p-2">
<DepartmentAccessTree
searchQuery={debouncedSearchQuery}
roleFilter={roleFilter}
statusFilter={statusFilter}
refreshToken={refreshToken}
currentUserId={currentUser?.id}
onEdit={handleEditUser}
onDelete={handleDeleteUser}
onSelect={handleSelectUser}
onActivate={handleActivateUser}
onHandover={setHandoverUser}
canEditUser={canEditManagedUser}
canActivateUser={canActivatePendingUser}
selectedUserId={selectedUser?.id}
/>
</div>
</div>
</div>
{showCreateModal ? (
<CreateUserModal isOpen={showCreateModal} allowedRoles={assignableRoles} onClose={closeCreateModal} onSuccess={handleCreateSuccess} />
) : null}
{activationUser ? (
<ActivateUserModal
isOpen={!!activationUser}
user={activationUser}
@ -338,50 +338,38 @@ function UsersPageContent() {
onClose={closeActivateModal}
onSuccess={handleActivateSuccess}
/>
)}
{handoverUser && <HandoverModal isOpen={!!handoverUser} user={handoverUser} onClose={closeHandoverModal} onSuccess={handleHandoverSuccess} />}
{selectedUser && (
) : null}
{handoverUser ? (
<HandoverModal isOpen={!!handoverUser} user={handoverUser} onClose={closeHandoverModal} onSuccess={handleHandoverSuccess} />
) : null}
{editingUser ? (
<EditUserModal
isOpen={!!selectedUser}
user={selectedUser}
isOpen={!!editingUser}
user={editingUser}
allowedRoles={selectedUserAllowedRoles}
disablePrivilegeFields={Boolean(currentUser && selectedUser.id === currentUser.id)}
disablePrivilegeFields={Boolean(currentUser && editingUser.id === currentUser.id)}
onClose={closeEditModal}
onSuccess={handleEditSuccess}
/>
)}
</div>
);
) : null}
</ObsidianControlShell>
)
}
function SecurityMetric({ label, value, color }: { label: string, value: string, color?: string }) {
function InspectorItem({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-apple-xs font-apple-bold text-apple-label-secondary">{label}</span>
<span className={cn("text-[11px] font-apple-black uppercase", color || "text-apple-blue")}>{value}</span>
<div className="flex items-center gap-3 rounded-[6px] border border-[#dce3e4] bg-white px-3 py-3">
<Icon className="h-4 w-4 shrink-0 text-[#6b777a]" />
<div className="min-w-0">
<p className="text-[11px] font-bold text-[#607073]">{label}</p>
<p className="truncate text-sm font-black text-[#152024]">{value}</p>
</div>
</div>
)
}
function InspectorGroup({ title, children }: { title: string, children: React.ReactNode }) {
return (
<div className="space-y-4">
<h4 className="text-[9px] font-apple-black uppercase tracking-[0.2em] text-apple-label-tertiary px-1">{title}</h4>
<div className="space-y-3">{children}</div>
</div>
)
}
function InspectorItem({ icon: Icon, label, value }: { icon: LucideIcon, label: string, value: string }) {
return (
<div className="flex items-center gap-4 rounded-2xl border border-apple-separator/10 bg-white/60 p-4 shadow-apple-xs">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-white shadow-apple-xs">
<Icon className="h-4 w-4 text-apple-label-tertiary opacity-60" />
</div>
<div className="flex flex-col">
<span className="text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary opacity-60">{label}</span>
<span className="text-xs font-apple-bold text-apple-label-primary truncate">{value}</span>
</div>
</div>
)
function formatDate(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN')
}

View File

@ -8,6 +8,9 @@ import { AppSideNav } from './AppSideNav'
// AppShell 只服务新平台主链;旧 /products 兼容链和工具页不进入这套全局壳子
export const APP_SHELL_ROOTS = [
'/',
'/profile',
'/users',
'/admin',
'/asset-center',
'/assisted-assembly',
'/draft-groups',
@ -47,8 +50,14 @@ export function AppShell({ children, user }: AppShellProps) {
const isDraftGroupsRoute = pathname === '/draft-groups' || pathname.startsWith('/draft-groups/')
const isIntakeRoute = pathname === '/intake' || pathname.startsWith('/intake/')
const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/')
const isProfileRoute = pathname === '/profile' || pathname.startsWith('/profile/')
const isUsersRoute = pathname === '/users' || pathname.startsWith('/users/')
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/')
const isWorkbenchShellRoute =
isHomeRoute ||
isProfileRoute ||
isUsersRoute ||
isAdminRoute ||
isAssetCenterRoute ||
isAssistedAssemblyRoute ||
isDraftGroupsRoute ||
@ -96,11 +105,12 @@ export function AppShell({ children, user }: AppShellProps) {
<main
className={
isWorkbenchShellRoute
? 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-hidden overscroll-none bg-[#f8f9fa]'
? 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-hidden overscroll-none'
: shouldLockMainViewport
? 'ml-[var(--layout-app-sidebar-width)] mt-14 h-[calc(100vh-56px)] overflow-hidden overscroll-none bg-white'
: 'ml-[var(--layout-app-sidebar-width)] mt-14 min-h-[calc(100vh-56px)]'
}
style={isWorkbenchShellRoute ? { backgroundColor: 'var(--obs-shell-bg)' } : undefined}
>
{children}
</main>

View File

@ -12,7 +12,6 @@ import {
Search,
Warehouse,
} from 'lucide-react'
import ThemeToggle from '@/components/ui/ThemeToggle'
import UserMenu from '@/components/ui/UserMenu'
import { APP_BRAND_LOGO_SRC, APP_BRAND_NAME } from '@/lib/branding'
@ -25,6 +24,12 @@ const WORKBENCH_NAV_ITEMS = [
{ href: '/warehouse', icon: Warehouse, label: '仓库管理' },
]
const AUXILIARY_ROUTE_LABELS = [
{ href: '/profile', label: '个人中心' },
{ href: '/users', label: '用户权限' },
{ href: '/admin', label: '后台设置' },
] as const
const WORKBENCH_BRAND_NAME =
APP_BRAND_NAME === '由壹点「产品开发平台」' ? '壹点产品开发平台' : APP_BRAND_NAME
@ -45,7 +50,9 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
const currentWorkbenchItem =
WORKBENCH_NAV_ITEMS.find((item) =>
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href),
) ?? WORKBENCH_NAV_ITEMS[0]
) ??
AUXILIARY_ROUTE_LABELS.find((item) => pathname === item.href || pathname.startsWith(item.href + '/')) ??
WORKBENCH_NAV_ITEMS[0]
if (variant === 'workbench') {
return (
@ -93,7 +100,6 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
<Bell size={16} strokeWidth={1.7} />
<span className="absolute right-2.5 top-2.5 h-1.5 w-1.5 rounded-full bg-red-500" />
</button>
<ThemeToggle />
<UserMenu user={user ?? null} />
</div>
</nav>
@ -152,8 +158,6 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
<Bell size={16} strokeWidth={1.5} />
</button>
<ThemeToggle />
<UserMenu user={user ?? null} />
</div>
</nav>

View File

@ -0,0 +1,242 @@
'use client'
import Link from 'next/link'
import type { ComponentType, ReactNode } from 'react'
import { ArrowLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ObsidianControlShellProps {
eyebrow: string
title: string
description?: string
backHref?: string
backLabel?: string
icon?: ComponentType<{ className?: string }>
actions?: ReactNode
rail?: ReactNode
children: ReactNode
inspector?: ReactNode
statusBar?: ReactNode
className?: string
contentClassName?: string
inspectorClassName?: string
}
export function ObsidianControlShell({
eyebrow,
title,
backHref,
backLabel = '返回',
icon: Icon,
actions,
rail,
children,
inspector,
className,
contentClassName,
inspectorClassName,
}: ObsidianControlShellProps) {
return (
<div className={cn('obsidian-control-shell h-full min-h-0 bg-[#0d1113] p-3 text-[#e7eeec]', className)}>
<div
className={cn(
'grid h-full min-h-0 gap-3',
rail && inspector && 'lg:grid-cols-[224px_minmax(0,1fr)_336px]',
rail && !inspector && 'lg:grid-cols-[224px_minmax(0,1fr)]',
!rail && inspector && 'lg:grid-cols-[minmax(0,1fr)_336px]',
)}
>
{rail ? (
<aside className="flex min-h-0 flex-col rounded-md border border-white/10 bg-[#111719]">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-white/10 px-3">
{backHref ? (
<Link
href={backHref}
aria-label={backLabel}
className="flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.03] text-[#9ba8a8] transition hover:bg-white/[0.07] hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
</Link>
) : null}
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#788586]">{eyebrow}</p>
<p className="truncate text-xs font-black text-[#dce7e4]">{backLabel}</p>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">{rail}</div>
</aside>
) : null}
<section className={cn('flex min-h-0 flex-col overflow-hidden rounded-md border border-white/10 bg-[#101517]', contentClassName)}>
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-white/10 bg-[#111719] px-4">
<div className="flex min-w-0 items-center gap-3">
{Icon ? (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-white/10 bg-white/[0.04] text-[#cbd8d5]">
<Icon className="h-4 w-4" />
</div>
) : null}
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#788586]">{eyebrow}</p>
<h1 className="truncate text-base font-black text-[#f4f7f6]">{title}</h1>
</div>
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</header>
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</section>
{inspector ? (
<aside className={cn('min-h-0 overflow-hidden rounded-md border border-white/10 bg-[#111719]', inspectorClassName)}>
{inspector}
</aside>
) : null}
</div>
</div>
)
}
export function ObsidianRailGroup({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="px-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#748083]">{title}</div>
<div className="space-y-1">{children}</div>
</div>
)
}
export function ObsidianRailButton({
active,
icon: Icon,
label,
meta,
href,
onClick,
}: {
active?: boolean
icon?: ComponentType<{ className?: string }>
label: string
meta?: string
href?: string
onClick?: () => void
}) {
const className = cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition',
active
? 'bg-[#e7eeec] text-[#101517]'
: 'text-[#c0ccca] hover:bg-white/[0.06] hover:text-white',
)
const content = (
<>
{Icon ? <Icon className="h-4 w-4 shrink-0" /> : null}
<span className="min-w-0 flex-1 truncate text-xs font-bold">{label}</span>
{meta ? <span className="text-[10px] font-black opacity-55">{meta}</span> : null}
</>
)
if (href) {
return (
<Link href={href} className={className}>
{content}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className}>
{content}
</button>
)
}
export function ObsidianPanel({
title,
eyebrow,
children,
className,
}: {
title?: string
eyebrow?: string
children: ReactNode
className?: string
}) {
return (
<section className={cn('rounded-md border border-white/10 bg-[#111719]', className)}>
{title || eyebrow ? (
<header className="border-b border-white/10 px-4 py-3">
{eyebrow ? (
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#748083]">{eyebrow}</p>
) : null}
{title ? <h2 className="mt-1 text-sm font-black text-[#edf4f2]">{title}</h2> : null}
</header>
) : null}
{children}
</section>
)
}
export function ObsidianInspectorSection({
title,
children,
}: {
title: string
children: ReactNode
}) {
return (
<section className="border-b border-white/10 px-4 py-4 last:border-b-0">
<h3 className="mb-3 text-[10px] font-black uppercase tracking-[0.18em] text-[#7f8d8e]">
{title}
</h3>
<div className="space-y-2">{children}</div>
</section>
)
}
export function ObsidianDataRow({
label,
value,
tone = 'default',
}: {
label: string
value: ReactNode
tone?: 'default' | 'success' | 'warning'
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-md border border-white/10 bg-[#151d1f] px-3 py-2.5">
<span className="text-[11px] font-bold text-[#8f9c9d]">{label}</span>
<span
className={cn(
'min-w-0 truncate text-right text-xs font-black',
tone === 'success' && 'text-[#9bdcc5]',
tone === 'warning' && 'text-[#e5bd72]',
tone === 'default' && 'text-[#edf4f2]',
)}
>
{value}
</span>
</div>
)
}
export function ObsidianStatusBar({
items,
}: {
items: Array<{ label: string; value: string; tone?: 'default' | 'success' | 'warning' }>
}) {
return (
<div className="flex flex-wrap items-center gap-2 text-[11px] text-[#aeb8bb]">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2 rounded-md border border-white/10 bg-white/[0.04] px-3 py-2">
<span
className={cn(
'h-1.5 w-1.5 rounded-full bg-[#748083]',
item.tone === 'success' && 'bg-[#27c083]',
item.tone === 'warning' && 'bg-[#f2b84b]',
)}
/>
<span className="font-bold text-[#748083]">{item.label}</span>
<span className="font-black text-white">{item.value}</span>
</div>
))}
</div>
)
}

View File

@ -1,4 +1,3 @@
import ThemeToggle from '@/components/ui/ThemeToggle'
import UserMenu from '@/components/ui/UserMenu'
interface PlatformHeaderToolsProps {
@ -15,7 +14,6 @@ interface PlatformHeaderToolsProps {
export function PlatformHeaderTools({ user }: PlatformHeaderToolsProps) {
return (
<div className="flex items-center justify-end gap-2">
<ThemeToggle />
<UserMenu user={user} />
</div>
)

View File

@ -68,7 +68,7 @@ export function ViewportStageShell({
{/* 3D 内容区 */}
<div className="absolute inset-0 z-0">{children}</div>
{/* Obsidian Atelier vertical viewport tools */}
{/* White Obsidian Atelier vertical viewport tools */}
<div className="absolute left-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1 rounded-[8px] bg-white/72 p-1.5 shadow-[0_18px_48px_rgba(45,51,53,0.14)] ring-1 ring-white/70 backdrop-blur-2xl transition-all duration-300 supports-[backdrop-filter]:bg-white/62">
{/* 交互模式 */}
{enableTranslateGizmo && (

View File

@ -1,5 +1,5 @@
/**
* Modal 弹窗组件 - Obsidian Atelier 风格
* Modal 弹窗组件 - White Obsidian Atelier 风格
*
* 表单弹窗使用实体面板保证可读性,只把暗色遮罩作为轻量玻璃层。
* 交互细节继续保留 ESC 关闭、焦点循环和关闭后焦点恢复。
@ -158,9 +158,10 @@ export default function Modal({
ref={modalRef}
onKeyDown={handleKeyDown}
data-testid="obsidian-modal-panel"
className={`relative w-full ${sizeClasses[size]} overflow-hidden ${className ?? ''}`}
className={`obsidian-modal-panel relative w-full ${sizeClasses[size]} overflow-hidden ${className ?? ''}`}
style={{
backgroundColor: '#ffffff',
backgroundColor: 'var(--obs-shell-panel-bg)',
border: '1px solid var(--obs-shell-border)',
borderRadius: '6px',
maxHeight: '92vh',
display: 'flex',
@ -173,12 +174,12 @@ export default function Modal({
onClick={(e) => e.stopPropagation()}
>
{(title || showCloseButton) && (
<div className="flex items-center justify-between border-b border-black/[0.03] px-8 py-6">
<div className="flex items-center justify-between border-b border-[var(--obs-shell-border)] px-8 py-6">
<div className="flex-1">
{title && (
<h2
id="modal-title"
className="text-xl font-black tracking-tight text-[#2d3335]"
className="text-xl font-black tracking-tight text-[var(--obs-shell-title)]"
>
{title}
</h2>
@ -188,7 +189,7 @@ export default function Modal({
<button
onClick={onClose}
aria-label="关闭"
className="ml-4 flex h-8 w-8 items-center justify-center rounded-md bg-black/5 text-[#5a6062] transition-all hover:bg-black/10"
className="ml-4 flex h-8 w-8 items-center justify-center rounded-md bg-[var(--obs-shell-muted-bg)] text-[var(--obs-shell-muted)] transition-all hover:bg-[var(--obs-shell-hover-bg)] hover:text-[var(--obs-shell-title)]"
>
<X className="h-4 w-4" strokeWidth={2.5} />
</button>
@ -197,17 +198,17 @@ export default function Modal({
)}
<div
className="flex-1 overflow-y-auto px-8 py-8"
className="obsidian-modal-body flex-1 overflow-y-auto px-8 py-8 text-[var(--obs-shell-text)]"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(45, 51, 53, 0.18) transparent',
scrollbarColor: 'var(--obs-shell-border) transparent',
}}
>
{children}
</div>
{footer && (
<div className="border-t border-black/[0.03] bg-[#f8f9fa] px-8 py-6">
<div className="border-t border-[var(--obs-shell-border)] bg-[var(--obs-shell-content-bg)] px-8 py-6">
{footer}
</div>
)}

View File

@ -1,71 +1,6 @@
/**
* 主题切换按钮组件
*
* 提供深浅色主题切换功能
*
* @created 2025-12-12
*/
'use client'
import React from 'react'
import { Sun, Moon } from 'lucide-react'
import { useUserPreferences } from '@/lib/preferences'
// 深浅色切换能力已下线;保留空组件只为兼容旧页面导入。
export default function ThemeToggle() {
const { preferences } = useUserPreferences()
const [mounted, setMounted] = React.useState(false)
// 避免服务端渲染不匹配
React.useEffect(() => {
setMounted(true)
}, [])
const toggleTheme = () => {
const currentTheme =
(document.documentElement.getAttribute('data-theme') as 'light' | 'dark' | null) ||
preferences.prefersColorScheme
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'
localStorage.setItem('apple-ui-theme', newTheme)
document.documentElement.setAttribute('data-theme', newTheme)
// 触发存储事件以更新其他组件
window.dispatchEvent(new CustomEvent('theme-change', { detail: { theme: newTheme } }))
}
if (!mounted) {
return (
<button
className="w-9 h-9 rounded-full flex items-center justify-center transition-colors"
style={{
backgroundColor: 'var(--color-background-tertiary)',
border: '1px solid var(--color-separator)'
}}
aria-label="切换主题"
>
<Sun className="w-4 h-4" strokeWidth={1.5} style={{ color: 'var(--color-label-secondary)' }} />
</button>
)
}
const isDark = preferences.prefersColorScheme === 'dark'
return (
<button
onClick={toggleTheme}
className="w-9 h-9 rounded-full flex items-center justify-center transition-all duration-200 ease-out hover:scale-105 active:scale-95"
style={{
backgroundColor: 'var(--color-background-tertiary)',
border: '1px solid var(--color-separator)'
}}
aria-label={isDark ? '切换到浅色模式' : '切换到深色模式'}
title={isDark ? '切换到浅色模式' : '切换到深色模式'}
>
{isDark ? (
<Sun className="w-4 h-4 transition-transform duration-200" strokeWidth={1.5} style={{ color: 'var(--color-label-secondary)' }} />
) : (
<Moon className="w-4 h-4 transition-transform duration-200" strokeWidth={1.5} style={{ color: 'var(--color-label-secondary)' }} />
)}
</button>
)
return null
}

View File

@ -127,6 +127,8 @@ export default function UserMenu({ user }: UserMenuProps) {
DESIGNER: '设计师',
USER: '普通用户',
}
const isAdmin = resolvedUser.role === 'SUPER_ADMIN' || resolvedUser.role === 'ADMIN'
const canManageUsers = isAdmin || resolvedUser.role === 'SUPERVISOR'
return (
<div className="relative z-[70]" ref={menuRef}>
@ -250,53 +252,57 @@ export default function UserMenu({ user }: UserMenuProps) {
{/* 菜单项 */}
<div className="py-1.5 px-1.5">
<button
onClick={() => {
router.push('/profile');
setIsOpen(false)
}}
type="button"
className="w-full px-3 py-2 flex items-center gap-2.5 rounded-apple-md transition-all duration-150 ease-out active:scale-98"
style={{ backgroundColor: 'transparent' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-background-tertiary)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<User
className="w-4 h-4"
strokeWidth={1.5}
style={{ color: 'var(--color-label-secondary)' }}
/>
<span
className="text-sm font-apple-system"
style={{ color: 'var(--color-label-primary)' }}
{canManageUsers && (
<button
onClick={() => {
router.push('/users');
setIsOpen(false)
}}
type="button"
className="w-full px-3 py-2 flex items-center gap-2.5 rounded-apple-md transition-all duration-150 ease-out active:scale-98"
style={{ backgroundColor: 'transparent' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-background-tertiary)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
个人资料
</span>
</button>
<User
className="w-4 h-4"
strokeWidth={1.5}
style={{ color: 'var(--color-label-secondary)' }}
/>
<span
className="text-sm font-apple-system"
style={{ color: 'var(--color-label-primary)' }}
>
用户权限管理
</span>
</button>
)}
<button
onClick={() => {
router.push('/settings');
setIsOpen(false)
}}
type="button"
className="w-full px-3 py-2 flex items-center gap-2.5 rounded-apple-md transition-all duration-150 ease-out active:scale-98"
style={{ backgroundColor: 'transparent' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-background-tertiary)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Settings
className="w-4 h-4"
strokeWidth={1.5}
style={{ color: 'var(--color-label-secondary)' }}
/>
<span
className="text-sm font-apple-system"
style={{ color: 'var(--color-label-primary)' }}
{isAdmin && (
<button
onClick={() => {
router.push('/admin');
setIsOpen(false)
}}
type="button"
className="w-full px-3 py-2 flex items-center gap-2.5 rounded-apple-md transition-all duration-150 ease-out active:scale-98"
style={{ backgroundColor: 'transparent' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-background-tertiary)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
设置
</span>
</button>
<Settings
className="w-4 h-4"
strokeWidth={1.5}
style={{ color: 'var(--color-label-secondary)' }}
/>
<span
className="text-sm font-apple-system"
style={{ color: 'var(--color-label-primary)' }}
>
后台设置
</span>
</button>
)}
</div>
{/* 登出按钮 */}

View File

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import Modal from '../Modal'
describe('Modal 组件', () => {
it('应该使用 Obsidian Atelier 弹窗外壳,而不是 Apple 大圆角样式', async () => {
it('应该使用 White Obsidian Atelier 弹窗外壳,而不是 Apple 大圆角样式', async () => {
render(
<Modal isOpen onClose={jest.fn()} title="新建资产族" footer={<button>创建</button>}>
<input aria-label="编码" />

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UserMenu from '../UserMenu'
const pushMock = jest.fn()
const refreshMock = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
refresh: refreshMock,
}),
}))
const adminUser = {
id: 'user-1',
name: '李承豪',
email: '222@qq.com',
role: 'ADMIN',
avatar: null,
department: null,
}
describe('UserMenu', () => {
beforeEach(() => {
pushMock.mockReset()
refreshMock.mockReset()
})
it('管理员菜单入口应指向真实后台页面,不再跳转到缺失路由', async () => {
const actor = userEvent.setup()
render(<UserMenu user={adminUser} />)
await actor.click(screen.getByRole('button', { name: '李 李承豪' }))
await actor.click(screen.getByRole('button', { name: '用户权限管理' }))
expect(pushMock).toHaveBeenCalledWith('/users')
expect(pushMock).not.toHaveBeenCalledWith('/profile')
await actor.click(screen.getByRole('button', { name: '李 李承豪' }))
await actor.click(screen.getByRole('button', { name: '后台设置' }))
expect(pushMock).toHaveBeenCalledWith('/admin')
expect(pushMock).not.toHaveBeenCalledWith('/settings')
})
})

View File

@ -176,16 +176,16 @@ export function ActivateUserModal({
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="激活账号" size="medium" className="liquid-glass">
<Modal isOpen={isOpen} onClose={onClose} title="激活账号" size="medium">
<form onSubmit={handleSubmit} className="space-y-6 p-2">
{errors.submit && (
<div className="flex items-center gap-3 rounded-apple-lg border border-red-100 bg-red-50 p-4">
<div className="flex items-center gap-3 rounded-md border border-red-100 bg-red-50 p-4">
<AlertCircle className="h-4 w-4 shrink-0 text-red-500" />
<p className="text-apple-xs font-apple-bold text-red-700">{errors.submit}</p>
</div>
)}
<div className="rounded-apple-xl border border-white bg-[#f2f4f6]/50 p-5 shadow-apple-xs">
<div className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-5">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-apple-blue" />
<h4 className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-secondary">
@ -206,7 +206,7 @@ export function ActivateUserModal({
options={roleOptions}
required
errorText={errors.role}
className="bg-white/50"
className="bg-white"
/>
<Select
label="所属部门"
@ -225,7 +225,7 @@ export function ActivateUserModal({
label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}`,
})),
]}
className="bg-white/50"
className="bg-white"
/>
<Select
label="所属小组"
@ -235,7 +235,7 @@ export function ActivateUserModal({
{ value: '', label: '未分配小组' },
...teams.map((team) => ({ value: team.id, label: team.name })),
]}
className="bg-white/50"
className="bg-white"
/>
</div>
@ -245,7 +245,7 @@ export function ActivateUserModal({
variant="plain"
onClick={onClose}
disabled={loading}
className="rounded-apple-md px-6 uppercase text-[10px] font-apple-black tracking-widest"
className="rounded-md px-6 uppercase text-[10px] font-apple-black tracking-widest"
>
取消
</Button>
@ -253,7 +253,7 @@ export function ActivateUserModal({
type="submit"
variant="filled"
loading={loading}
className="rounded-apple-md px-10 uppercase text-[10px] font-apple-black tracking-widest shadow-apple-blue/20"
className="rounded-md px-10 uppercase text-[10px] font-apple-black tracking-widest"
>
激活账号
</Button>

View File

@ -140,22 +140,21 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
onClose={onClose}
title="新建账号"
size="medium"
className="liquid-glass"
>
<form onSubmit={handleSubmit} className="space-y-8 p-2">
{errors.submit && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-100 rounded-apple-lg animate-in fade-in zoom-in-95">
<div className="flex items-center gap-3 rounded-md border border-red-100 bg-red-50 p-4 animate-in fade-in zoom-in-95">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-apple-xs font-apple-bold text-red-700 uppercase tracking-wide">{errors.submit}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-6">
<h4 className="text-[10px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em] border-b border-apple-separator/10 pb-2">基础信息</h4>
<Input label="用户姓名" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="张三" required errorText={errors.name} className="bg-white/50" />
<Input label="电子邮箱" type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} placeholder="user@example.com" required errorText={errors.email} className="bg-white/50" />
<Input label="联系电话" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} placeholder="13800138000" className="bg-white/50" />
<Input label="用户姓名" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="张三" required errorText={errors.name} className="bg-white" />
<Input label="电子邮箱" type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} placeholder="user@example.com" required errorText={errors.email} className="bg-white" />
<Input label="联系电话" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} placeholder="13800138000" className="bg-white" />
</div>
<div className="space-y-6">
@ -165,15 +164,15 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
value: role,
label: ROLE_LABELS[role],
}))
} required className="bg-white/50" />
} required className="bg-white" />
<Select label="所属部门" value={formData.departmentId} onChange={(e) => setFormData({ ...formData, departmentId: e.target.value, teamId: '' })} options={[
{ value: '', label: '未分配部门' },
...departments.map((dept) => ({ value: dept.id, label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}` })),
]} className="bg-white/50" />
]} className="bg-white" />
</div>
</div>
<div className="bg-[#f2f4f6]/50 rounded-apple-xl p-6 space-y-5 shadow-apple-xs border border-white">
<div className="space-y-5 rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-6">
<div className="flex items-center gap-2 mb-1">
<div className="w-1.5 h-1.5 rounded-full bg-apple-blue shadow-[0_0_8px_rgba(0,122,255,0.4)]" />
<h4 className="text-[10px] font-apple-black text-apple-label-secondary uppercase tracking-widest">安全信息</h4>
@ -188,8 +187,8 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-apple-separator/5">
<Button type="button" variant="plain" onClick={onClose} disabled={loading} className="rounded-apple-md px-6 uppercase text-[10px] font-apple-black tracking-widest">取消</Button>
<Button type="submit" variant="filled" loading={loading} className="rounded-apple-md px-10 shadow-apple-blue/20 uppercase text-[10px] font-apple-black tracking-widest">创建账号</Button>
<Button type="button" variant="plain" onClick={onClose} disabled={loading} className="rounded-md px-6 uppercase text-[10px] font-apple-black tracking-widest">取消</Button>
<Button type="submit" variant="filled" loading={loading} className="rounded-md px-10 uppercase text-[10px] font-apple-black tracking-widest">创建账号</Button>
</div>
</form>
</Modal>

View File

@ -87,6 +87,8 @@ interface DepartmentAccessTreeProps {
statusFilter: string;
refreshToken: number;
currentUserId?: string;
selectedUserId?: string;
onSelect?: (user: UserTreeItem) => void;
onEdit: (user: UserTreeItem) => void;
onDelete: (userId: string) => Promise<void> | void;
onActivate: (user: UserTreeItem) => Promise<void> | void;
@ -105,12 +107,10 @@ interface SectionHeaderProps {
function SummaryBadge({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-apple-md border border-apple-separator/10 bg-apple-bg-secondary/85 px-2.5 py-1.5">
<div className="text-[9px] font-apple-black uppercase tracking-[0.16em] text-apple-label-tertiary">
{label}
</div>
<div className="mt-0.5 text-[11px] font-apple-bold text-apple-label-primary">{value}</div>
</div>
<span className="inline-flex h-6 items-center gap-1.5 rounded-[5px] border border-[#dce3e4] bg-[#f7f9f9] px-2 text-[10px] font-bold text-[#607073]">
<span>{label}</span>
<span className="font-black text-[#152024]">{value}</span>
</span>
);
}
@ -119,26 +119,40 @@ function SectionHeader({ icon, title, count, isOpen, onToggle }: SectionHeaderPr
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2.5 rounded-apple-lg px-2.5 py-2 text-left transition-all duration-300 hover:bg-apple-bg-secondary/60"
className="flex h-8 w-full items-center gap-2 rounded-[5px] px-2 text-left transition-colors hover:bg-[#f0f4f4] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
>
<ChevronDown
size={14}
className={cn('text-apple-label-tertiary transition-transform', isOpen && 'rotate-180')}
className={cn('text-[#6b777a] transition-transform', isOpen && 'rotate-180')}
/>
<div className="text-apple-label-tertiary">{icon}</div>
<span className="text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">
<div className="text-[#6b777a]">{icon}</div>
<span className="text-[11px] font-black text-[#384548]">
{title}
</span>
<span className="ml-auto text-[10px] font-apple-black text-apple-label-tertiary opacity-50">
{count}
<span className="ml-auto rounded-[4px] bg-[#e9eeee] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
{count} 人
</span>
</button>
);
}
function UserLedgerHeader() {
return (
<div className="hidden rounded-[5px] border border-[#e6ecec] bg-[#f7f9f9] px-3 py-1.5 text-[10px] font-black text-[#6a7679] xl:grid xl:grid-cols-[minmax(220px,1.25fr)_minmax(170px,0.95fr)_minmax(140px,0.8fr)_minmax(112px,0.65fr)_auto] xl:items-center">
<span>账号</span>
<span>通讯</span>
<span>组织</span>
<span>最近登录</span>
<span className="text-right">操作</span>
</div>
);
}
function TeamNodeView({
team,
currentUserId,
selectedUserId,
onSelect,
onEdit,
onDelete,
onActivate,
@ -148,6 +162,8 @@ function TeamNodeView({
}: {
team: TeamNode;
currentUserId?: string;
selectedUserId?: string;
onSelect?: (user: UserTreeItem) => void;
onEdit: (user: UserTreeItem) => void;
onDelete: (userId: string) => Promise<void> | void;
onActivate: (user: UserTreeItem) => Promise<void> | void;
@ -158,45 +174,48 @@ function TeamNodeView({
const [isOpen, setIsOpen] = useState(false);
return (
<div className="rounded-apple-lg border border-apple-separator/10 bg-[#fbfcfd] shadow-apple-xs">
<div className="rounded-[6px] border border-[#dce3e4] bg-white">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className="flex w-full items-center justify-between gap-4 px-4 py-3.5 text-left"
className="flex min-h-[42px] w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-[#f8faf9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-apple-bold text-apple-label-primary">{team.name}</span>
<span className="rounded-apple-sm bg-apple-bg-secondary px-1.5 py-0.5 text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
<span className="truncate text-[13px] font-black text-[#152024]">{team.name}</span>
<span className="rounded-[4px] border border-[#dce3e4] bg-[#f7f9f9] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
{team.summary.memberCount} 人
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] text-apple-label-secondary">
<span className="inline-flex items-center gap-1 rounded-apple-sm bg-apple-blue/5 px-2 py-1 font-apple-medium text-apple-blue">
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-[#607073]">
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[#eef3f3] px-1.5 py-0.5 font-bold text-[#4e5b5e]">
<ShieldCheck className="h-3 w-3" />
主管
</span>
<span className="font-apple-bold text-apple-label-primary">{team.leader?.name || '未设置'}</span>
{team.description ? <span className="truncate text-apple-label-tertiary">{team.description}</span> : null}
<span className="font-bold text-[#263033]">{team.leader?.name || '未设置'}</span>
{team.description ? <span className="truncate text-[#6b777a]">{team.description}</span> : null}
</div>
</div>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 text-apple-label-tertiary transition-transform duration-200',
'h-4 w-4 shrink-0 text-[#6b777a] transition-transform duration-150',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</button>
{isOpen && (
<div className="border-t border-apple-separator/10 px-4 py-4">
<div className="border-t border-[#e6ecec] bg-[#fafcfc] px-2 py-2">
{team.users.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1.5">
<UserLedgerHeader />
{team.users.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
@ -208,7 +227,7 @@ function TeamNodeView({
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-apple-lg border border-dashed border-apple-separator/20 bg-white px-4 py-3 text-sm text-apple-label-tertiary">
<div className="flex items-center gap-2 rounded-[6px] border border-dashed border-[#d6dee0] bg-white px-4 py-3 text-sm text-[#607073]">
<Users className="h-4 w-4" />
<span>该小组当前没有匹配的成员</span>
</div>
@ -223,6 +242,8 @@ function DepartmentNodeView({
department,
depth,
currentUserId,
selectedUserId,
onSelect,
onEdit,
onDelete,
onActivate,
@ -233,6 +254,8 @@ function DepartmentNodeView({
department: DepartmentNode;
depth: number;
currentUserId?: string;
selectedUserId?: string;
onSelect?: (user: UserTreeItem) => void;
onEdit: (user: UserTreeItem) => void;
onDelete: (userId: string) => Promise<void> | void;
onActivate: (user: UserTreeItem) => Promise<void> | void;
@ -250,39 +273,39 @@ function DepartmentNodeView({
return (
<div
className={cn(
'rounded-apple-xl border border-apple-separator/10 bg-white shadow-apple-sm',
'rounded-[6px] border border-[#d8e0e1] bg-white',
depth > 0 && 'ml-4 mt-2'
)}
>
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className="flex w-full items-center justify-between gap-4 px-4 py-4 text-left"
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#f8faf9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-apple-lg bg-apple-blue/10 text-apple-blue">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[6px] border border-[#dce3e4] bg-[#f2f6f6] text-[#526164]">
<Building2 className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-apple-black text-apple-label-primary">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[14px] font-black text-[#152024]">
{department.name}
</span>
<span className="rounded-apple-sm bg-apple-bg-secondary px-1.5 py-0.5 text-[9px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
<span className="rounded-[4px] border border-[#dce3e4] bg-[#f7f9f9] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
{department.summary.totalUserCount} 人
</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-2 text-[10px] text-apple-label-secondary">
<span className="truncate text-apple-label-tertiary">{department.fullPath}</span>
<div className="inline-flex items-center gap-1 rounded-apple-sm bg-apple-blue/5 px-2 py-1 font-apple-medium text-apple-blue">
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px] text-[#607073]">
<span className="truncate text-[#6b777a]">{department.fullPath}</span>
<div className="inline-flex items-center gap-1 rounded-[4px] bg-[#eef3f3] px-1.5 py-0.5 font-bold text-[#4e5b5e]">
<ShieldCheck className="h-3 w-3" />
<span>主管</span>
</div>
<span className="font-apple-bold text-apple-label-primary">
<span className="font-bold text-[#263033]">
{department.manager?.name || '未设置'}
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="mt-2 flex flex-wrap gap-1.5">
<SummaryBadge label="直属人员" value={`${department.summary.directUserCount} 人`} />
<SummaryBadge label="总人数" value={`${department.summary.totalUserCount} 人`} />
<SummaryBadge label="子部门" value={`${department.summary.childDepartmentCount} 个`} />
@ -293,16 +316,16 @@ function DepartmentNodeView({
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 text-apple-label-tertiary transition-transform duration-200',
'h-4 w-4 shrink-0 text-[#6b777a] transition-transform duration-150',
isOpen ? 'rotate-180' : 'rotate-0'
)}
/>
</button>
{isOpen && (
<div className="border-t border-apple-separator/10 px-4 py-4">
<div className="border-t border-[#e6ecec] bg-[#fafcfc] px-2 py-2">
{hasDirectUsers && (
<div className="mb-4">
<div className="mb-3">
<SectionHeader
icon={<UserCircle2 size={15} />}
title="直属成员"
@ -311,11 +334,14 @@ function DepartmentNodeView({
onToggle={() => setDirectUsersOpen((current) => !current)}
/>
{directUsersOpen && (
<div className="mt-3 space-y-2">
<div className="mt-2 space-y-1.5">
<UserLedgerHeader />
{department.users.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
@ -331,7 +357,7 @@ function DepartmentNodeView({
)}
{hasTeams && (
<div className="mb-4">
<div className="mb-3">
<SectionHeader
icon={<Users2 size={15} />}
title="小组"
@ -340,12 +366,14 @@ function DepartmentNodeView({
onToggle={() => setTeamsOpen((current) => !current)}
/>
{teamsOpen && (
<div className="mt-3 space-y-2">
<div className="mt-2 space-y-1.5">
{department.teams.map((team) => (
<TeamNodeView
key={team.id}
team={team}
currentUserId={currentUserId}
selectedUserId={selectedUserId}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
@ -360,17 +388,19 @@ function DepartmentNodeView({
)}
{hasChildren && (
<div className="mt-4 border-l border-apple-separator/10 pl-4">
<div className="mb-3 text-[10px] font-apple-black uppercase tracking-[0.18em] text-apple-label-tertiary">
<div className="mt-3 border-l border-[#dce3e4] pl-3">
<div className="mb-2 text-[11px] font-black text-[#607073]">
子部门
</div>
<div className="space-y-2">
<div className="space-y-1.5">
{department.children.map((child) => (
<DepartmentNodeView
key={child.id}
department={child}
depth={depth + 1}
currentUserId={currentUserId}
selectedUserId={selectedUserId}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
@ -384,7 +414,7 @@ function DepartmentNodeView({
)}
{!hasDirectUsers && !hasTeams && !hasChildren && (
<div className="flex items-center gap-2 rounded-apple-lg border border-dashed border-apple-separator/20 bg-apple-bg-secondary px-4 py-3 text-sm text-apple-label-tertiary">
<div className="flex items-center gap-2 rounded-[6px] border border-dashed border-[#d6dee0] bg-white px-4 py-3 text-sm text-[#607073]">
<Users className="h-4 w-4" />
<span>该部门当前没有匹配的组织成员</span>
</div>
@ -411,6 +441,8 @@ export function DepartmentAccessTree({
statusFilter,
refreshToken,
currentUserId,
selectedUserId,
onSelect,
onEdit,
onDelete,
onActivate,
@ -477,8 +509,8 @@ export function DepartmentAccessTree({
if (loading) {
return (
<div className="flex items-center justify-center rounded-apple-xl border border-apple-separator/10 bg-white py-12">
<RefreshCw className="h-5 w-5 animate-spin text-apple-label-tertiary" />
<div className="flex items-center justify-center rounded-[6px] border border-[#dce3e4] bg-white py-12">
<RefreshCw className="h-5 w-5 animate-spin text-[#6b777a]" />
</div>
);
}
@ -502,21 +534,23 @@ export function DepartmentAccessTree({
if (!hasDepartments && unassignedUsers.length === 0) {
return (
<div className="rounded-apple-xl border border-dashed border-apple-separator/15 bg-white px-6 py-10 text-center">
<Building2 className="mx-auto mb-3 h-8 w-8 text-apple-label-tertiary/60" />
<p className="text-sm font-apple-medium text-apple-label-secondary">暂无匹配的组织数据</p>
<div className="rounded-[6px] border border-dashed border-[#d6dee0] bg-white px-6 py-10 text-center">
<Building2 className="mx-auto mb-3 h-8 w-8 text-[#6b777a]" />
<p className="text-sm font-medium text-[#607073]">暂无匹配的组织数据</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-2.5">
{departments.map((department) => (
<DepartmentNodeView
key={department.id}
department={department}
depth={0}
currentUserId={currentUserId}
selectedUserId={selectedUserId}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}
@ -527,33 +561,38 @@ export function DepartmentAccessTree({
))}
{unassignedUsers.length > 0 && (
<div className="space-y-4 pt-6">
<div className="space-y-2.5 pt-2">
<button
type="button"
onClick={() => setUnassignedOpen((current) => !current)}
className={cn(
'flex w-full items-center gap-3 rounded-apple-lg px-4 py-3 transition-all duration-300',
unassignedOpen ? 'bg-white shadow-apple-sm' : 'hover:bg-white/40'
'flex min-h-[42px] w-full items-center gap-3 rounded-[6px] px-3 py-2 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7]',
unassignedOpen
? 'border border-[#d8e0e1] bg-white'
: 'border border-transparent hover:border-[#dce3e4] hover:bg-[#f8faf9]'
)}
>
<ChevronDown
size={14}
className={cn('text-apple-label-tertiary transition-transform', unassignedOpen && 'rotate-180')}
className={cn('text-[#6b777a] transition-transform', unassignedOpen && 'rotate-180')}
/>
<UserCircle2 size={16} className="text-apple-label-tertiary" />
<span className="text-apple-sm font-apple-bold tracking-tight text-apple-label-primary uppercase">
<UserCircle2 size={16} className="text-[#6b777a]" />
<span className="text-[13px] font-black text-[#152024]">
未分配 / 外部人员
</span>
<span className="ml-auto text-[10px] font-apple-black text-apple-label-tertiary opacity-40">
<span className="ml-auto rounded-[4px] bg-[#e9eeee] px-1.5 py-0.5 text-[10px] font-black text-[#607073]">
{unassignedUsers.length} 人
</span>
</button>
{unassignedOpen && (
<div className="space-y-3 pl-10">
<div className="space-y-1.5 rounded-[6px] border border-[#d8e0e1] bg-[#fafcfc] p-2">
<UserLedgerHeader />
{unassignedUsers.map((user) => (
<UserListItem
key={user.id}
user={user}
selected={selectedUserId === user.id}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onActivate={onActivate}

View File

@ -222,11 +222,10 @@ export function EditUserModal({
onClose={onClose}
title="账号资料"
size="medium"
className="liquid-glass"
>
<form onSubmit={handleSubmit} className="space-y-8 p-2">
{errors.submit && (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-100 rounded-apple-lg animate-in fade-in zoom-in-95">
<div className="flex items-center gap-3 rounded-md border border-red-100 bg-red-50 p-4 animate-in fade-in zoom-in-95">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-apple-xs font-apple-bold text-red-700 uppercase tracking-wide">
{errors.submit}
@ -234,7 +233,7 @@ export function EditUserModal({
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-6">
<h4 className="text-[10px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em] border-b border-apple-separator/10 pb-2">
基础信息
@ -247,7 +246,7 @@ export function EditUserModal({
placeholder="张三"
required
errorText={errors.name}
className="bg-white/50"
className="bg-white"
/>
<Input
label="电子邮箱"
@ -257,7 +256,7 @@ export function EditUserModal({
placeholder="user@example.com"
required
errorText={errors.email}
className="bg-white/50"
className="bg-white"
/>
<Input
label="联系电话"
@ -265,7 +264,7 @@ export function EditUserModal({
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="13800138000"
className="bg-white/50"
className="bg-white"
/>
</div>
@ -280,7 +279,7 @@ export function EditUserModal({
options={availableRoleOptions}
required
disabled={disablePrivilegeFields || user.role === 'SUPER_ADMIN'}
className="bg-white/50"
className="bg-white"
/>
<Select
label="当前状态"
@ -294,7 +293,7 @@ export function EditUserModal({
]}
required
disabled={disablePrivilegeFields}
className="bg-white/50"
className="bg-white"
/>
<Select
label="所属部门"
@ -308,12 +307,12 @@ export function EditUserModal({
})),
]}
disabled={disablePrivilegeFields}
className="bg-white/50"
className="bg-white"
/>
</div>
</div>
<div className="bg-[#f2f4f6]/50 rounded-apple-xl p-6 space-y-5">
<div className="space-y-5 rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-6">
<div className="flex items-center gap-2 mb-1">
<div className="w-1 h-1 rounded-full bg-apple-blue" />
<h4 className="text-[10px] font-apple-black text-apple-label-secondary uppercase tracking-widest">
@ -350,7 +349,7 @@ export function EditUserModal({
variant="plain"
onClick={onClose}
disabled={loading}
className="rounded-apple-md px-6 uppercase text-[10px] font-apple-black tracking-widest"
className="rounded-md px-6 uppercase text-[10px] font-apple-black tracking-widest"
>
取消
</Button>
@ -358,7 +357,7 @@ export function EditUserModal({
type="submit"
variant="filled"
loading={loading}
className="rounded-apple-md px-10 shadow-apple-blue/20 uppercase text-[10px] font-apple-black tracking-widest"
className="rounded-md px-10 uppercase text-[10px] font-apple-black tracking-widest"
>
保存修改
</Button>

View File

@ -320,11 +320,10 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
onClose={onClose}
title="离职交接"
size="large"
className="liquid-glass"
>
<div className="space-y-8 p-2">
{errorMessage && (
<div className="flex items-center gap-3 rounded-apple-lg border border-red-100 bg-red-50 p-4">
<div className="flex items-center gap-3 rounded-md border border-red-100 bg-red-50 p-4">
<AlertCircle className="h-4 w-4 shrink-0 text-red-500" />
<p className="text-apple-xs font-apple-bold text-red-700">{errorMessage}</p>
</div>
@ -336,7 +335,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
</div>
) : checklist ? (
<>
<div className="rounded-apple-xl border border-white bg-[#f2f4f6]/70 p-6 shadow-apple-xs">
<div className="rounded-md border border-[#dde3e5] bg-[#fbfcfc] p-6">
<div className="mb-4 flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-apple-blue shadow-[0_0_8px_rgba(0,122,255,0.4)]" />
<h4 className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-apple-label-secondary">
@ -373,7 +372,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-apple-xl border border-apple-separator/10 bg-white p-5 shadow-apple-sm">
<div className="rounded-md border border-[#dde3e5] bg-white p-5">
<div className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
待转交 SKU
</div>
@ -381,7 +380,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
{checklist.summary.totalSKUs}
</div>
</div>
<div className="rounded-apple-xl border border-apple-separator/10 bg-white p-5 shadow-apple-sm">
<div className="rounded-md border border-[#dde3e5] bg-white p-5">
<div className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
创建产品
</div>
@ -389,7 +388,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
{checklist.summary.totalProducts}
</div>
</div>
<div className="rounded-apple-xl border border-apple-separator/10 bg-white p-5 shadow-apple-sm">
<div className="rounded-md border border-[#dde3e5] bg-white p-5">
<div className="text-[10px] font-apple-black uppercase tracking-widest text-apple-label-tertiary">
协作产品
</div>
@ -400,7 +399,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
</div>
{managementWarnings.length > 0 && (
<div className="rounded-apple-xl border border-amber-100 bg-amber-50/80 p-5 shadow-apple-xs">
<div className="rounded-md border border-amber-200 bg-amber-50 p-5">
<div className="mb-3 flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-600" />
<h4 className="text-[10px] font-apple-black uppercase tracking-[0.2em] text-amber-700">
@ -425,24 +424,24 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
value={recipientSearch}
onChange={(event) => setRecipientSearch(event.target.value)}
placeholder="输入姓名或邮箱筛选"
className="bg-white/50"
className="bg-white"
/>
<Select
label="交接给谁"
value={toUserId}
onChange={(event) => setToUserId(event.target.value)}
options={recipientOptions}
className="bg-white/50"
className="bg-white"
/>
<Textarea
label="交接原因"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="例如:离职、转岗、职责调整"
className="bg-white/50"
className="bg-white"
/>
<label className="flex items-start gap-3 rounded-apple-lg border border-apple-separator/10 bg-white px-4 py-3">
<label className="flex items-start gap-3 rounded-md border border-[#dde3e5] bg-white px-4 py-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-apple-separator/30 accent-apple-blue"
@ -492,7 +491,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
<label
key={sku.id}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-apple-lg border px-4 py-3 transition-all',
'flex cursor-pointer items-start gap-3 rounded-md border px-4 py-3 transition-all',
checked
? 'border-apple-blue/20 bg-apple-blue/5'
: 'border-apple-separator/10 bg-white hover:border-apple-separator/20',
@ -521,7 +520,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
})}
</div>
) : (
<div className="flex items-center gap-2 rounded-apple-lg border border-dashed border-apple-separator/20 bg-apple-bg-secondary px-4 py-3 text-sm text-apple-label-tertiary">
<div className="flex items-center gap-2 rounded-md border border-dashed border-[#dde3e5] bg-[#fbfcfc] px-4 py-3 text-sm text-apple-label-tertiary">
<Users className="h-4 w-4" />
<span>当前没有可转交的 SKU</span>
</div>
@ -535,7 +534,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
variant="plain"
onClick={onClose}
disabled={submitting}
className="rounded-apple-md px-6 text-[10px] font-apple-black uppercase tracking-widest"
className="rounded-md px-6 text-[10px] font-apple-black uppercase tracking-widest"
>
取消
</Button>
@ -544,7 +543,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
variant="filled"
loading={submitting}
onClick={handleSubmit}
className="rounded-apple-md px-10 text-[10px] font-apple-black uppercase tracking-widest"
className="rounded-md px-10 text-[10px] font-apple-black uppercase tracking-widest"
>
确认交接
</Button>

View File

@ -1,6 +1,6 @@
/**
* 用户列表项组件
* Apple HIG 2025 风格
* 白曜石工坊用户行
*
* 性能优化:
* - 使用 React.memo 避免不必要的重渲染
@ -10,11 +10,25 @@
'use client';
import { memo, useCallback, useMemo } from 'react';
import { Mail, Phone, Edit, Trash2, Clock, CheckCircle2, RefreshCw } from 'lucide-react';
import { Role } from '@prisma/client';
import { RoleBadge } from '@/components/auth/RoleBadge';
import Button from '@/components/ui/Button';
import {
memo,
useCallback,
useMemo,
type KeyboardEvent,
type MouseEvent,
} from 'react';
import {
Building2,
CheckCircle2,
Clock,
Edit,
Mail,
Phone,
RefreshCw,
Trash2,
Users,
type LucideIcon,
} from 'lucide-react';
import Image from '@/components/ui/Image';
import { cn } from '@/lib/utils';
@ -45,43 +59,142 @@ interface UserListItemProps {
onDelete: (userId: string) => void;
onActivate?: (user: UserListItemProps['user']) => void;
onHandover?: (user: UserListItemProps['user']) => void;
onSelect?: (user: UserListItemProps['user']) => void;
currentUserId?: string;
canEdit?: boolean;
canActivate?: boolean;
selected?: boolean;
}
const statusConfig = {
ACTIVE: {
label: '在职',
color: 'bg-emerald-50 text-emerald-700 border-emerald-100',
iconColor: 'text-emerald-500',
color: 'border-[#bfe4d4] bg-[#edf8f3] text-[#1f7a56]',
},
PENDING: {
label: '待激活',
color: 'bg-amber-50 text-amber-700 border-amber-100',
iconColor: 'text-amber-500',
color: 'border-[#ead9aa] bg-[#fff8e8] text-[#8a641b]',
},
INACTIVE: {
label: '离职',
color: 'bg-apple-bg-tertiary text-apple-label-tertiary border-apple-separator/20',
iconColor: 'text-apple-label-tertiary',
color: 'border-[#d8dee0] bg-[#f2f5f5] text-[#687477]',
},
SUSPENDED: {
label: '暂停',
color: 'bg-red-50 text-red-700 border-red-100',
iconColor: 'text-red-500',
label: '锁定',
color: 'border-[#efc8c4] bg-[#fff1ef] text-[#a9483f]',
},
};
const roleConfig: Record<string, { label: string; color: string; avatar: string }> = {
SUPER_ADMIN: {
label: '超级管理员',
color: 'border-[#e3d2ad] bg-[#fbf5e7] text-[#7d5a18]',
avatar: 'border-[#e3d2ad] bg-[#fbf5e7] text-[#7d5a18]',
},
ADMIN: {
label: '管理员',
color: 'border-[#d7c7b0] bg-[#fff4e7] text-[#985f22]',
avatar: 'border-[#d7c7b0] bg-[#fff4e7] text-[#985f22]',
},
SUPERVISOR: {
label: '主管',
color: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
avatar: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
},
SUPERVISOR_DEPARTMENT: {
label: '部门主管',
color: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
avatar: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
},
SUPERVISOR_TEAM: {
label: '小组主管',
color: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
avatar: 'border-[#cfdbde] bg-[#f2f6f6] text-[#405256]',
},
DESIGNER: {
label: '设计师',
color: 'border-[#cbdbea] bg-[#eef5fb] text-[#2f5f86]',
avatar: 'border-[#cbdbea] bg-[#eef5fb] text-[#2f5f86]',
},
USER: {
label: '普通用户',
color: 'border-[#d8dee0] bg-[#f5f7f7] text-[#4f5b5e]',
avatar: 'border-[#d8dee0] bg-[#f5f7f7] text-[#4f5b5e]',
},
};
function getInitials(name: string, email: string) {
const source = (name || email || '?').trim().replace(/\s+/g, '');
const chars = Array.from(source);
if (chars.length === 0) return '?';
if (/^[a-zA-Z]/.test(chars[0])) {
return chars.slice(0, 2).join('').toUpperCase();
}
return chars.slice(0, 2).join('');
}
function IdentityChip({ children, className }: { children: string; className?: string }) {
return (
<span
className={cn(
'inline-flex h-[22px] items-center rounded-[5px] border px-2 text-[10px] font-black leading-none',
className
)}
>
{children}
</span>
);
}
function RowActionButton({
icon: Icon,
label,
tone = 'neutral',
title,
onClick,
}: {
icon: LucideIcon;
label?: string;
tone?: 'neutral' | 'warning' | 'danger' | 'success';
title: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[5px] border px-2.5 text-[12px] font-bold transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7] focus-visible:ring-offset-1',
tone === 'neutral' &&
'border-[#d6dee0] bg-[#f7f9f9] text-[#324044] hover:border-[#b8c4c7] hover:bg-[#eef3f3]',
tone === 'warning' &&
'border-[#ead9aa] bg-[#fff9eb] text-[#8a641b] hover:border-[#d8bd72] hover:bg-[#fff3d3]',
tone === 'danger' &&
'border-transparent bg-transparent px-2 text-[#9d5750] hover:border-[#efc8c4] hover:bg-[#fff1ef]',
tone === 'success' &&
'border-[#bfe4d4] bg-[#eff9f4] text-[#1f7a56] hover:border-[#95d5b9] hover:bg-[#e5f5ee]'
)}
>
<Icon className="h-3.5 w-3.5" />
{label ? <span>{label}</span> : null}
</button>
);
}
export const UserListItem = memo(function UserListItem({
user,
onEdit,
onDelete,
onActivate,
onHandover,
onSelect,
currentUserId,
canEdit = true,
canActivate = true,
selected = false,
}: UserListItemProps) {
const isCurrentUser = useMemo(() => user.id === currentUserId, [user.id, currentUserId]);
const isSuperAdmin = useMemo(() => user.role === 'SUPER_ADMIN', [user.role]);
@ -91,6 +204,8 @@ export const UserListItem = memo(function UserListItem({
() => statusConfig[user.status as keyof typeof statusConfig] || statusConfig.ACTIVE,
[user.status]
);
const roleStyle = useMemo(() => roleConfig[user.role] || roleConfig.USER, [user.role]);
const initials = useMemo(() => getInitials(user.name, user.email), [user.email, user.name]);
const formattedLastLogin = useMemo(() => {
if (!user.lastLoginAt) return '从未登录';
@ -102,161 +217,180 @@ export const UserListItem = memo(function UserListItem({
);
}, [user.lastLoginAt]);
const handleEdit = useCallback(() => onEdit(user), [onEdit, user]);
const handleDelete = useCallback(() => onDelete(user.id), [onDelete, user.id]);
const handleActivate = useCallback(() => onActivate?.(user), [onActivate, user]);
const handleHandover = useCallback(() => onHandover?.(user), [onHandover, user]);
const handleSelect = useCallback(() => onSelect?.(user), [onSelect, user]);
const handleRowKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.currentTarget !== event.target) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
handleSelect();
},
[handleSelect]
);
const handleEdit = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onEdit(user);
},
[onEdit, user]
);
const handleDelete = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onDelete(user.id);
},
[onDelete, user.id]
);
const handleActivate = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onActivate?.(user);
},
[onActivate, user]
);
const handleHandover = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onHandover?.(user);
},
[onHandover, user]
);
return (
<div
role="button"
tabIndex={0}
aria-pressed={selected}
data-testid="user-list-item"
data-user-id={user.id}
data-selected={selected ? 'true' : 'false'}
onClick={handleSelect}
onKeyDown={handleRowKeyDown}
style={
selected
? { boxShadow: 'inset 3px 0 0 #152024, 0 0 0 1px rgba(21, 32, 36, 0.06)' }
: undefined
}
className={cn(
'group relative rounded-apple-lg border border-apple-separator/10 bg-white/90 transition-all duration-300 hover:border-apple-separator/20 hover:shadow-apple-sm',
isPending && 'border-amber-200/60 bg-amber-50/25'
'group relative grid cursor-pointer grid-cols-1 gap-2 rounded-[6px] border border-[#dce3e4] bg-white px-3 py-2 text-left transition-[background,border-color,box-shadow] duration-150',
'hover:border-[#b8c4c7] hover:bg-[#f8faf9] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#b9c8c7] focus-visible:ring-offset-1',
'xl:grid-cols-[minmax(220px,1.25fr)_minmax(170px,0.95fr)_minmax(140px,0.8fr)_minmax(112px,0.65fr)_auto] xl:items-center',
selected && 'border-[#152024] bg-[#eef5f4]',
isPending && !selected && 'border-[#ead9aa] bg-[#fffaf0]'
)}
>
{isPending && (
<div className="absolute bottom-3 left-0 top-3 w-1 rounded-r-full bg-amber-400/90" />
<div className="absolute bottom-2 left-0 top-2 w-1 rounded-r-full bg-[#d59c2b]" />
)}
<div className="flex flex-col gap-3 px-4 py-3.5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3">
<div className="relative shrink-0">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full text-[13px] font-apple-bold text-white shadow-apple-xs',
isPending ? 'bg-amber-400' : 'bg-apple-blue'
)}
>
{user.avatar ? (
<Image
src={user.avatar}
alt={user.name}
className="h-full w-full rounded-full object-cover"
rounded="full"
/>
) : (
user.name.charAt(0).toUpperCase()
)}
</div>
{isCurrentUser && (
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500 shadow-apple-xs" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
<h3 className="max-w-full truncate text-sm font-apple-bold tracking-tight text-apple-label-primary">
{user.name}
</h3>
<RoleBadge role={user.role as Role} />
<div
className={cn(
'rounded-apple-sm border px-2 py-0.5 text-[9px] font-apple-black uppercase tracking-[0.16em]',
statusStyle.color
)}
>
{statusStyle.label}
</div>
{isCurrentUser && (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[9px] font-apple-black uppercase tracking-[0.16em] text-emerald-600">
当前账号
</span>
)}
{user.employeeDirectoryId && (
<span className="rounded bg-apple-bg-secondary px-1.5 py-0.5 text-[9px] font-apple-bold uppercase tracking-[0.14em] text-apple-label-tertiary opacity-70">
内部人员
</span>
)}
{user.loginSource === 'DINGTALK' && (
<span className="rounded bg-apple-blue/5 px-1.5 py-0.5 text-[9px] font-apple-bold uppercase tracking-[0.14em] text-apple-blue/70">
钉钉
</span>
)}
</div>
<div className="mt-2 grid gap-2 text-[11px] font-apple-medium text-apple-label-secondary lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex min-w-0 items-center gap-1.5">
<Mail className="h-3.5 w-3.5 shrink-0 opacity-35" />
<span className="truncate">{user.email}</span>
</div>
{user.phone && (
<div className="flex min-w-0 items-center gap-1.5">
<Phone className="h-3.5 w-3.5 shrink-0 opacity-35" />
<span className="truncate">{user.phone}</span>
</div>
)}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 lg:justify-end">
{user.department && (
<div className="flex min-w-0 items-center gap-1.5">
<div className="h-1 w-1 shrink-0 rounded-full bg-apple-separator/40" />
<span className="truncate">{user.department.name}</span>
</div>
)}
{user.team && (
<div className="flex min-w-0 items-center gap-1.5">
<div className="h-1 w-1 shrink-0 rounded-full bg-apple-separator/40" />
<span className="truncate">{user.team.name}</span>
</div>
)}
<div className="flex min-w-0 items-center gap-1.5 text-apple-label-tertiary">
<Clock className="h-3.5 w-3.5 shrink-0 opacity-30" />
<span className="truncate">最近登录:{formattedLastLogin}</span>
</div>
</div>
</div>
</div>
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative shrink-0">
<div
className={cn(
'flex h-9 w-9 items-center justify-center overflow-hidden rounded-[6px] border text-[11px] font-black',
roleStyle.avatar
)}
>
{user.avatar ? (
<Image
src={user.avatar}
alt={user.name}
className="h-full w-full rounded-[6px]"
rounded="small"
lazy={false}
/>
) : (
initials
)}
</div>
{isCurrentUser && (
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-[#18a06a]" />
)}
</div>
<div className="flex flex-wrap items-center gap-2 xl:max-w-[320px] xl:justify-end">
{isPending && onActivate && canActivate && (
<Button
variant="tinted"
size="small"
onClick={handleActivate}
className="rounded-apple-md border-apple-blue/20 px-3.5 shadow-apple-blue/5"
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
激活
</Button>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="min-w-0 truncate text-[13px] font-black leading-5 text-[#152024]">
{user.name}
</h3>
<IdentityChip className={roleStyle.color}>{roleStyle.label}</IdentityChip>
<IdentityChip className={statusStyle.color}>{statusStyle.label}</IdentityChip>
{selected && (
<IdentityChip className="border-[#152024] bg-[#152024] text-[#fdfdfc]">当前选择</IdentityChip>
)}
{canEdit && (
<Button
variant="plain"
size="small"
onClick={handleEdit}
className="rounded-apple-md border border-apple-separator/10 text-apple-label-secondary hover:bg-apple-bg-secondary"
>
<Edit className="mr-1.5 h-3.5 w-3.5" />
编辑
</Button>
{isCurrentUser && (
<IdentityChip className="border-[#bfe4d4] bg-[#edf8f3] text-[#1f7a56]">当前账号</IdentityChip>
)}
{!isCurrentUser && user.status === 'ACTIVE' && onHandover && (
<Button
variant="plain"
size="small"
onClick={handleHandover}
className="rounded-apple-md border border-amber-100 text-amber-600 hover:bg-amber-50"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
交接
</Button>
{user.employeeDirectoryId && (
<IdentityChip className="border-[#d8dee0] bg-[#f7f9f9] text-[#5d696c]">内部人员</IdentityChip>
)}
{!isCurrentUser && !isSuperAdmin && (
<button
onClick={handleDelete}
className="rounded-apple-md p-2 text-apple-label-tertiary opacity-50 transition-all hover:bg-apple-red/5 hover:text-apple-red group-hover:opacity-100"
title="移除用户"
>
<Trash2 className="h-4 w-4" />
</button>
{user.loginSource === 'DINGTALK' && (
<IdentityChip className="border-[#cbdbea] bg-[#eef5fb] text-[#2f5f86]">钉钉</IdentityChip>
)}
</div>
</div>
</div>
<div className="grid min-w-0 gap-1 text-[11px] font-medium text-[#607073]">
<div className="flex min-w-0 items-center gap-1.5">
<Mail className="h-3.5 w-3.5 shrink-0 text-[#9aa7aa]" />
<span className="truncate">{user.email}</span>
</div>
{user.phone ? (
<div className="flex min-w-0 items-center gap-1.5">
<Phone className="h-3.5 w-3.5 shrink-0 text-[#9aa7aa]" />
<span className="truncate">{user.phone}</span>
</div>
) : null}
</div>
<div className="grid min-w-0 gap-1 text-[11px] font-medium text-[#607073]">
<div className="flex min-w-0 items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 shrink-0 text-[#9aa7aa]" />
<span className="truncate">{user.department?.name || '未归属部门'}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<Users className="h-3.5 w-3.5 shrink-0 text-[#9aa7aa]" />
<span className="truncate">{user.team?.name || '未归属小组'}</span>
</div>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-[#607073]">
<Clock className="h-3.5 w-3.5 shrink-0 text-[#9aa7aa]" />
<span className="truncate">{formattedLastLogin}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5 xl:justify-end">
{isPending && onActivate && canActivate && (
<RowActionButton
icon={CheckCircle2}
label="激活"
title={`激活 ${user.name}`}
tone="success"
onClick={handleActivate}
/>
)}
{canEdit && (
<RowActionButton icon={Edit} label="编辑" title={`编辑 ${user.name}`} onClick={handleEdit} />
)}
{!isCurrentUser && user.status === 'ACTIVE' && onHandover && (
<RowActionButton
icon={RefreshCw}
label="交接"
title={`交接 ${user.name}`}
tone="warning"
onClick={handleHandover}
/>
)}
{!isCurrentUser && !isSuperAdmin && (
<RowActionButton icon={Trash2} title={`移除 ${user.name}`} tone="danger" onClick={handleDelete} />
)}
</div>
</div>
);
});

View File

@ -3,7 +3,24 @@ import userEvent from '@testing-library/user-event'
import { DepartmentAccessTree } from '../DepartmentAccessTree'
jest.mock('@/components/users/UserListItem', () => ({
UserListItem: ({ user }: { user: { name: string } }) => <div data-testid="user-card">{user.name}</div>,
UserListItem: ({
user,
selected,
onSelect,
}: {
user: { id: string; name: string }
selected?: boolean
onSelect?: (user: { id: string; name: string }) => void
}) => (
<button
type="button"
data-testid="user-card"
data-selected={selected ? 'true' : 'false'}
onClick={() => onSelect?.(user)}
>
{user.name}
</button>
),
}))
describe('DepartmentAccessTree', () => {
@ -154,4 +171,31 @@ describe('DepartmentAccessTree', () => {
expect(screen.queryByText('未分配成员')).not.toBeInTheDocument()
})
it('应把人员选中态和点击事件传给用户行', async () => {
const actor = userEvent.setup()
const handleSelect = jest.fn()
render(
<DepartmentAccessTree
searchQuery=""
roleFilter=""
statusFilter=""
refreshToken={0}
selectedUserId="user-root"
onSelect={handleSelect}
onEdit={jest.fn()}
onDelete={jest.fn()}
onActivate={jest.fn()}
onHandover={jest.fn()}
/>,
)
const selectedCard = await screen.findByRole('button', { name: '部门直属成员' })
expect(selectedCard).toHaveAttribute('data-selected', 'true')
await actor.click(screen.getByRole('button', { name: '未分配成员' }))
expect(handleSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'user-free' }))
})
})

View File

@ -1,7 +1,7 @@
/**
* Apple 风格用户偏好设置和动态字体支持
*
* 支持用户偏好设置检测和动态字体缩放
* 支持可访问性偏好检测和动态字体缩放。颜色主题固定为浅色。
*
* @created 2025-12-11
*/
@ -34,14 +34,10 @@ export function detectUserPreferences(): UserPreferences {
return DEFAULT_PREFERENCES
}
// 强制默认浅色,除非 localStorage 中有明确设置
const savedTheme = localStorage.getItem('apple-ui-theme') as 'light' | 'dark' | null
const theme = savedTheme || 'light'
return {
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
prefersHighContrast: window.matchMedia('(prefers-contrast: high)').matches,
prefersColorScheme: theme,
prefersColorScheme: 'light',
prefersReducedTransparency: window.matchMedia('(prefers-reduced-transparency: reduce)').matches,
fontSize: parseInt(localStorage.getItem('apple-ui-font-size') || '100', 10),
}
@ -54,13 +50,7 @@ export function useUserPreferences() {
React.useEffect(() => {
// 初始检测
const detected = detectUserPreferences()
// 检查是否有手动设置的主题
const savedTheme = localStorage.getItem('apple-ui-theme') as 'light' | 'dark' | null
if (savedTheme) {
detected.prefersColorScheme = savedTheme
}
setPreferences(detected)
// 监听媒体查询变化
@ -95,25 +85,13 @@ export function useUserPreferences() {
}
}
// 监听主题切换
const handleThemeChange = (e: CustomEvent) => {
const newTheme = e.detail.theme as 'light' | 'dark'
localStorage.setItem('apple-ui-theme', newTheme)
setPreferences(prev => ({
...prev,
prefersColorScheme: newTheme
}))
}
window.addEventListener('storage', handleStorageChange)
window.addEventListener('theme-change', handleThemeChange as EventListener)
return () => {
listeners.forEach(({ mediaQuery, listener }) => {
mediaQuery.removeEventListener('change', listener)
})
window.removeEventListener('storage', handleStorageChange)
window.removeEventListener('theme-change', handleThemeChange as EventListener)
}
}, [])
@ -198,8 +176,8 @@ export function applyPreferencesToCSS(preferences: UserPreferences) {
root.classList.remove('reduced-transparency')
}
// 应用颜色主题
root.setAttribute('data-theme', preferences.prefersColorScheme)
// 颜色主题固定为浅色;不再暴露深浅色切换。
root.setAttribute('data-theme', 'light')
}
// 偏好设置提供者组件
@ -284,19 +262,12 @@ export function AccessibilityPanel() {
{preferences.prefersReducedTransparency ? '已启用' : '未启用'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-apple-label-primary">颜色主题</span>
<span className="text-sm text-apple-label-primary capitalize">
{preferences.prefersColorScheme === 'dark' ? '深色' : '浅色'}
</span>
</div>
</div>
<FontSizeControl />
<div className="text-xs text-apple-label-secondary">
这些设置基于您的系统偏好自动检测,部分设置可以手动调整。
这些设置基于您的系统偏好自动检测,字体大小可以手动调整。
</div>
</div>
)