feat: align admin workspaces with white obsidian
This commit is contained in:
parent
1740036d24
commit
ab3d2d77d2
65
AGENTS.md
65
AGENTS.md
@ -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`
|
||||
|
||||
@ -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 只处理冲突文件。
|
||||
```
|
||||
|
||||
@ -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`
|
||||
- 没同步文档,导致下一个人继续靠猜
|
||||
|
||||
@ -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`
|
||||
- 结果汇报格式要简单
|
||||
|
||||
不适合放进去的内容:
|
||||
|
||||
@ -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 <你的分支名>
|
||||
```
|
||||
|
||||
|
||||
@ -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 <你的临时任务分支>
|
||||
```
|
||||
|
||||
|
||||
@ -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`
|
||||
- 这份文档只负责讲边界,不额外创造新的角色体系或新的协作入口
|
||||
- 这份文档只固定系统边界,不固定当前负责人;具体任务归属以当前任务单、交接摘要和已确认协作信息为准
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
### 视觉有没有跑偏
|
||||
|
||||
- 有没有明显偏离当前统一视觉标准
|
||||
- 新平台主链页面、后台管理页面是否优先符合 `白曜石工坊统一视觉标准.md`
|
||||
- `苹果风格视觉标准.md` 是否只作为基础组件、交互细节和可访问性补充参考
|
||||
- 有没有临时拼凑感很强的新界面
|
||||
- 新页面和现有页面的层级、间距、交互有没有失控
|
||||
|
||||
|
||||
@ -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`
|
||||
- 每次任务先说清楚:这次只改什么、不改什么、哪些目录不能碰
|
||||
- 主导航、主流程骨架、公共数据结构,不要擅自大改
|
||||
- 同步主线遇到冲突时,先只处理自己负责的文件,不要硬改别人的模块
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ const blueColor = appleColorTokens.system.blue.light
|
||||
|
||||
**最佳实践:**
|
||||
- 优先使用语义化颜色(如 `apple-bg-primary`)
|
||||
- 深色模式会自动切换颜色
|
||||
- 当前产品固定浅色主题,不再提供深浅色切换
|
||||
- 使用系统颜色保持一致性
|
||||
|
||||
## 基础组件
|
||||
|
||||
@ -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、浮动工具条和轻量状态浮层。
|
||||
- 板材、五金、组件、包装是否按元组件表达,而不是普通展示卡片。
|
||||
@ -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 # 间距定义
|
||||
|
||||
@ -150,7 +150,7 @@ MINIO_DATA_DIR=/path/to/minio-data ./scripts/backup-files.sh
|
||||
|
||||
**当前版本特性:**
|
||||
|
||||
- 顶部导航:返回首页、主题切换
|
||||
- 顶部导航:返回首页、固定浅色工作台
|
||||
- 检查项:
|
||||
- 设计令牌系统
|
||||
- 字体系统
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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>,
|
||||
|
||||
119
furniture-3d-system/src/app/admin/AdminPortalClient.tsx
Normal file
119
furniture-3d-system/src/app/admin/AdminPortalClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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]">
|
||||
确定要删除分类 "{deletingCategory?.name}" 吗?
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* 基础资料库 (Material Library)
|
||||
* Obsidian Atelier - 元组件资料工作台
|
||||
* White Obsidian Atelier - 元组件资料工作台
|
||||
* 布局标准:资料索引 - 材质/基材工作台 - 实体 Inspector
|
||||
*/
|
||||
|
||||
|
||||
@ -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 标签页,查看 <html> 标签的 data-theme 属性</p>
|
||||
<p>3. 当前系统固定为浅色主题,不再提供深浅色切换</p>
|
||||
<p>4. 检查 Elements 标签页,查看 <html> 标签的 data-theme 属性应为 light</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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]"] {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)' }}>
|
||||
{/* 居中容器 - 响应式宽度 */}
|
||||
|
||||
178
furniture-3d-system/src/app/profile/page.tsx
Normal file
178
furniture-3d-system/src/app/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
5
furniture-3d-system/src/app/settings/page.tsx
Normal file
5
furniture-3d-system/src/app/settings/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function SettingsRouteAlias() {
|
||||
redirect('/admin')
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* 登出按钮 */}
|
||||
|
||||
@ -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="编码" />
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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' }))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user