Commit 3ff6b4e1 authored by Andy-bubu's avatar Andy-bubu

feat: migrate UI to Xinyuan HeroUI design system

parent 48dc0d0a
...@@ -19,14 +19,12 @@ ...@@ -19,14 +19,12 @@
- **组件函数**:必须使用大驼峰形式,例如 `const UserHeader = () => { ... }`。 - **组件函数**:必须使用大驼峰形式,例如 `const UserHeader = () => { ... }`。
- **变量与普通函数**:使用小驼峰 (camelCase)。 - **变量与普通函数**:使用小驼峰 (camelCase)。
- **常量**:使用全大写下划线 (SNAKE_CASE)。 - **常量**:使用全大写下划线 (SNAKE_CASE)。
- **技术栈偏好**: - **技术栈要求**:
- 优先使用 React Hooks 和函数组件。 - 使用 React Hooks 和函数组件。
- 样式处理:**优先使用 Tailwind Plus / Catalyst UI Kit(见 `src/catalyst-ui-kit`)+ Tailwind CSS**。 - 页面和共享 UI 组件必须直接使用 `@heroui/react` 与 `@heroui-pro/react`。
- 交互组件:**优先使用 Headless UI**(已在 Catalyst 组件内封装,除非缺组件才直接用 `@headlessui/react`)。 - 禁止新增或扩展 `src/catalyst-ui-kit`、`@headlessui/react`、旧 UI 适配器和可见原生表单控件。
- 组件选择优先级(从高到低): - HeroUI 复合组件遵守官方 compound component 与 `slot` 结构,交互使用 `onPress`。
1. `src/catalyst-ui-kit/javascript/*`(Button/Input/Dialog/Table/Combobox/SidebarLayout/...) - DataGrid 使用 HeroUI Pro 原生选择、排序、分页、固定列和 ActionBar,不手写选择列。
2. 基于 Catalyst 样式规范的自定义组件(仅在 Kit 无现成组件时)
3. 原生 HTML + 自写样式(尽量避免)
## 4. 接口请求规范 ## 4. 接口请求规范
...@@ -39,12 +37,15 @@ ...@@ -39,12 +37,15 @@
- 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。 - 如果 `record-api.md` 或 `subjectsDefinition.md` 中的定义不清晰,请务必询问我,不要随意猜测字段名。
- 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。 - 每次生成代码后,简要说明你引用了哪个 API 和哪些数据对象。
## 6. UI 规范(Tailwind Plus / Headless UI 强制约束) ## 6. UI 规范(鑫元 HeroUI 强制约束)
- **全项目 UI 统一**:默认使用 `src/catalyst-ui-kit` 中的 Catalyst 组件与样式体系,避免引入其它 UI 框架。 - **全项目 UI 统一**:遵循 `xinyuan-design-system` 与 `docs/xinyuan-design-system.md`。
- **Headless UI 优先**:弹窗/下拉/选择器等交互优先用 Headless UI(直接或通过 Catalyst 组件)。 - **直接引用**:页面不得通过 Catalyst、Headless UI 或兼容包装层间接使用 HeroUI。
- **一致性**:表单控件优先用 Catalyst 的 `Input/Select/Checkbox/Textarea/Fieldset`,表格用 `Table`,对话框用 `Dialog`。 - **一致性**:表单使用 HeroUI,复杂表格和 Shell 使用 HeroUI Pro,弹窗使用 HeroUI Modal/AlertDialog。
- **可访问性**:移动端侧栏/弹层必须使用 Headless UI 的 Dialog 等可访问性组件(focus trap / esc 关闭等)。 - **默认外观**:除 `src/design-system/theme.css` 的主题变量和批准的 Login/AppShell 布局外,组件尺寸、圆角、边框、阴影、Hover、选中态均使用 HeroUI/HeroUI Pro 默认实现;禁止页面 CSS 重画组件。
- **主题唯一性**:`src/design-system/theme.css` 是组件主题唯一权威;`tokens.css` 只允许提供 Shell/Login 别名,不得覆盖 DataGrid、Tabs、Card、Modal、Checkbox、Pagination 或图表样式。
- **图标统一**:所有业务功能图标通过 `src/components/AppIcons.jsx` 使用 Ant Design Icons,禁止 Heroicons、Lucide、Iconify 和自绘功能图标。
- **可访问性**:保留 HeroUI/React Aria 的键盘、焦点、slot 与 Portal 结构,不以自写 DOM 模拟。
# AI 开发执行规范 # AI 开发执行规范
...@@ -54,12 +55,12 @@ ...@@ -54,12 +55,12 @@
| 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 | | 字段类型 (Type) | UI 组件 (Component) | 交互逻辑 / 备注 |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| **文本 (String)** | Catalyst `Input` | 标准文本输入 | | **文本 (String)** | HeroUI `TextField` + `Input` | 标准文本输入 |
| **引用 (Reference)** | `ReferenceSearchSelect`(Headless UI Combobox) | 必须通过 API 获取列表,选中后仅存储 `_id` | | **引用 (Reference)** | HeroUI `Popover` + `ListBox` 复合组件 | 必须通过 API 获取列表,选中后仅存储 `_id` |
| **数字 (Number)** | Catalyst `Input`(`type="number"`) | 仅允许输入数字 | | **数字 (Number)** | HeroUI `NumberField` / `Input` | 仅允许输入数字 |
| **日期 (Date)** | Catalyst `Input`(`type="date"` / `datetime-local`) | 提交时格式化为 `ISO 8601` 字符串 | | **日期 (Date)** | HeroUI `DatePicker` | 提交时格式化为 `ISO 8601` 字符串 |
| **布尔 (Boolean)** | Catalyst `Checkbox` | 映射为 true/false | | **布尔 (Boolean)** | HeroUI `Checkbox` | 映射为 true/false |
| **JSON** | Catalyst `Textarea` | 需要包含 JSON 校验逻辑 | | **JSON** | HeroUI `TextArea` | 需要包含 JSON 校验逻辑 |
| **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 | | **子对象 (Subject)** | **动态表单递归** | **核心逻辑:** 必须先调用 `loadSubject(subjectName)` 接口读取该子对象的字段定义,然后递归应用本映射表生成子表单。 |
--- ---
......
# 鑫元 UI 架构
## 组件边界
- 页面直接使用 `@heroui/react` 和 `@heroui-pro/react`。
- 禁止在 `catalyst-ui-kit`、Headless UI 或旧适配器内部嵌入 HeroUI。
- 禁止继续扩展 `catalyst-ui-kit`;新功能不得引用旧 UI 组件。
- 允许的共享组件只组合 HeroUI/HeroUI Pro 官方复合结构,不复刻旧 props,不重画组件外观。
- 业务数据、接口参数、权限、校验、保存回调和状态流转继续由页面与 service 层拥有。
## 标准实现
- DataGrid:HeroUI Pro `DataGrid` 原生 `selectedKeys`、`onSelectionChange`、`selectionMode`、`showSelectionCheckboxes`,配合原生固定列、Pagination 和 ActionBar。
- Pagination:直接使用 HeroUI compound API,不增加页容量 Select/Autocomplete。HeroUI 不定义每页条数,项目数据层统一使用 10 条/页并继续传递 `page_size=10`。
- DataGrid 宽度:共享层直接使用 HeroUI Pro `ColumnSize`。非宽表将第一业务主列设为 `1fr`,选择/操作列固定并以 `min-w-full` 保证完整宽度链;宽表保留业务最小像素宽度,仅在 DataGrid 内横向滚动。页面不得用背景、伪元素或空列填补右侧空白。
- DataGrid 行操作:共享 `TableActionButton` 直接组合 HeroUI `Button`、`Dropdown`、`Tooltip` 与 Ant Design Icons,不兼容旧按钮 API。普通操作使用 `secondary` 正圆,危险操作使用 `danger-soft` 正圆;32px 按钮、16px 图标、8px 间距并与表头左对齐,列宽按可见按钮数动态计算。外观状态由 HeroUI variant 与 `theme.css` 管理,布局类只保证圆心和 SVG 中心重合。
- Modal:HeroUI `Modal`/`AlertDialog` compound API,标题、正文和页脚上下结构,Portal 继承根主题。内容型新增表单与详情统一使用 `4xl`(1088px);同一表单的 Input、TextArea、DateField.Group、Autocomplete 和多选控件统一使用浅色 `primary`、深色 `secondary` 的 HeroUI field variant。提交约束仅在用户点击确认且校验失败后通过受控 `AlertDialog` 展示,不在表单中常驻。可编辑详情默认是查看态,Footer 右下的单一“编辑”主操作切换到整窗编辑,查看态仅通过右上关闭图标退出;草稿由弹窗统一持有,Footer 原位切换取消/保存并通过原更新服务一次提交,不允许字段级即时保存。
- 表单:HeroUI TextField、Input、TextArea、Autocomplete、SearchField、Checkbox、DatePicker;下拉型枚举、用户、引用、单选、多选、年份与页容量通过 `XinyuanAutocomplete.jsx` 组合官方 Autocomplete compound API,上传使用 HeroUI Pro DropZone。
- 表单布局:`4xl` 新增/编辑弹窗桌面使用等宽双列,普通输入、日期、单选和多选各占一列,只有长文本 TextArea 跨两列;移动端回退单列。跨列只由内容密度决定,不由 `multiple` 数据属性决定。
- Autocomplete:选择状态只使用 HeroUI 当前 `selectedKey` / `selectedKeys`、`selectionMode` 和 `onSelectionChange`,搜索状态只使用 `inputValue` / `onInputChange`。远程字段继续在业务层拥有防抖、请求和 ID-only 映射;共享组件不合成旧 DOM 事件、不复刻旧 Select API。
- Tabs:直接使用 HeroUI Tabs compound API。页面级、模块级和详情级都按文字内容确定各项宽度,只在 compound slot 的 `className` 上设置 `Tabs/ListContainer: w-fit max-w-full min-w-0`、`List: w-max min-w-max`、`Tab: w-auto min-w-max flex-none whitespace-nowrap`。标签始终单行完整展示,项目不得均分或固定 Tab 宽度,溢出只由 `Tabs.ListContainer` 的官方 `ScrollShadow` 处理。选中态、尺寸、圆角、颜色、阴影和动效采用官方默认,仅由 `theme.css` 提供主题值;禁止全局选择 `.tabs__*` 重画外观。
- 图表:HeroUI Pro PieChart、BarChart、ComposedChart、KPI 和 ChartTooltip;保留原统计数据与业务回调,不保留 ECharts 私有主题。
- 页面布局:`rc-app-page` 统一承担响应式页边距;页面根、模块、网格、工具栏和列表分别使用 `rc-page-stack`、`rc-page-section`、`rc-page-grid`、`rc-page-toolbar`、`rc-list-stack`。这些类只管理布局几何,不选择或覆盖 HeroUI 内部 DOM。
## 主题
`src/design-system/theme.css` 是权威主题,必须与 `xinyuan-design-system` 基线逐字节一致。加载顺序为 Tailwind、HeroUI、HeroUI Pro、`theme.css`、`tokens.css`、Shell/Login 样式。`tokens.css` 只提供 Shell/Login 所需的 `--rc-*` 别名,不覆盖 HeroUI 核心或组件 Token。内容页面禁止用 CSS 选择 HeroUI 内部 BEM 类、slot 状态或选中属性。
页面几何 Token 是允许的扩展:Navbar Header、HeroUI Pro 折叠按钮、主题按钮和页面内容共用 `--rc-shell-inline-gutter`,桌面为 48px、平板为 32px、手机为 16px,左右完全一致。页面一级模块、模块网格和列表内部节奏为 32/24/16px;手机端一级模块和网格收敛为 24/16px。页面不得新增另一套私有 gutter 或顶层 `space-y-*`。
## HeroUI Pro 交付
当前授权镜像版本为 `1.0.0-beta.7`。完整本地镜像已打包为仓库内的 `vendor/heroui-pro-react-1.0.0-beta.7.tgz`,`package.json` 使用 `file:vendor/heroui-pro-react-1.0.0-beta.7.tgz`,避免公共 npm 空壳包和跨项目符号链接。团队执行 `npm install --legacy-peer-deps` 即可获得同一包;后续如启用私有 npm,应保持相同版本与完整导出集合。
当前兼容运行时固定为 `@heroui/react@3.2.2`、`@heroui/styles@3.2.2`、`react-aria-components@1.20.0` 和 `@react-aria/utils@3.34.1`。`react-aria-components@1.20.0` 同时满足 HeroUI 的 `^1.19.0`、HeroUI Pro 的 `>=1.19.0` 和 Adobe Spectrum 的 `1.20.0`,安装结果仅保留根目录一个物理实例,避免 Checkbox、Autocomplete、ListBox、Table、Tabs 与 Modal 的 React Aria Context 分裂。
`src/catalyst-ui-kit` 仅作为未引用历史源码隔离保留。不得进入修改、不得重新导入,也不得通过它间接使用 HeroUI。`@headlessui/react` 和 `@heroicons/react` 不属于运行依赖。
运行时必须只有一套兼容的 HeroUI、React Aria 和 React。每次依赖变化后执行:
```bash
npm ls @heroui-pro/react @heroui/react @heroui/styles react-aria-components @react-aria/utils
```
该命令必须正常退出并显示 `deduped`,同时执行 Pro 子路径解析/导入校验和 `npm run build`。不要通过 npm `overrides` 强行降低 Adobe Spectrum 的 React Aria 版本;这会产生 `invalid` 依赖树。
# 鑫元 UI 迁移清单
状态:`待迁移`、`迁移中`、`待浏览器验收`、`完成`。
| 页面/模块 | 路由 | 当前来源 | 目标组件 | 必须保留的业务边界 | 状态 | 浏览器验收 |
| --- | --- | --- | --- | --- | --- | --- |
| 登录 | `/login` | HeroUI | HeroUI | 验证码、AES、redirect、错误回调 | 待浏览器验收 | 待验收 |
| AppShell | 全局 | HeroUI Pro | Sidebar、Navbar | 路由、主题、退出登录 | 待浏览器验收 | 待验收 |
| 应用切换 | 全局弹窗 | Catalyst Dialog | HeroUI Modal | 应用查询、当前项、跳转 | 待浏览器验收 | 待验收 |
| 驾驶舱 | `/integrity_risk_table/dashboard` | 混合 | HeroUI Pro KPI/PieChart | 查询与统计口径 | 待浏览器验收 | 待验收 |
| 整改分析 | `/integrity_risk_table/business_dashboard` | 混合 | HeroUI Pro BarChart/ComposedChart/PieChart | 查询与统计口径 | 待浏览器验收 | 待验收 |
| 问题整改 | `/problem_rectification` | Catalyst + HeroUI | HeroUI Pro DataGrid | 查询、分页、权限、批量与行操作 | 待浏览器验收 | 待验收 |
| 往期归档 | `/issue_rectification_archive` | 旧工作台 | HeroUI Pro DataGrid | 归档查询、详情、分页 | 待浏览器验收 | 待验收 |
| 整改处理 | `/problem_rectification_form` | Catalyst | HeroUI | 填写、复核、终审状态机 | 待浏览器验收 | 待验收 |
| 整改流程 | `/problem_rectification_flow_table` | 混合 | HeroUI Pro DataGrid | OA、通知、上传和审批状态机 | 待浏览器验收 | 待验收 |
| 详情与引用 | 全局弹窗 | Catalyst + 原生控件 | HeroUI Modal/ListBox | 字段权限、保存值和引用 ID | 待浏览器验收 | 待验收 |
## 验收门槛
- 已迁移范围的 Catalyst、Headless UI、旧适配器和可见原生控件引用为零。
- Checkbox 单选、取消、全选、半选和禁用状态无 Context/slot 错误。
- Autocomplete、SearchField、ListBox、Modal、Tooltip、ActionBar 在深浅主题和移动端正确继承 Portal 主题。
- DataGrid 横向滚动、固定列、分页、排序、选择和批量操作保持原业务结果。
- 登录、退出、路由、查询、新增、编辑、删除、保存和审批回调不变。
## 2026-08-18 静态复核
- `theme.css` 继续逐字节匹配鑫元权威主题;`tokens.css` 已删除 DataGrid、Modal、Checkbox、Tabs、Card 和阴影等组件外观覆盖,仅保留 Shell/Login 别名。
- 表格、筛选、Tabs、分页、卡片、弹窗和状态组件不再使用页面私有外观类,布局类只承担宽度、网格、间距、滚动和响应式。
- 驾驶舱和整改分析已移除 ECharts、私有 option、主题监听和 HTML Tooltip,改用 HeroUI Pro KPI、PieChart、BarChart、ComposedChart 与默认 ChartTooltip。
- 整改处理、整改流程、问题清单、详情与引用的页面私有旧色类已清零,状态、提示、空状态、卡片与 KPI 已切换到 HeroUI/HeroUI Pro 和鑫元语义 Token。
- 未启用的 `ResourceManagementUi.jsx`、`ModuleEmptyState.jsx` 已同步迁移,避免后续功能重新引用旧式表面。
- 业务页面与共享组件中的 Catalyst/Headless/Heroicons 引用、可见原生控件和旧调色板类扫描均为零;隔离保留的 `src/catalyst-ui-kit` 不属于运行引用范围。
- HeroUI Pro CSS 与六个要求的组件子路径可解析/导入,HeroUI/React Aria 依赖树为单套兼容版本。
- `npm run build` 与 `git diff --check` 已通过。
- 当前桌面会话没有可用浏览器连接,因此表格勾选、Portal、暗色切换和响应式仍保持“待浏览器验收”,不以构建结果替代。
- 本地枚举、表单单选/多选、远程用户/引用、统计年份和分页条数已统一为 HeroUI Autocomplete;活动业务代码的 Select、原生 select 与页面自建选择 Popover 扫描为零。
- Autocomplete 编译和静态 selection 契约检查已通过;打开态搜索、单选关闭、多选保留、清空、键盘焦点及深浅色 Portal 仍待真实浏览器验收。
- 页面布局已统一到流式 HeroUI 示例节奏:Navbar 操作与页面内容共用对称 gutter,桌面左右 48px、平板左右 32px、手机左右 16px;一级模块 32px、模块网格 24px、列表内部 16px。驾驶舱、整改分析、问题清单、归档、整改处理、整改流程和空状态页已接入共享语义布局类。
- Tabs 已统一为 HeroUI compound API 的内容宽度模式:每项宽度由自身文字和 HeroUI 默认内边距决定,不均分、不固定;所有标签 `flex-none + min-w-max + whitespace-nowrap`,不足时仅由 `Tabs.ListContainer` 内部滚动。没有覆盖 HeroUI 的颜色、圆角、指示器、阴影或选中态,选中 key 与切换回调保持原样。
- DataGrid 行操作已统一为 HeroUI `secondary` / `danger-soft` 正圆按钮,Ant Design 16px 图标采用三层网格居中,操作组左对齐且间距 8px;操作列宽度随 1/2/3 个可见入口计算为 80/112/144px,4 个及以上折叠为“前两个 + 更多”。业务回调、权限、禁用条件和操作排序未改,真实像素居中与 Tooltip/Dropdown 打开态仍待浏览器验收。
- 短列表宽度已改为 HeroUI Pro 原生 `1fr` 弹性列:第一业务主列吸收剩余空间,选择列、操作列和其他字段保留最小宽度,表格内容至少铺满 DataGrid。归档等少列列表不再出现右侧大块空白;真实宽表仍保留内部横向滚动。各视口的实际列分配仍待浏览器验收。
- 页容量选择器已从共享分页、问题清单和归档列表移除。分页栏只保留 HeroUI compound Pagination 内容,数据层默认并固定为 10 条/页;翻页与服务端 `page_size` 逻辑未改。
- 归档页的数字指标卡已从页面私有 `Card + Chip` 排版替换为 HeroUI Pro `KPI` compound API;标题在 Header,数值和语义图标由 Content 原生两列布局承载,不再显示右上角说明图标。统计口径与数值未改。
- 驾驶舱与归档页的 KPI 组已改用 16px 关联项间距;一级模块仍保持 32px,普通图表/模块网格仍保持 24px。HeroUI Pro KPI 内部 padding 和组件外观未覆盖。
- 列表筛选已改为条件数量自适应的 HeroUI 布局:1–2 个条件直接位于列表标题上方,3 个以上保留独立 Card;问题清单的 10 个条件首行展示 3 个等宽高频项,其余 7 项使用原生 HeroUI Disclosure 展开。筛选组采用 12px 间距、标签与控件 6px 间距,宽屏展开区为 4 列;该页原本已随筛选状态自动请求,已移除重复查询和清空图标入口,筛选值、请求参数与自动刷新时机未改。
- 筛选折叠入口不再使用手写条件渲染和箭头状态,已切换为 `Disclosure.Trigger + Disclosure.Indicator + Disclosure.Content`;真实展开动画、键盘操作、焦点状态、暗色表面和各视口布局仍待浏览器验收。
- 展开筛选区已限制在首行字段区域内并复用同一三列模板,6 个附加条件形成 3×2 对齐网格;右侧“收起”操作列不再参与展开字段的宽度分配。平板回退 2 列、手机回退 1 列。
- 筛选 Card 内的 Autocomplete、DateField.Group 和普通 Input 已改为根主题驱动的 HeroUI 官方 variant:浅色 `primary` 避免 secondary 灰度过深,暗色 `secondary` 避免字段表面与 Card 融为一体;未增加私有颜色、描边或主题覆盖。
- 问题清单“整改情况”列已提高到 420px(固定时 360px),使用两行换行和 HeroUI Tooltip 展示全文,避免内容越过单元格;所有 user 类型列表字段已改为 HeroUI `Avatar` + 姓名的紧凑身份展示,人员列至少 220px。
- 驾驶舱与整改分析的年度范围控件已去除重复“统计年份”可见标签,保留 `aria-label`,使用 144px 宽的 HeroUI Autocomplete `primary` 触发器;年份来源、默认值和切换回调未改变。
- 问题整改详情已调整为 HeroUI Modal 的 1088px 内容型宽度,28px inset、内部滚动和右上角全屏/关闭操作组;全屏图标不再落到标题下方,字段内容移除逐字段阴影 Card,改为紧凑两列标签/值网格。
- 问题整改详情已改为弹窗级查看/编辑状态:默认字段只读,单一“编辑”入口进入整窗编辑,Footer 统一“取消 / 保存”;取消恢复原记录,保存沿用原 `updateRecord` 和嵌套 payload 一次提交变更,视图禁用、锁定和只读字段不进入编辑。生产构建与静态状态检查已通过,真实点击与接口请求仍待浏览器验收。
- 新增问题整改记录弹窗已与详情统一为 HeroUI `4xl`(1088px);Input、TextArea、DateField.Group、Autocomplete 和多选控件共用浅色 `primary` / 深色 `secondary` variant 并铺满字段列。原“检查项目名称、问题简述至少填写一项”不再常驻,只有点击“确认新增”且原校验失败时才打开 HeroUI `AlertDialog`,关闭后保留草稿且不发送新增请求。已在真实浏览器验证 1280x720 浅色/深色和 390x844:桌面弹窗宽 1088px,移动端宽 358px且保留 16px安全边距;三类字段均为 36px高、同列等宽、同主题背景,校验失败期间未出现新增接口请求。
- 新增表单的短字段已统一为桌面等宽双列,责任部门等多选字段不再单独占满整行,并会与下一项整改进度配对;问题简述、整改情况和备注等长文本仍跨两列,移动端全部回退单列。真实浏览器已验证责任部门/整改进度同排各 508px,390px 视口下各字段依次回退为 302px 单列。
- 问题清单与归档列表的单一关键词字段已从筛选 Card 拆出,改为列表标题右侧的 HeroUI `SearchField secondary`;桌面保持 320–384px,手机换行全宽,原关键词值、自动请求和清空回调保持不变。
- 仍需在 1440x900、1024x768、768x1024 与 390x844 浏览器视口核验对齐线、换行、DataGrid 内部横向滚动和移动端无页面级横向溢出。
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -9,9 +9,8 @@ ...@@ -9,9 +9,8 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@headlessui/react": "^2.2.10", "@fontsource/inter": "^5.3.0",
"@heroicons/react": "^2.2.0", "@heroui-pro/react": "file:vendor/heroui-pro-react-1.0.0-beta.7.tgz",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "^3.2.2", "@heroui/react": "^3.2.2",
"@heroui/styles": "^3.2.2", "@heroui/styles": "^3.2.2",
"@internationalized/date": "^3.12.2", "@internationalized/date": "^3.12.2",
...@@ -30,15 +29,13 @@ ...@@ -30,15 +29,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": ">=8.6.0", "embla-carousel": ">=8.6.0",
"embla-carousel-react": ">=8.6.0", "embla-carousel-react": ">=8.6.0",
"maplibre-gl": ">=6.0.0", "maplibre-gl": ">=6.0.0",
"marked": ">=18.0.0", "marked": ">=18.0.0",
"motion": "^12.23.11", "motion": "^12.23.11",
"react": "^19.2.7", "react": "^19.2.7",
"react-aria-components": "^1.19.0", "react-aria-components": "^1.20.0",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-markdown": ">=10.1.0", "react-markdown": ">=10.1.0",
"react-resizable-panels": ">=4.10.0", "react-resizable-panels": ">=4.10.0",
......
import { useLayoutEffect, useState } from 'react'; import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react/toast'; import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application'; import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
......
...@@ -117,7 +117,9 @@ function SidebarContents({ ...@@ -117,7 +117,9 @@ function SidebarContents({
<Sidebar.Content> <Sidebar.Content>
{menuGroups.map((group) => ( {menuGroups.map((group) => (
<Sidebar.Group key={group.title}> <Sidebar.Group key={group.title}>
{group.showLabel !== false ? (
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
) : null}
<Sidebar.Menu aria-label={group.title}> <Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => { {group.items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
......
This diff is collapsed.
...@@ -2,30 +2,23 @@ import { Upload } from '@/components/AppIcons'; ...@@ -2,30 +2,23 @@ import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone'; import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react'; import { useCallback } from 'react';
function createFileChangeEvent(files) {
return {
target: { files },
currentTarget: { files },
};
}
export default function FileUploadField({ export default function FileUploadField({
accept, accept,
ariaLabel = '选择文件', ariaLabel = '选择文件',
className = '', className = '',
description = '将文件拖到此处,或从本地选择。', description = '将文件拖到此处,或从本地选择。',
disabled = false, isDisabled = false,
fileName = '', fileName = '',
onChange, onFilesChange,
triggerLabel = '选择文件', triggerLabel = '选择文件',
}) { }) {
const emitFiles = useCallback( const emitFiles = useCallback(
(files) => { (files) => {
if (!disabled) { if (!isDisabled) {
onChange?.(createFileChangeEvent(files)); onFilesChange?.(Array.from(files || []));
} }
}, },
[disabled, onChange], [isDisabled, onFilesChange],
); );
const handleDrop = useCallback( const handleDrop = useCallback(
...@@ -42,15 +35,15 @@ export default function FileUploadField({ ...@@ -42,15 +35,15 @@ export default function FileUploadField({
); );
return ( return (
<DropZone className={`rc-file-upload ${className}`.trim()}> <DropZone className={className}>
<DropZone.Area <DropZone.Area
aria-label={ariaLabel} aria-label={ariaLabel}
isDisabled={disabled} isDisabled={isDisabled}
onDrop={handleDrop} onDrop={handleDrop}
> >
<DropZone.Input <DropZone.Input
accept={accept} accept={accept}
disabled={disabled} disabled={isDisabled}
onSelect={emitFiles} onSelect={emitFiles}
/> />
<DropZone.Icon> <DropZone.Icon>
...@@ -60,7 +53,7 @@ export default function FileUploadField({ ...@@ -60,7 +53,7 @@ export default function FileUploadField({
<DropZone.Description> <DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description} {fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description> </DropZone.Description>
<DropZone.Trigger isDisabled={disabled}> <DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" /> <Upload aria-hidden="true" />
{triggerLabel} {triggerLabel}
</DropZone.Trigger> </DropZone.Trigger>
......
import { Label, ListBox, Select } from '@heroui/react'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import clsx from 'clsx';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_filter_empty__'; const EMPTY_OPTION_KEY = '__rc_filter_empty__';
...@@ -30,9 +28,15 @@ export default function FilterSelect({ ...@@ -30,9 +28,15 @@ export default function FilterSelect({
options = [], options = [],
placeholder = '请选择', placeholder = '请选择',
value = '', value = '',
variant,
}) { }) {
const normalizedOptions = useMemo( const normalizedOptions = useMemo(
() => options.map(normalizeOption), () =>
options.map(normalizeOption).map((option) => ({
...option,
id: option.value || EMPTY_OPTION_KEY,
isDisabled: option.disabled,
})),
[options], [options],
); );
const selectedValue = String(value ?? ''); const selectedValue = String(value ?? '');
...@@ -42,63 +46,20 @@ export default function FilterSelect({ ...@@ -42,63 +46,20 @@ export default function FilterSelect({
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue; const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue;
return ( return (
<Select <XinyuanAutocomplete
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
className={clsx('rc-filter-select', className)} className={className}
fullWidth
isDisabled={disabled} isDisabled={disabled}
emptyText={emptyText}
options={normalizedOptions}
placeholder={placeholder}
selectedKey={selectedKey} selectedKey={selectedKey}
variant="secondary" variant={variant}
onSelectionChange={(key) => { onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? ''); const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue); onChange?.(nextValue);
}} }}
> valueText={selectedOption?.label || placeholder}
<Select.Trigger className="rc-filter-select__trigger"> />
<Select.Value className="rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
!selectedOption && 'rc-filter-select__placeholder',
)}
title={selectedOption?.label || placeholder}
>
{selectedOption?.label || placeholder}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
</Select.Trigger>
<Select.Popover
className="rc-filter-select__popover"
placement="bottom start"
>
<ListBox
aria-label={ariaLabel || placeholder}
className="rc-filter-select__listbox"
>
{normalizedOptions.length > 0 ? (
normalizedOptions.map((option) => (
<ListBox.Item
key={option.value || EMPTY_OPTION_KEY}
id={option.value || EMPTY_OPTION_KEY}
className="rc-filter-select__option"
isDisabled={option.disabled}
textValue={option.label}
>
<Label
className="rc-filter-select__option-label"
title={option.label}
>
{option.label}
</Label>
<ListBox.ItemIndicator className="rc-filter-select__option-indicator" />
</ListBox.Item>
))
) : (
<div className="rc-filter-select__empty">{emptyText}</div>
)}
</ListBox>
</Select.Popover>
</Select>
); );
} }
import { Calendar as HeroCalendar } from '@heroui/react/calendar'; import {
import { DateField } from '@heroui/react/date-field'; Calendar as HeroCalendar,
import { DatePicker } from '@heroui/react/date-picker'; DateField,
import { TimeField } from '@heroui/react/time-field'; DatePicker,
I18nProvider,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date'; import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { I18nProvider } from '@heroui/react';
import clsx from 'clsx';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Calendar as CalendarIcon } from './AppIcons'; import { Calendar as CalendarIcon } from './AppIcons';
...@@ -99,6 +100,7 @@ export default function FormDatePicker({ ...@@ -99,6 +100,7 @@ export default function FormDatePicker({
required = false, required = false,
type = 'date', type = 'date',
value, value,
variant,
...props ...props
}) { }) {
const resolvedMode = const resolvedMode =
...@@ -127,7 +129,7 @@ export default function FormDatePicker({ ...@@ -127,7 +129,7 @@ export default function FormDatePicker({
<DatePicker <DatePicker
{...props} {...props}
aria-label={ariaLabel || '选择日期'} aria-label={ariaLabel || '选择日期'}
className={clsx('rc-form-date-picker', className)} className={['w-full', className].filter(Boolean).join(' ')}
granularity={resolvedGranularity} granularity={resolvedGranularity}
isDisabled={disabled} isDisabled={disabled}
isReadOnly={readOnly} isReadOnly={readOnly}
...@@ -139,24 +141,14 @@ export default function FormDatePicker({ ...@@ -139,24 +141,14 @@ export default function FormDatePicker({
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode)) emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
} }
> >
<DateField.Group <DateField.Group fullWidth variant={variant}>
className="rc-form-date-picker__group" <DateField.Input>
fullWidth
variant="secondary"
>
<DateField.Input className="rc-form-date-picker__input">
{(segment) => ( {(segment) => (
<DateField.Segment <DateField.Segment segment={segment} />
className="rc-form-date-picker__segment"
segment={segment}
/>
)} )}
</DateField.Input> </DateField.Input>
<DateField.Suffix className="rc-form-date-picker__suffix"> <DateField.Suffix>
<DatePicker.Trigger <DatePicker.Trigger aria-label="打开日期选择">
className="rc-form-date-picker__trigger"
aria-label="打开日期选择"
>
<DatePicker.TriggerIndicator> <DatePicker.TriggerIndicator>
<CalendarIcon /> <CalendarIcon />
</DatePicker.TriggerIndicator> </DatePicker.TriggerIndicator>
...@@ -164,11 +156,8 @@ export default function FormDatePicker({ ...@@ -164,11 +156,8 @@ export default function FormDatePicker({
</DateField.Suffix> </DateField.Suffix>
</DateField.Group> </DateField.Group>
<DatePicker.Popover <DatePicker.Popover placement="bottom start">
className="rc-form-date-picker__popover" <HeroCalendar>
placement="bottom start"
>
<HeroCalendar className="rc-form-date-picker__calendar">
<HeroCalendar.Header> <HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" /> <HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading /> <HeroCalendar.Heading />
...@@ -184,11 +173,11 @@ export default function FormDatePicker({ ...@@ -184,11 +173,11 @@ export default function FormDatePicker({
</HeroCalendar.Grid> </HeroCalendar.Grid>
</HeroCalendar> </HeroCalendar>
{resolvedMode === 'datetime' ? ( {resolvedMode === 'datetime' ? (
<div className="rc-form-date-picker__time-panel"> <div className="flex items-center justify-between gap-3 p-3">
<span className="rc-form-date-picker__time-label">时间</span> <span className="text-sm text-muted">时间</span>
<TimeField <TimeField
aria-label="选择时间" aria-label="选择时间"
className="rc-form-date-picker__time-field" className="w-36"
granularity="minute" granularity="minute"
hideTimeZone hideTimeZone
hourCycle={24} hourCycle={24}
...@@ -201,16 +190,10 @@ export default function FormDatePicker({ ...@@ -201,16 +190,10 @@ export default function FormDatePicker({
) )
} }
> >
<TimeField.Group <TimeField.Group fullWidth variant={variant}>
className="rc-form-date-picker__time-group" <TimeField.Input>
variant="secondary"
>
<TimeField.Input className="rc-form-date-picker__time-input">
{(segment) => ( {(segment) => (
<TimeField.Segment <TimeField.Segment segment={segment} />
className="rc-form-date-picker__segment"
segment={segment}
/>
)} )}
</TimeField.Input> </TimeField.Input>
</TimeField.Group> </TimeField.Group>
......
import { Label, ListBox, Select } from '@heroui/react'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import clsx from 'clsx';
import { Children, Fragment, isValidElement, useMemo } from 'react'; import { Children, Fragment, isValidElement, useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_form_select_empty__'; const EMPTY_OPTION_KEY = '__rc_form_select_empty__';
...@@ -69,7 +67,15 @@ export default function FormSelect({ ...@@ -69,7 +67,15 @@ export default function FormSelect({
value, value,
...props ...props
}) { }) {
const options = useMemo(() => normalizeOptions(children), [children]); const options = useMemo(
() =>
normalizeOptions(children).map((option) => ({
...option,
id: toOptionKey(option.value),
isDisabled: option.disabled,
})),
[children],
);
const emptyOption = options.find((option) => option.value === ''); const emptyOption = options.find((option) => option.value === '');
const resolvedPlaceholder = placeholder || emptyOption?.label || '请选择'; const resolvedPlaceholder = placeholder || emptyOption?.label || '请选择';
const selectedValues = multiple const selectedValues = multiple
...@@ -87,10 +93,6 @@ export default function FormSelect({ ...@@ -87,10 +93,6 @@ export default function FormSelect({
.join('、')} 等 ${selectedOptions.length} 项` .join('、')} 等 ${selectedOptions.length} 项`
: options.find((option) => option.value === selectedValues[0])?.label || : options.find((option) => option.value === selectedValues[0])?.label ||
resolvedPlaceholder; resolvedPlaceholder;
const hasSelection = multiple
? selectedOptions.length > 0
: selectedValues[0] !== '';
const emitChange = (nextValues) => { const emitChange = (nextValues) => {
const normalizedValues = nextValues.map(fromOptionKey); const normalizedValues = nextValues.map(fromOptionKey);
onChange?.({ onChange?.({
...@@ -104,73 +106,33 @@ export default function FormSelect({ ...@@ -104,73 +106,33 @@ export default function FormSelect({
}); });
}; };
const sharedProps = { const selectionProps = {};
...props,
'aria-label': ariaLabel || resolvedPlaceholder,
className: clsx('rc-form-select rc-filter-select', className),
fullWidth: true,
isDisabled: disabled,
isRequired: required,
selectionMode: multiple ? 'multiple' : 'single',
variant: 'secondary',
};
if (multiple) { if (multiple) {
sharedProps.selectedKeys = new Set(selectedValues.map(toOptionKey)); selectionProps.selectedKeys = new Set(selectedValues.map(toOptionKey));
sharedProps.onSelectionChange = (keys) => { selectionProps.onSelectionChange = (keys) => {
const nextKeys = const nextKeys =
keys === 'all' ? options.map((option) => option.value) : [...keys]; keys === 'all' ? options.map((option) => option.value) : [...keys];
emitChange(nextKeys); emitChange(nextKeys);
}; };
} else { } else {
sharedProps.selectedKey = toOptionKey(selectedValues[0]); selectionProps.selectedKey = toOptionKey(selectedValues[0]);
sharedProps.onSelectionChange = (key) => emitChange([key]); selectionProps.onSelectionChange = (key) => emitChange([key]);
} }
return ( return (
<Select {...sharedProps}> <XinyuanAutocomplete
<Select.Trigger className="rc-form-select__trigger rc-filter-select__trigger"> {...props}
<Select.Value className="rc-form-select__value rc-filter-select__value"> {...selectionProps}
<span
className={clsx(
'block min-w-0 truncate',
!hasSelection && 'rc-filter-select__placeholder',
)}
title={displayText}
>
{displayText}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
</Select.Trigger>
<Select.Popover
className="rc-form-select__popover rc-filter-select__popover"
placement="bottom start"
>
<ListBox
aria-label={ariaLabel || resolvedPlaceholder} aria-label={ariaLabel || resolvedPlaceholder}
className="rc-filter-select__listbox" className={className}
isDisabled={disabled}
isRequired={required}
name={name}
options={options}
placeholder={resolvedPlaceholder}
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
> valueText={displayText}
{options.map((option) => ( />
<ListBox.Item
key={toOptionKey(option.value)}
id={toOptionKey(option.value)}
className="rc-form-select__option rc-filter-select__option"
isDisabled={option.disabled}
textValue={option.label}
>
<Label
className="rc-filter-select__option-label"
title={option.label}
>
{option.label}
</Label>
<ListBox.ItemIndicator className="rc-filter-select__option-indicator" />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
); );
} }
import { ChevronDown } from '@/components/AppIcons'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
import { Button, Label, ListBox, Popover } from '@heroui/react'; import { Button, Label } from '@heroui/react';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
const DEFAULT_ID_LOOKUP_CHUNK_SIZE = 50; const DEFAULT_ID_LOOKUP_CHUNK_SIZE = 50;
...@@ -311,6 +310,7 @@ export default function ReferenceSearchSelect({ ...@@ -311,6 +310,7 @@ export default function ReferenceSearchSelect({
optionsAnchor, optionsAnchor,
showClearAction = true, showClearAction = true,
selectedSummaryMode = 'labels', selectedSummaryMode = 'labels',
variant,
}) { }) {
const rawSelectedValues = useMemo(() => { const rawSelectedValues = useMemo(() => {
if (multiple) { if (multiple) {
...@@ -528,10 +528,6 @@ export default function ReferenceSearchSelect({ ...@@ -528,10 +528,6 @@ export default function ReferenceSearchSelect({
]; ];
}, [options, selectedOptions]); }, [options, selectedOptions]);
const selectedSummaryTitle = selectedOptions
.map((option) => option.label)
.join('、');
const commitChange = (nextIds) => { const commitChange = (nextIds) => {
const normalized = uniqIds(nextIds); const normalized = uniqIds(nextIds);
...@@ -567,7 +563,7 @@ export default function ReferenceSearchSelect({ ...@@ -567,7 +563,7 @@ export default function ReferenceSearchSelect({
.join('、')} 等 ${selectedOptions.length} 项` .join('、')} 等 ${selectedOptions.length} 项`
: selectedOptions[0]?.label || placeholder; : selectedOptions[0]?.label || placeholder;
const listBoxSelectionProps = multiple const selectionProps = multiple
? { ? {
selectedKeys: new Set(selectedIds), selectedKeys: new Set(selectedIds),
onSelectionChange: (keys) => { onSelectionChange: (keys) => {
...@@ -577,9 +573,9 @@ export default function ReferenceSearchSelect({ ...@@ -577,9 +573,9 @@ export default function ReferenceSearchSelect({
}, },
} }
: { : {
selectedKeys: new Set(selectedIds[0] ? [selectedIds[0]] : []), selectedKey: selectedIds[0] || null,
onSelectionChange: (keys) => { onSelectionChange: (key) => {
const nextKey = keys === 'all' ? menuOptions[0]?.id : [...keys][0]; const nextKey = key ? String(key) : '';
commitChange(nextKey ? [String(nextKey)] : []); commitChange(nextKey ? [String(nextKey)] : []);
setActive(false); setActive(false);
}, },
...@@ -594,99 +590,48 @@ export default function ReferenceSearchSelect({ ...@@ -594,99 +590,48 @@ export default function ReferenceSearchSelect({
}; };
return ( return (
<span className="reference-search-select block min-w-0 w-full"> <XinyuanAutocomplete
<Popover isOpen={active} onOpenChange={handleOpenChange}> {...selectionProps}
<Button
aria-expanded={active}
aria-label={placeholder} aria-label={placeholder}
className="reference-search-select__shell rc-filter-select__trigger" emptyText="无匹配结果"
data-disabled={disabled ? 'true' : undefined} errorText={error}
inputValue={query}
isDisabled={disabled} isDisabled={disabled}
type="button" isLoading={loading}
variant="tertiary" isOpen={active}
> options={menuOptions}
<span className="rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
selectedIds.length === 0 && 'rc-filter-select__placeholder',
)}
title={selectedSummaryTitle || displayText}
>
{displayText}
</span>
</span>
<ChevronDown
className="rc-filter-select__indicator"
data-open={active ? 'true' : undefined}
/>
</Button>
<Popover.Content
className={clsx(
'rc-filter-options-popover reference-search-select__options',
optionsPanelClassName,
)}
placement={optionsAnchor || 'bottom start'} placement={optionsAnchor || 'bottom start'}
> placeholder={placeholder}
<Popover.Dialog> popoverClassName={optionsPanelClassName}
{allowClear ? ( popoverHeader={allowClear ? (
<div className="reference-search-select__popover-actions flex justify-end px-2 py-1"> <div className="flex justify-end px-3 pb-1">
<Button size="sm" variant="tertiary" onPress={clearSelection}> <Button size="sm" variant="tertiary" onPress={clearSelection}>
清空 清空
</Button> </Button>
</div> </div>
) : null} ) : null}
{error ? ( searchPlaceholder={placeholder}
<div className="rc-filter-options-popover__status rc-filter-options-popover__status--error">
{error}
</div>
) : null}
{loading ? (
<div className="rc-filter-options-popover__status">搜索中...</div>
) : null}
{!loading && !error && menuOptions.length === 0 ? (
<div className="rc-filter-options-popover__status">
无匹配结果
</div>
) : null}
{!loading && !error && menuOptions.length > 0 ? (
<ListBox
{...listBoxSelectionProps}
aria-label={placeholder}
className="rc-filter-select__listbox"
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
> shouldFilter={false}
{menuOptions.map((option) => ( valueText={displayText}
<ListBox.Item variant={variant}
id={option.id} onInputChange={setQuery}
key={option.id} onOpenChange={handleOpenChange}
className="rc-filter-option rc-filter-select__option" renderOption={(option) => (
textValue={option.label}
>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Label className="block truncate" title={option.label}> <Label className="block truncate" title={option.label}>
{option.label} {option.label}
</Label> </Label>
{option.description ? ( {option.description ? (
<div <div
className="rc-filter-option__description truncate text-xs" className="truncate text-xs text-muted"
title={option.description} title={option.description}
> >
{option.description} {option.description}
</div> </div>
) : null} ) : null}
</div> </div>
<ListBox.ItemIndicator /> )}
</ListBox.Item> />
))}
</ListBox>
) : null}
</Popover.Dialog>
</Popover.Content>
</Popover>
</span>
); );
} }
import { ChevronDown } from '@/components/AppIcons'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Button, Input, Label, ListBox, Popover } from '@heroui/react'; import { Button } from '@heroui/react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
export default function ResourceFilterMultiSelect({ export default function ResourceFilterMultiSelect({
...@@ -8,8 +8,8 @@ export default function ResourceFilterMultiSelect({ ...@@ -8,8 +8,8 @@ export default function ResourceFilterMultiSelect({
onChange, onChange,
placeholder, placeholder,
disabled = false, disabled = false,
searchable = false,
searchPlaceholder = '输入关键字搜索', searchPlaceholder = '输入关键字搜索',
variant,
}) { }) {
const [searchKeyword, setSearchKeyword] = useState(''); const [searchKeyword, setSearchKeyword] = useState('');
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
...@@ -17,16 +17,15 @@ export default function ResourceFilterMultiSelect({ ...@@ -17,16 +17,15 @@ export default function ResourceFilterMultiSelect({
const selectedOptions = options.filter((option) => const selectedOptions = options.filter((option) =>
selectedValues.includes(option.value), selectedValues.includes(option.value),
); );
const normalizedSearchKeyword = searchKeyword.trim().toLowerCase(); const autocompleteOptions = useMemo(
const visibleOptions = useMemo(() => { () =>
if (!searchable || !normalizedSearchKeyword) return options; options.map((option) => ({
return options.filter((option) => ...option,
`${option.label || ''} ${option.value || ''}` id: option.value,
.trim() textValue: `${option.label || ''} ${option.value || ''}`.trim(),
.toLowerCase() })),
.includes(normalizedSearchKeyword), [options],
); );
}, [normalizedSearchKeyword, options, searchable]);
const summaryText = const summaryText =
selectedOptions.length === 0 selectedOptions.length === 0
? placeholder ? placeholder
...@@ -47,10 +46,7 @@ export default function ResourceFilterMultiSelect({ ...@@ -47,10 +46,7 @@ export default function ResourceFilterMultiSelect({
); );
const handleClearSelection = useCallback( const handleClearSelection = useCallback(
(event) => { () => {
event?.preventDefault?.();
event?.stopPropagation?.();
if (disabled || selectedValues.length === 0) { if (disabled || selectedValues.length === 0) {
return; return;
} }
...@@ -69,102 +65,36 @@ export default function ResourceFilterMultiSelect({ ...@@ -69,102 +65,36 @@ export default function ResourceFilterMultiSelect({
}, []); }, []);
return ( return (
<span className="resource-filter-multi-select rc-filter-select block min-w-0 w-full"> <XinyuanAutocomplete
<Popover isOpen={isOpen} onOpenChange={handleOpenChange}>
<Button
aria-expanded={isOpen}
aria-label={placeholder} aria-label={placeholder}
className="resource-filter-multi-select__trigger rc-filter-select__trigger" emptyText="没有匹配选项"
data-disabled={disabled ? 'true' : undefined} inputValue={searchKeyword}
isDisabled={disabled} isDisabled={disabled}
type="button" isOpen={isOpen}
variant="tertiary" options={autocompleteOptions}
> placeholder={placeholder}
<span className="rc-filter-select__value"> popoverHeader={(
<span <div className="flex items-center justify-between gap-3 px-3 pb-1">
className={ <span className="text-xs text-muted">可多选</span>
selectedOptions.length > 0
? 'block truncate'
: 'rc-filter-select__placeholder block truncate'
}
title={summaryText}
>
{summaryText}
</span>
</span>
<ChevronDown
className="rc-filter-select__indicator"
data-open={isOpen ? 'true' : undefined}
/>
</Button>
<Popover.Content
className="resource-filter-multi-select__popover rc-filter-options-popover"
placement="bottom start"
>
<Popover.Dialog>
<div className="flex items-center justify-between gap-3 px-2 py-1">
<span className="text-xs text-[var(--rc-text-muted)]">可多选</span>
<Button <Button
isDisabled={disabled || selectedValues.length === 0} isDisabled={disabled || selectedValues.length === 0}
size="sm" size="sm"
type="button" type="button"
variant="tertiary" variant="tertiary"
onClick={handleClearSelection}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPress={handleClearSelection} onPress={handleClearSelection}
> >
清空 清空
</Button> </Button>
</div> </div>
{searchable ? ( )}
<div className="px-2 py-2"> searchPlaceholder={searchPlaceholder}
<Input
aria-label={searchPlaceholder}
fullWidth
placeholder={searchPlaceholder}
type="search"
value={searchKeyword}
variant="secondary"
onChange={(event) => setSearchKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault();
}}
/>
</div>
) : null}
{visibleOptions.length > 0 ? (
<ListBox
aria-label={placeholder}
className="rc-filter-select__listbox"
selectedKeys={new Set(selectedValues)} selectedKeys={new Set(selectedValues)}
selectionMode="multiple" selectionMode="multiple"
valueText={summaryText}
variant={variant}
onInputChange={setSearchKeyword}
onOpenChange={handleOpenChange}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
> />
{visibleOptions.map((option) => (
<ListBox.Item
id={option.value}
key={option.value}
className="rc-filter-option resource-filter-multi-select__option"
textValue={option.label}
>
<Label className="truncate" title={option.label}>
{option.label}
</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
) : (
<div className="rc-filter-options-popover__status">
没有匹配选项
</div>
)}
</Popover.Dialog>
</Popover.Content>
</Popover>
</span>
); );
} }
This diff is collapsed.
...@@ -9,59 +9,21 @@ import { ...@@ -9,59 +9,21 @@ import {
More, More,
Send, Send,
} from '@/components/AppIcons'; } from '@/components/AppIcons';
import { Button, Dropdown, Label, Tooltip } from '@heroui/react'; import {
Button,
Dropdown,
Label,
Tooltip,
buttonVariants,
} from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Children, Fragment, isValidElement } from 'react'; import { Children, Fragment, isValidElement } from 'react';
const STICKY_ACTION_BASE_HEADER_CLASS = const ACTION_ICON_CLASS =
'sticky right-0 z-10 bg-[var(--rc-data-grid-header-background)] text-left'; 'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0';
const STICKY_ACTION_BASE_CELL_CLASS =
'sticky right-0 z-[1] bg-[var(--rc-bg-surface)] group-hover:bg-[var(--rc-data-grid-row-hover-background)] hover:z-40 focus-within:z-40';
const STICKY_FIRST_COLUMN_BASE_HEADER_CLASS =
'sticky left-0 z-20 bg-[var(--rc-data-grid-header-background)] text-left';
const STICKY_FIRST_COLUMN_BASE_CELL_CLASS =
'sticky left-0 z-[2] bg-[var(--rc-bg-surface)] group-hover:bg-[var(--rc-data-grid-row-hover-background)] hover:z-30 focus-within:z-30';
const ACTION_COLUMN_WIDTHS = {
icon: 'w-[4.5rem] min-w-[4.5rem]',
single: 'w-[5rem] min-w-[5rem]',
double: 'w-[7rem] min-w-[7rem]',
compact: 'w-[9rem] min-w-[9rem]',
medium: 'w-[11rem] min-w-[11rem]',
wide: 'w-[14rem] min-w-[14rem]',
xwide: 'w-[18rem] min-w-[18rem]',
};
export const PROJECT_MANAGER_LIST_TABLE_CLASS =
'rounded-lg border border-zinc-200 bg-white text-sm text-zinc-700 [&_thead]:bg-zinc-50/70 [&_thead_th]:border-b [&_thead_th]:border-zinc-200 [&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-sm [&_thead_th]:font-semibold [&_thead_th]:text-zinc-500 [&_tbody_td]:px-4 [&_tbody_td]:text-sm [&_tbody_td]:text-zinc-700';
export const PROJECT_MANAGER_LIST_ROW_CLASS =
'group border-b border-zinc-100 transition-colors [&>td]:bg-zinc-50 [&>td]:py-3.5 hover:[&>td]:bg-zinc-100';
export const PROJECT_MANAGER_LIST_EDITABLE_CONTROL_CLASS =
'[&_input]:h-9 [&_input]:rounded-md [&_input]:border-zinc-200 [&_input]:bg-zinc-50/60 [&_input]:text-sm [&_input]:font-normal [&_input]:text-zinc-600 [&_input]:placeholder-zinc-400 [&_input]:shadow-none [&_input:disabled]:bg-zinc-50 [&_input:disabled]:text-zinc-400 [&_select]:h-9 [&_select]:rounded-md [&_select]:border-zinc-200 [&_select]:bg-zinc-50/60 [&_select]:text-sm [&_select]:text-zinc-600 [&_select]:shadow-none [&_select:disabled]:bg-zinc-50 [&_select:disabled]:text-zinc-400';
const ACTION_TONE_CLASSES = {
approve: 'pm-table-action--approve',
delete: 'pm-table-action--danger',
edit: 'pm-table-action--default',
history: 'pm-table-action--default',
reject: 'pm-table-action--danger',
revoke: 'pm-table-action--warning',
submit: 'pm-table-action--primary',
view: 'pm-table-action--default',
};
const ACTION_COLOR_TONE = { function resolveActionTone({ icon, tone }) {
blue: 'submit',
green: 'approve',
red: 'delete',
};
function resolveActionTone({ color, icon, label, outline, tone }) {
if (tone) { if (tone) {
return tone; return tone;
} }
...@@ -74,37 +36,7 @@ function resolveActionTone({ color, icon, label, outline, tone }) { ...@@ -74,37 +36,7 @@ function resolveActionTone({ color, icon, label, outline, tone }) {
return iconTone; return iconTone;
} }
if (/删除|移除|作废|清退|delete|remove|trash/i.test(String(label || ''))) { return 'view';
return 'delete';
}
if (/驳回|拒绝|reject/i.test(String(label || ''))) {
return 'reject';
}
return ACTION_COLOR_TONE[color] || (outline ? 'edit' : 'view');
}
export function getStickyActionHeaderClass(size = 'medium') {
return clsx(
STICKY_ACTION_BASE_HEADER_CLASS,
ACTION_COLUMN_WIDTHS[size] || ACTION_COLUMN_WIDTHS.medium,
);
}
export function getStickyActionCellClass(size = 'medium') {
return clsx(
STICKY_ACTION_BASE_CELL_CLASS,
ACTION_COLUMN_WIDTHS[size] || ACTION_COLUMN_WIDTHS.medium,
);
}
export function getStickyFirstColumnHeaderClass(className) {
return clsx(STICKY_FIRST_COLUMN_BASE_HEADER_CLASS, className);
}
export function getStickyFirstColumnCellClass(className) {
return clsx(STICKY_FIRST_COLUMN_BASE_CELL_CLASS, className);
} }
export const TABLE_ACTION_ICONS = { export const TABLE_ACTION_ICONS = {
...@@ -163,52 +95,59 @@ export function TableActionGroup({ className, children, ...props }) { ...@@ -163,52 +95,59 @@ export function TableActionGroup({ className, children, ...props }) {
<div <div
{...props} {...props}
className={clsx( className={clsx(
'icon-actions flex flex-nowrap items-center justify-end gap-1.5 whitespace-nowrap', 'icon-actions flex flex-nowrap items-center justify-start gap-2 whitespace-nowrap',
className, className,
)} )}
> >
{visibleActions} {visibleActions}
{overflowActions.length > 0 ? ( {overflowActions.length > 0 ? (
<Dropdown> <Dropdown>
<Tooltip delay={0}>
<Dropdown.Trigger <Dropdown.Trigger
className="pm-table-action pm-table-action--default inline-flex size-8 items-center justify-center rounded-full"
aria-label="更多操作" aria-label="更多操作"
className={clsx(
buttonVariants({
isIconOnly: true,
size: 'sm',
variant: 'secondary',
}),
ACTION_BUTTON_LAYOUT_CLASS,
)}
title="更多操作" title="更多操作"
> >
<More size={16} /> <More className={ACTION_ICON_CLASS} size={16} aria-hidden="true" />
</Dropdown.Trigger> </Dropdown.Trigger>
<Tooltip.Content>更多操作</Tooltip.Content> <Dropdown.Popover placement="bottom end">
</Tooltip>
<Dropdown.Popover className="rc-dropdown-popover min-w-36" placement="bottom end">
<Dropdown.Menu <Dropdown.Menu
aria-label="更多操作" aria-label="更多操作"
onAction={(key) => overflowActions[Number(key)]?.props?.onClick?.()} onAction={(key) => overflowActions[Number(key)]?.props?.onPress?.()}
> >
{overflowActions.map((action, index) => { {overflowActions.map((action, index) => {
const label = action?.props?.label || `操作 ${index + 3}`; const label = action?.props?.label;
if (!label) {
return null;
}
const Icon = action?.props?.icon; const Icon = action?.props?.icon;
const actionTone = resolveActionTone({ const actionTone = resolveActionTone({
color: action?.props?.color,
icon: Icon, icon: Icon,
label,
outline: action?.props?.outline,
tone: action?.props?.tone, tone: action?.props?.tone,
}); });
const isDanger = ['delete', 'reject'].includes(actionTone); const isDanger = ['delete', 'reject'].includes(actionTone);
return ( return (
<Dropdown.Item <Dropdown.Item
id={String(index)} id={String(index)}
isDisabled={action?.props?.disabled} isDisabled={action?.props?.isDisabled}
key={`${label}-${index}`} key={`${label}-${index}`}
className={clsx( className={isDanger ? 'text-danger' : undefined}
'rc-dropdown-option rc-table-action-menu-item',
isDanger && 'rc-table-action-menu-item--danger',
)}
textValue={label} textValue={label}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{Icon ? <Icon className="size-4" aria-hidden="true" /> : null} {Icon ? (
<Icon
className={ACTION_ICON_CLASS}
size={16}
aria-hidden="true"
/>
) : null}
<Label>{label}</Label> <Label>{label}</Label>
</span> </span>
</Dropdown.Item> </Dropdown.Item>
...@@ -224,20 +163,15 @@ export function TableActionGroup({ className, children, ...props }) { ...@@ -224,20 +163,15 @@ export function TableActionGroup({ className, children, ...props }) {
export function TableActionButton({ export function TableActionButton({
className, className,
color, isDisabled,
disabled,
icon: Icon, icon: Icon,
label, label,
onClick, onPress,
outline = false,
tone, tone,
...props ...props
}) { }) {
const resolvedTone = resolveActionTone({ const resolvedTone = resolveActionTone({
color,
icon: Icon, icon: Icon,
label,
outline,
tone, tone,
}); });
...@@ -248,17 +182,21 @@ export function TableActionButton({ ...@@ -248,17 +182,21 @@ export function TableActionButton({
isIconOnly isIconOnly
aria-label={label} aria-label={label}
title={label} title={label}
isDisabled={disabled} isDisabled={isDisabled}
size="sm" size="sm"
variant="ghost" variant={
onClick={onClick} ['delete', 'reject'].includes(resolvedTone)
className={clsx( ? 'danger-soft'
'pm-table-action inline-flex size-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50', : 'secondary'
ACTION_TONE_CLASSES[resolvedTone] || ACTION_TONE_CLASSES.view, }
className, onPress={onPress}
)} className={clsx(ACTION_BUTTON_LAYOUT_CLASS, className)}
> >
<Icon className="size-4" size={16} aria-hidden="true" /> <Icon
className={ACTION_ICON_CLASS}
size={16}
aria-hidden="true"
/>
</Button> </Button>
<Tooltip.Content>{label}</Tooltip.Content> <Tooltip.Content>{label}</Tooltip.Content>
</Tooltip> </Tooltip>
......
import { ChevronDown, Close, Search } from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
SearchField,
Spinner,
} from '@heroui/react';
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
return new Set(selectedKeys || []);
}
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
}
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
className,
emptyText = '暂无可选项',
errorText = '',
filter,
fullWidth = true,
inputValue,
isDisabled = false,
isLoading = false,
isOpen,
isRequired = false,
name,
onInputChange,
onOpenChange,
onSelectionChange,
options = [],
placeholder = '请选择',
placement = 'bottom start',
popoverClassName,
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
selectedKey,
selectedKeys,
selectionMode = 'single',
shouldFilter = true,
valueText,
variant,
}) {
const selectedKeySet = getSelectedKeySet(
selectionMode,
selectedKey,
selectedKeys,
);
const selectedOptions = options.filter((option) =>
selectedKeySet.has(option.id),
);
const resolvedValueText =
valueText ??
(selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const visibleOptions = isLoading || errorText ? [] : options;
return (
<Autocomplete
{...rootSelectionProps}
aria-label={ariaLabel || placeholder}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
name={name}
selectionMode={selectionMode}
variant={variant}
onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span
className="block min-w-0 truncate"
title={resolvedValueText}
>
{resolvedValueText}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={14} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover className={popoverClassName} placement={placement}>
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField aria-label={searchPlaceholder} fullWidth>
<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>
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
renderEmptyState={() => (
<div
className="grid min-h-20 place-items-center px-3 py-4"
data-slot="empty-state"
>
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.textValue || option.label}
>
{renderOption ? (
renderOption(option)
) : (
<Label className="truncate" title={option.label}>
{option.label}
</Label>
)}
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
import { Close } from '@/components/AppIcons';
import { Modal } from '@heroui/react';
import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = {
xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]',
};
export default function XinyuanModal({
isOpen,
onOpenChange,
title,
description,
size = 'lg',
dialogClassName,
bodyClassName,
children,
footer,
}) {
const modalSize = ['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'lg';
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={isOpen} onOpenChange={onOpenChange} variant="blur">
<Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog
className={clsx(
DIALOG_WIDTH_CLASS[size] && 'p-7',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
{description ? (
<div className="text-sm text-muted">{description}</div>
) : null}
</Modal.Header>
<Modal.Body className={bodyClassName}>
{children}
</Modal.Body>
{footer ? (
<Modal.Footer>{footer}</Modal.Footer>
) : null}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
This diff is collapsed.
const FALLBACK_TOKENS = {
brand: '#9e1b1b',
brandLight: '#b83a3a',
brandDark: '#7e1515',
success: '#16845b',
warning: '#b46a00',
danger: '#c93636',
teal: '#0f766e',
surface: '#ffffff',
subtle: '#f8fafc',
border: '#e3e8ef',
text: '#172033',
secondary: '#4e5d73',
muted: '#718096',
};
function readToken(styles, name, fallback) {
const value = styles?.getPropertyValue(name)?.trim();
return value || fallback;
}
export function getXinyuanChartTheme() {
if (typeof window === 'undefined') {
return FALLBACK_TOKENS;
}
const root = document.documentElement;
const styles = window.getComputedStyle(root);
const isDark = root.classList.contains('dark') || root.dataset.theme === 'dark';
const theme = {
brand: readToken(styles, '--rc-brand-primary', FALLBACK_TOKENS.brand),
brandLight: readToken(styles, '--rc-brand-light', FALLBACK_TOKENS.brandLight),
brandDark: readToken(styles, '--rc-brand-dark', FALLBACK_TOKENS.brandDark),
success: readToken(styles, '--rc-color-success', FALLBACK_TOKENS.success),
warning: readToken(styles, '--rc-color-warning', FALLBACK_TOKENS.warning),
danger: readToken(styles, '--rc-color-danger', FALLBACK_TOKENS.danger),
teal: readToken(styles, '--rc-color-teal', FALLBACK_TOKENS.teal),
surface: readToken(styles, '--rc-bg-surface', FALLBACK_TOKENS.surface),
subtle: readToken(styles, '--rc-bg-subtle', FALLBACK_TOKENS.subtle),
border: readToken(styles, '--rc-border-default', FALLBACK_TOKENS.border),
text: readToken(styles, '--rc-text-primary', FALLBACK_TOKENS.text),
secondary: readToken(styles, '--rc-text-secondary', FALLBACK_TOKENS.secondary),
muted: readToken(styles, '--rc-text-muted', FALLBACK_TOKENS.muted),
};
return {
...theme,
palette: [theme.brand, theme.brandLight, theme.warning, theme.success, theme.teal, theme.brandDark],
isDark,
grid: isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(23, 32, 51, 0.10)',
axis: isDark ? 'rgba(255, 255, 255, 0.22)' : 'rgba(23, 32, 51, 0.14)',
tooltipBackground: theme.surface,
tooltipShadow: 'var(--rc-shadow-overlay, 0 16px 40px rgb(15 23 42 / 14%))',
};
}
export function chartTooltip(theme, formatter) {
return {
trigger: 'item',
renderMode: 'html',
appendToBody: true,
confine: true,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.tooltipBackground,
shadowColor: 'transparent',
extraCssText: `box-shadow: ${theme.tooltipShadow}; border-radius: 10px; padding: 10px 12px;`,
textStyle: { color: theme.text, fontSize: 12 },
formatter,
};
}
export function chartAxis(theme) {
return {
axisLabel: { color: theme.muted, fontSize: 12 },
axisLine: { lineStyle: { color: theme.axis } },
axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid, width: 1 } },
};
}
...@@ -272,9 +272,6 @@ ...@@ -272,9 +272,6 @@
} }
.rc-app-main { .rc-app-main {
--rc-page-title-inset: calc(
var(--rc-space-6) + var(--rc-navbar-trigger-size) + var(--rc-space-3)
);
display: flex; display: flex;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
...@@ -300,7 +297,7 @@ ...@@ -300,7 +297,7 @@
min-height: 4rem; min-height: 4rem;
max-height: 4rem; max-height: 4rem;
gap: var(--rc-space-3); gap: var(--rc-space-3);
padding: 0 var(--rc-space-6); padding: 0 var(--rc-navbar-inline-padding);
} }
.rc-navbar [data-slot='sidebar-trigger'] { .rc-navbar [data-slot='sidebar-trigger'] {
...@@ -309,6 +306,7 @@ ...@@ -309,6 +306,7 @@
max-width: var(--rc-navbar-trigger-size); max-width: var(--rc-navbar-trigger-size);
height: var(--rc-navbar-trigger-size); height: var(--rc-navbar-trigger-size);
flex: 0 0 var(--rc-navbar-trigger-size); flex: 0 0 var(--rc-navbar-trigger-size);
margin-inline-start: 0;
} }
.rc-navbar-icon-button.button, .rc-navbar-icon-button.button,
...@@ -412,10 +410,60 @@ ...@@ -412,10 +410,60 @@
min-width: 0; min-width: 0;
min-height: 100%; min-height: 100%;
margin: 0; margin: 0;
padding: var(--rc-space-4) var(--rc-page-title-inset) var(--rc-space-5); padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
padding-inline-start: var(--rc-page-inline-start);
padding-inline-end: var(--rc-page-inline-end);
overflow-x: clip; overflow-x: clip;
} }
/* Shared page rhythm. These classes control layout only, never HeroUI internals. */
.rc-page-stack,
.rc-page-section,
.rc-list-stack,
.rc-list-data-stack {
display: flex;
min-width: 0;
flex-direction: column;
}
.rc-page-stack {
gap: var(--rc-page-section-gap);
}
.rc-page-section {
gap: var(--rc-page-module-gap);
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
}
.rc-kpi-grid {
min-width: 0;
gap: var(--rc-related-item-gap);
}
.rc-page-toolbar {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--rc-page-toolbar-gap);
}
.rc-page-toolbar-responsive {
flex-direction: column;
align-items: stretch;
}
.rc-list-stack,
.rc-list-data-stack {
gap: var(--rc-list-stack-gap);
}
.rc-mobile-sidebar-backdrop { .rc-mobile-sidebar-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
...@@ -454,6 +502,10 @@ ...@@ -454,6 +502,10 @@
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
.rc-app-main {
--rc-shell-inline-gutter: var(--rc-space-8);
}
.rc-sidebar { .rc-sidebar {
display: none; display: none;
} }
...@@ -463,15 +515,25 @@ ...@@ -463,15 +515,25 @@
} }
} }
@media (min-width: 768px) {
.rc-page-toolbar-responsive {
flex-direction: row;
align-items: center;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.rc-app-main { .rc-app-main {
--rc-page-title-inset: calc( --rc-shell-inline-gutter: var(--rc-space-4);
var(--rc-space-6) + var(--rc-navbar-icon-button-size) + var(--rc-space-3) --rc-page-padding-block-start: var(--rc-space-4);
); --rc-page-padding-block-end: var(--rc-space-6);
--rc-page-section-gap: var(--rc-space-6);
--rc-page-module-gap: var(--rc-space-4);
} }
.rc-app-page { .rc-app-page {
padding: var(--rc-space-4) var(--rc-page-title-inset) var(--rc-space-5); padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
} }
.rc-navbar [data-slot='sidebar-trigger'] { .rc-navbar [data-slot='sidebar-trigger'] {
......
/*
* HeroUI Theme Customization
* Add this to your global.css after importing @heroui/styles
* Only includes variables users need to customize
* @see https://heroui.com/docs/react/getting-started/theming
*/
:root,
.light,
.default,
[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.0019 27.03);
--border: oklch(90.00% 0.0019 27.03);
--danger: oklch(65.32% 0.2337 358.52);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(94.00% 0.0019 27.03);
--default-foreground: oklch(21.03% 0.0059 27.03);
--field-background: oklch(100.00% 0.0009 27.03);
--field-border: transparent;
--field-foreground: oklch(21.03% 0.0019 27.03);
--field-placeholder: oklch(55.17% 0.0038 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0019 27.03);
--muted: oklch(55.17% 0.0038 27.03);
--overlay: oklch(100.00% 0.0006 27.03);
--overlay-foreground: oklch(21.03% 0.0019 27.03);
--scrollbar: oklch(87.10% 0.0019 27.03);
--segment: oklch(100.00% 0.0019 27.03);
--segment-foreground: oklch(21.03% 0.0019 27.03);
--separator: oklch(92.00% 0.0019 27.03);
--success: oklch(73.29% 0.1942 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0009 27.03);
--surface-foreground: oklch(21.03% 0.0019 27.03);
--surface-secondary: oklch(95.24% 0.0015 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0019 27.03);
--surface-tertiary: oklch(93.73% 0.0015 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0019 27.03);
--warning: oklch(78.19% 0.1591 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
/* Border Radius */
--radius: 0.5rem;
--field-radius: 0.75rem;
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
}
.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.0019 27.03);
--border: oklch(28.00% 0.0019 27.03);
--danger: oklch(59.40% 0.1974 357.41);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(27.40% 0.0019 27.03);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0038 27.03);
--field-border: transparent;
--field-foreground: oklch(99.11% 0.0019 27.03);
--field-placeholder: oklch(70.50% 0.0038 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0019 27.03);
--muted: oklch(70.50% 0.0038 27.03);
--overlay: oklch(21.03% 0.0038 27.03);
--overlay-foreground: oklch(99.11% 0.0019 27.03);
--scrollbar: oklch(70.50% 0.0019 27.03);
--segment: oklch(39.64% 0.0019 27.03);
--segment-foreground: oklch(99.11% 0.0019 27.03);
--separator: oklch(25.00% 0.0019 27.03);
--success: oklch(73.29% 0.1942 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0038 27.03);
--surface-foreground: oklch(99.11% 0.0019 27.03);
--surface-secondary: oklch(25.70% 0.0029 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0019 27.03);
--surface-tertiary: oklch(27.21% 0.0029 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0019 27.03);
--warning: oklch(82.03% 0.1393 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
}
\ No newline at end of file
This diff is collapsed.
import { useEffect, useState } from 'react';
function isRootDarkTheme() {
if (typeof document === 'undefined') return false;
const root = document.documentElement;
return root.classList.contains('dark') || root.dataset.theme === 'dark';
}
export default function useRootDarkTheme() {
const [isDark, setIsDark] = useState(isRootDarkTheme);
useEffect(() => {
const root = document.documentElement;
const syncTheme = () => setIsDark(isRootDarkTheme());
const observer = new MutationObserver(syncTheme);
syncTheme();
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
return () => observer.disconnect();
}, []);
return isDark;
}
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Input } from '@/catalyst-ui-kit/javascript/input'; import { Alert, Label } from '@heroui/react';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
import { import {
getReferenceSubjectName, getReferenceSubjectName,
...@@ -58,66 +59,68 @@ export default function ReferenceSearchSelect({ ...@@ -58,66 +59,68 @@ export default function ReferenceSearchSelect({
}; };
}, [keyword, subjectName]); }, [keyword, subjectName]);
const toggleRecord = (record, checked) => { const handleSelectionChange = (keys) => {
const recordId = normalizeRecordId(record);
if (!recordId) {
return;
}
if (!multiple) { if (!multiple) {
onChange(checked ? recordId : null); onChange(keys ? String(keys) : null);
return; return;
} }
if (checked) { const nextIds = keys === 'all'
onChange(Array.from(new Set([...selectedIds, recordId]))); ? options.map(normalizeRecordId).filter(Boolean)
return; : Array.from(keys).map(String);
} onChange(nextIds);
onChange(selectedIds.filter((item) => item !== recordId));
}; };
if (!subjectName) { if (!subjectName) {
return ( return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"> <Alert status="warning">
<Alert.Content>
<Alert.Description>
引用字段缺少目标 Subject 配置,暂不能搜索选择。 引用字段缺少目标 Subject 配置,暂不能搜索选择。
</div> </Alert.Description>
</Alert.Content>
</Alert>
); );
} }
return ( const autocompleteOptions = options.map((record) => {
<div className="space-y-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={placeholder || '搜索引用记录标题'}
/>
<div className="max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-zinc-200 bg-zinc-50 p-2">
{options.length === 0 ? (
<div className="rounded-xl bg-white px-3 py-4 text-sm text-zinc-500">暂无可选记录</div>
) : (
options.map((record) => {
const recordId = normalizeRecordId(record); const recordId = normalizeRecordId(record);
const checked = selectedIds.includes(recordId); return {
id: recordId,
label: normalizeRecordLabel(record) || recordId,
description: record.name || recordId,
};
});
const selectionProps = multiple
? { selectedKeys: new Set(selectedIds) }
: { selectedKey: selectedIds[0] || null };
const valueText = toArray(value)
.map((item) => normalizeRecordLabel(item) || normalizeRecordId(item))
.filter(Boolean)
.join('、');
return ( return (
<label <XinyuanAutocomplete
key={recordId} {...selectionProps}
className="flex cursor-pointer items-center gap-3 rounded-xl bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition hover:bg-rose-50" aria-label="引用记录"
> emptyText="暂无可选记录"
<input inputValue={keyword}
type={multiple ? 'checkbox' : 'radio'} options={autocompleteOptions}
checked={checked} placeholder={placeholder || '搜索引用记录标题'}
onChange={(event) => toggleRecord(record, event.target.checked)} searchPlaceholder={placeholder || '搜索引用记录标题'}
/> selectionMode={multiple ? 'multiple' : 'single'}
<span> shouldFilter={false}
<span className="block font-medium text-zinc-950">{normalizeRecordLabel(record) || recordId}</span> valueText={valueText || placeholder || '搜索引用记录标题'}
<span className="block text-xs text-zinc-500">{record.name || recordId}</span> onInputChange={setKeyword}
onSelectionChange={handleSelectionChange}
renderOption={(option) => (
<span className="min-w-0">
<Label className="block truncate">{option.label}</Label>
<span className="block truncate text-xs text-muted">
{option.description}
</span>
</span> </span>
</label>
);
})
)} )}
</div> />
</div>
); );
} }
This diff is collapsed.
...@@ -18,6 +18,7 @@ import { Outlet, useLocation } from 'umi'; ...@@ -18,6 +18,7 @@ import { Outlet, useLocation } from 'umi';
const MENU_GROUPS = [ const MENU_GROUPS = [
{ {
title: APPLICATION_DISPLAY_NAME, title: APPLICATION_DISPLAY_NAME,
showLabel: false,
items: [ items: [
{ {
name: '驾驶舱', name: '驾驶舱',
......
...@@ -77,7 +77,7 @@ ...@@ -77,7 +77,7 @@
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作 - 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、筛选条件、关键词和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size` - `problem_rectification` 主列表采用传统分页表格,不一次性加载全部数据;每次按当前视图、筛选条件、关键词和页码调用 `/record/query`,分页参数使用 `page` 与 `page_size`
- 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底 - 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底
- 主列表支持每页 `10 / 20 / 50 / 100` 条,切换视图、修改筛选或清空筛选时回到第 1 页;表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围 - 主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。
- 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数 - 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数
- 主列表标题列直接展示可点击的延期申请状态:存在历史问题级延期或部门级延期明细时,按当前阶段显示“延期已发起 / 延期待审批 / 延期待终审 / 延期处理中 / 延期已生效 / 延期已退回”日历徽标,不能等用户再次点击推送后才提示已发起;状态同时使用图标和文字,不只依赖颜色 - 主列表标题列直接展示可点击的延期申请状态:存在历史问题级延期或部门级延期明细时,按当前阶段显示“延期已发起 / 延期待审批 / 延期待终审 / 延期处理中 / 延期已生效 / 延期已退回”日历徽标,不能等用户再次点击推送后才提示已发起;状态同时使用图标和文字,不只依赖颜色
- 点击延期状态后打开“延期申请详情”弹窗,按责任部门展示原预计完成时间、申请完成时间、延期事由、填写人、一级审批人、最终审批人以及三级流程状态;申请完成时间或延期事由尚未提交时明确显示“待填写”,历史问题级延期按“整条问题”展示;行操作区同时提供“查看延期申请”日历按钮,保证窄屏下标题列被固定列压缩时仍可进入详情 - 点击延期状态后打开“延期申请详情”弹窗,按责任部门展示原预计完成时间、申请完成时间、延期事由、填写人、一级审批人、最终审批人以及三级流程状态;申请完成时间或延期事由尚未提交时明确显示“待填写”,历史问题级延期按“整条问题”展示;行操作区同时提供“查看延期申请”日历按钮,保证窄屏下标题列被固定列压缩时仍可进入详情
......
This diff is collapsed.
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