Commit 52b17cdc authored by Andy-bubu's avatar Andy-bubu

feat: unify xinyuan brand and chart colors

parent 677c3711
......@@ -8,7 +8,7 @@
## 审计原则
- 只迁移 UI/UX,不改变路由、接口、权限、认证、字段、提交数据、状态机和业务回调。
- 以鑫元设计系统为唯一视觉基线,主品牌色使用 `#9E1B1B`。
- 以鑫元设计系统为唯一视觉基线,当前鑫元基金应用主品牌色固定为 `#DA241A`。
- 页面必须同时满足浅色、深色、桌面、平板和移动端要求。
- 不以换色作为完成标准;必须检查真实控件、Portal、图表、滚动层和交互状态。
......@@ -17,6 +17,7 @@
- 已接入 React 19、本地 HeroUI/HeroUI Pro 镜像、鑫元 Token、红/白 Logo、主题持久化与 Portal 深色桥接。
- 已将运行外壳拆分并实际使用 `AppShell`、`DashboardSidebar`、`DashboardNavbar`,保留 Umi 路由与原退出登录回调。
- 已统一登录、应用切换器、资源统计、项目甘特、个人日历、项目清单、筛选、分页、导入和全部业务弹窗。
- 项目清单与通知分页默认每页 10 条,提供 10/20/50 条/页选择;页容量下拉不显示搜索,HeroUI Pagination 保持 `sm` 字号。
- 可见原生输入、选择、文本域和表格已迁移到 HeroUI/HeroUI Pro;仅时间轴与日历事件保留原生语义按钮以维持精确网格定位和键盘可访问性。
- 项目清单与导入预览已使用 HeroUI Pro DataGrid;负责人/协作人远程搜索保留原系统用户查询协议并迁移到 Popover + ListBox。
- 新建/编辑、周进度和危险删除确认均使用共享 Modal;长表单内部滚动,Footer 固定可见,删除确认独立居中展示。
......@@ -68,7 +69,7 @@
- 日期与时间字段需迁移到 HeroUI DatePicker 适配器,保留现有字符串格式和回调。
- Excel 导入仍使用可见原生文件按钮,必须迁移到 HeroUI Pro DropZone。
- 项目编辑/新建、导入、应用切换弹窗需统一遮罩、圆角、关闭按钮、Footer 和移动端按钮堆叠。
- 例行成功反馈不得占用页面布局,应统一为顶部居中自动消失 Toast。
- 例行成功反馈和“暂无真实项目记录”提示不得占用页面布局,应统一为顶部居中、约 3-4 秒自动消失的 Toast;筛选请求加载期间不显示常驻文案,保持 Surface 高度稳定;需要用户修正的请求错误才保留上下文 Alert。
## P1 页面级迁移
......@@ -77,7 +78,7 @@
- KPI 卡使用多套渐变、`rounded-3xl` 与私有色板,应统一为 24px 模块卡和语义图标表面。
- 页面标题与说明需由 Navbar 统一承担,内容区避免重复标题。
- 图表区说明文案移至标题旁信息 Tooltip,不长期占用垂直空间。
- ECharts Tooltip、轴线、图例、饼图分隔线和空状态需读取主题 Token 并随主题重建。
- ECharts Tooltip、轴线、图例、饼图分隔线和空状态需读取主题 Token 并随主题重建;系列色使用低饱和品牌红、暖色、中性与灰蓝层级,禁止绿色系、荧光色、彩虹式相邻对撞和页面私有图表颜色。
- 四个 KPI 在桌面保持四列,窄屏按基线降列,不能产生横向溢出。
### 项目预定工作台
......@@ -85,7 +86,7 @@
- 页头按钮、Tabs、筛选卡、列表区、分页和批量操作需按统一顺序重排。
- 列表表头与行目前为自绘表格,应迁移到 HeroUI Pro DataGrid,并保留排序、选择、操作回调和字段显示。
- 固定列、长文本截断、状态 Chip、空状态、加载态、错误态和分页需要统一适配。
- 甘特与日历的外层、行背景、当前日期、事件 Chip、图例和 Tooltip 必须支持深色主题。
- 甘特与日历的外层、行背景、当前日期、事件 Chip 和 Tooltip 必须支持深色主题;甘特图不再单独展示重复状态图例,状态直接由任务条和 Chip 表达。
- 统计分布区、最近更新区和筛选区需使用一致 Card 语言,避免嵌套空白盒与大面积彩色块。
### 应用切换器
......@@ -198,7 +199,7 @@
- 仪表盘同一视区不再同时使用大面积品牌红与绿/青色:完成 KPI 图标改为中性表面,保留品牌红、琥珀和危险红的主次层级。
- 项目状态圆环、结果状态柱图和进度区间柱图移除绿/青色及高饱和紫色,改用品牌红、橙色、深浅灰蓝和中性色组合。
- 甘特图“提前完成”图例、任务条与个人日历完成事件改为中性表达,避免与超时、已完成等红色状态产生视觉对撞。
- 甘特图任务条与个人日历完成事件改为中性表达,避免与超时、已完成等红色状态产生视觉对撞。
- 绿色仅保留在导入成功、保存成功等必须明确区分成功/失败的瞬时功能反馈中,不再作为统计装饰色使用。
- 深浅主题截图复验通过:`.codex-audit/full/01-stat-light.png`、`.codex-audit/full/03-stat-dark.png`、`.codex-audit/full/04-booking-gantt-dark.png`、`.codex-audit/full/05-booking-calendar-dark.png`。
......
......@@ -67,13 +67,19 @@ Layout route/path change + 60s timer + notification-state event
- 共享 feature CSS:只允许业务无关布局、第三方不可挂载 React 的渲染桥,以及 Skill 明确批准的共享 contracts。
- 页面 className:仅允许 flex/grid/width/gap/alignment/overflow/responsive。
- 禁止页面选择 HeroUI BEM/slot/pseudo-element 或覆盖组件外观/状态。
- 需要结构分隔但不增加填充层的弹窗区域使用 HeroUI `Surface variant="transparent"`,共享 `rc-outline-surface` 仅提供 1px 主题中性灰 `--rc-border-outline` 描边与基础 8px 圆角;该 Token 直接继承 `--border`,模块内记录项使用相同契约保持层级一致。
## Typography
- Shared content typography follows the approved hierarchy: body text is 14px/400 secondary, table primary values are 14px/550 primary, table secondary values are 13px/400 using `--rc-data-grid-secondary-color`, and table headers are 12px/500 muted. Paired rows use a 4px vertical gap and tabular numerals where numeric comparison is relevant.
- Pages use the shared `rc-body-text`, `rc-data-grid-primary`, `rc-data-grid-secondary`, `rc-data-grid-header`, and `rc-filter-label` classes for these roles. Page-local color or font-weight rules are not permitted.
## DataGrid 架构
- DataGrid 使用 `selectedKeys`、`onSelectionChange`、`selectionMode`、`showSelectionCheckboxes`。
- 列 schema 保留业务 render/sort/callback,转换为 Pro ColumnSize。
- 首业务列和操作列只在真实横向溢出时固定。
- ActionBar 由受控选择状态驱动;Pagination 保留 `page` 和固定 `page_size=10`。
- ActionBar 由受控选择状态驱动;Pagination 保留 `page`,默认每页 10 条,并通过无搜索的 HeroUI-native 页容量选择支持 10/20/50 条/页。
- 不手写 selection column,不嵌入旧 Checkbox,不维护第二套选择模型。
## 甘特图架构
......@@ -82,10 +88,12 @@ Layout route/path change + 60s timer + notification-state event
- 可见结构使用 HeroUI 原生组合:L1 `Card` 承载分析模块,L2 `Surface secondary` 承载时间轴,L3 `Surface tertiary` 承载刻度和任务行。
- 实际进度与时间进度使用 `ProgressBar` compound API;结果状态使用语义 `Chip`,空范围使用 Pro `EmptyState`。
- 共享 CSS 只控制网格、宽度、间距、对齐与横向滚动;组件颜色、圆角、描边和交互状态由 HeroUI variant 与鑫元共享 Tokens 负责。
- 图表颜色由 `tokens.css` 的共享 `--rc-chart-*` 序列和 `XinyuanEChart` 适配器统一提供,优先使用品牌红深浅、暖色、中性和灰蓝层级;业务页面不得添加绿色、荧光色或页面私有图表色。
## 个人日历架构
- 个人日历直接使用 HeroUI Pro `Agenda` 的 month compound API:`Header`、`Navigation`、`Body`、`MonthGrid`、`MonthRow`、`MonthCell`、`MonthSpanningEvent` 与 `MonthEvent`。
- Agenda Header 的月份导航由官方 `NavButton`、受控 `heading` 和 `TodayButton` 组成,顺序固定为上月、当前年月、下月、今天;Header 水平与垂直 inset 均与甘特图 Card Header 对齐。
- 页面只把 `pm_project_task` 的计划开始/结束日期转换为只读全天事件;Agenda 持有月份矩阵、跨周分段、非本月日期、今日状态和键盘交互。
- `onEventSelect` 继续映射到既有项目编辑回调;不传入创建、移动、缩放或删除回调,日历不会修改业务数据。
- 外层业务模块使用批准的 HeroUI Card contract;窄视口只在 Agenda Body 内横向滚动,不允许形成页面级横向溢出。
......@@ -94,9 +102,9 @@ Layout route/path change + 60s timer + notification-state event
- Modal/AlertDialog/Dropdown/Autocomplete/DatePicker/Tooltip 直接使用 HeroUI Portal 结构。
- Autocomplete 统一在共享 HeroUI-native 组合中使用当前 `react-stately` Select 的受控 `value/onChange` 契约:单选值为 ID 或 `null`,多选值为 ID 数组;官方 ClearButton、触发器显示和选项勾选均由该值驱动。远程用户字段只在业务边界输出原有 ID、`''`、ID 数组或 `[]`。`selectedKeys/onSelectionChange` 仅用于 DataGrid 等集合选择,不用于 Autocomplete 根。
- 根 `html` 同时携带 class 与 `data-theme`,Portal 从根继承主题。
- 查看态详情使用单一 L2 Surface;编辑态 controls 直接位于 Overlay 并使用 `secondary`。
- 应用切换保留目标的应用 API、归一化和导航 callback;`ApplicationSwitcherDialog` 解包现有响应形态,按 `siteInfo.group` 以应用 name 匹配真实可访问应用,保持后端组序/组内序,隐藏空组并将未引用应用归入“其他应用”,无配置时回退“全部应用”。外层使用批准的 `XinyuanModal`/HeroUI Modal compound composition。
- 内容 Shell 固定 `light`,Sidebar 通过共享暗色 token 渲染;Portal 从根继承主题,不提供主题切换状态。
- 查看态详情使用无框标签/值布局,正文网格与 Modal Header 共享左右内容边界,`md` 起双列并使用 32px 列间距、8px 行间距、字段上下 12px 和标签和值间距 6px;标签为 14px/500 muted、值为 14px/400 foreground。避免在 Modal 内增加灰色填充 Surface,需要结构边界的独立对象才使用透明描边契约。可编辑记录查看态 Footer 只保留右对齐“编辑”主操作,右上角关闭图标负责退出;编辑态 controls 直接位于同一 Overlay 并使用 `secondary`,已有记录的删除固定在 Footer 右侧操作组中、位于“取消”之前并通过受控 AlertDialog 确认;查看切换编辑时不得同时挂载第二个 Modal,应保持 4xl 面板几何稳定。
- 应用切换保留目标的应用 API、归一化和导航 callback;`ApplicationSwitcherDialog` 解包现有响应形态,按 `siteInfo.group` 以应用 name 匹配真实可访问应用,保持后端组序/组内序,隐藏空组并将未引用应用归入“其他应用”,无配置时回退“全部应用”。外层使用 HeroUI Modal compound composition,搜索与滚动区域遵循基准尺寸。
- 移动导航复用 HeroUI Pro Sidebar Provider 的 `isMobileOpen/setMobileOpen` 状态,并以同包 Sheet compound 建立具名 Dialog,规避当前 Sidebar.Mobile 未向 Dialog 根透传可访问名称的问题。
## 删除顺序
......
......@@ -33,16 +33,20 @@
- [x] 页面与 Navbar 对齐 48/32/16px 对称 gutter。
- [x] 用户入口使用 Dropdown.Trigger,真实退出可用且 base-aware。
- [x] 应用切换使用 HeroUI Modal、动态站点分组、移动单列/sm 起固定两列和 36px identity well。
- [x] 主题在刷新和路由切换后保持,全部 Portal 跟随根主题。
- [x] 内容区固定浅色、Sidebar 固定深色,移除主题切换和 localStorage 主题状态。
## 资源统计
- [x] 顶部概览和核心指标全部改为 Pro KPI compound,不保留手写数字 Surface。
- [x] KPI 外卡 24px、1px token border、共享 shadow、16px board gap。
- [x] ECharts 与私有 tooltip/theme 引用为零。
- [x] 状态环图改为 Pro PieChart。
- [x] 结果/进度/成员负载改为 Pro BarChart/ComposedChart。
- [x] ChartTooltip 指示点与系列 Token 一致。
- [x] 图表统一使用 ECharts 与共享 `XinyuanEChart` 适配器,无 Recharts/Pro Charts 活动引用。
- [x] 状态环图、结果/进度柱图和成员负载组合图保持真实业务口径并使用共享图表 Token。
- [x] Chart Tooltip、坐标轴和饼图标签由适配器统一配置。
- [x] 图表共享色系固定为品牌红深浅 -> 暖色 -> 中性 -> 灰蓝;已检查资源统计全部图表,未使用绿色系、荧光色或彩虹式相邻系列色。
- [x] 资源统计、甘特图、个人日历和风险清单模块已移除重复副标题,范围信息保留在控件和数据区域内。
- [x] 灰色 L2 嵌套 Surface 统一使用 `rc-nested-surface` 的 8px 圆角,外层工作台模块仍保持 24px。
- [x] 整个周进度记录模块及其记录项使用 HeroUI `Surface variant="transparent"` + `rc-outline-surface`,去除灰色填充并统一使用灰色描边。
- [x] 项目状态工作流说明改为字段标题右侧信息 Tooltip,保留原文案和可访问名称。
- [x] 风险列表/空/错/加载保留真实数据口径。
## 资源预定
......@@ -54,14 +58,17 @@
- [x] 所有日期字段使用 HeroUI DatePicker/DateField/Calendar。
- [x] 导入使用 Pro DropZone,保留 Excel 解析与提交行为。
- [x] 项目清单使用 Pro DataGrid 原生选择、排序、固定列和横向滚动。
- [x] 项目清单行操作使用 HeroUI tertiary 图标按钮与 Tooltip;预览使用 4xl Modal 和单一 secondary Surface,不触发业务请求。
- [x] Pagination 使用 HeroUI compound,固定 page_size=10,无页容量控件。
- [x] 项目清单行操作使用 HeroUI tertiary 图标按钮与 Tooltip;预览使用 4xl Modal,功能标题/项目标题分层且正文与 Header 左右对齐,字段按无框双列标签/值详情布局展示;查看态 Footer 使用“编辑”主操作,右上角关闭图标退出;编辑态已有记录的删除位于 Footer 右侧操作组、排在“取消”之前并沿用受控确认弹窗,编辑复用同一 Modal Overlay 与稳定面板几何,不触发额外业务请求。
- [x] Pagination 使用 HeroUI compound,保持 `sm` 字号;页容量使用共享 `XinyuanAutocomplete` 的 10/20/50 条/页选项,打开下拉不显示搜索。
- [x] 选中态使用 Pro ActionBar 和右侧关闭清除。
- [x] 弹窗使用直接 Modal/AlertDialog;详情/表单为 4xl。
- [x] 新建、启动、完成、周进度、删除、导入回调与请求时机不变。
- [x] 甘特图使用 HeroUI Card/Surface/ProgressBar/Chip 与 Pro EmptyState,保留原日期区间算法并落实 L1/L2/L3 层级。
- [x] 甘特图周/月/年、前后时间段、今天、任务打开和横向滚动交互保持不变。
- [x] 个人项目日历使用 Pro Agenda 月视图、连续跨日/跨周事件、官方月份导航和模块内滚动;不保留手写日期网格。
- [x] 个人项目日历导航位置按基准统一为“上月 / 当前年月 / 下月 / 今天”,月份文字与 Agenda 状态同步。
- [x] 个人项目日历标题与项目甘特图标题使用相同 Card 内容边缘,移除 Agenda Header 额外水平内边距。
- [x] 个人项目日历标题与项目甘特图标题使用相同垂直内容基线,移除 Agenda Header 默认顶部/底部内边距。
## 浏览器验收
......
......@@ -16,9 +16,10 @@
全局顺序固定为:Tailwind、HeroUI、HeroUI Pro、权威 `theme.css`、鑫元扩展 `tokens.css`、共享 Shell/业务中立布局样式。
- `src/design-system/theme.css` 必须与鑫元 Skill 权威副本逐字节一致。
- `src/design-system/theme.css` 保持 HeroUI 主题结构与组件变量契约;本应用在主题层固定鑫元基金品牌色 `#DA241A`,扩展 Token 与运行时值必须一致。
- `tokens.css` 只能新增或别名 `--rc-*`,不得重定义 theme.css 的核心变量。
- 应用根 `body` 统一关闭合成字重并固定文本栅格策略。开发服务通过 `HMR=none` 禁用 Umi 模块热替换,避免异常热更新循环反复替换 `umi.css` 并让 Inter 字体在已加载字体与回退字体之间切换。
- 共享字重 Token 固定为 400(正文/表格次字段)、500(标签/表头)、550(表格主字段)、600(Navbar/模块标题)、700(章节标题);页面不使用 650 等非规范字重。表格主字段、次字段和表头分别使用共享 `rc-data-grid-primary`、`rc-data-grid-secondary`、`rc-data-grid-header` 类,双行字段使用 `rc-data-grid-text-pair` 的 4px 间距。次字段使用专用 `--rc-data-grid-secondary-color`(浅色 `#7A8596`、深色 `#8B8B95`),避免依赖浏览器默认继承。
- 页面 `className` 只承担 flex/grid/width/gap/alignment/overflow/responsive。
- 页面不得设置组件背景、颜色、描边、圆角、阴影或交互状态,不得选择 HeroUI 内部 slot/BEM/伪元素。
......@@ -36,6 +37,8 @@
- L0 page canvas:`background`。
- L1 首层内容:HeroUI `Surface default` / `surface`。
- L2 嵌套内容与 Surface/Card/Modal 内字段:`surface-secondary` / HeroUI `secondary`。
- 灰色 L2 嵌套模块使用共享 `rc-nested-surface`,圆角固定为基础 8px;不得使用直角或 24px 外层模块圆角。
- 弹窗内需要结构分隔但不增加填充层的区域使用 HeroUI `Surface variant="transparent"` 与共享 `rc-outline-surface`,以 1px `--rc-border-outline` 中性灰描边和基础 8px 圆角表达层级;`--rc-border-outline` 直接继承主题 `--border`,不混入正文深色;同一模块的记录项沿用该契约。
- L3 仅用于 L2 内真实静态子对象:`surface-tertiary`。
- Modal、Popover、Dropdown、Tooltip、Calendar 使用独立 `overlay`,不是 L3。
- 筛选必须用 Surface,列表区域透明,DataGrid 自身拥有唯一数据表面。
......@@ -46,13 +49,16 @@
- 应用切换:46rem/42rem,24px 圆角与 28px inset,手机一列、sm 起固定两列,36px `Surface tertiary + Avatar soft` identity well;分组来自 `site.group`,按后端顺序渲染并将未引用应用归入“其他应用”。
- 筛选:1-2 个字段不加容器;3 个以上使用 `Surface default rounded-3xl p-6`,前三项常用字段,其余放 HeroUI Disclosure。
- 列表工具栏:搜索在左、列分组紧随、业务操作在右、主操作最右;手机搜索先独占一行。
- DataGrid:原生选择/排序/固定列/滚动;20px 外圆角;固定每页 10 条;Pagination 与 DataGrid 间距 8px。
- DataGrid:原生选择/排序/固定列/滚动;20px 外圆角;Pagination 默认每页 10 条并可选择 10/20/50 条/页,页容量下拉不显示搜索;Pagination 与 DataGrid 间距 8px。
- ActionBar:选中后位于当前主内容视口下方中央,右侧关闭清除选择。
- Modal:标准 24px 圆角;详情/复杂表单 4xl;查看态只有一个 secondary Surface,编辑态移除该 Surface。
- Modal:标准 24px 圆角;详情/复杂表单 4xl;Header 中功能标题与记录标题分层展示,正文与 Header 共享内容左右边界;查看态使用无框标签/值布局,不增加灰色填充 Surface,字段从 `md` 起双列、列间距 32px、行间距 8px、字段上下 12px、标签和值间距 6px,标签为 14px/500 muted、值为 14px/400 foreground;可编辑记录的查看态 Footer 只保留右对齐的“编辑”主操作,右上角关闭图标负责退出;编辑态 controls 直接位于同一 Overlay 并使用 `secondary`,已有记录的删除作为 Footer 右侧操作组中的独立危险次操作,位于“取消”之前且不抢占最右主操作,确认仍通过受控 AlertDialog;查看/编辑切换复用同一 4xl Modal 几何,避免双 Modal 进出场造成跳动。
- KPI:HeroUI Pro KPI compound,24px 外卡、16px board gap、实际 data-count 布局。
- 图表:HeroUI Pro PieChart、BarChart、ComposedChart 与 ChartTooltip;数据系列只用共享 8 色 Token。
- 图表:ECharts + `echarts-for-react` 通过共享 `XinyuanEChart` 适配器渲染;应用扩展 `--rc-chart-*` 使用 `#DA241A` 品牌红及低饱和红、暖色、中性、灰蓝层级,禁止绿色系、荧光色和彩虹式相邻对撞。适配器读取扩展色板后再向 ECharts 注入具体颜色;Tooltip、坐标轴和饼图间隙由适配器统一处理。
- 模块头部只保留功能标题;图表、甘特图、日历和清单不显示重复性副标题或日期说明,必要的范围和解释由控件、坐标轴或弹窗上下文承载。
- 字段标题旁的持久性规则说明使用小型 HeroUI Tooltip 信息图标承载;以项目状态为例,工作流说明不再占用字段下方垂直空间。
- 个人日历:HeroUI Pro Agenda 月视图;跨日期项目使用 MonthSpanningEvent,单日项目使用 MonthEvent,导航和日期状态使用 Agenda 官方 compound API。
- 消息通知:Navbar 直接使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`;36px Bell 固定在主题按钮左侧。独立通知页使用 HeroUI Tabs、SearchField、Card、Pagination 和 HeroUI Pro EmptyState,侧栏不增加通知入口。
- 个人日历 Header 导航按“上月、当前年月、下月、今天”顺序排列,当前年月使用 Agenda 的 `heading` 受控显示,不把“本月”按钮置于箭头之间;Agenda Header 移除额外水平和垂直 inset,与甘特图标题共享 Card 内容边缘。
- 消息通知:Navbar 直接使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`;36px Bell 位于右侧动作区。独立通知页使用 HeroUI Tabs、SearchField、Card、Pagination 和 HeroUI Pro EmptyState,侧栏不增加通知入口。
## 跨应用功能范围
......@@ -63,7 +69,7 @@
## 响应式与主题
- 视口:1440x900、1280x720、768x1024、390x844。
- 根节点统一 `.light/.dark` 和 `data-theme`,持久化键保持 `xinyuan_color_theme`。
- 内容 Shell 固定 `.light`,Sidebar 使用共享深色层级;Portal 继承根主题,不提供主题切换或主题持久化。
- Portal、Sidebar.Mobile、Modal、Dropdown、Autocomplete、DatePicker、Tooltip、ActionBar 与图表 Tooltip 必须继承根主题。
- 页面不按主题切换 component variant,不使用 `bg-white`、zinc/slate/blue/cyan/rose/pink 私有外观桥。
......
......@@ -7,10 +7,10 @@
- 目标应用:部门任务管理
- 目标仓库:`project_manager_xy`
- 当前分支:`codex/xinyuan-ui-ux-migration`
- 唯一视觉与交互基准:`problem_rectification`
- 唯一视觉与交互基准:`audit_project_manager`
- 目标 base/publicPath:`/project_manager_xy/`
- 目标原始启动地址:`http://localhost:8001/project_manager_xy/`
- 基准对照地址:`http://localhost:8004/problem_rectification_new/`
- 基准对照地址:`http://localhost:8004/audit_project_manager/`
- 业务路由:`/resource/stat`、`/resource/booking`
## HeroUI 安装硬门槛
......@@ -49,17 +49,17 @@
| Sidebar | HeroUI Pro Sidebar,offcanvas 已启用 | 统一实际基准几何、单组标签、默认 Trigger、用户 Dropdown.Trigger |
| Navbar | HeroUI Pro Navbar | 对齐 48/32/16px gutter 和真实 Sidebar.Trigger |
| 应用切换 | 平铺应用列表,未读取站点分组 | 基于 `GET /info -> site.group` 的 HeroUI Modal 分组,移动单列、sm 起固定两列、36px identity well |
| 主题 | 根 `.light/.dark` + localStorage | 保留键与行为,改用权威 theme.css 驱动全部 Portal |
| 主题 | 根节点可能残留 localStorage 切换 | 固定浅色内容区与深色 Sidebar,不提供主题切换入口 |
| 退出 | 现有 `userLogout` + clearUser | 保留真实回调,修正 base-aware 登录跳转 |
## 页面与组件审计
| 页面/模块 | 发现 | 状态 |
| --- | --- | --- |
| 资源统计 | 原手写 MetricCard、ECharts/私有 tooltip/theme、页面视觉类 | 已迁移到 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip |
| 资源统计 | 原 HeroUI Pro Charts/Recharts 组合 | 已迁移到 ECharts + 共享 XinyuanEChart 适配器与 KPI |
| 资源预定 | 原旧 Dialog/DataTable/分页包装、Card 筛选、原生日期和私有视图外观 | 已迁移到 HeroUI Tabs/Surface/Disclosure/DatePicker 与 Pro 数据组件 |
| 个人项目日历 | 原 Card 内手写 7 列月份网格、按天重复项目按钮,未落实连续区间条 | 已迁移到 Pro Agenda 月视图;项目按计划周期连续展示,跨周自动分段,点击保持原编辑流程 |
| 项目清单 | 原共享 DataTable 和自定义分页 | 已迁移到 Pro DataGrid 原生选择、固定列、Pagination、ActionBar |
| 项目清单 | 原共享 DataTable 和自定义分页 | 已迁移到 Pro DataGrid 原生选择、固定列、带无搜索页容量选择的 Pagination、ActionBar |
| 项目表单 | 原 FormSelect/FormDatePicker/SystemUserSearchSelect | 已迁移到 HeroUI-native Autocomplete/DatePicker 组合;远程搜索空集合启动问题已修复 |
| 导入 | 原旧页面边界 | 已使用 Pro DropZone 与 DataGrid 预览,保留解析和 importRecords 回调 |
| 弹窗 | 原 `@/components/Dialog` 兼容包装 | 已使用 HeroUI Modal/AlertDialog current compound API |
......@@ -95,15 +95,25 @@
- `npm run build`:通过,Webpack compiled successfully。
- `git diff --check`:通过,无空白错误。
- `bash /Users/liuchenlong/.codex/skills/xinyuan-design-system/scripts/audit_project.sh /Users/liuchenlong/Desktop/job/project_manager_xy`:退出码 0;权威主题、原生 HeroUI 架构和四份文档检查通过。脚本仍输出 6 类启发式警告,均已逐项复核:Pro 子路径警告源于 Node `require.resolve` 加载包内 CSS;Modal、DataGrid 字体层级、人员双行信息和可点击主字段使用当前 HeroUI compound/页面数据 schema,未沿用旧包装;`--rc-shadow-card` 与 Skill 原始副本逐字一致,脚本的单行正则不能匹配格式化后的多行值。作为对照,同一脚本对问题整改本身也输出 Pro 子路径、DataGrid 命名和 shadow 正则等警告。这些警告不代表活动页面遗留;已用源代码扫描、`exports`、`import.meta.resolve`、生产构建和浏览器计算结果复核。
- 旧组件扫描:Catalyst、Headless UI、compat、ECharts、旧 DataTable/MetricCard/FormSelect 等活动引用为 0;`recharts` 仅作为 HeroUI Pro 图表底层和 `Cell` 官方组合保留。
- 旧组件扫描:Catalyst、Headless UI、compat、旧 DataTable/MetricCard/FormSelect 及 Recharts/Pro Charts 活动引用为 0;图表统一使用 ECharts 适配器。
- 图表色系扫描:资源统计的状态环图、结果/进度柱图、成员负载组合图均只从应用扩展共享 `--rc-chart-*` 序列取色,采用 `#DA241A` 品牌红及低饱和红、暖色、中性、灰蓝层级;页面图表未使用绿色系、荧光色或私有色值。主题与扩展 Token 的运行时计算值已复验一致。
- 原生控件扫描:可见 `<select>` 与 `type=date|datetime-local|file` 为 0。
- 页面私有视觉扫描:禁用色系/背景/描边/圆角/阴影 utility 为 0;页面 CSS/LESS/SCSS 的 HeroUI slot/BEM/伪元素命中为 0。
- Typography 扫描:Navbar 20px/600、模块标题 16px/600、正文 400、标签 500 均由共享 Token/组件合同提供;侧栏应用名已从 650 修正为 600,未保留非规范字重。
- 路径扫描:package、lock、配置和源码中的 `file:`、`/Users/`、相邻项目 `node_modules` 为 0;唯一锁文件为 `pnpm-lock.yaml`。pnpm 标准链接解析到本项目自身 `.pnpm` 虚拟仓库,不跨项目。
- 依赖树:`@heroui-pro/react@1.0.0-beta.8`、`@heroui/react@3.2.4`、`@heroui/styles@3.2.4`、React/React DOM `19.2.8`、React Aria Components `1.20.0`、`@react-aria/utils@3.34.1` 为单一顶层版本。
- 权威主题 SHA-256:`a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`,`cmp` 通过。
浏览器中,目标与问题整改已在 1440x900、1280x720、768x1024、390x844 的浅色/深色模式同时打开比较;两端八组视口的页面级横向溢出均为 0。目标已实际操作登录、退出、Sidebar、Navbar、应用切换、Dropdown、Tabs、筛选、Autocomplete、DatePicker、Checkbox、DataGrid、Pagination、ActionBar、项目/周进度/导入/删除弹窗、KPI、三类图表和 Tooltip,最后一次控制台检查为 0 error、0 warning。截图与快照位于 `.playwright-cli/`。
## 2026-09-02 品牌与图表专项复核
- 目标应用运行于 `http://localhost:8012/project_manager_xy/`;本轮仅在既有工作区修改主题 Token、图表 Token 和共享图表分配器,没有改变页面布局、接口、路由、权限、校验、保存回调或业务数据结构。
- HeroUI `--accent`、`--primary`、`--primary-500`、`--focus` 固定为 `#DA241A`,`--accent-hover` 为 `#C91F17`;`primary-50..950` 和 `--color-primary-*` 均来自同一品牌红色阶,深色模式保持同值。
- 共享 `--xinyuan-chart-1..16` / `--rc-chart-1..16` 使用品牌红深浅、暖棕、灰紫、灰蓝的低饱和序列:`#DA241A`、`#B83A32`、`#C95A52`、`#D47A70`、`#A65A45`、`#B87958`、`#C99772`、`#8A6F63`、`#9A8174`、`#6F6674`、`#807783`、`#938B96`、`#4F6380`、`#61758F`、`#74879A`、`#8798A8`;未使用绿色、荧光色或彩虹式相邻配色。
- 资源统计的环图、结果/进度柱图和成员负载组合图均通过 `XinyuanEChart` 使用共享 Token;单系列和主要/风险序列使用品牌红,比较序列使用灰紫或灰蓝。图表分配器按稳定业务顺序取色,不再按 key 哈希产生不可预期的视觉跳跃。
- Playwright 实测登录页启用态主按钮背景为 `rgb(218, 36, 26)`、白色前景;1440x900 与 390x844 的页面级横向溢出均为 0;浅色和临时深色 Token 检查均返回品牌红。未登录状态不注入伪造会话或测试数据。
## 2026-08-27 跨应用五功能审计
- 应用身份:`APPLICATION_DISPLAY_NAME='部门任务管理'`,base/publicPath 为 `/project_manager_xy/`。
......@@ -116,4 +126,5 @@
- 基准对照:问题整改 `/notifications` 的分类 Tabs、状态 Tabs、搜索、空态、Bell 位置和页面间距已在真实浏览器 1440x900 下打开记录;目标修改前确认无通知入口和通知路由。
- 最终浏览器对照:目标与问题整改在 1440x900、1280x720、768x1024、390x844 的浅色/深色均同时实渲染;所有组合页面横向溢出为 0,Bell 为 36x36 且右侧几何一致。390x844 深色截图中 Navbar、两组 Tabs、全部已读、搜索和 EmptyState 几何一致。
- 真实接口当前返回通知空列表和未读 0;浏览器临时路由夹具覆盖未读 1/99/100(100 显示 `99+` 且 aria 保留 100)、转义搜索 `a.*[`、未读服务端状态、两页 Pagination、单条/全部已读、动态 action、站内/站外跳转与恶意 HTML 纯文本渲染。夹具只存在于 Playwright 会话,没有写入源码或业务数据。
- 项目清单与通知列表分页默认每页 10 条,可切换 10/20/50 条/页;页容量选择沿用共享 HeroUI Autocomplete 受控值且隐藏搜索,Pagination 保持 `sm` 字号。
- 最终目标通知页控制台为 0 error、0 warning;登录时人为误读验证码产生的历史请求错误已通过全新浏览器会话隔离,不计入页面最终控制台结果。
......@@ -31,6 +31,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
......
......@@ -77,6 +77,12 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.23
echarts:
specifier: ^6.1.0
version: 6.1.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.8)
embla-carousel:
specifier: ^8.6.0
version: 8.6.0
......@@ -109,7 +115,7 @@ importers:
version: 4.12.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
recharts:
specifier: ^3.10.1
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)
version: 3.10.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@4.2.1)
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
......@@ -3671,6 +3677,15 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
......@@ -4473,8 +4488,8 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
immer@11.1.17:
resolution: {integrity: sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==}
immer@11.1.18:
resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==}
immer@8.0.4:
resolution: {integrity: sha512-jMfL18P+/6P6epANRvRk6q8t+3gGhqsJ9EuJ25AXE+9bNTYtssvzeYbEd0mXRYWCmmXSIbnlpz6vd6iJlmGGGQ==}
......@@ -6666,18 +6681,6 @@ packages:
redux:
optional: true
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'}
......@@ -7167,6 +7170,9 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
......@@ -7565,6 +7571,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
......@@ -7980,6 +7989,9 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
......@@ -9141,14 +9153,14 @@ snapshots:
'@loadable/component@5.15.2(react@18.3.1)':
dependencies:
'@babel/runtime': 7.23.6
'@babel/runtime': 7.29.7
hoist-non-react-statics: 3.3.2
react: 18.3.1
react-is: 16.13.1
'@loadable/component@5.15.2(react@19.2.8)':
dependencies:
'@babel/runtime': 7.23.6
'@babel/runtime': 7.29.7
hoist-non-react-statics: 3.3.2
react: 19.2.8
react-is: 16.13.1
......@@ -9472,17 +9484,17 @@ snapshots:
dependencies:
react: 19.2.8
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
'@reduxjs/toolkit@2.12.0(react-redux@8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(redux@4.2.1))(react@19.2.8)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.17
immer: 11.1.18
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.2.0
optionalDependencies:
react: 19.2.8
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
react-redux: 8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(redux@4.2.1)
'@shikijs/core@4.4.3':
dependencies:
......@@ -10440,7 +10452,7 @@ snapshots:
'@umijs/history@5.3.1':
dependencies:
'@babel/runtime': 7.23.6
'@babel/runtime': 7.29.7
query-string: 6.14.1
'@umijs/lint@4.7.6(eslint@8.35.0(supports-color@8.1.1))(stylelint@14.8.2(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)':
......@@ -12272,6 +12284,18 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.8):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 19.2.8
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {}
electron-to-chromium@1.5.411: {}
......@@ -13271,7 +13295,7 @@ snapshots:
history@5.3.0:
dependencies:
'@babel/runtime': 7.23.6
'@babel/runtime': 7.29.7
hmac-drbg@1.0.1:
dependencies:
......@@ -13383,7 +13407,7 @@ snapshots:
image-size@0.5.5:
optional: true
immer@11.1.17: {}
immer@11.1.18: {}
immer@8.0.4: {}
......@@ -15895,7 +15919,7 @@ snapshots:
react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.23.6
'@babel/runtime': 7.29.7
invariant: 2.2.4
prop-types: 15.8.1
react: 18.3.1
......@@ -15905,7 +15929,7 @@ snapshots:
react-helmet-async@1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@babel/runtime': 7.23.6
'@babel/runtime': 7.29.7
invariant: 2.2.4
prop-types: 15.8.1
react: 19.2.8
......@@ -15986,15 +16010,6 @@ snapshots:
react-dom: 19.2.8(react@19.2.8)
redux: 4.2.1
react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
redux: 5.0.1
react-refresh@0.14.0: {}
react-refresh@0.14.2: {}
......@@ -16121,24 +16136,26 @@ snapshots:
real-require@0.1.0: {}
recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1):
recharts@3.10.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@4.2.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
'@reduxjs/toolkit': 2.12.0(react-redux@8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(redux@4.2.1))(react@19.2.8)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.51.0
eventemitter3: 5.0.4
immer: 11.1.17
immer: 11.1.18
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-is: 19.2.8
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
react-redux: 8.1.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(redux@4.2.1)
reselect: 5.2.0
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.8)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
- react-native
- redux
redent@3.0.0:
......@@ -16592,6 +16609,8 @@ snapshots:
sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {}
slash@4.0.0: {}
......@@ -17067,6 +17086,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3):
......@@ -17562,4 +17583,8 @@ snapshots:
yocto-queue@1.2.2: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}
......@@ -23,7 +23,6 @@ import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
......@@ -33,7 +32,6 @@ import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
......@@ -76,12 +74,30 @@ export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const LogOut = withSize(LogoutOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
return (
<span
aria-hidden="true"
role="img"
{...props}
style={iconStyle(size, style)}
className={['anticon', props.className].filter(Boolean).join(' ')}
>
<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true">
<path
clipRule="evenodd"
d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6A1.5 1.5 0 0 1 12 12.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
</span>
);
}
export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
......
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({
children,
notificationCount,
......@@ -27,25 +13,9 @@ export default function AppShell({
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
......@@ -55,12 +25,8 @@ export default function AppShell({
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, Sun } from './AppIcons';
import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
......@@ -21,7 +17,9 @@ export default function DashboardNavbar({
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
/>
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
......@@ -30,11 +28,7 @@ export default function DashboardNavbar({
<Badge.Anchor>
<Button
isIconOnly
aria-label={
normalizedNotificationCount > 0
? `通知消息,${normalizedNotificationCount}条未读`
: '通知消息'
}
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
......@@ -48,29 +42,13 @@ export default function DashboardNavbar({
color="danger"
size="sm"
>
{normalizedNotificationCount > 99
? '99+'
: normalizedNotificationCount}
{normalizedNotificationCount > 99 ? '99+' : normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
......
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sheet } from '@heroui-pro/react/sheet';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
......@@ -64,9 +63,56 @@ function SidebarContents({
onLogout,
onOpenApplicationSwitcher,
pathname,
theme,
userInfo,
}) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => pathname === item.path);
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator>
<ChevronDown size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return (
<>
<Sidebar.Header>
......@@ -74,12 +120,7 @@ function SidebarContents({
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
className="rc-sidebar-logo"
src={xyLogoWhite}
/>
</div>
......@@ -95,49 +136,25 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{group.showLabel !== false ? (
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
) : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
group.showLabel === false ? (
<Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
) : renderGroup(group)
))}
</Sidebar.Content>
......@@ -148,27 +165,35 @@ function SidebarContents({
);
}
function MobileSidebar(props) {
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) return null;
if (!isMobile) {
return null;
}
return (
<Sheet
<Sheet.Root
isOpen={isMobileOpen}
placement={side}
onOpenChange={setMobileOpen}
>
<Sheet.Backdrop variant="blur">
<Sheet.Content>
<Sheet.Dialog aria-label={`${APPLICATION_DISPLAY_NAME}导航`}>
<div className="app-sidebar-glass rc-mobile-sidebar">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog
aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog"
>
<div
className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile"
>
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet>
</Sheet.Root>
);
}
......@@ -178,7 +203,7 @@ export default function DashboardSidebar(props) {
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<MobileSidebar {...props} />
<AccessibleMobileSidebar {...props} />
</>
);
}
......@@ -55,6 +55,7 @@ export default function XinyuanAutocomplete({
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey,
selectedKeys,
selectionMode = 'single',
......@@ -162,21 +163,23 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
{showSearch ? (
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
) : null}
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
......
import {
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: {
color: theme.axis,
fontFamily: theme.fontFamily,
fontSize: 12,
...item?.axisLabel,
},
axisLine: {
...item?.axisLine,
lineStyle: {
color: theme.grid,
...item?.axisLine?.lineStyle,
},
},
axisTick: {
...item?.axisTick,
lineStyle: {
color: theme.grid,
...item?.axisTick?.lineStyle,
},
},
splitLine: {
...item?.splitLine,
lineStyle: {
color: theme.grid,
...item?.splitLine?.lineStyle,
},
},
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
export default function XinyuanEChart({
ariaLabel,
className = '',
height = 320,
option,
}) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => {
chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' });
}, []);
useEffect(() => {
window.addEventListener('resize', hideTooltip);
return () => window.removeEventListener('resize', hideTooltip);
}, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applyPieTheme = (series) => {
if (series?.type !== 'pie') return series;
return {
...series,
itemStyle: {
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
...series.itemStyle,
},
padAngle: series.padAngle ?? 1,
};
};
const themedSeries = Array.isArray(option?.series)
? option.series.map(applyPieTheme)
: applyPieTheme(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: {
enabled: true,
decal: { show: false },
...option?.aria,
},
color: option?.color || theme.palette,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
...option?.textStyle,
},
...option,
series: themedSeries,
legend: option?.legend ? {
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...option.legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...option.legend?.textStyle,
},
} : undefined,
tooltip: {
appendToBody: true,
backgroundColor: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
borderWidth: 1,
className: 'rc-echarts-tooltip',
confine: true,
enterable: false,
extraCssText: `border-radius: 12px; box-shadow: ${theme.tooltipShadow};`,
padding: [10, 12],
renderMode: 'html',
...option?.tooltip,
textStyle: {
color: theme.foreground,
fontFamily: theme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
},
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
return (
<div
aria-label={ariaLabel}
className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()}
role="img"
tabIndex={0}
>
<ReactECharts
lazyUpdate
notMerge
onEvents={{ globalout: hideTooltip }}
option={themedOption}
opts={{ renderer: 'canvas' }}
ref={chartRef}
style={{ height, width: '100%' }}
/>
</div>
);
}
export const APPLICATION_DISPLAY_NAME = '部门任务管理';
export const APPLICATION_BASE_PATH = '/project_manager_xy/';
export const APPLICATION_ROUTE_NAME = APPLICATION_BASE_PATH.replace(/^\/+|\/+$/g, '');
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const APPLICATION_NAME = APPLICATION_ROUTE_NAME;
export const ROUTE_TITLES = Object.freeze({
'/notifications': '通知消息',
......
export const XINYUAN_CHART_PALETTE = Object.freeze([
// The app-level sequence is provided by extension Tokens so every chart
// uses the same restrained brand-red, warm-neutral and gray-blue system.
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
......@@ -7,6 +9,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
......@@ -22,89 +32,112 @@ function normalizeChartKey(key) {
return String(key ?? '').trim();
}
function hashChartKey(key) {
return Array.from(key).reduce(
(value, character) => ((value * 31) + character.codePointAt(0)) >>> 0,
0,
export function createXinyuanChartColorMap(keys = [], options = {}) {
const sourceKeys = Array.isArray(keys) ? keys : [];
const order = Array.isArray(options?.order) ? options.order : [];
const orderedKeys = [...new Set(sourceKeys.map(normalizeChartKey).filter(Boolean))];
const preferredOrder = new Map(
order.map(normalizeChartKey).filter(Boolean).map((key, index) => [key, index]),
);
}
export function getXinyuanChartColor(key, fallbackIndex = 0) {
const stableKey = normalizeChartKey(key);
if (!stableKey) {
return XINYUAN_CHART_PALETTE[fallbackIndex % XINYUAN_CHART_PALETTE.length];
}
const stableKeys = orderedKeys.sort((left, right) => {
const leftOrder = preferredOrder.has(left)
? preferredOrder.get(left)
: Number.MAX_SAFE_INTEGER;
const rightOrder = preferredOrder.has(right)
? preferredOrder.get(right)
: Number.MAX_SAFE_INTEGER;
return XINYUAN_CHART_PALETTE[hashChartKey(stableKey) % XINYUAN_CHART_PALETTE.length];
}
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))]
.sort();
return left.localeCompare(right, 'zh-CN');
});
const unknownPositions = new Map(
stableKeys
.filter((key) => !preferredOrder.has(key))
.map((key, index) => [key, index]),
);
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
const hasExplicitOrder = preferredOrder.size > 0;
let paletteIndex = hasExplicitOrder && preferredOrder.has(key)
? preferredOrder.get(key) % XINYUAN_CHART_PALETTE.length
: hasExplicitOrder
? preferredOrder.size + unknownPositions.get(key)
: keyIndex % XINYUAN_CHART_PALETTE.length;
if (keyIndex < XINYUAN_CHART_PALETTE.length) {
if (
paletteIndex >= XINYUAN_CHART_PALETTE.length
|| usedIndexes.has(paletteIndex)
) {
const preferredIndex = keyIndex % XINYUAN_CHART_PALETTE.length;
paletteIndex = preferredIndex;
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % XINYUAN_CHART_PALETTE.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
usedIndexes.add(paletteIndex);
colorMap.set(key, XINYUAN_CHART_PALETTE[paletteIndex]);
});
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') {
return false;
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
return window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim() || fallback;
}
export function colorizeXinyuanChartTooltipPayload(
payload = [],
resolveColor,
) {
return payload.map((entry, index) => {
const rechartsPayload = entry?.payload || {};
const sourceDatum = rechartsPayload?.payload || rechartsPayload;
const key = sourceDatum?.name
?? rechartsPayload?.name
?? entry?.name
?? entry?.dataKey
?? '';
const candidateColors = [
resolveColor?.({ entry, index, key, sourceDatum }),
rechartsPayload?.fill,
sourceDatum?.fill,
entry?.fill,
entry?.color,
entry?.stroke,
getXinyuanChartColor(key, index),
];
const color = candidateColors.find(isVisibleChartColor);
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match ? readRootToken(match[1], fallback) : String(color || fallback);
}
return {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
};
});
export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
return new Map(
Array.from(createXinyuanChartColorMap(keys, options)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
fontFamily: documentFont || 'Inter, sans-serif',
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: XINYUAN_CHART_PALETTE.map((color) => (
resolveXinyuanChartColor(color, '#667085')
)),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
sectorGap: readRootToken('--xinyuan-chart-sector-gap', '#ffffff'),
sectorRadius: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-radius', '10'),
) || 10,
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
}
......@@ -89,7 +89,7 @@
overflow: hidden;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
font-weight: var(--rc-font-semibold);
line-height: 1.2rem;
font-synthesis: none;
text-rendering: optimizeLegibility;
......@@ -435,6 +435,94 @@
gap: var(--rc-page-module-gap);
}
.rc-section-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1.125rem;
font-weight: var(--rc-font-bold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-module-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1rem;
font-weight: var(--rc-font-semibold);
line-height: 1.5rem;
letter-spacing: 0;
}
/* Shared content typography. Keep the hierarchy explicit so fallback fonts
render business values with the same visual weight as the baseline app. */
.rc-body-text {
color: var(--rc-text-secondary) !important;
font-size: 0.875rem;
font-weight: var(--rc-font-normal);
font-variant-numeric: tabular-nums;
line-height: 1.5rem;
}
.rc-data-grid-primary {
color: var(--rc-text-primary) !important;
font-size: 0.875rem;
font-weight: var(--rc-font-data);
font-variant-numeric: tabular-nums;
line-height: 1.25rem;
}
.rc-data-grid-secondary {
color: var(--rc-data-grid-secondary-color) !important;
font-size: 0.8125rem;
font-weight: var(--rc-font-normal);
font-variant-numeric: tabular-nums;
line-height: 1.125rem;
}
.rc-data-grid-text-pair {
display: flex;
min-width: 0;
max-width: 100%;
flex-direction: column;
gap: var(--rc-data-grid-text-pair-gap);
}
.rc-data-grid-text-pair > * {
min-width: 0;
max-width: 100%;
}
.rc-data-grid-header {
color: var(--rc-text-muted) !important;
font-size: 0.75rem;
font-weight: var(--rc-font-medium);
line-height: 1rem;
}
.rc-filter-label {
color: var(--rc-text-primary);
font-size: 0.875rem;
font-weight: var(--rc-font-medium);
line-height: 1.25rem;
}
.rc-modal-header-actions {
align-self: flex-start;
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-start: auto;
max-width: 100%;
}
@media (max-width: 40rem) {
.rc-modal-header-actions {
flex-basis: 100%;
}
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
......@@ -453,7 +541,7 @@
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
/* Charts keep keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
......@@ -465,6 +553,10 @@
outline-offset: 2px;
}
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
......@@ -509,27 +601,65 @@
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
.rc-nested-surface {
border-radius: var(--rc-radius-container);
overflow: hidden;
}
.rc-nested-surface {
/* Structural regions that need separation without another filled surface. */
.rc-outline-surface {
border: 1px solid var(--rc-border-outline);
border-radius: var(--rc-radius-container);
overflow: hidden;
}
.rc-application-identity-well,
.rc-application-identity-avatar {
border-radius: var(--rc-radius-control);
.rc-filter-surface {
border-radius: var(--rc-radius-overlay);
}
.rc-application-identity-avatar {
.rc-agenda-navigation {
align-items: center;
gap: var(--rc-space-2);
}
/* Card supplies the shared module inset; avoid Agenda adding a second gutter. */
.rc-agenda-header {
padding-inline: 0;
padding-block: 0;
}
.rc-agenda-month-label {
min-width: 5.5rem;
color: var(--rc-text-primary);
font-size: var(--rc-text-sm);
font-variant-numeric: tabular-nums;
font-weight: var(--rc-font-medium);
line-height: 1.5rem;
text-align: center;
white-space: nowrap;
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
.rc-workspace-module {
border-radius: 1.5rem;
border: 1px solid var(--border);
background: transparent;
box-shadow: none;
}
.rc-application-switcher-dialog {
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
.rc-empty-state-message {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
.rc-empty-field-text {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
......@@ -674,6 +804,116 @@
gap: var(--rc-list-stack-gap);
}
/* Keep long data sets inside the table while the column headings remain visible. */
.rc-data-table-sticky .data-table-scroll {
position: relative;
max-height: min(60dvh, 42rem);
overflow-x: auto;
overflow-y: auto;
overscroll-behavior: contain;
}
/* DataGrid adds a non-scrolling resize wrapper. Let sticky headers use the
outer scroll container as their containing block instead of this wrapper. */
.rc-data-table-sticky .table__resizable-container {
overflow: visible;
}
.rc-data-table-sticky [data-slot='table-header'] .table__column {
position: sticky;
top: 0;
inset-block-start: 0;
z-index: 4;
display: table-cell;
color: var(--muted);
background: var(--surface-secondary);
box-shadow: inset 0 -1px 0 var(--separator);
}
.rc-data-table-sticky [data-slot='table-header'] .table__column.bg-accent-soft {
background: var(--accent-soft);
color: var(--accent-soft-foreground);
}
/* The leading pinned column must sit above scrolling body cells. Keep its
surface opaque so text from adjacent columns cannot bleed through while
horizontal or vertical scrolling. */
.rc-data-table-sticky [data-pinned='start'] {
position: sticky;
z-index: 6;
background: var(--surface);
}
.rc-data-table-sticky [data-slot='table-header'] [data-pinned='start'] {
z-index: 8;
background: var(--surface-secondary);
}
/* Mirror HeroUI's row states on the opaque pinned surface so the leading
column still responds to hover and selection instead of looking detached. */
@media (hover: hover) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
/* Keep the pinned cell opaque; transparent hover fills let scrolled
sibling-cell text show through the fixed column. */
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 40%,
var(--color-surface-secondary)
);
}
}
}
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 10%,
var(--color-surface-secondary)
);
}
}
@media (max-width: 48rem) {
.rc-data-table-sticky .data-table-scroll {
max-height: min(54dvh, 32rem);
}
}
@media (min-width: 769px) {
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline: 0;
}
body:has(.rc-app-shell[data-state='expanded'])
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline-start: var(--rc-sidebar-width);
}
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
......@@ -807,7 +1047,7 @@
min-width: 0;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
font-weight: var(--rc-font-semibold);
line-height: 1.2rem;
}
......@@ -881,6 +1121,23 @@
font-weight: var(--rc-font-semibold);
}
.rc-application-identity-well {
display: grid;
width: 2.25rem;
height: 2.25rem;
flex: none;
place-items: center;
overflow: hidden;
border-radius: var(--rc-radius-control);
}
.rc-application-identity-avatar {
width: 100%;
height: 100%;
border-radius: var(--rc-radius-control);
background: transparent;
}
.rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
......@@ -1180,7 +1437,7 @@
overflow: hidden;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
font-weight: var(--rc-font-semibold);
line-height: 1.2rem;
letter-spacing: 0;
font-synthesis: none;
......@@ -1204,12 +1461,12 @@
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
color: var(--rc-sidebar-foreground);
background-color: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
--button-fg: var(--foreground);
--button-bg-hover: var(--rc-sidebar-item-selected);
--button-bg-pressed: var(--rc-sidebar-item-selected);
--button-fg: var(--rc-sidebar-item-selected-foreground);
}
.app-sidebar-app-trigger .anticon {
......@@ -1229,16 +1486,16 @@
@media (hover: hover) {
.app-sidebar-app-trigger.button:hover,
.app-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
}
.app-sidebar-app-trigger.button:active,
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
.app-sidebar-app-dialog {
......@@ -1273,7 +1530,7 @@
padding: 0.375rem 0.5rem;
color: var(--rc-text-muted);
font-size: 0.75rem;
font-weight: 500;
font-weight: var(--rc-font-medium);
line-height: 1rem;
letter-spacing: 0;
}
......@@ -1285,7 +1542,7 @@
padding: 0.375rem 0.5rem;
border-radius: calc(var(--radius) * 2);
color: var(--foreground);
font-weight: 400;
font-weight: var(--rc-font-normal);
}
.template-dashboard-shell .sidebar__menu-icon,
......@@ -1333,7 +1590,7 @@
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
font-weight: 500;
font-weight: var(--rc-font-medium);
}
.template-dashboard-shell
......@@ -1405,14 +1662,14 @@
.app-sidebar-user-name {
color: var(--foreground);
font-size: 0.875rem;
font-weight: 500;
font-weight: var(--rc-font-medium);
line-height: 1.125rem;
}
.app-sidebar-user-description {
color: var(--muted);
font-size: 0.75rem;
font-weight: 500;
font-weight: var(--rc-font-medium);
line-height: 1rem;
}
......@@ -1422,3 +1679,89 @@
border-right: 0;
}
}
/* Fixed split-shell theme: dark navigation, permanently light content canvas. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
color: var(--rc-sidebar-foreground);
border-color: var(--rc-sidebar-border) !important;
background-color: var(--rc-sidebar-background) !important;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
background: var(--rc-sidebar-item-selected);
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content *,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content * {
color: var(--rc-sidebar-item-selected-foreground) !important;
}
.template-dashboard-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--rc-sidebar-item-hover);
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'],
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
background: var(--rc-sidebar-item-selected);
}
.app-sidebar-user-avatar,
.app-sidebar-app-trigger.button,
.app-sidebar-user-trigger.dropdown__trigger {
color: var(--rc-sidebar-foreground);
background: transparent;
}
.app-sidebar-user-avatar {
background: var(--rc-sidebar-item-hover);
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label,
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon,
.app-sidebar-user-description {
color: var(--rc-sidebar-muted);
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.app-sidebar-title,
.app-sidebar-user-name,
.app-sidebar-app-trigger.button {
color: var(--rc-sidebar-foreground);
}
.app-sidebar-user-trigger.dropdown__trigger:focus-visible,
.app-sidebar-app-trigger.button:focus-visible,
.template-dashboard-shell .sidebar__menu-item:focus-visible,
.rc-mobile-sidebar .sidebar__menu-item:focus-visible {
outline: 2px solid var(--rc-sidebar-focus);
outline-offset: 2px;
}
......@@ -11,38 +11,66 @@
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
/* Fixed Xinyuan brand scale. HeroUI uses --accent for primary actions;
* numbered aliases keep Tailwind integrations on the same red family. */
--primary-50: #FEF2F1;
--primary-100: #FDE3E1;
--primary-200: #FBC8C5;
--primary-300: #F49A94;
--primary-400: #EA645B;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #8F1711;
--primary-900: #67100D;
--primary-950: #3D0907;
--primary: var(--primary-500);
--primary-foreground: #FFFFFF;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-foreground: #FFFFFF;
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(94.00% 0.0000 29.20);
--default-foreground: oklch(21.03% 0.0059 29.20);
--field-background: oklch(100.00% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
--field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0000 29.20);
--overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0000 29.20);
--success: #61758F;
--success-foreground: #FFFFFF;
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
......@@ -51,42 +79,114 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: #DA241A;
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
--xinyuan-chart-1: #DA241A;
--xinyuan-chart-2: #B83A32;
--xinyuan-chart-3: #C95A52;
--xinyuan-chart-4: #D47A70;
--xinyuan-chart-5: #A65A45;
--xinyuan-chart-6: #B87958;
--xinyuan-chart-7: #C99772;
--xinyuan-chart-8: #8A6F63;
--xinyuan-chart-9: #9A8174;
--xinyuan-chart-10: #6F6674;
--xinyuan-chart-11: #807783;
--xinyuan-chart-12: #938B96;
--xinyuan-chart-13: #4F6380;
--xinyuan-chart-14: #61758F;
--xinyuan-chart-15: #74879A;
--xinyuan-chart-16: #8798A8;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
/* Keep the brand scale stable when a dark portal is explicitly opened. */
--primary-50: #FEF2F1;
--primary-100: #FDE3E1;
--primary-200: #FBC8C5;
--primary-300: #F49A94;
--primary-400: #EA645B;
--primary-500: #DA241A;
--primary-600: #C91F17;
--primary-700: #B51B14;
--primary-800: #8F1711;
--primary-900: #67100D;
--primary-950: #3D0907;
--primary: var(--primary-500);
--primary-foreground: #FFFFFF;
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--accent: #DA241A;
--accent-hover: #C91F17;
--accent-foreground: #FFFFFF;
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(27.40% 0.0000 29.20);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
--field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0000 29.20);
--overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0000 29.20);
--success: #74879A;
--success-foreground: #FFFFFF;
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
......@@ -2,10 +2,8 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
[data-theme='default'] {
/* Application brand alias: HeroUI primary interactions use Xinyuan Fund red. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
......@@ -13,6 +11,7 @@
--rc-bg-canvas: var(--background);
--rc-bg-surface: var(--surface);
--rc-border-default: var(--border);
--rc-border-outline: var(--rc-border-default);
--rc-field-background: var(--field-background);
--rc-field-border-focus: var(--focus);
--rc-button-secondary-border: color-mix(
......@@ -27,10 +26,26 @@
var(--background)
);
--rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
--rc-data-grid-secondary-color: #7a8596;
--rc-data-grid-text-pair-gap: 0.25rem;
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans);
--rc-font-normal: 400;
--rc-font-medium: 500;
--rc-font-data: 550;
--rc-font-semibold: 600;
--rc-font-bold: 700;
--rc-line-normal: 1.5;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
......@@ -45,6 +60,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
......@@ -82,14 +98,14 @@
0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-info: #4F6380;
--rc-metric-tone-success: #807783;
--rc-metric-tone-teal: #74879A;
--rc-metric-tone-danger: #DA241A;
--rc-metric-tone-warning: #B87958;
--rc-metric-tone-violet: #6F6674;
--rc-metric-tone-indigo: #61758F;
--rc-metric-tone-coral: #C95A52;
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
......@@ -137,41 +153,38 @@
var(--surface)
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
/*
* Data visualization only. Keep this categorical sequence separate from
* UI state colors: warm brand shades -> warm neutrals -> cool gray-blue.
* No green series colors are used by this app.
*/
--rc-chart-1: #DA241A;
--rc-chart-2: #B83A32;
--rc-chart-3: #C95A52;
--rc-chart-4: #D47A70;
--rc-chart-5: #A65A45;
--rc-chart-6: #B87958;
--rc-chart-7: #C99772;
--rc-chart-8: #8A6F63;
--rc-chart-9: #9A8174;
--rc-chart-10: #6F6674;
--rc-chart-11: #807783;
--rc-chart-12: #938B96;
--rc-chart-13: #4F6380;
--rc-chart-14: #61758F;
--rc-chart-15: #74879A;
--rc-chart-16: #8798A8;
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
--rc-chart-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-4);
--rc-chart-comparison: var(--rc-chart-13);
--rc-chart-neutral: var(--rc-chart-10);
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
--rc-data-grid-secondary-color: #8b8b95;
}
import xinyuanLogo from '@/assets/xy-logo-red.png';
import { APPLICATION_DISPLAY_NAME, APPLICATION_NAME } from '@/config/application';
import { getApplications } from '@/services/DataService';
import XinyuanModal from '@/components/XinyuanModal';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import xyLogoRed from '@/assets/xy-logo-red.png';
import { APPLICATION_ROUTE_NAME } from '@/config/application';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
ScrollShadow,
SearchField,
Skeleton,
Surface,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const CURRENT_APPLICATION_NAME = APPLICATION_NAME;
function unwrapApplications(response) {
const candidates = [
response,
......@@ -56,23 +60,21 @@ function resolveApplicationUrl(application) {
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
const current = name === CURRENT_APPLICATION_NAME;
return {
name,
title: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
title: current
? APPLICATION_DISPLAY_NAME
: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo:
application?.logo ||
fallback?.logo ||
(name === APPLICATION_ROUTE_NAME ? xyLogoRed : null),
url: resolveApplicationUrl({
name,
}),
logo: xinyuanLogo,
url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
......@@ -105,6 +107,15 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
mergedMap.set(application.name, normalizeApplication(application, application));
});
const currentApplication = mergedMap.get(CURRENT_APPLICATION_NAME);
mergedMap.set(
CURRENT_APPLICATION_NAME,
normalizeApplication(
{ name: CURRENT_APPLICATION_NAME },
currentApplication || { title: APPLICATION_DISPLAY_NAME },
),
);
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
......@@ -128,14 +139,19 @@ function buildApplicationSections(applications, applicationGroups) {
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
.map((name) => {
if (groupedApplicationNames.has(name)) {
return null;
}
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
......@@ -162,6 +178,10 @@ function buildApplicationSections(applications, applicationGroups) {
}
function isCurrentApplication(application) {
if (application?.name === CURRENT_APPLICATION_NAME) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
......@@ -188,60 +208,76 @@ function isCurrentApplication(application) {
function ApplicationCard({ application, current }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
const description = application.description?.trim();
const hasDistinctDescription =
description && description !== application.title.trim();
return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
>
<Surface aria-hidden="true" className="rc-application-identity-well grid size-9 shrink-0 place-items-center overflow-hidden" variant="tertiary">
<Avatar
className="rc-application-identity-avatar size-full"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-5" title={application.title}>
{application.title}
</span>
<Description className="mt-0.5 block truncate leading-4" title={application.description}>
{application.description || application.name || '应用'}
</Description>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
<ItemCard
aria-current={current ? 'page' : undefined}
className="h-full min-w-0 no-underline"
href={application.url}
rel={target ? 'noreferrer' : undefined}
render={(props) => <a {...props} />}
target={target}
variant={current ? 'outline' : 'default'}
>
<ItemCard.Icon aria-hidden="true">
<Surface variant="default" className="rc-application-identity-well">
<Avatar
className="rc-application-identity-avatar"
color="accent"
variant="soft"
>
<Avatar.Image
alt=""
className="object-contain p-1.5"
src={application.logo || xinyuanLogo}
/>
<Avatar.Fallback>
<img alt="" className="object-contain p-1.5" src={xinyuanLogo} />
</Avatar.Fallback>
</Avatar>
</Surface>
</ItemCard.Icon>
<ItemCard.Content>
<ItemCard.Title title={application.title}>
{application.title}
</ItemCard.Title>
{hasDistinctDescription ? (
<ItemCard.Description title={description}>
{description}
</ItemCard.Description>
) : null}
</ItemCard.Content>
{current ? (
<ItemCard.Action>
<Chip color="accent" size="sm">当前</Chip>
</ItemCard.Action>
) : null}
</ItemCard>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
<ItemCardGroup
aria-label="正在加载应用"
className="grid-cols-1 sm:grid-cols-2"
columns={2}
layout="grid"
variant="transparent"
>
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
<ItemCard key={index} variant="default">
<ItemCard.Icon><Skeleton className="size-9" /></ItemCard.Icon>
<ItemCard.Content className="gap-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</ItemCard.Content>
</ItemCard>
))}
</div>
</ItemCardGroup>
);
}
......@@ -266,35 +302,52 @@ export default function ApplicationSwitcherDialog({
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [applications, setApplications] = useState(() =>
mergeApplications([], fallbackApplications),
);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || '';
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
const filteredApplicationSections = useMemo(() => {
const keyword = applicationSearch.trim().toLocaleLowerCase();
if (!keyword) {
return applicationSections;
}
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) => [
application.title,
application.description,
application.name,
].some((value) => String(value || '').toLocaleLowerCase().includes(keyword))),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
setApplications((previous) =>
mergeApplications(previous, fallbackApplications),
);
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
setApplicationSearch('');
return;
}
......@@ -324,7 +377,7 @@ export default function ApplicationSwitcherDialog({
return;
}
setApplications(fallbackApplications);
setApplications(mergeApplications([], fallbackApplications));
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
......@@ -338,53 +391,108 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications, open]);
return (
<XinyuanModal
description="选择你可访问的应用"
dialogClassName="rc-application-switcher-dialog"
isOpen={open}
size="xl"
title="应用切换"
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="space-y-6">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium leading-5">{section.label}</h3>
<Description>{section.applications.length} 个应用</Description>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
/>
))}
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog
aria-label="应用切换"
className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))',
}}
>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<div className="flex w-full min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SearchField
aria-label="搜索应用"
className="w-full min-w-0 max-w-full sm:flex-1 sm:w-auto"
value={applicationSearch}
variant="secondary"
onChange={setApplicationSearch}
>
<SearchField.Group>
<SearchField.SearchIcon>
<MagnifyingGlassIcon size={16} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="搜索应用" />
<SearchField.ClearButton aria-label="清空应用搜索" />
</SearchField.Group>
</SearchField>
</div>
</section>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</XinyuanModal>
</Modal.Header>
<Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow
className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1"
hideScrollBar={false}
offset={12}
size={24}
>
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
<div className="space-y-6">
{filteredApplicationSections.map((section) => (
<ItemCardGroup
key={section.key}
className="grid-cols-1 sm:grid-cols-2"
columns={2}
layout="grid"
variant="transparent"
>
<ItemCardGroup.Header className="flex items-center justify-between gap-3">
<ItemCardGroup.Title>{section.label}</ItemCardGroup.Title>
<ItemCardGroup.Description className="mt-0">
{section.applications.length} 个应用
</ItemCardGroup.Description>
</ItemCardGroup.Header>
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
/>
))}
</ItemCardGroup>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>
{applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}
</EmptyState.Title>
<EmptyState.Description>
{applicationSearch.trim()
? '请尝试其他关键词。'
: '当前账号还没有分配其它应用权限。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</ScrollShadow>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
......@@ -13,6 +13,7 @@ import {
Edit,
Eye,
ExclamationTriangleIcon,
Info,
Project,
} from '@/components/AppIcons';
import { ActionBar } from '@heroui-pro/react/action-bar';
......@@ -36,6 +37,7 @@ import {
Surface,
Tabs,
TextArea,
toast,
Tooltip,
} from '@heroui/react';
import { parseDate, parseDateTime } from '@internationalized/date';
......@@ -65,6 +67,8 @@ import {
deleteProjectTask,
} from './projectTaskService';
const PAGE_SIZE_OPTIONS = [10, 20, 50];
function resolveCurrentDate() {
return new Date().toISOString().slice(0, 10);
}
......@@ -607,22 +611,6 @@ function buildGanttColumns(anchorDate, viewType) {
};
}
function formatGanttRangeTitle(anchorDate, viewType) {
const anchor = createDate(anchorDate);
if (viewType === GANTT_VIEW_TYPES.WEEK) {
const start = startOfIsoWeek(anchor);
const end = endOfIsoWeek(anchor);
return `${formatDateKey(start)} ~ ${formatDateKey(end)}`;
}
if (viewType === GANTT_VIEW_TYPES.YEAR) {
return `${anchor.getFullYear()}年`;
}
return `${anchor.getFullYear()}年${padNumber(anchor.getMonth() + 1)}月`;
}
function moveGanttAnchor(anchorDate, viewType, direction) {
const anchor = createDate(anchorDate);
......@@ -722,31 +710,11 @@ function ToolbarCard({ children }) {
function FilterField({ label, children }) {
return (
<div className="rc-filter-control min-w-0">
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
<div className="rc-filter-label mb-1.5">{label}</div>
{children}
</div>
);
}
function GanttLegend() {
const legendItems = [
{ color: 'accent', label: '正常进行' },
{ color: 'success', label: '提前完成' },
{ color: 'danger', label: '已超时' },
{ color: 'success', label: '已完成' },
{ color: 'warning', label: '受阻' },
];
return (
<div className="flex flex-wrap items-center gap-2">
{legendItems.map((item) => (
<Chip key={item.label} color={item.color} size="sm" variant="soft">
{item.label}
</Chip>
))}
</div>
);
}
function getGanttStatusPresentation(task) {
if (task.projectStatus === 'blocked') {
return { color: 'warning', label: '受阻' };
......@@ -790,7 +758,6 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
column.startKey <= task.plannedEndDate,
),
);
const rangeTitle = formatGanttRangeTitle(viewAnchor, viewType);
const columnMinWidth = getGanttColumnMinWidth(viewType);
const timelineGridStyle = {
gridTemplateColumns: `240px repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
......@@ -801,8 +768,7 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
<Card className="rc-card-surface rc-dashboard-card">
<Card.Header className="flex-col items-stretch gap-4 xl:flex-row xl:items-center">
<div className="min-w-0 flex-1">
<Card.Title>项目甘特图</Card.Title>
<Card.Description>{rangeTitle}</Card.Description>
<Card.Title className="rc-module-title">项目甘特图</Card.Title>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end">
<Tabs
......@@ -856,14 +822,13 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
</div>
</Card.Header>
<Card.Content className="grid gap-4">
<GanttLegend />
<Surface className="rc-nested-surface min-w-0 p-3 sm:p-4" variant="secondary">
{visibleTasks.length ? (
<div className="w-full min-w-0 overflow-x-auto">
<div className="grid gap-2" style={{ minWidth: `${timelineMinWidth}px` }}>
<div className="grid items-stretch gap-1" style={timelineGridStyle}>
<Surface className="rc-nested-surface flex items-center px-3 py-2" variant="tertiary">
<span className="text-xs font-medium text-muted">项目</span>
<span className="rc-data-grid-header">项目</span>
</Surface>
{timelineMeta.columns.map((column) => (
<Surface
......@@ -874,9 +839,9 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
{column.isToday ? (
<Chip color="accent" size="sm" variant="soft">{column.primary}</Chip>
) : (
<span className="text-sm font-medium">{column.primary}</span>
<span className="rc-data-grid-primary">{column.primary}</span>
)}
<span className="text-xs text-muted">{column.secondary}</span>
<span className="rc-data-grid-secondary">{column.secondary}</span>
</Surface>
))}
</div>
......@@ -908,12 +873,12 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
}}
>
<span className="grid min-w-0 gap-1 text-left">
<span className="block truncate font-medium">{task.projectName}</span>
<span className="rc-data-grid-primary block truncate">{task.projectName}</span>
<span className="flex min-w-0 items-center gap-2">
<Chip color={status.color} size="sm" variant="soft">{status.label}</Chip>
<span className="truncate text-xs text-muted">{formatUserLabel(task.projectOwnerUser)}</span>
<span className="rc-data-grid-secondary truncate">{formatUserLabel(task.projectOwnerUser)}</span>
</span>
<span className="block truncate text-xs text-muted">
<span className="rc-data-grid-secondary block truncate">
{task.plannedStartDate} ~ {task.plannedEndDate}
</span>
</span>
......@@ -944,7 +909,7 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
</ProgressBar>
<Tooltip delay={0}>
<Tooltip.Trigger className="min-w-0">
<span className="block truncate text-xs text-muted">
<span className="rc-data-grid-secondary block truncate">
{task.latestWeeklySummary || task.delayReason || '暂无周进展'}
{task.weeklyUpdateCount ? ` · 共 ${task.weeklyUpdateCount} 条` : ''}
</span>
......@@ -1043,21 +1008,23 @@ function ProjectCalendarBoard({
return (
<Card className="rc-card-surface rc-dashboard-card">
<Agenda {...agenda} className="h-[36rem] sm:h-[42rem]">
<Agenda.Header className="flex-row flex-wrap items-center gap-4">
<Agenda.Header className="rc-agenda-header flex-row flex-wrap items-center gap-4">
<div className="min-w-0 flex-1">
<Agenda.Heading>个人项目日历</Agenda.Heading>
<Card.Description>{agenda.heading}</Card.Description>
<Agenda.Heading className="rc-module-title">个人项目日历</Agenda.Heading>
</div>
<Agenda.Navigation>
<Agenda.Navigation className="rc-agenda-navigation">
<Tooltip delay={0}>
<Agenda.NavButton slot="previous"><Back size={16} /></Agenda.NavButton>
<Tooltip.Content>上个月</Tooltip.Content>
</Tooltip>
<Agenda.TodayButton>本月</Agenda.TodayButton>
<span className="rc-agenda-month-label" aria-live="polite">
{agenda.heading}
</span>
<Tooltip delay={0}>
<Agenda.NavButton slot="next"><ChevronRight size={16} /></Agenda.NavButton>
<Tooltip.Content>下个月</Tooltip.Content>
</Tooltip>
<Agenda.TodayButton>今天</Agenda.TodayButton>
</Agenda.Navigation>
</Agenda.Header>
<Agenda.Body>
......@@ -1107,13 +1074,18 @@ function ProjectCalendarBoard({
}
function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[0]);
const [selectedKeys, setSelectedKeys] = useState(new Set());
const pageSize = 10;
useEffect(() => {
setPage(0);
}, [tasks]);
useEffect(() => {
const totalPagesForSize = Math.max(1, Math.ceil(tasks.length / pageSize));
setPage((currentPage) => Math.min(currentPage, totalPagesForSize - 1));
}, [pageSize, tasks.length]);
const pageRows = useMemo(
() => tasks.slice(page * pageSize, page * pageSize + pageSize),
[page, pageSize, tasks],
......@@ -1126,9 +1098,9 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
pinned: 'start',
isRowHeader: true,
cell: (task) => (
<span className="min-w-0">
<span className="block truncate font-medium">{task.projectName}</span>
<span className="block truncate text-sm text-muted">
<span className="rc-data-grid-text-pair">
<span className="rc-data-grid-primary block truncate">{task.projectName}</span>
<span className="rc-data-grid-secondary block truncate">
{[task.bigCategory, task.projectCategory].filter(Boolean).join(' · ') || '未分类'}
</span>
</span>
......@@ -1139,9 +1111,9 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
header: '负责人',
minWidth: 160,
cell: (task) => (
<span className="min-w-0">
<span className="block truncate font-medium">{formatUserLabel(task.projectOwnerUser)}</span>
<span className="block truncate text-sm text-muted">
<span className="rc-data-grid-text-pair">
<span className="rc-data-grid-primary block truncate">{formatUserLabel(task.projectOwnerUser)}</span>
<span className="rc-data-grid-secondary block truncate">
{task.projectOwnerUser?.username || task.projectOwnerUser?.id || ''}
</span>
</span>
......@@ -1158,9 +1130,9 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
header: '计划周期',
minWidth: 280,
cell: (task) => (
<span className="min-w-0">
<span className="block truncate">{task.plannedStartDate || '-'} ~ {task.plannedEndDate || '-'}</span>
<span className="block truncate text-sm text-muted">
<span className="rc-data-grid-text-pair">
<span className="rc-data-grid-primary block truncate">{task.plannedStartDate || '-'} ~ {task.plannedEndDate || '-'}</span>
<span className="rc-data-grid-secondary block truncate">
{task.isOvertime ? `超时 ${task.overtimeDays} 天` : task.resultStatusLabel}
</span>
</span>
......@@ -1170,16 +1142,16 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
id: 'progress',
header: '完成进度',
minWidth: 120,
cell: (task) => formatPercent(task.progressPercent),
cell: (task) => <span className="rc-data-grid-primary">{formatPercent(task.progressPercent)}</span>,
},
{
id: 'weekly',
header: '最近周进展',
minWidth: 240,
cell: (task) => (
<span className="min-w-0">
<span className="block truncate">{task.latestWeeklySummary || task.delayReason || '暂无周进展'}</span>
<span className="block truncate text-sm text-muted">共 {task.weeklyUpdateCount} 条</span>
<span className="rc-data-grid-text-pair">
<span className="rc-data-grid-primary block truncate">{task.latestWeeklySummary || task.delayReason || '暂无周进展'}</span>
<span className="rc-data-grid-secondary block truncate">共 {task.weeklyUpdateCount} 条</span>
</span>
),
},
......@@ -1223,7 +1195,7 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
return (
<section className="grid gap-2">
<h2 className="text-base font-medium">项目列表</h2>
<h2 className="rc-module-title">项目列表</h2>
<DataGrid
allowsColumnResize
aria-label="项目列表"
......@@ -1248,8 +1220,34 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
if (nextTaskId) onSelectTask(nextTaskId);
}}
/>
<Pagination size="sm">
<Pagination.Summary>共 {tasks.length} 条,第 {tasks.length ? page + 1 : 0} / {tasks.length ? totalPages : 0} 页</Pagination.Summary>
<Pagination className="min-w-0" size="sm">
<Pagination.Summary>
<span>
共 {tasks.length} 条,第 {tasks.length ? page + 1 : 0} / {tasks.length ? totalPages : 0} 页
</span>
<XinyuanAutocomplete
aria-label="每页条数"
className="w-24 shrink-0"
isClearable={false}
options={PAGE_SIZE_OPTIONS.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}))}
placeholder="每页条数"
selectedKey={String(pageSize)}
showSearch={false}
valueText={`${pageSize} 条/页`}
variant="secondary"
onSelectionChange={(key) => {
const nextPageSize = Number(key);
if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) {
setPageSize(nextPageSize);
setPage(0);
setSelectedKeys(new Set());
}
}}
/>
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous isDisabled={page <= 0} onPress={() => setPage(page - 1)}>
......@@ -1580,29 +1578,23 @@ function PreviewField({ label, value, className = '' }) {
const displayValue = normalizedValue && normalizedValue !== '-' ? normalizedValue : '暂无内容';
return (
<div className={className}>
<div className={`min-w-0 py-3 ${className}`.trim()}>
<dt className="text-sm font-medium text-muted">{label}</dt>
<dd className="mt-1.5 break-words">{displayValue}</dd>
<dd className="mt-1.5 min-w-0 break-words text-sm font-normal text-foreground">
{displayValue}
</dd>
</div>
);
}
function ProjectPreviewDialog({ open, task, onClose }) {
function ProjectPreviewContent({ task }) {
const metadata = task?.metadata || {};
const weeklyUpdates = Array.isArray(task?.weeklyUpdates) ? task.weeklyUpdates : [];
const ownerLabels = (task?.projectOwnerUsers || []).map(formatUserLabel);
const collaboratorLabels = (task?.projectCollaboratorUsers || []).map(formatUserLabel);
return (
<XinyuanModal
isOpen={open}
size="4xl"
title="项目预览"
description={task?.projectName || ''}
footer={<Button variant="secondary" onPress={onClose}>关闭</Button>}
onOpenChange={(isOpen) => { if (!isOpen) onClose?.(); }}
>
<Surface className="rc-nested-surface px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
<div className="min-w-0">
<dl className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
<PreviewField label="项目名称" value={task?.projectName} />
<PreviewField label="大类" value={task?.bigCategory} />
......@@ -1640,11 +1632,11 @@ function ProjectPreviewDialog({ open, task, onClose }) {
label="延期原因"
value={task?.delayReason}
/>
<div className="md:col-span-2">
<div className="min-w-0 py-3 md:col-span-2">
<dt className="text-sm font-medium text-muted">周进度记录</dt>
<dd>
<dd className="mt-1.5 min-w-0">
{weeklyUpdates.length > 0 ? (
<div className="mt-1.5 grid gap-3">
<div className="grid gap-3">
{weeklyUpdates.map((update, index) => (
<section
key={`${update.week_start_date || 'week'}-${index}`}
......@@ -1672,8 +1664,7 @@ function ProjectPreviewDialog({ open, task, onClose }) {
</dd>
</div>
</dl>
</Surface>
</XinyuanModal>
</div>
);
}
......@@ -1687,6 +1678,8 @@ function ProjectTaskDialog({
onSubmit,
onDelete,
fieldOptions,
viewOnly = false,
onEdit,
}) {
const [formValues, setFormValues] = useState(buildTaskFormValues(null));
const [weeklyUpdateDialogOpen, setWeeklyUpdateDialogOpen] = useState(false);
......@@ -1707,7 +1700,7 @@ function ProjectTaskDialog({
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
setWeeklyUpdateError('');
setDeleteConfirmOpen(false);
}, [open, task]);
}, [open, task, viewOnly]);
const updateField = (field, value) => {
setFormValues((current) => ({
......@@ -1860,9 +1853,24 @@ function ProjectTaskDialog({
<XinyuanModal
isOpen={open}
size="4xl"
title={task ? '编辑项目' : '新建项目'}
footer={(
title={viewOnly ? '项目预览' : task ? '编辑项目' : '新建项目'}
description={viewOnly ? task?.projectName || '' : ''}
dialogClassName="min-h-[min(48rem,calc(100dvh-2rem))]"
footer={viewOnly ? (
<Button variant="primary" onPress={() => onEdit?.(task)}>
编辑
</Button>
) : (
<>
{hasPersistedTask ? (
<Button
isDisabled={isBusy}
variant="danger-soft"
onPress={() => setDeleteConfirmOpen(true)}
>
删除当前任务
</Button>
) : null}
<Button isDisabled={isBusy} variant="secondary" onPress={onClose}>取消</Button>
<Button
form="project-task-form"
......@@ -1877,6 +1885,7 @@ function ProjectTaskDialog({
)}
onOpenChange={(isOpen) => { if (!isOpen && !isBusy) onClose?.(); }}
>
{viewOnly ? <ProjectPreviewContent task={task} /> : (
<form
className="grid gap-6"
id="project-task-form"
......@@ -2057,16 +2066,24 @@ function ProjectTaskDialog({
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium">项目状态 <span aria-hidden="true">*</span></span>
<span className="flex items-center gap-1 font-medium">
<span>项目状态 <span aria-hidden="true">*</span></span>
<Tooltip delay={0}>
<Tooltip.Trigger
aria-label="查看项目状态工作流"
className="inline-flex size-4 shrink-0 items-center justify-center"
>
<Info size={14} />
</Tooltip.Trigger>
<Tooltip.Content>工作流:草稿 {'->'} 进行中 {'->'} 已完成</Tooltip.Content>
</Tooltip>
</span>
<OptionAutocomplete
value={formValues.project_status}
onChange={(value) => updateField('project_status', value)}
options={projectStatusOptions}
isRequired
/>
<div className="text-xs text-muted">
工作流:草稿 {'->'} 进行中 {'->'} 已完成
</div>
</label>
<label className="grid gap-2 text-sm">
......@@ -2122,7 +2139,7 @@ function ProjectTaskDialog({
</label>
</div>
<Surface className="p-4" variant="secondary">
<Surface className="rc-outline-surface p-4" variant="transparent">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="font-medium">周进度记录</div>
<Button variant="secondary" onPress={openCreateWeeklyUpdate}>
......@@ -2135,8 +2152,8 @@ function ProjectTaskDialog({
weeklyUpdates.map((update, index) => (
<Surface
key={`${update.week_start_date || 'week'}-${index}`}
className="p-4"
variant="tertiary"
className="rc-outline-surface p-4"
variant="transparent"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
......@@ -2201,25 +2218,16 @@ function ProjectTaskDialog({
{PRIORITY_LABEL_MAP[formValues.priority] || '未设置优先级'}
</div>
{hasPersistedTask ? (
<Button
className="mt-3"
isDisabled={isBusy}
variant="danger-soft"
onPress={() => setDeleteConfirmOpen(true)}
>
删除当前任务
</Button>
) : null}
</div>
</div>
</form>
)}
</XinyuanModal>
<AlertDialog.Root>
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop
isOpen={deleteConfirmOpen}
isOpen={deleteConfirmOpen && !viewOnly}
onOpenChange={(isOpen) => { if (!isOpen && !isBusy) setDeleteConfirmOpen(false); }}
variant="blur"
>
......@@ -2330,7 +2338,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
setUsers(normalizedUsers);
setTasks(enrichedTasks);
if (enrichedTasks.length === 0) {
setError('当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。');
toast.warning('当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。', {
timeout: 3500,
});
}
} catch (requestError) {
if (requestId !== latestWorkspaceRequestId.current) {
......@@ -2714,9 +2724,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
<Alert status="warning"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
) : null}
{loading ? (
<Alert status="default"><Alert.Content><Alert.Description>正在加载...</Alert.Description></Alert.Content></Alert>
) : null}
</div>
</div>
</Disclosure>
......@@ -2787,8 +2794,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
) : null}
<ProjectTaskDialog
open={dialogOpen}
task={editingTask}
open={dialogOpen || Boolean(previewTask)}
task={dialogOpen ? editingTask : previewTask}
viewOnly={!dialogOpen && Boolean(previewTask)}
submitting={dialogSubmitting}
deleting={dialogDeleting}
submitError={dialogError}
......@@ -2799,17 +2807,16 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}
setDialogOpen(false);
setEditingTask(null);
setPreviewTask(null);
setDialogDeleting(false);
setDialogError('');
}}
onSubmit={handleDialogSubmit}
onDelete={handleDialogDelete}
/>
<ProjectPreviewDialog
open={Boolean(previewTask)}
task={previewTask}
onClose={() => setPreviewTask(null)}
onEdit={(task) => {
setPreviewTask(null);
openEditDialog(task);
}}
/>
<ProjectImportDialog
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
......@@ -39,6 +40,8 @@ const READ_STATUS_OPTIONS = [
{ key: 'unread', label: '未读' },
];
const PAGE_SIZE_OPTIONS = [10, 20, 50];
function formatNotificationTime(value) {
if (!value) return '时间未知';
const date = new Date(value);
......@@ -136,6 +139,7 @@ export default function NotificationListPage() {
const [searchText, setSearchText] = useState('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(NOTIFICATION_PAGE_SIZE);
const [notifications, setNotifications] = useState([]);
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
......@@ -143,7 +147,7 @@ export default function NotificationListPage() {
const [error, setError] = useState('');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const totalPages = Math.max(1, Math.ceil(total / NOTIFICATION_PAGE_SIZE));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageIndexes = useMemo(() => {
const pageWindowStart = Math.max(0, Math.min(page - 2, totalPages - 5));
const pageWindowEnd = total ? Math.min(totalPages, pageWindowStart + 5) : 0;
......@@ -166,7 +170,7 @@ export default function NotificationListPage() {
setLoading(true);
setError('');
loadNotificationPage({ page, category, readStatus, keyword })
loadNotificationPage({ page, pageSize, category, readStatus, keyword })
.then((result) => {
if (cancelled) return;
setNotifications(result.list);
......@@ -185,7 +189,12 @@ export default function NotificationListPage() {
return () => {
cancelled = true;
};
}, [category, keyword, page, readStatus, reloadKey]);
}, [category, keyword, page, pageSize, readStatus, reloadKey]);
useEffect(() => {
const totalPagesForSize = Math.max(1, Math.ceil(total / pageSize));
setPage((currentPage) => Math.min(currentPage, totalPagesForSize - 1));
}, [pageSize, total]);
useEffect(() => {
let cancelled = false;
......@@ -324,7 +333,30 @@ export default function NotificationListPage() {
) : null}
{!error && total > 0 ? (
<Pagination size="sm">
<Pagination.Summary>共 {total} 条通知,第 {page + 1} / {totalPages} 页</Pagination.Summary>
<Pagination.Summary>
<span>共 {total} 条通知,第 {page + 1} / {totalPages} 页</span>
<XinyuanAutocomplete
aria-label="每页条数"
className="w-24 shrink-0"
isClearable={false}
options={PAGE_SIZE_OPTIONS.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}))}
placeholder="每页条数"
selectedKey={String(pageSize)}
showSearch={false}
valueText={`${pageSize} 条/页`}
variant="secondary"
onSelectionChange={(key) => {
const nextPageSize = Number(key);
if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) {
setPageSize(nextPageSize);
setPage(0);
}
}}
/>
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item><Pagination.Previous isDisabled={loading || page <= 0} onPress={() => setPage(page - 1)}><Pagination.PreviousIcon /><span>上一页</span></Pagination.Previous></Pagination.Item>
{pageIndexes.map((pageIndex) => (
......
......@@ -20,7 +20,7 @@
- 左侧菜单:`资源预定`
- `资源预定` 顶部使用项目甘特图、个人日历图、项目清单三个 Tabs;移动端 Tabs 可横向滚动,批量导入和新建项目操作位于独立工具行
- 筛选区默认展示项目搜索、项目状态、项目负责人;项目协作人位于可展开的筛选区域。筛选不提供刷新或查询按钮,选择类条件变化后立即查询,关键词停止输入 250ms 后自动查询;连续请求只允许最后一次结果写回页面
- 筛选区默认展示项目搜索、项目状态、项目负责人;项目协作人位于可展开的筛选区域。筛选不提供刷新或查询按钮,选择类条件变化后立即查询,关键词停止输入 250ms 后自动查询;连续请求只允许最后一次结果写回页面。请求加载期间筛选 Surface 保持稳定,不显示常驻加载提示。
- 枚举和用户选择字段统一使用 HeroUI Autocomplete:选择后触发器立即回显当前标签,再次打开时保留选项勾选;非必填且可编辑的已选字段显示清除按钮,清除后按原字段协议写回空字符串或空 ID 数组
- `项目清单` 使用原生行选择;选择一条记录后在视口下方显示操作栏,可编辑所选项目或清空选择
......@@ -70,8 +70,8 @@
1. 成员登录系统
2. 日历图查询当前用户包含在 `project_owner` 或 `project_collaborator` 数组中的项目记录
3. 甘特图按项目展示所有任务;个人日历图仅展示当前用户关联项目
4. 项目清单按固定每页 10 条分页;行操作区提供“预览项目”和“编辑项目”两个图标入口,选择后操作栏继续进入原项目编辑流程
5. 点击“预览项目”打开 4xl 只读详情弹窗,在单一 secondary Surface 中展示项目基础信息、状态与进度、计划/实际日期及周进度;关闭预览不会触发保存、删除或额外查询请求
4. 项目清单默认每页 10 条分页,可在分页栏选择 10、20 或 50 条/页;行操作区提供“预览项目”和“编辑项目”两个图标入口,选择后操作栏继续进入原项目编辑流程
5. 点击“预览项目”打开 4xl 详情弹窗,以无框标签/值布局展示项目基础信息、状态与进度、计划/实际日期及周进度;查看态底部“编辑”按钮沿用原项目编辑流程,右上角关闭图标退出预览,关闭或切换编辑不会触发额外查询请求
6. 可在甘特图、日历图或项目清单中查看项目名称、计划时间、共享进度、状态
### 4.3 成员更新项目进度
......@@ -83,7 +83,7 @@
5. 点击“完成项目”后,前端直接调用保存接口,状态切换为 `completed`,并自动带入 `actual_finish_date=当天`;如 `actual_start_date` 为空也会一并补当天,完成进度不足 100 时自动补到 100,保存成功后关闭弹窗
6. 项目完成时,如当前完成进度小于 100,可默认补到 100
7. 项目新建/编辑使用 4xl 弹窗;枚举字段从 Subject 配置进入可搜索选项弹层,用户字段进入远程搜索弹层,日期字段进入日期面板
8. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的 `completion_percent`
8. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;已有项目的删除入口位于编辑态 Footer 右侧操作组、排在“取消”之前,点击后通过确认弹窗执行原删除流程;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的 `completion_percent`
9. 若已超过计划结束日期仍未完成,补充 `delay_reason`
10. 对于已落库项目,可在编辑弹框内点击“删除当前任务”
11. 删除操作使用二次确认弹窗;确认后前端调用删除接口,按软删除口径将记录移出甘特图、日历图与项目清单
......@@ -136,7 +136,7 @@
- 即将到期项目
- 超时项目清单
- 风险清单区位于成员负载明细下方,采用独立全宽区块展示,避免作为右侧纵列造成页面左右高度失衡
- 图表统一使用 PieChart、BarChart、ComposedChart 与 ChartTooltip;浅色和深色模式共享同一业务系列色映射,统计口径不变
- 图表统一使用 ECharts,通过共享 `XinyuanEChart` 适配器处理 Pie、Bar、组合图、Tooltip 和主题色;统计口径不变。系列色按共享 Token 使用品牌红深浅、暖色、中性与灰蓝层级,不使用绿色系、荧光色或页面私有颜色
- 建议补充统计维度:
- 项目状态分布
- 结果状态分布(按时间规则实时派生)
......
import {
colorizeXinyuanChartTooltipPayload,
createXinyuanChartColorMap,
getXinyuanChartColor,
XINYUAN_CHART_SEMANTIC,
createResolvedXinyuanChartColorMap,
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import XinyuanEChart from '@/components/XinyuanEChart';
import {
CheckCircleIcon,
Clock,
......@@ -12,15 +11,10 @@ import {
Team,
} from '@/components/AppIcons';
import { UserContext } from '@/wrapper/Auth';
import { BarChart } from '@heroui-pro/react/bar-chart';
import { ComposedChart } from '@heroui-pro/react/composed-chart';
import { ChartTooltipContent } from '@heroui-pro/react/chart-tooltip';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { PieChart } from '@heroui-pro/react/pie-chart';
import { Alert, Card, Spinner, Surface } from '@heroui/react';
import { Cell } from 'recharts';
import { useContext, useEffect, useMemo, useState } from 'react';
import {
formatDateLabel,
......@@ -37,6 +31,10 @@ const PROGRESS_BUCKETS = [
{ label: '100%', min: 100, max: 100 },
];
const STATUS_CHART_ORDER = ['已完成', '进行中', '受阻', 'assigned', '草稿'];
const RESULT_CHART_ORDER = ['已超时', '提前完成', '超时完成', '准时完成'];
const PROGRESS_CHART_ORDER = PROGRESS_BUCKETS.map((bucket) => bucket.label);
function formatUserLabel(user) {
return user?.displayName || user?.username || user?.id || '-';
}
......@@ -64,24 +62,12 @@ function StatKpi({ icon: Icon, label, tone, value }) {
);
}
function TooltipContent({ active, colorForEntry, label, payload, valueFormatter }) {
return (
<ChartTooltipContent
active={active}
label={label}
payload={colorizeXinyuanChartTooltipPayload(payload, colorForEntry)}
valueFormatter={valueFormatter}
/>
);
}
function ChartCard({ children, description, title }) {
function ChartCard({ children, title }) {
return (
<Card>
<Card.Header>
<div className="min-w-0">
<Card.Title>{title}</Card.Title>
<Card.Description>{description}</Card.Description>
<Card.Title className="rc-module-title">{title}</Card.Title>
</div>
</Card.Header>
<Card.Content>{children}</Card.Content>
......@@ -100,125 +86,79 @@ function ChartEmptyState() {
}
function StatusPieChart({ data, total }) {
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name));
return (
<PieChart className="rc-chart-interaction" height={320}>
<PieChart.Pie
data={data}
dataKey="value"
innerRadius="52%"
nameKey="name"
outerRadius="72%"
paddingAngle={2}
>
{data.map((item, index) => (
<PieChart.Cell
key={item.name}
fill={colorMap.get(item.name) || getXinyuanChartColor(item.name, index)}
/>
))}
<PieChart.Label
content={({ viewBox }) => {
const { cx = 0, cy = 0 } = viewBox || {};
return (
<text textAnchor="middle" x={cx} y={cy}>
<tspan fill="var(--foreground)" fontSize="24" fontWeight="600" x={cx} dy="-0.1em">
{total}
</tspan>
<tspan fill="var(--muted)" fontSize="12" x={cx} dy="1.6em">
项目总数
</tspan>
</text>
);
}}
position="center"
/>
</PieChart.Pie>
<PieChart.Tooltip
content={
<TooltipContent
colorForEntry={({ index, key }) => colorMap.get(String(key)) || getXinyuanChartColor(key, index)}
valueFormatter={(value) => `${value} 个`}
/>
}
/>
</PieChart>
);
const option = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const colorMap = createResolvedXinyuanChartColorMap(data.map((item) => item.name), {
order: STATUS_CHART_ORDER,
});
return {
legend: { bottom: 0, left: 'center' },
series: [{
type: 'pie',
name: '项目状态',
radius: ['52%', '72%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
data: data.map((item) => ({ ...item, itemStyle: { color: colorMap.get(item.name) } })),
label: { show: true, formatter: ({ name, value }) => [
`{name|${name}}`,
`{value|${value} 个}`,
].join('\n'), rich: {
name: { color: theme.label, fontSize: 12, lineHeight: 16, fontWeight: 400 },
value: { color: theme.foreground, fontSize: 13, fontWeight: 600, lineHeight: 18 },
} },
labelLayout: { hideOverlap: true, moveOverlap: 'shiftY' },
labelLine: { show: true, length: 14, length2: 16 },
}],
tooltip: { trigger: 'item', valueFormatter: (value) => `${value} 个` },
graphic: [{ type: 'text', left: 'center', top: '37%', style: {
text: [`{value|${total}}`, '{name|项目总数}'].join('\n'),
textAlign: 'center',
rich: {
value: { fill: theme.foreground, fontSize: 24, fontWeight: 600, lineHeight: 30 },
name: { fill: theme.label, fontSize: 12, fontWeight: 400, lineHeight: 18 },
},
} }],
};
}, [data, total]);
return <XinyuanEChart ariaLabel="项目状态分布图" option={option} />;
}
function VerticalBarChart({ data, colorKey = 'name' }) {
const colorMap = createXinyuanChartColorMap(data.map((item) => item[colorKey]));
return (
<BarChart className="rc-chart-interaction" data={data} height={320}>
<BarChart.Grid vertical={false} />
<BarChart.XAxis dataKey="name" />
<BarChart.YAxis allowDecimals={false} />
<BarChart.Tooltip
content={
<TooltipContent
colorForEntry={({ index, sourceDatum }) =>
colorMap.get(sourceDatum?.[colorKey]) || getXinyuanChartColor(sourceDatum?.[colorKey], index)
}
valueFormatter={(value) => `${value} 个`}
/>
}
/>
<BarChart.Bar dataKey="value" maxBarSize={34} name="项目数" radius={[4, 4, 0, 0]}>
{data.map((item, index) => (
<Cell
key={item[colorKey]}
fill={colorMap.get(item[colorKey]) || getXinyuanChartColor(item[colorKey], index)}
/>
))}
</BarChart.Bar>
</BarChart>
);
const option = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const colorMap = createResolvedXinyuanChartColorMap(data.map((item) => item[colorKey]), {
order: colorKey === 'name' && data.some((item) => RESULT_CHART_ORDER.includes(item.name))
? RESULT_CHART_ORDER
: PROGRESS_CHART_ORDER,
});
return {
grid: { left: 12, right: 12, top: 12, bottom: 20, containLabel: true },
xAxis: { type: 'category', data: data.map((item) => item.name), axisTick: { show: false } },
yAxis: { type: 'value', minInterval: 1 },
series: [{ type: 'bar', name: '项目数', barMaxWidth: 34, data: data.map((item) => ({ value: item.value, itemStyle: { color: colorMap.get(item[colorKey]) || theme.palette[0], borderRadius: [6, 6, 0, 0] } })) }],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (value) => `${value} 个` },
};
}, [colorKey, data]);
return <XinyuanEChart ariaLabel="项目数量柱状图" option={option} />;
}
function MemberLoadChart({ data }) {
return (
<ComposedChart
className="rc-chart-interaction"
data={data}
height={320}
margin={{ left: 8, right: 8, top: 24 }}
>
<ComposedChart.Grid vertical={false} />
<ComposedChart.XAxis dataKey="name" />
<ComposedChart.YAxis allowDecimals={false} />
<ComposedChart.Tooltip
content={
<TooltipContent
colorForEntry={({ entry }) => ({
totalProjects: XINYUAN_CHART_SEMANTIC.primary,
inProgressProjects: XINYUAN_CHART_SEMANTIC.comparison,
overdueProjects: XINYUAN_CHART_SEMANTIC.risk,
})[entry?.dataKey]}
valueFormatter={(value) => `${value} 个`}
/>
}
/>
<ComposedChart.Bar
dataKey="totalProjects"
fill={XINYUAN_CHART_SEMANTIC.primary}
maxBarSize={34}
name="参与项目"
radius={[4, 4, 0, 0]}
/>
<ComposedChart.Line
dataKey="inProgressProjects"
name="进行中"
stroke={XINYUAN_CHART_SEMANTIC.comparison}
strokeWidth={2}
/>
<ComposedChart.Line
dataKey="overdueProjects"
name="超时"
stroke={XINYUAN_CHART_SEMANTIC.risk}
strokeWidth={2}
/>
</ComposedChart>
);
const option = useMemo(() => {
const theme = getXinyuanEChartsTheme();
return {
grid: { left: 12, right: 12, top: 24, bottom: 20, containLabel: true },
xAxis: { type: 'category', data: data.map((item) => item.name), axisTick: { show: false } },
yAxis: { type: 'value', minInterval: 1 },
series: [
{ type: 'bar', name: '参与项目', data: data.map((item) => item.totalProjects), barMaxWidth: 34, itemStyle: { color: theme.palette[0], borderRadius: [6, 6, 0, 0] } },
{ type: 'line', name: '进行中', data: data.map((item) => item.inProgressProjects), smooth: true, itemStyle: { color: theme.palette[13] }, lineStyle: { color: theme.palette[13], width: 2 } },
{ type: 'line', name: '超时', data: data.map((item) => item.overdueProjects), smooth: true, itemStyle: { color: theme.palette[1] }, lineStyle: { color: theme.palette[1], width: 2 } },
],
tooltip: { trigger: 'axis' },
};
}, [data]);
return <XinyuanEChart ariaLabel="成员负载趋势图" option={option} />;
}
export default function ResourceStatPage({ currentUsername = '' }) {
......@@ -310,10 +250,10 @@ export default function ResourceStatPage({ currentUsername = '' }) {
}, [currentUserAliases, tasks, users]);
const memberColumns = useMemo(() => [
{ id: 'name', header: '成员', accessorKey: 'name', isRowHeader: true, minWidth: 180, width: '1fr' },
{ id: 'totalProjects', header: '参与项目', accessorKey: 'totalProjects', align: 'center', minWidth: 112 },
{ id: 'inProgressProjects', header: '进行中', accessorKey: 'inProgressProjects', align: 'center', minWidth: 112 },
{ id: 'overdueProjects', header: '超时', accessorKey: 'overdueProjects', align: 'center', minWidth: 112 },
{ id: 'name', header: '成员', accessorKey: 'name', isRowHeader: true, minWidth: 180, width: '1fr', cell: (row) => <span className="rc-data-grid-primary">{row.name}</span> },
{ id: 'totalProjects', header: '参与项目', accessorKey: 'totalProjects', align: 'center', minWidth: 112, cell: (row) => <span className="rc-data-grid-primary">{row.totalProjects}</span> },
{ id: 'inProgressProjects', header: '进行中', accessorKey: 'inProgressProjects', align: 'center', minWidth: 112, cell: (row) => <span className="rc-data-grid-primary">{row.inProgressProjects}</span> },
{ id: 'overdueProjects', header: '超时', accessorKey: 'overdueProjects', align: 'center', minWidth: 112, cell: (row) => <span className="rc-data-grid-primary">{row.overdueProjects}</span> },
], []);
if (loading) {
......@@ -357,22 +297,22 @@ export default function ResourceStatPage({ currentUsername = '' }) {
</div>
<div className="grid gap-6 xl:grid-cols-2">
<ChartCard title="项目状态分布" description="按项目状态汇总当前项目数量。">
<ChartCard title="项目状态分布">
{dashboard.statusGroups.length ? <StatusPieChart data={dashboard.statusGroups} total={dashboard.total} /> : <ChartEmptyState />}
</ChartCard>
<ChartCard title="结果状态分布" description="按计划与实际完成时间派生结果状态。">
<ChartCard title="结果状态分布">
{dashboard.resultStatusGroups.length ? <VerticalBarChart data={dashboard.resultStatusGroups} /> : <ChartEmptyState />}
</ChartCard>
<ChartCard title="进度区间分布" description="按 completion_percent 分桶展示推进层级。">
<ChartCard title="进度区间分布">
{dashboard.progressBuckets.some((item) => item.value > 0) ? <VerticalBarChart data={dashboard.progressBuckets} /> : <ChartEmptyState />}
</ChartCard>
<ChartCard title="成员负载 Top 8" description="按负责人和协作人的关联项目数汇总。">
<ChartCard title="成员负载 Top 8">
{dashboard.memberLoad.length ? <MemberLoadChart data={dashboard.memberLoad.slice(0, 8)} /> : <ChartEmptyState />}
</ChartCard>
</div>
<section className="grid gap-2">
<h2 className="text-base font-medium">成员负载明细</h2>
<h2 className="rc-module-title">成员负载明细</h2>
<DataGrid
aria-label="成员负载明细"
columns={memberColumns}
......@@ -386,12 +326,12 @@ export default function ResourceStatPage({ currentUsername = '' }) {
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<Card.Header><Card.Title>即将到期</Card.Title><Card.Description>按计划完成日期升序展示。</Card.Description></Card.Header>
<Card.Header><Card.Title className="rc-module-title">即将到期</Card.Title></Card.Header>
<Card.Content className="grid gap-3">
{dashboard.upcoming.length ? dashboard.upcoming.map((task) => (
<Surface key={task._id || task.id || task.name} className="rc-nested-surface p-4" variant="secondary">
<div className="font-medium">{task.projectName}</div>
<div className="mt-1 text-sm text-muted">
<div className="rc-data-grid-primary">{task.projectName}</div>
<div className="rc-data-grid-secondary mt-1">
截止 {formatDateLabel(task.plannedEndDate)} · {task.projectStatusLabel} · {task.resultStatusLabel} · {formatPercent(task.progressPercent)}
</div>
</Surface>
......@@ -399,12 +339,12 @@ export default function ResourceStatPage({ currentUsername = '' }) {
</Card.Content>
</Card>
<Card>
<Card.Header><Card.Title>超时清单</Card.Title><Card.Description>按超时天数倒序展示。</Card.Description></Card.Header>
<Card.Header><Card.Title className="rc-module-title">超时清单</Card.Title></Card.Header>
<Card.Content className="grid gap-3">
{dashboard.overdueList.length ? dashboard.overdueList.map((task) => (
<Surface key={task._id || task.id || task.name} className="rc-nested-surface p-4" variant="secondary">
<div className="font-medium">{task.projectName}</div>
<div className="mt-1 text-sm text-muted">
<div className="rc-data-grid-primary">{task.projectName}</div>
<div className="rc-data-grid-secondary mt-1">
超时 {task.overtimeDays} 天 · {task.projectStatusLabel} · {task.resultStatusLabel}
</div>
</Surface>
......
......@@ -53,8 +53,8 @@ body .login-page {
--rc-login-field-filled-background: #fbf1f1;
--rc-login-field-filled-background-hover: #faeaea;
--rc-login-field-autofill-background: #fbf1f1;
--rc-login-field-border-hover: rgb(158 27 27 / 24%);
--rc-login-field-focus-ring: rgb(158 27 27 / 16%);
--rc-login-field-border-hover: rgb(218 36 26 / 24%);
--rc-login-field-focus-ring: rgb(218 36 26 / 16%);
}
body .login-brand img {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment