Commit 14cd2363 authored by Andy-bubu's avatar Andy-bubu

feat: align integrity management with Xinyuan UI

parent cd90a7d4
...@@ -13,3 +13,5 @@ ...@@ -13,3 +13,5 @@
/.mfsu /.mfsu
.swc .swc
.pnpm-store .pnpm-store
.playwright-cli/
docs/ui-acceptance/
...@@ -22,6 +22,12 @@ export default defineConfig({ ...@@ -22,6 +22,12 @@ export default defineConfig({
fastRefresh: true, fastRefresh: true,
base: APP_BASE_PATH, base: APP_BASE_PATH,
publicPath: APP_BASE_PATH, publicPath: APP_BASE_PATH,
links: [
{
rel: 'stylesheet',
href: `${APP_BASE_PATH}fonts/inter.css`,
},
],
alias: { alias: {
react: resolvePackageRoot('react'), react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'), 'react-dom': resolvePackageRoot('react-dom'),
...@@ -72,7 +78,7 @@ export default defineConfig({ ...@@ -72,7 +78,7 @@ export default defineConfig({
], ],
}, },
], ],
npmClient: 'npm', npmClient: 'pnpm',
proxy: { proxy: {
'/api': { '/api': {
target: 'https://xy.streams7.com/api', target: 'https://xy.streams7.com/api',
......
# 廉洁从业管理 UI 架构
## 运行结构
```text
app.js (主题初始化)
-> Site/Auth wrappers (现有鉴权与登出)
-> integrity_practice_management/Layout
-> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
-> RecordDetailDrawer (Modal/Tabs/Surface/动态字段)
-> UserAssignmentDialog / AlertDialog
```
## 责任边界
- `src/config/application.js`:应用名、base、主题存储键。
- `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
- `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。
- `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
- `record-workbench`:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
- `RecordDetailDrawer`:详情草稿、Modal 状态和字段编排;保存仍由 `useSubjectRecord` 处理。
- `TailwindEditableField`:按动态字段类型选择官方控件;不硬编码选项或 API 字段。
- service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。
## 状态与数据流
```text
路由 -> Subject 配置 -> GET Subject/View -> POST record/query
-> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调
-> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name
```
主题状态写入 `xinyuan_color_theme`,同时更新根 `data-theme` 与 `dark` class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离
`.umirc.ts` 只挂载 Login、Layout 和七个 SubjectWorkbench 路由。`src/pages/problem_rectification`、`src/catalyst-ui-kit` 与 demo services 是仓库历史源码,不属于当前运行图。审计必须分别报告“活动路由扫描”和“全仓扫描”,不能用历史命中否定活动迁移,也不能宣称全仓已清零。
## 字体与开发时热更新
- Inter 400/500/600 的拉丁字符字体文件固定放在 `public/fonts/`,由 `.umirc.ts` 生成的常驻 `inter.css` link 按需加载。
- `src/tailwind.css` 不得重新导入 `@fontsource/inter/*.css`。否则字体注册会进入 `umi.css`,开发时替换样式表会造成英文、数字和符号在 Inter 与系统回退字体之间跳动。
- Tailwind v4 使用 `source(none)` 和显式 `@source`,只扫描活动源码入口、`components`、`pages` 和 `wrapper`。禁止自动扫描 `src/.umi`,避免 Umi 生成文件与 Tailwind 编译互相触发无限 HMR。
- 同一工作区只允许一个 Umi dev 实例。多个实例会共享写入 `src/.umi` 并形成交叉编译循环。
# 鑫元 UI 迁移检查清单
## 安装与依赖
- [x] 在目标根目录执行指定 `pnpm dlx hpsetup@latest ...` 命令并成功
- [x] `@heroui-pro/react@1.0.0-beta.8` 来自正式 pnpm 安装
- [x] 根入口、CSS 和 11 个 Pro 子路径通过导入检查
- [x] `package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml` 已更新
- [x] 删除含跨项目 `file:` 路径的过期 `package-lock.json`
- [x] 授权值未写入仓库
## 活动路由
- [x] 登录、鉴权、真实退出
- [x] Pro Sidebar/Navbar/offcanvas/mobile
- [x] HeroUI 应用切换 Modal:46rem/42rem、桌面两列/移动单列、12px 列间距、API Logo/首字 identity、唯一当前态;用户 Dropdown
- [x] 七条 SubjectWorkbench 路由
- [x] Tabs、SearchField/Surface 筛选
- [x] Pro DataGrid 选择、排序、固定列、横向滚动
- [x] HeroUI Pagination(固定每页 10 条、无页容量选择器)、ActionBar、EmptyState
- [x] 4+ 行动作 More 菜单与危险动作末位
- [x] 详情/编辑 Modal、指派 Modal、删除 AlertDialog
- [x] Select、Autocomplete、DatePicker、DropZone 直接 compound API
- [x] 活动路由 KPI/图表判定为不适用
## 保护与扫描
- [x] API、参数、路由、权限、校验、保存回调和业务状态未改
- [x] 活动源码 Catalyst/Headless/Heroicons/ECharts/旧包装引用为零
- [x] 活动源码原生 select/date/datetime-local/file/button 为零
- [x] 活动页面私有背景、色板、描边、圆角和阴影扫描为零
- [x] 包和锁文件本机绝对路径、相邻项目、`file:` 依赖为零
- [x] `npm run build` 通过
- [x] `git diff --check` 通过
- [x] 鑫元 `audit_project.sh` 已运行并分类历史目录告警
## 浏览器
- [x] 目标与问题整改同时运行并同视口比较
- [x] 1440x900、1280x720、768x1024、390x844
- [x] Shell 浅/深色真实按钮切换
- [x] 登录空/填充/聚焦及浅/深色主题继承
- [x] 移动 Sidebar、应用切换、用户菜单和 Portal
- [x] DataGrid populated/empty、选择、排序、ActionBar、Pagination
- [x] 所有活动 DataGrid 的操作列预览入口、首列只读文本、32px 图标按钮与 More 危险项顺序
- [x] 操作列固定内容宽度;非溢出表格由首个业务列 `1fr` 吸收余量,表头与首个动作按标准内边距对齐,右侧无弹性空白
- [x] Tabs、Select、Autocomplete、DatePicker 打开态
- [x] 编辑弹窗远程引用多选:长标题省略、数量汇总、搜索/清空、受控选择、移动端视口边界与单选关闭行为
- [x] 详情查看/编辑、指派、删除确认
- [x] 详情 Modal 4xl/24px、secondary 查看区、tertiary DataGrid 表头、default 表体及固定列层级(四视口浅/深色)
- [x] 新页面重载控制台 0 error
- [x] 真实退出请求 200、base-aware 登录跳转、`current_user`/`auth_token` 清理
## 明确保留
- [ ] 全仓旧组件扫描为零:未完成。未挂载历史 `src/pages/problem_rectification` 与 `src/catalyst-ui-kit` 仍保留,且不进入当前应用路由。
- [ ] KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。
# 廉洁从业管理鑫元设计规范 # 廉洁从业管理鑫元设计系统
本文件是后续新增和修改功能的 UI 执行合同。业务逻辑以现有 service、API 和流程文档为准;任何新页面都必须复用本项目设计系统,不得创建页面私有的第二套颜色、表格、弹窗、Tabs 或图标语言。 本文件约束 `/integrity_practice_management/` 的活动路由。唯一视觉和结构基准是已确认的“问题整改”应用;不得重新设计或在页面层覆盖 HeroUI 外观。
## 1. 品牌与应用身份 ## 来源与加载顺序
- 唯一应用展示名称:`廉洁从业管理`。登录、Sidebar、Navbar、应用切换当前项、浏览器标题必须共用此常量。 1. `.umirc.ts` 中的 `public/fonts/inter.css` 常驻 link:按需加载 Inter 400/500/600,不参与 `umi.css` 热替换。
- 主色:`#9E1B1B`;Hover:`#B83A3A`;Pressed:`#7E1515`。禁止把 sky、blue、cyan、rose、pink 或任意渐变当品牌色。 2. `src/design-system/theme.css`:Skill 权威主题原始副本。
- 浅色表面使用红色鑫元花形 Logo,深色表面使用白色花形 Logo。不得反色、重绘、加色块或使用 RSM 资产。 3. `@heroui/styles` 与 `@heroui-pro/react/css`:官方组件样式。
- Sidebar Logo 视觉尺寸 26-28px,标题间距约 10px;登录 Logo 36px,避免放大位图导致模糊。 4. `src/design-system/tokens.css`、`shell.css`、`src/tailwind.css`、`src/global.less`:问题整改共享样式与当前应用必要适配。
## 2. 技术与组件来源 Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排除 Umi 生成目录 `src/.umi`,避免生成文件反向触发 CSS 编译循环和字体回退抖动。
- Tailwind CSS 必须先加载,随后加载 `@heroui/styles`、`@heroui-pro/react/css`,最后加载鑫元 Tokens 和项目样式。 页面 `className` 仅承担 flex、grid、宽度、间距、对齐、滚动和响应式。背景、颜色、描边、圆角、阴影及交互状态由官方 variant、compound API 和上述共享样式负责。
- HeroUI 基础组件从 `@heroui/react` 导入;HeroUI Pro 的 Sidebar、Navbar、DataGrid、ActionBar、EmptyState、DropZone 和 Charts 从对应 Pro 子路径导入。
- Pro 包使用本机完整镜像:`file:../project_manager_new/node_modules/@heroui-pro/react`。升级或重装后必须验证 `dist/index.js`、`dist/css/index.css` 和关键子路径可解析。
- HeroUI v3 使用 compound anatomy 与 `onPress`;Button variant 使用 `primary`、`secondary`、`tertiary`、`outline`、`ghost`、`danger`、`danger-soft`。
- 图标统一走 `src/components/AppIcons.jsx`。业务普通按钮 text-only;只有真正的 icon action 使用图标,且必须有 Tooltip 和 `aria-label`。
## 3. Tokens 与几何 ## 组件合同
- 页面画布:浅色 `#F5F5F5`,深色 `#0F0F11`;表面:浅色白、深色 `#18181B`。 - Shell:`@heroui-pro/react/sidebar`、`navbar`;桌面 offcanvas 真折叠,移动端 `Sidebar.Mobile`。
- 桌面和移动内容左右 inset 分别为 68px 和 72px;宽表只允许在 DataGrid 内部横向滚动。 - 全局操作:HeroUI Button/Tooltip/Dropdown;用户菜单的退出项调用真实登出回调。
- 控件半径 8px,Popover 12px,DataGrid 20px,普通 Modal 和 Dashboard/Workbench 模块 24px。 - 应用切换:HeroUI Modal + Card/Surface/Avatar/Chip;最大 46rem x 42rem,桌面两列、移动端单列,列间距 12px。应用项使用 HeroUI Card 默认 24px 圆角与 16px 内边距;API 有 Logo 时显示真实 Logo,无 Logo 时显示应用首字,不得给所有应用重复套用鑫元 Logo。
- 普通按钮 32px 高,触控环境 36px;Navbar 64px;Sidebar 导航行 36px。 - 数据视图:HeroUI Tabs;不手写 segmented/tab 外观。
- 字距恒为 0。Navbar 标题 20/600;Modal 标题 16/500;表头 12/500;普通单元格 14/400;主值 14/550;次值 13/400;Chip 12/500。 - 筛选:HeroUI Surface + SearchField/Autocomplete/DatePicker/Disclosure。没有业务配置的筛选不得添加。
- 列表:HeroUI Pro DataGrid、EmptyState、ActionBar + HeroUI Pagination;分页固定沿用业务默认每页 10 条,不显示额外页容量选择器。首列只在真实横向溢出时固定且仅展示文本,详情入口统一为操作列首个预览图标。操作列宽度由 32px 动作按钮、8px 间距和标准单元格内边距计算,`minWidth/width/maxWidth` 必须一致;表格未横向溢出时首个业务列使用 `1fr` 吸收剩余宽度,操作列不得参与余量分配;仅在横向溢出时固定在右端。
- 行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。
- Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
- 动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。
- 远程引用:使用 HeroUI `Popover.Root + Button + SearchField + controlled ListBox`。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过 `title` 暴露全文;弹层最大 24rem、移动端收敛到 `100vw - 24px`、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。
- KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。
## 4. 登录 ## 业务和数据保护
- 使用居中的认证面板,不使用营销分屏、宣传卡片或大面积背景图。 - URL、方法、参数、请求时机、权限、路由、分页口径、校验、保存/登出回调和业务状态流不得因 UI 迁移改变。
- 桌面面板约 28rem,内容 padding 约 2.25rem;卡片内不重复“登录”标题。 - 所有动态字段继续写入 `metadata`。
- 保留后端验证码。账号、密码、验证码真实可见控件统一 40px 高;字段 label 到控件 12px,字段组之间 24px,验证码到提交按钮 24px。 - Reference/User 只保存 `_id` 或 `_id[]`。
- 空输入保持默认表面;有值或浏览器 autofill 后使用不透明 `#FBF1F1`,Hover `#FAEAEA`,Focus 为鑫元红描边与轻光晕。Chrome autofill 必须同时覆盖 `box-shadow` 和 `-webkit-box-shadow`。 - Date/Datetime 在 service/hook 现有边界转换为 ISO 8601。
- 登录错误保留为需要用户处理的上下文 Alert;提交 loading、disabled 和验证码刷新逻辑不得改变。 - Select 只读取 Subject `settings.options`。
- 主键、视图禁用、记录锁定与只读判断沿用现有配置和 hook。
## 5. Shell 与应用切换 ## 维护门槛
- 使用 HeroUI Pro Sidebar `collapsible="offcanvas"`。桌面收起后宽度为 0,不保留 icon rail、Logo、应用按钮或头像。 每次 UI 变更至少执行生产构建、`git diff --check`、鑫元审计脚本、活动源码旧组件/原生控件/私有视觉扫描、路径扫描、依赖树和 Pro ESM 子路径检查。涉及交互流程时同步更新业务流程文档,并在基准应用旁以相同视口验收。
- Navbar trigger 使用共享 `SidebarPanel` filled 图标,禁止汉堡、MenuFold、描边近似图或箭头。
- Navbar 只有 trigger、当前页面标题和右侧全局操作;背景透明、无底边、无阴影、无 blur。
- 右侧主题按钮 36px,浅色显示 Moon,深色显示 Sun;根级主题写入 `xinyuan_color_theme` 并让 Portal 继承。
- 用户 footer 整行可点击,Dropdown 提供真实“退出登录”,复用现有 `userInfo.logout`。
- 应用切换必须使用居中 Dialog:最大 46rem 宽、42rem 高、三列紧凑卡片、36px identity、24px关闭按钮;只显示一个“当前”,保留副标题“选择你可访问的应用”,不加统计、分组、权限标签或装饰外链图标。
## 6. Tabs、筛选与表单
- 替换页面主数据集的分类使用较大的 HeroUI `filter-tabs-wrap`;模块内模式、范围和指标使用紧凑 `rc-segmented-control`。两者桌面按内容收缩,窄屏只在控件内部滚动。
- 页面级分类 Tab 只有在存在两个及以上可切换视图时才显示。只有一个可用视图时隐藏整个 Tab 轨道,不把当前页面名或唯一视图名做成单独胶囊;继续使用该唯一视图的原 key、筛选和数据请求,隐藏仅影响展示。
- 页面 Tabs 和普通命令不放装饰图标或无决策价值的数量 Badge。
- 实时筛选不显示查询按钮。首行最多三个字段,右侧只保留重置 icon 和轻量展开控制;不显示泛化“筛选条件”标题。
- 所有 Select、Reference、Date/DateTime、Upload、Checkbox 和 Textarea 走共享 HeroUI 适配器。禁止原生 `<select>`、OS 日期弹窗、Catalyst 下拉和页面私有 Popover。
- HeroUI Select 统一使用当前 `value / onChange` 受控接口:单选值为一个 key 或 `null`,多选值为 key 数组;禁止使用旧 `selectedKey / selectedKeys / onSelectionChange` 控制 Select。Trigger 必须渲染共享 `Select.Indicator + ChevronDown` 箭头,选项变化应立即写入弹窗草稿,单选后关闭、多选时保持弹层打开。
- DateTime 必须保留真实时间编辑;Placeholder 写期望值或示例,不写裸“可选”。
## 7. DataGrid 与列表
- 所有业务表格使用 HeroUI Pro DataGrid。当前系统不在 DataGrid 上方重复显示数据集标题:关键词搜索占用原列表标题的左侧位置,业务操作保持同一行右对齐;操作列表头在列内居中,行内图标组贴近操作列右边缘,删除按钮在图标组内最右;没有搜索和操作时不保留空标题行。DataGrid 自己拥有 20px 表面,Pagination 紧随其后,无外层大 Card 和分隔线。
- DataGrid 表头必须使用 Subject 字段或视图配置中的中文业务名称;`metadata.*`、snake_case、数据库键及其他技术标识不得直接展示。缺失字段定义时,由共享列适配层统一解析中文名称,禁止在页面组件内零散替换。
- 当前系统所有 DataGrid 行操作统一使用 32px 正圆图标按钮:普通操作使用 `var(--default)` 中性浅灰背景,Hover 使用 `var(--default-hover)`;删除等危险操作保留浅红背景和危险色图标。所有图标保持 16px 居中、Tooltip 和可访问名称,禁用态降低透明度。
- 详情弹窗内的单列关联记录属于辅助长文本列表,不套用主清单首列强调字重:正文固定为 14px/400、48px 行高、单行省略,并通过 Tooltip 快速预览完整文本。首列必须可点击并打开对应关联记录的只读详情弹窗,关闭后返回原记录详情。关联 DataGrid 不建立独立的纵向滚动区,由详情弹窗正文统一负责纵向滚动;单列表不产生横向滚动,多列关联记录只在真实宽度溢出时由 DataGrid 负责横向滚动。
- 所有详情编辑控件使用 HeroUI/HeroUI Pro:日期和日期时间使用 `FormDatePicker`,附件使用 `DropZone`,布尔值与单/多选使用 HeroUI Checkbox/Radio,远程人员选择使用 HeroUI ComboBox。禁止显示原生 date/file/checkbox/radio 控件;关联详情在同一个详情弹窗内前进并通过“返回上一级”恢复父记录,禁止叠加第二层详情 Modal。
- DataGrid 必须始终占满当前主内容区宽度。即使只有少量列,也要让 wrapper、DataGrid root、滚动层、resizable container 和真实 table/content 的完整宽度链保持 `width: 100%` / `min-width: 100%`,禁止按列宽总和收缩并在右侧留下空白;只有列宽总和超过可用宽度时才在 DataGrid 内部横向滚动。
- 存在详情能力的列表,首列必须使用共享可点击主字段:默认保持主文字色,Hover/键盘聚焦时仅切换为鑫元红,不加底色或下划线,并始终通过 Tooltip 提供全文。首列宽度根据当前数据真实文案估算,优先在 220-320px 内合理扩宽显示;真实横向滚动时首列必须设置明确最大宽度,不能被表格弹性布局拉伸成大块空白。
- 分页固定提供 10 / 20 / 50,默认 10;切换回第一页并保留筛选、排序和权限范围。当前页为圆形描边和品牌文字。
- 只有真实发生横向滚动的宽表固定列,判断依据是所有列最小宽度总和是否超过 DataGrid 可视宽度,不能按列数猜测。发生滚动时固定首个业务列在左侧、固定操作列在右侧;窗口缩放和 Sidebar 开合后必须重新判断。固定列必须不透明,表头和 Hover 连续,文字不得穿越列边界。
- 行编辑/删除使用固定操作列 icon action;危险入口 danger-soft,最终确认才使用 solid danger。
- 所有列表操作遵循稳定顺序:普通动作保持业务配置的原始顺序,删除、移除、作废、清退等危险动作永远位于操作列最右侧。多选 ActionBar 同样把危险动作排在全部业务按钮最右侧、关闭 X 之前;页面不得通过局部配置覆盖这一规则。
- 操作列表头与图标组统一左对齐,并共享单元格的 12px 左侧内边距;首个 32px 图标按钮必须从“操作”标题左边缘开始排列。即使非滚动表格让操作列吸收了剩余宽度,也不得把图标组推到单元格右侧或用页面私有偏移补偿。
- 多选操作只显示视口底部偏中的 HeroUI Pro ActionBar,随 Sidebar 开合重新居中;最右侧 X 清除选择。
- ActionBar 必须承载当前列表所有支持多记录执行的业务动作,直接复用行操作的权限、可见性、确认和提交回调;编辑、查看等仅适用于单条记录的动作不得进入多选浮层。带条件的批量动作仅在全部选中记录都满足条件时展示,删除入口使用 `danger-soft`。
- Checkbox 选中与半选时外层、边框和 `::before` 同为 `#9E1B1B`,白色 indicator,无黑线、白边、阴影或滤镜。
- 空状态只有一个共享 HeroUI Pro EmptyState;Loading 使用独立 Spinner,不得出现两个“暂无数据”。
## 8. Modal、Toast 与反馈
- Modal 居中,24px 圆角;标准 padding 28px,确认框 24px。尺寸按内容选择,普通表单 `lg`,双列表单 `2xl/3xl`,复杂详情和导入 `4xl`。
- 所有普通业务弹窗必须复用 `src/components/Dialog.jsx` 的 `Dialog / DialogTitle / DialogBody / DialogActions`,以及 `rc-dialog-*` Token 样式;不得另建页面私有 Modal 外壳、Header/Footer 分隔线、标题字号或关闭按钮几何。
- 记录详情也必须使用居中 HeroUI `4xl` Modal,桌面宽度 `min(100vw - 80px, 1088px)`,移动端宽度 `100vw - 32px`;禁止右侧抽屉和底部 Sheet。标题与 Footer 固定,只有 Body 内部滚动;全屏能力只能扩展居中 Modal,不得改变为贴边面板。
- 详情正文采用无外层大卡片的表单式信息网格:桌面普通短字段两列,移动端一列,长文本、附件和关联记录自动通栏。不得向业务用户展示 `text`、`select`、`reference` 等字段技术类型;不得用整行高分隔和嵌套大 Card 制造低信息密度。查看态字段仅展示值且不可点击编辑,编辑能力由弹窗底部唯一的 `编辑` 主按钮表达。
- 详情中的关联记录列表必须使用 HeroUI Pro DataGrid,不得使用 Catalyst/native Table。关联区域外层保持透明,不得因字段可编辑而出现红色背景、红色描边或整块 Hover surface;查看态列表上方仅显示关联数量,不放字段级编辑按钮,编辑态由弹窗统一切换为引用选择控件。DataGrid 继续遵守 20px 连续表面、真实溢出判断和溢出时固定首列规则。
- 弹窗关联 DataGrid 优先读取目标 Subject 的 grid view 列配置;没有配置时才从有效业务字段推断。隐藏内部 ID/关联键、全空列和无阅读价值的技术列,弹窗默认最多展示 6 个核心列。表头必须单行省略,不得逐字竖排;标题 280px、长文本 360px、人员/多值 220px、日期/状态 160px,异常长内容单行截断并通过 HeroUI Tooltip 展示全文。真实滚动容器最大高度 320px,禁止裁切表格后让页面或字段区域代替滚动。
- 标准标题下不放重复说明;可选说明收进标题右侧 info Tooltip。关闭按钮 24px,与标题中心和内容右边缘对齐。
- Footer 桌面右对齐,取消在左、主操作在右;取消为 outline,提交为 primary,最终删除为 danger。移动端全宽反向堆叠。
- 保存、删除、导入和导出成功使用顶部居中、3-4 秒自动消失的 Toast,不得用页面流 Alert 推挤内容。
- Modal backdrop 约 50% 黑并 blur 12px;必须覆盖 Sidebar、Navbar、DataGrid 固定列和其它 Portal。
## 9. Dashboard 与图表
- KPI Card 必须组合 `rc-card-surface rc-dashboard-card`:24px 圆角、1px token 边框、纯色 surface 和共享轻阴影,禁止渐变和每卡异色。
- 4/8 个 KPI 为一行四个,6 个为 3+3,5 个为 3+2;每卡右侧 48px icon surface、16px 半径、20px 图标,图标与语义 tone 不相邻重复。
- 图表通过共享 `useXinyuanChartTheme` 获取颜色、轴线、网格、legend 和 Tooltip。主序列以鑫元红为首,后续使用成功、warning、teal 和中性色,不能成为单一红色调。
- Tooltip 必须跟随 light/dark surface,避免遮挡数据;坐标轴、网格、空状态和加载状态都使用 semantic tokens。
## 10. 验收清单
- 视口:1440x900、1280x720、768x1024、390x844。
- 交互:Sidebar 开/合、移动 offcanvas、用户菜单/退出、应用切换、登录、Tabs、所有打开态 Select/Date/Reference、DataGrid 选中/半选、ActionBar、Pagination、Modal、图表 Hover。
- 主题:浅色和暗色均检查 Dropdown、Tooltip、Calendar、Modal、ActionBar、Toast 和图表 Tooltip,无白色孤岛。
- 质量:无页面横向溢出、文字/图标重叠、非居中 icon、重复空状态或 console error;通过构建、设计审计脚本和 `git diff --check`。
### 8.1 详情弹窗查看与编辑
- 列表首列是查看详情的唯一入口,Hover 时使用鑫元主红色表达可点击状态;操作列不重复展示查看图标。
- 操作列使用铅笔图标进入编辑,图标按钮必须包含 Tooltip 和可访问名称。
- 详情弹窗默认进入只读查看态。字段值不可点击编辑,弹窗底部只保留一个主按钮 `编辑`;无编辑权限或记录锁定时显示次按钮 `关闭`。
- 点击 `编辑` 后,弹窗整体进入编辑态,所有具有权限的字段同时显示统一 HeroUI 表单控件。字段内部不得再出现独立的清空、取消或保存操作。
- 编辑态底部统一为次按钮 `取消` 和主按钮 `保存`。取消恢复服务端快照;保存一次性提交本次全部字段变更,成功后回到查看态。
- 关联记录在查看态使用 HeroUI Pro DataGrid 展示;编辑态切换为引用选择控件。上传文件可先完成文件传输,但记录字段只在弹窗统一保存时提交。
# 廉洁从业管理 UI/UX 全局审计 # 廉洁从业管理鑫元 UI 审计
审计日期:2026-08-13 审计日期:2026-08-20
审计分支:`codex/xinyuan-ui-ux`
审计基线:鑫元设计系统、HeroUI 3.2.2、HeroUI Pro 1.0.0-beta.7
业务边界:只调整 UI、交互呈现和设计基础设施,不改变接口、路由、权限、字段、提交数据、状态流转或业务回调。
## 1. 项目现状 分支:`codex/xinyuan-ui-ux`
- 技术栈为 Umi Max 4、React JSX、Tailwind CSS 4、Headless UI、Catalyst 和 ECharts。 唯一实现基准:`/Users/liuchenlong/Desktop/job/problem_rectification`
- 当前应用 base 为 `/integrity_practice_management/`,登录路径为 `/login`,7 个业务路由共用 `SubjectWorkbench`。
- 仓库内还存在未挂载到本应用路由的 `problem_rectification` 旧模块。本次迁移只覆盖廉洁从业管理的活跃路由与其共用组件,不修改该遗留模块的业务页面。
- 项目原本没有 HeroUI/HeroUI Pro。已按本机完整镜像接入 Pro 包,并将 HeroUI、React 及必要 peers 对齐到镜像的运行合同。
- `npm install` 报告 66 个既有依赖安全告警(11 low、25 moderate、29 high、1 critical)。本次不执行可能带来破坏性升级的 `npm audit fix --force`。
## 2. 审计结论 ## 安装硬门槛
### P0:身份、登录与 Shell - 实际目录:`/Users/liuchenlong/Desktop/job/integrity_practice_management_new`
- 执行命令:`pnpm dlx hpsetup@latest <授权值>`
- 结果:成功;授权值未写入源码、文档、环境文件或 Git 元数据。
- 正式版本:`@heroui-pro/react@1.0.0-beta.8`
- 解析目录:`node_modules/.pnpm/@heroui-pro+react@1.0.0-beta.8/node_modules/@heroui-pro/react`
- 完整性:`dist/index.js`、`dist/css/index.css` 存在。
- ESM 动态导入通过:根入口、`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state`、`kpi`、`pie-chart`、`bar-chart`、`composed-chart`、`chart-tooltip`。
- CSS 解析通过:`@heroui-pro/react/css`。
- 依赖文件变化:`package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml`;安装器改为 pnpm,删除含相邻项目 `file:` 路径的过期 `package-lock.json`。
- 当前执行环境未暴露 HeroUI Pro MCP 资源或资源模板;组件合同以正式安装包 exports、类型声明、两项 Skill 和基准源码核验。
- 登录页是营销式左右分屏、深色渐变和功能宣传卡片,不符合鑫元居中认证面板规范。 ## 原始状态
- 登录标题曾受后端站点标题影响,登录、侧栏、Navbar、浏览器标题、应用切换当前项可能不一致。
- 账号、密码、验证码使用原生输入,控件高度、filled/autofill 状态、Focus 色和验证码对齐均不符合鑫元基线。
- 侧栏为深色固定 288px,桌面不可 offcanvas 收起;激活项使用大面积红色,Navbar 还有边线和磨砂背景。
- 用户区不可点击且没有退出菜单;主题切换与全局暗色持久化缺失。
- Sidebar trigger 使用汉堡图标,不是 HeroUI Pro 的 `LayoutSideContentLeft` filled 图形。
- 应用切换器包含分组、数量统计、权限信息、外链装饰和大面积渐变卡片,偏离批准的 46rem / 42rem 三列紧凑 Dialog。
### P0:数据列表与业务弹窗 迁移前已启动目标应用并记录浏览器状态;验收截图不纳入源代码管理。原应用业务状态为:七条路由共用动态 Subject 工作台;当前授权后端会话返回空字段、空 View 和空记录。登录 AES 加密、验证码、鉴权存储、登出请求、Subject/Record API、动作配置和分页协议均可定位。
- 活跃工作台使用 Catalyst Table、自写固定列、自写页码算法和原生 Select;没有 HeroUI Pro DataGrid、Pagination 和浮动 ActionBar。 主要 UI 问题:局部迁移混用 Catalyst、Headless UI、旧 Dialog/Select/Date/File 包装、私有 HeroUI slot CSS、自制表格/分页样式、相邻项目依赖和不完整的 Shell/应用切换。
- 每页选项包含 100,未统一为 10 / 20 / 50;当前页样式也不是鑫元圆形描边样式。
- 空状态是页面私有大虚线卡片,缺少共享 HeroUI Pro EmptyState。
- 行操作图标、表头、普通单元格、固定列背景和选择态仍使用页面私有样式。
- 确认、指派、详情弹窗继续使用 Catalyst Dialog;尺寸、24px 圆角、遮罩、关闭按钮、footer 层级和暗色 Portal 尚未统一。
- 成功反馈仍是页面流内 Alert,会推挤列表;应迁移为顶部居中的短时 Toast。
### P1:颜色、层级和控件 ## 路由与模块
- `global.less` 仍定义 `#009cd9` 蓝色品牌与渐变画布。 活动路由全部位于 `/integrity_practice_management/`:
- 活跃 UI 中散落 `sky`、`blue`、`rose`、`pink`、任意红色、渐变、重阴影和多套圆角。
- Tabs 使用私有胶囊按钮并带渐变选中态,没有按“页面数据集切换”和“模块局部模式切换”区分层级。
- 普通按钮携带装饰图标,Tag/Chip、筛选卡、卡片标题和说明文案没有统一合同。
- 表单仍有原生日期、Select、文件输入和 Catalyst 控件,需要逐步通过共享 HeroUI 适配器替换。
### P1:主题与图表 | 路由 | Subject | 状态 |
| --- | --- | --- |
| `/self_check_the_results_of_the_working_draft` | 同名 Subject | 已迁移 |
| `/self_inspection_submission_form` | 同名 Subject | 已迁移 |
| `/statistical_table` | 同名 Subject | 已迁移 |
| `/integrity_practice_report` | 同名 Subject | 已迁移 |
| `/self_check_the_draft_template` | 同名 Subject | 已迁移 |
| `/self_check_the_working_draft` | 同名 Subject | 已迁移 |
| `/self_inspection_annual_submission_materials` | 同名 Subject | 已迁移 |
- 没有根级 light/dark 主题;Dropdown、Modal、Tooltip、日期选择器等 Portal 无暗色继承合同。 七条路由统一复用 `AppShell -> DashboardSidebar/DashboardNavbar -> SubjectWorkbench -> RecordWorkbench/RecordDetailDrawer`。仓库中的 `src/pages/problem_rectification` 和 `src/catalyst-ui-kit` 没有出现在 `.umirc.ts` 活动路由图中,作为未挂载历史源码保留;全仓审计会命中其中的 Catalyst/Headless/ECharts,但活动路由扫描为零。
- ECharts 颜色和 Tooltip 使用页面局部配置,没有统一鑫元语义色、轴线、网格和暗色 Tooltip 主题。
- KPI 与工作台模块没有统一 24px 卡片半径、边框、表面和业务图标规则。
### P2:维护性与可访问性 ## 审计结论
- 设计 Token、Shell、图标、按钮、选择器、DataGrid、Modal、Toast 等基础能力缺少单一共享来源。 - 登录:已使用基准居中 Card、Logo、字段高度、24px 节奏、验证码、filled/focus/autofill 状态;保留原登录协议。
- 部分 icon-only 按钮只有 `title`,缺少一致 Tooltip;键盘 Focus 和 Portal 层级需要浏览器逐项验证。 - Shell:HeroUI Pro `Sidebar.Provider collapsible="offcanvas"`、Sidebar、Mobile、Trigger、Navbar 与真实用户 Dropdown/登出。
- 页面中存在大量普通文本 600/700 字重、负 letter spacing 和视口字号缩放倾向,不符合鑫元排版合同。 - 应用切换:HeroUI Modal,46rem/42rem 合同,桌面两列、移动端单列,当前应用状态与 identity well。
- Tabs/筛选:HeroUI Tabs、SearchField、Surface;当前业务仅配置标题关键词,不添加虚构筛选字段。
- 列表:Pro DataGrid、原生多选/排序/列宽/固定列/横向滚动、HeroUI Pagination、Pro ActionBar/EmptyState;分页固定每页 10 条并按问题整改基准移除页容量选择器;4 个以上动作只直出前两个,其余进入 Dropdown,危险动作最后。
- 弹窗:HeroUI Modal/AlertDialog;详情查看态单一 secondary Surface;指派保留远程用户查询和 ID 提交。
- 字段:HeroUI Input/TextArea/Checkbox/Select/Autocomplete/DatePicker,Pro DropZone;Select 选项仅来自 `settings.options`,用户/引用仅保存 ID,日期继续转 ISO。
- Portal/暗色:根主题同步 `data-theme` 与 `dark`,Modal、Dropdown、Autocomplete、Select、DatePicker 均实测打开态。
- KPI/图表:七条活动路由无 KPI 或图表模块,不适用;没有制造测试模块或业务数字。相关 Pro 子路径已验证可导入。
## 3. 迁移顺序 ## 浏览器验收
1. 对齐本地依赖,建立 `src/design-system` Tokens、Shell、主题和身份资产。 目标 `http://localhost:8002/integrity_practice_management/` 与基准 `http://localhost:8102/problem_rectification_new/` 同时打开。Shell 使用真实主题按钮切换;登录页无主题按钮,暗色验收使用预置根主题状态。验收截图已按源代码管理要求移除。
2. 迁移登录页,保留现有 AES 密码处理、验证码请求、登录请求和 redirect 行为。
3. 迁移 AppShell、Sidebar、Navbar、用户退出和应用切换 Dialog,保留全部路由和权限数据。
4. 迁移 `SubjectWorkbench` 的 Tabs、筛选、DataGrid、Pagination、ActionBar、Toast 和确认弹窗。
5. 迁移指派与详情弹窗及动态字段控件;不改变 metadata、Reference ID、日期序列化或 action handler。
6. 统一图表主题和第三方 Portal 暗色表面;没有活跃图表的页面仍提供共享 hook 供后续功能使用。
7. 生产构建、静态审计、`git diff --check`,并使用真实账号在 1440x900、1280x720、768x1024、390x844 验收。
## 4. 完成门槛 | 项目 | 1440x900 | 1280x720 | 768x1024 | 390x844 | 浅/深色 |
| --- | --- | --- | --- | --- | --- |
| 登录空态 | 通过 | 通过 | 通过 | 通过 | 通过 |
| Shell/Navbar/Sidebar | 通过 | 通过 | 通过 | 通过 | 通过 |
| 移动 offcanvas | 不适用 | 不适用 | 通过 | 通过 | 浅/深色通过 |
- 登录、Shell、应用切换、Tabs、筛选、打开态下拉、DataGrid、ActionBar、Pagination、Modal、Toast、图表 Tooltip 和暗色 Portal 均通过浏览器检查。 额外打开态:登录 filled/focus、应用切换、用户菜单、Tabs、Select、Autocomplete、DatePicker、DataGrid populated/empty、选择/ActionBar、排序、Pagination、行 More、详情查看/编辑、指派 Modal、删除 AlertDialog。代表性记录只存在于 Playwright 会话路由夹具;未写入源码,未执行保存、删除、归档或推送。
- 使用账号 `Andy` 验证实际权限数据;不得只用空列表判断 DataGrid 完成。
- 页面无横向溢出、文字遮挡、重复空状态、白色暗色孤岛、黑色 Checkbox、蓝色 Focus 或错误的品牌 Logo。 操作列专项复测:主列表与详情中的关联记录 DataGrid 均把预览入口放在操作列,首个业务列仅展示文本;1-3 项直接展示,4 项以上为前两项加 More。1440x900、1280x720、768x1024、390x844 浅深色均已验证;实测预览、编辑、More 均为 32x32,图标中心偏差 0px,移动端无页面级横向溢出,控制台 0 error。
- `npm run build`、鑫元审计脚本和 `git diff --check` 通过;浏览器无新增 console error。
操作列宽度回归复测:与运行中的“问题整改”在 1440x900 同视口比较,基准操作列为 144px。当前主表恢复同一列宽分配规则:未横向溢出时由首个业务列 `1fr` 吸收剩余宽度,操作列保持 144px;实测操作列 `x=1244..1388`,内容区 `x=1260..1372`,左右内边距均为 16px,三个 32px 按钮间距均为 8px。1440x900、1280x720、768x1024、390x844 浅/深色均复测,操作列在每个视口都为 144px;窄视口横向溢出态固定在 DataGrid 右端,四个视口 `document.body.scrollWidth === innerWidth`。
应用切换专项复测:对照“问题整改”的 46rem/42rem Modal、Header、24px 关闭按钮和 36px identity 几何,使用桌面两列、移动端单列。1440x900 下两列应用项实测为 338x72px,HeroUI Card 使用默认 24px 圆角和 16px 内边距,网格间距 12px;移除无业务 Logo 时统一显示鑫元 Logo 的错误兜底,恢复 API Logo / 应用首字规则;只显示一个“当前”。390x844 下应用项为 310x72px,正文独立滚动,`document.body.scrollWidth === innerWidth === 390`;浅深色均已验证。
操作图标三层几何专项复测:清除 HeroUI `.button svg` 默认的 `4px -2px` margin,并把表格操作按钮在桌面和移动端都锁定为 32x32 正圆。`button`、Ant `.anticon` 和内部 `svg` 逐层实测为 32x32 / 16x16 / 16x16,内层 SVG margin 为 0,水平及垂直圆心偏差均为 0px。
详情弹窗层级专项复测:内容型详情弹窗实测为 4xl/1088px(移动端左右各 16px)、24px 圆角;查看区为 secondary Surface/16px,嵌套 DataGrid 为 20px,表头及固定表头为 tertiary Surface,表体及固定单元格为 default Surface。1440x900、1280x720、768x1024、390x844 均完成浅/深色打开态验证;代表性关联数据只存在于 Playwright 会话路由夹具,未写入源码。
编辑弹窗远程引用专项复测:多选触发器不再拼接长标题,固定显示“已选择 N 项”;HeroUI Popover 内使用 SearchField 和受控 ListBox,长标题与记录标识分两行,标题实际宽度受弹层内容区约束并单行省略。390x844 弹层实测宽 364px、标题宽 320px,页面无横向溢出;1440x900 弹层宽 384px、标题宽 338px。1440x900、1280x720、768x1024、390x844 均完成浅/深色打开态验证。取消一项后触发器即时更新且弹层保持打开,重新选择、搜索清空、清空全部均通过;单选字段选中后关闭。夹具只存在于浏览器路由中,未写入源码或执行保存。
分页专项复测:按问题整改基准删除额外的“10 条/页”页容量选择器,只保留 HeroUI compound Pagination,业务分页口径固定为每页 10 条。21 条浏览器夹具下实测共 3 页,第 2 页切换成功且当前页保持 `aria-current="page"`;1440x900 浅色与 390x844 深色下页容量控件数量均为 0,移动端 `body.scrollWidth === innerWidth === 390`。
退出登录使用独立 `logout-proof` 会话复测:点击 Sidebar 用户菜单的“退出登录”后,`POST /api/auth/logout` 返回 200,页面跳转到 `/integrity_practice_management/login`,`current_user` 与 `auth_token` 同时从 localStorage 删除,仅保留非鉴权主题键;控制台 0 error。此前旧验收会话中登录页仍带 `current_user` 是会话夹具残留,不是产品退出流程的持久化行为。
新页面重载后控制台:0 error;保留 1 条开发环境 warning。曾发现并修复 `ComboBox.InputGroup fullWidth` DOM 透传错误,以及 checkbox 行点击误开详情的交互冲突。
## 未挂载历史边界
`audit_project.sh` 成功运行但输出告警不为零:命中未挂载的 `src/pages/problem_rectification`、`src/catalyst-ui-kit`、旧 RSM 资产和与基准逐字一致的共享 CSS。它们不进入廉洁从业活动路由或生产异步 chunk。本次按“只改当前应用 UI、保护业务”约束不删除历史业务源码;活动源码专用扫描无 Catalyst、Headless、Heroicons、ECharts、原生 select/date/file/button、旧包装引用或页面私有背景/色板/描边/圆角/阴影。
## 最终命令与结果
```bash
npm run build
git diff --check
bash .codex/skills/xinyuan-design-system/scripts/audit_project.sh \
/Users/liuchenlong/Desktop/job/integrity_practice_management_new
```
- 生产构建成功,廉洁从业生产异步路由为 `Layout` 与 `SubjectWorkbench`;`git diff --check` 为零输出。
- 鑫元审计脚本退出码 0;告警按上节归类为未挂载历史源码、旧资产与权威共享 CSS。
```bash
rg -n "catalyst-ui-kit|@headlessui/react|@heroicons/react|echarts(?:-for-react)?|Dialog\\.jsx|FormSelect|FormDatePicker|FileUploadField|<select\\b|<button\\b|type=['\"](?:date|datetime-local|file)['\"]" \
src/app.js src/wrapper/Auth.jsx src/pages/Login.jsx \
src/pages/integrity_practice_management src/pages/RecordDetailDrawer \
src/components/record-workbench \
src/components/{AppShell,DashboardNavbar,DashboardSidebar,ApplicationSwitcherDialog,AppIcons}.jsx \
src/config --glob '*.{js,jsx,ts,tsx}'
rg -n "className=.*(?:bg-|text-(?:slate|zinc|gray|red|blue|sky|cyan|rose|pink)-|border(?:-|\\[)|rounded-|shadow-|hover:|focus:|data-\\[.*\\]:)|style=\\{\\{[^}]*?(?:background|color|border|borderRadius|boxShadow)" \
src/pages/Login.jsx src/pages/integrity_practice_management \
src/pages/RecordDetailDrawer src/components/record-workbench \
--glob '*.{js,jsx,ts,tsx}'
rg -n "(?:specifier|version|resolution):\\s*file:|['\"]file:(?:\\.\\.?/|/)|/Users/|project_manager_new/node_modules" \
package.json pnpm-lock.yaml pnpm-workspace.yaml .umirc.ts .npmrc
```
- 三项活动源码/页面私有视觉/本机路径扫描均为零输出;授权格式 `hp_[A-Za-z0-9]+` 仓库扫描也是零命中。
- 全仓历史计数:Catalyst 4、Headless 82、Heroicons 13、ECharts 1 个文件,均位于未挂载目录或历史代码边界,不宣称全仓清零。
```bash
pnpm list @heroui-pro/react @heroui/react @heroui/styles react react-dom \
react-aria-components @react-aria/utils --depth 1
pnpm why @heroui-pro/react @heroui/react @heroui/styles \
react-aria-components @react-aria/utils react react-dom
pnpm install --lockfile-only --offline --frozen-lockfile
```
- 根运行时为 React/React DOM `19.2.8`、HeroUI/Styles `3.2.4`、React Aria Components `1.20.0`、React Aria Utils `3.34.1`,满足 HeroUI Pro `1.0.0-beta.8` peer 范围;冻结锁文件检查显示 `Already up to date`。
- 依赖树还包含 Umi/DVA 工具链内部 React/React DOM 18 副本;`.umirc.ts` 已把应用运行时 alias 到根 React 19。没有缺失 HeroUI peer。
- Node ESM 脚本已逐项导入根入口、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart、ComposedChart、ChartTooltip,并解析 CSS 子路径,全部通过。
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -8,28 +8,52 @@ ...@@ -8,28 +8,52 @@
"start": "npm run dev" "start": "npm run dev"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "file:../project_manager_new/node_modules/@ant-design/icons", "@ant-design/icons": "^6.3.2",
"@fontsource/inter": "^5.3.0",
"@headlessui/react": "^2.2.10", "@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react", "@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "file:../project_manager_new/node_modules/@heroui/react", "@heroui/react": "^3.2.4",
"@heroui/styles": "file:../project_manager_new/node_modules/@heroui/styles", "@heroui/styles": "^3.2.4",
"@number-flow/react": "file:../project_manager_new/node_modules/@number-flow/react", "@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@number-flow/react": "^0.6.2",
"@react-aria/utils": "^3.34.1",
"@react-stately/utils": "^3.12.1",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "^4.5.3", "@umijs/max": "^4.5.3",
"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": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"motion": "file:../project_manager_new/node_modules/motion", "maplibre-gl": "^6.4.1",
"react": "file:../project_manager_new/node_modules/react", "marked": "^18.0.10",
"react-aria-components": "file:../project_manager_new/node_modules/react-aria-components", "motion": "^12.43.0",
"react-dom": "file:../project_manager_new/node_modules/react-dom", "react": "^19.2.8",
"tailwind-merge": "file:../project_manager_new/node_modules/tailwind-merge", "react-aria-components": "^1.20.0",
"tailwind-variants": "file:../project_manager_new/node_modules/tailwind-variants", "react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
"umi-request": "^1.4.0", "umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0", "umi-request-progress": "^1.1.0",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -3,3 +3,5 @@ allowBuilds: ...@@ -3,3 +3,5 @@ allowBuilds:
core-js-pure: false core-js-pure: false
es5-ext: false es5-ext: false
esbuild: false esbuild: false
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("./inter-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("./inter-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 600;
src: url("./inter-latin-600-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { APPLICATION_BASE_PATH, THEME_STORAGE_KEY } from '@/config/application';
if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
const theme = storedTheme === 'dark' ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}
const appBasePath = String(
INTEGRITY_PRACTICE_APP_BASE_PATH || APPLICATION_BASE_PATH || '/',
).replace(/\/+$/, '');
if (typeof window !== 'undefined' && appBasePath && window.location.pathname === '/login') {
window.location.replace(
`${appBasePath}/login${window.location.search}${window.location.hash}`,
);
}
export async function getInitialState() { export async function getInitialState() {
return { return {
......
...@@ -23,6 +23,8 @@ import SunOutlined from '@ant-design/icons/SunOutlined'; ...@@ -23,6 +23,8 @@ import SunOutlined from '@ant-design/icons/SunOutlined';
import ToolOutlined from '@ant-design/icons/ToolOutlined'; import ToolOutlined from '@ant-design/icons/ToolOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined'; import UploadOutlined from '@ant-design/icons/UploadOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined'; import PlusOutlined from '@ant-design/icons/PlusOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
function iconStyle(size, style) { function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style }; return size === undefined ? style : { fontSize: size, ...style };
...@@ -58,6 +60,7 @@ export const Fullscreen = withSize(FullscreenOutlined); ...@@ -58,6 +60,7 @@ export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined); export const FullscreenExit = withSize(FullscreenExitOutlined);
export const LogOut = withSize(LogoutOutlined); export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined); export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined);
export const Reload = withSize(ReloadOutlined); export const Reload = withSize(ReloadOutlined);
export const Send = withSize(SendOutlined); export const Send = withSize(SendOutlined);
export const Search = withSize(SearchOutlined); export const Search = withSize(SearchOutlined);
...@@ -66,6 +69,7 @@ export const Sun = withSize(SunOutlined); ...@@ -66,6 +69,7 @@ export const Sun = withSize(SunOutlined);
export const Tool = withSize(ToolOutlined); export const Tool = withSize(ToolOutlined);
export const Upload = withSize(UploadOutlined); export const Upload = withSize(UploadOutlined);
export const Plus = withSize(PlusOutlined); export const Plus = withSize(PlusOutlined);
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export function SidebarPanel({ size = 16, style, ...props }) { export function SidebarPanel({ size = 16, style, ...props }) {
return ( return (
......
import { THEME_STORAGE_KEY } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { useLayoutEffect, useState } from 'react';
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, open, onOpenChange, 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 {}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar theme={theme} {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar theme={theme} 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>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import xyLogo from '@/assets/xy-logo_small.png';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import * as Headless from '@headlessui/react'; import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Close } from './AppIcons';
function normalizeApplication(application, fallback = {}) { function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || ''; const name = application?.name || fallback?.name || '';
...@@ -37,48 +37,59 @@ function isCurrentApplication(application) { ...@@ -37,48 +37,59 @@ function isCurrentApplication(application) {
function ApplicationCard({ application }) { function ApplicationCard({ application }) {
const current = isCurrentApplication(application); const current = isCurrentApplication(application);
const external = application.url?.startsWith('http'); const external = application.url?.startsWith('http');
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return ( return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a <a
className="app-switcher-card" aria-current={current ? 'page' : undefined}
data-current={current ? 'true' : undefined} className="flex min-w-0 items-center gap-3 no-underline"
href={application.url} href={application.url}
rel={external ? 'noreferrer' : undefined} rel={external ? 'noreferrer' : undefined}
target={external ? '_blank' : undefined} target={external ? '_blank' : undefined}
> >
{application.logo ? ( <Surface aria-hidden="true" className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]" variant="tertiary">
<img alt={application.title} className="app-switcher-card__logo" src={application.logo} /> <Avatar className="size-full rounded-[var(--rc-radius-control)] bg-transparent" color="accent" variant="soft">
) : application.name === 'integrity_practice_management' ? ( {application.logo ? <Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" /> : null}
<img alt="鑫元" className="app-switcher-card__logo" src={xyLogo} /> <Avatar.Fallback>{initial}</Avatar.Fallback>
) : ( </Avatar>
<span className="app-switcher-card__initial">{application.title.slice(0, 1)}</span> </Surface>
)} <span className="min-w-0 flex-1">
<span className="app-switcher-card__copy"> <span className="block break-words font-medium leading-5 text-foreground" title={application.title}>{application.title}</span>
<strong>{application.title}</strong> <span className="block truncate text-sm text-muted" title={application.description}>{application.description || application.name}</span>
<small>{application.description || application.name}</small>
</span> </span>
{current ? <span className="app-switcher-card__current">当前</span> : null} {current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a> </a>
</Card.Content>
</Card>
); );
} }
export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) { export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
const fallback = useMemo( const fallback = useMemo(
() => (userInfo?.applications || []).map((item) => normalizeApplication(item, item)).filter((item) => item.name), () => (userInfo?.applications || [])
.map((item) => normalizeApplication(item, { ...item, description: item?.title }))
.filter((item) => item.name),
[userInfo], [userInfo],
); );
const [applications, setApplications] = useState(fallback); const [applications, setApplications] = useState(fallback);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (!open) return undefined; if (!open) return undefined;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError('');
getApplications() getApplications()
.then((response) => { .then((response) => {
if (!cancelled) setApplications(mergeApplications(Array.isArray(response) ? response : [], fallback)); if (!cancelled) setApplications(mergeApplications(Array.isArray(response) ? response : [], fallback));
}) })
.catch(() => { .catch(() => {
if (!cancelled) setApplications(fallback); if (!cancelled) {
setApplications(fallback);
setError('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
...@@ -89,28 +100,35 @@ export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) { ...@@ -89,28 +100,35 @@ export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
}, [fallback, open]); }, [fallback, open]);
return ( return (
<Headless.Dialog className="app-switcher-root" onClose={onClose} open={open}> <Modal.Root>
<Headless.DialogBackdrop className="app-switcher-backdrop" transition /> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<div className="app-switcher-viewport"> <Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && onClose?.()}>
<Headless.DialogPanel className="app-switcher-dialog" transition> <Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<header className="app-switcher-header"> <Modal.Dialog
<div> className="max-h-[min(42rem,calc(100dvh-2rem))]"
<Headless.DialogTitle>应用切换</Headless.DialogTitle> style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
<p>选择你可访问的应用</p> >
<Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0"><Modal.Heading>应用切换</Modal.Heading><Description>选择你可访问的应用</Description></div>
</Modal.Header>
<Modal.Body>
{error ? <Alert status="warning"><Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
{loading && applications.length === 0 ? (
<div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => <Card className="h-full" 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>)}
</div> </div>
<button aria-label="关闭应用切换" className="app-switcher-close" onClick={onClose} type="button"> ) : applications.length > 0 ? (
<Close size={16} /> <div className="grid gap-3 sm:grid-cols-2">
</button>
</header>
<div className="app-switcher-body">
{loading && applications.length === 0 ? <p className="app-switcher-state">应用加载中...</p> : null}
{!loading && applications.length === 0 ? <p className="app-switcher-state">暂无可切换应用</p> : null}
<div className="app-switcher-grid">
{applications.map((application) => <ApplicationCard application={application} key={application.name} />)} {applications.map((application) => <ApplicationCard application={application} key={application.name} />)}
</div> </div>
</div> ) : (
</Headless.DialogPanel> <EmptyState size="md"><EmptyState.Header><EmptyState.Title>暂无可切换应用</EmptyState.Title><EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description></EmptyState.Header></EmptyState>
</div> )}
</Headless.Dialog> </Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
); );
} }
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<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">
<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 { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录';
return {
displayName,
username,
initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U',
};
}
function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Dropdown.Trigger aria-label="打开用户菜单" className="app-sidebar-user-trigger rc-sidebar-user-trigger">
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? <Avatar.Image alt={user.displayName} src={userInfo.avatar} /> : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span className="app-sidebar-user-copy rc-sidebar-user-copy" data-sidebar="label">
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => key === 'logout' && onLogout?.()}>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item"><Label>退出登录</Label><LogOut size={14} /></div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, pathname, userInfo }) {
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<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" src={xyLogoWhite} />
</div>
<div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label">
<span className="app-sidebar-title rc-sidebar-title">{APPLICATION_DISPLAY_NAME}</span>
</div>
<div 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>
</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>
))}
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} onLogout={onLogout} /></Sidebar.Footer>
</>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar"><SidebarContents {...props} /></Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 className="sr-only" slot="title">{APPLICATION_DISPLAY_NAME}导航</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
);
}
import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Close } from './AppIcons';
export function Dialog({ size = 'lg', className, children, open, onClose, ...props }) {
return (
<Modal.Root
isOpen={Boolean(open)}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop {...props} className="rc-dialog-backdrop" variant="blur">
<Modal.Container placement="center" scroll="inside" size={size}>
<Modal.Dialog className={clsx('rc-dialog-panel', `rc-dialog-panel--${size}`, className)}>
<Modal.CloseTrigger
aria-label="关闭"
className="rc-dialog-close rc-dialog-close--automatic absolute inline-flex items-center justify-center rounded-full"
>
<Close size={16} />
</Modal.CloseTrigger>
{children}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export function DialogTitle({ className, ...props }) {
return (
<Modal.Header>
<Modal.Heading {...props} className={clsx('rc-dialog-title', className)} />
</Modal.Header>
);
}
export function DialogBody({ className, ...props }) {
return <Modal.Body {...props} className={clsx('rc-dialog-body', className)} />;
}
export function DialogActions({ className, ...props }) {
return <Modal.Footer {...props} className={clsx('rc-dialog-actions', className)} />;
}
import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react';
function createFileChangeEvent(files) {
return { target: { files }, currentTarget: { files } };
}
export default function FileUploadField({
accept,
ariaLabel = '选择文件',
className = '',
description = '将文件拖到此处,或从本地选择。',
disabled = false,
fileName = '',
multiple = false,
onChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback((files) => {
if (!disabled) onChange?.(createFileChangeEvent(files));
}, [disabled, onChange]);
const handleDrop = useCallback(async (event) => {
const fileItems = Array.from(event?.items || []).filter(
(item) => item?.kind === 'file' && typeof item.getFile === 'function',
);
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(multiple ? files : files.slice(0, 1));
}, [emitFiles, multiple]);
return (
<DropZone className={`rc-file-upload ${className}`.trim()}>
<DropZone.Area aria-label={ariaLabel} isDisabled={disabled} onDrop={handleDrop}>
<DropZone.Input accept={accept} disabled={disabled} multiple={multiple} onSelect={emitFiles} />
<DropZone.Icon><Upload /></DropZone.Icon>
<DropZone.Label title={fileName || undefined}>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>{fileName ? '已选择文件,可重新选择进行替换。' : description}</DropZone.Description>
<DropZone.Trigger isDisabled={disabled}>{triggerLabel}</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
}
import { Calendar as CalendarIcon } from '@/components/AppIcons';
import { Calendar } from '@heroui/react/calendar';
import { DateField } from '@heroui/react/date-field';
import { DatePicker } from '@heroui/react/date-picker';
import { TimeField } from '@heroui/react/time-field';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import clsx from 'clsx';
import { useMemo } from 'react';
function parseValue(value, mode) {
if (!value) return null;
try {
return mode === 'datetime' ? parseDateTime(String(value).slice(0, 16)) : parseDate(String(value).slice(0, 10));
} catch {
return null;
}
}
function formatValue(value, mode) {
if (!value) return '';
const text = value.toString();
if (mode === 'date') return text.slice(0, 10);
return text.includes('T') ? text.slice(0, 16) : `${text.slice(0, 10)}T00:00`;
}
function parseTimeValue(value) {
if (!value || !String(value).includes('T')) return null;
try { return parseTime(String(value).slice(11, 16)); } catch { return null; }
}
export default function FormDatePicker({ className, disabled = false, mode, onChange, value }) {
const resolvedMode = mode === 'datetime' ? 'datetime' : 'date';
const parsedValue = useMemo(() => parseValue(value, resolvedMode), [resolvedMode, value]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
const emit = (nextValue) => onChange?.(formatValue(nextValue, resolvedMode));
return (
<DatePicker aria-label={resolvedMode === 'datetime' ? '选择日期和时间' : '选择日期'} className={clsx('rc-form-date-picker', className)} granularity={resolvedMode === 'datetime' ? 'minute' : 'day'} isDisabled={disabled} value={parsedValue} onChange={emit}>
<DateField.Group className="rc-form-date-picker__group" fullWidth variant="secondary">
<DateField.Input className="rc-form-date-picker__input">{(segment) => <DateField.Segment className="rc-form-date-picker__segment" segment={segment} />}</DateField.Input>
<DateField.Suffix className="rc-form-date-picker__suffix"><DatePicker.Trigger aria-label="打开日期选择" className="rc-form-date-picker__trigger"><DatePicker.TriggerIndicator><CalendarIcon size={16} /></DatePicker.TriggerIndicator></DatePicker.Trigger></DateField.Suffix>
</DateField.Group>
<DatePicker.Popover className="rc-form-date-picker__popover" placement="bottom start">
<Calendar className="rc-form-date-picker__calendar">
<Calendar.Header><Calendar.NavButton slot="previous" /><Calendar.Heading /><Calendar.NavButton slot="next" /></Calendar.Header>
<Calendar.Grid><Calendar.GridHeader>{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}</Calendar.GridHeader><Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody></Calendar.Grid>
</Calendar>
{resolvedMode === 'datetime' ? (
<div className="rc-form-date-picker__time-panel"><span>时间</span><TimeField aria-label="选择时间" granularity="minute" hideTimeZone hourCycle={24} value={timeValue} onChange={(nextTime) => {
const date = String(value || parsedValue || '').slice(0, 10);
if (date) onChange?.(`${date}T${nextTime ? nextTime.toString().slice(0, 5) : '00:00'}`);
}}><TimeField.Group className="rc-form-date-picker__time-group" variant="secondary"><TimeField.Input>{(segment) => <TimeField.Segment className="rc-form-date-picker__segment" segment={segment} />}</TimeField.Input></TimeField.Group></TimeField></div>
) : null}
</DatePicker.Popover>
</DatePicker>
);
}
import { ChevronDown } from '@/components/AppIcons';
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
const EMPTY_ARRAY = [];
function normalizeValue(value, multiple) {
if (multiple) {
return (Array.isArray(value) ? value : value === null || value === undefined || value === '' ? [] : [value])
.map(String);
}
return value === null || value === undefined || value === '' ? null : String(value);
}
function getDisplayText(options, value, multiple, placeholder) {
const selectedValues = multiple ? value : value === null ? EMPTY_ARRAY : [value];
const labels = options
.filter((option) => selectedValues.includes(String(option.value)))
.map((option) => String(option.label ?? option.value));
if (labels.length === 0) return placeholder;
if (!multiple || labels.length <= 2) return labels.join('、');
return `${labels.slice(0, 2).join('、')} 等 ${labels.length} 项`;
}
export default function FormSelect({
ariaLabel,
className,
disabled = false,
multiple = false,
onChange,
options = EMPTY_ARRAY,
placeholder = '请选择',
value,
}) {
const normalizedValue = normalizeValue(value, multiple);
const displayText = getDisplayText(options, normalizedValue, multiple, placeholder);
const hasSelection = multiple ? normalizedValue.length > 0 : normalizedValue !== null;
return (
<Select
aria-label={ariaLabel || placeholder}
className={clsx('rc-form-select', className)}
fullWidth
isDisabled={disabled}
selectionMode={multiple ? 'multiple' : 'single'}
shouldCloseOnSelect={!multiple}
value={normalizedValue}
variant="secondary"
onChange={(nextValue) => {
if (multiple) {
onChange?.((Array.isArray(nextValue) ? nextValue : []).map(String));
return;
}
onChange?.(nextValue === null || nextValue === undefined || nextValue === '' ? null : String(nextValue));
}}
>
<Select.Trigger className="rc-form-select__trigger">
<Select.Value className="rc-form-select__value">
<span className={clsx('rc-form-select__text', !hasSelection && 'is-placeholder')}>{displayText}</span>
</Select.Value>
<Select.Indicator className="rc-form-select__indicator"><ChevronDown size={16} /></Select.Indicator>
</Select.Trigger>
<Select.Popover className="rc-form-select__popover" placement="bottom start">
<ListBox aria-label={ariaLabel || placeholder} className="rc-form-select__listbox">
{options.map((option) => (
<ListBox.Item
className="rc-form-select__option"
id={String(option.value)}
isDisabled={Boolean(option.disabled)}
key={String(option.value)}
textValue={String(option.label ?? option.value)}
>
<Label className="rc-form-select__option-label">{option.label ?? option.value}</Label>
<ListBox.ItemIndicator className="rc-form-select__option-indicator" />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
);
}
import { ChevronDown, Close, Reload, Search } from '@/components/AppIcons'; import { Close, Eye, More, Reload, Search } from '@/components/AppIcons';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import { ActionBar } from '@heroui-pro/react/action-bar'; import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid'; import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Button, Card, Label, ListBox, Pagination, SearchField, Select, Spinner, Tabs, Tooltip } from '@heroui/react'; import { Alert, Button, Dropdown, Label, Pagination, SearchField, Spinner, Surface, Tabs, Tooltip, buttonVariants } from '@heroui/react';
import clsx from 'clsx';
import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useLayoutEffect, useMemo, useRef, useState } from 'react';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
const PAGE_SIZE_OPTIONS = [10, 20, 50]; const ACTION_ICON_CLASS = '!grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid !size-8 shrink-0 place-items-center p-0';
function getRecordId(record) { function getRecordId(record) {
return String(record?.id || record?._id || record?.name || ''); return String(record?.id || record?._id || record?.name || '');
...@@ -61,37 +61,79 @@ function orderActions(actions = []) { ...@@ -61,37 +61,79 @@ function orderActions(actions = []) {
.map(({ action }) => action); .map(({ action }) => action);
} }
function resolveActionColumnWidth(actionCount) {
if (actionCount <= 0) return 0;
const visibleActionCount = actionCount >= 4 ? 3 : actionCount;
return Math.max(80, visibleActionCount * 32 + (visibleActionCount - 1) * 8 + 32);
}
function DataGridEmpty({ loading, title = '暂无数据' }) { function DataGridEmpty({ loading, title = '暂无数据' }) {
if (loading) return <div className="rc-data-grid-loading"><Spinner size="sm" /></div>; if (loading) return <div className="flex min-h-48 items-center justify-center"><Spinner size="sm" /></div>;
return ( return (
<EmptyState className="rc-data-grid-empty" size="md"> <EmptyState size="md">
<EmptyState.Header><EmptyState.Title>{title}</EmptyState.Title></EmptyState.Header> <EmptyState.Header><EmptyState.Title>{title}</EmptyState.Title></EmptyState.Header>
</EmptyState> </EmptyState>
); );
} }
function RowActions({ actions, context }) { function RowActions({ actions, context }) {
const orderedActions = orderActions(actions);
const visibleActions = orderedActions.length >= 4 ? orderedActions.slice(0, 2) : orderedActions;
const overflowActions = orderedActions.length >= 4 ? orderedActions.slice(2) : [];
return ( return (
<div className="rc-row-actions"> <div className="flex items-center justify-start gap-2">
{orderActions(actions).map((action) => { {visibleActions.map((action) => {
const Icon = action.icon; const Icon = action.icon;
return ( return (
<Tooltip delay={0} key={action.key}> <Tooltip delay={0} key={action.key}>
<Button <Button
isIconOnly isIconOnly
aria-label={action.label} aria-label={action.label}
className="rc-row-action"
isDisabled={isActionDisabled(action, context)} isDisabled={isActionDisabled(action, context)}
size="sm" size="sm"
variant={action.tone === 'danger' ? 'danger-soft' : 'ghost'} variant={isDestructiveAction(action) ? 'danger-soft' : 'tertiary'}
onPress={() => action.onClick?.(context)} onPress={() => action.onClick?.(context)}
className={ACTION_BUTTON_LAYOUT_CLASS}
> >
{Icon ? <Icon className="size-4" aria-hidden="true" /> : action.label.slice(0, 1)} <Icon className={ACTION_ICON_CLASS} size={16} aria-hidden="true" />
</Button> </Button>
<Tooltip.Content>{action.label}</Tooltip.Content> <Tooltip.Content>{action.label}</Tooltip.Content>
</Tooltip> </Tooltip>
); );
})} })}
{overflowActions.length > 0 ? (
<Dropdown>
<Dropdown.Trigger
aria-label="更多操作"
className={`${buttonVariants({ isIconOnly: true, size: 'sm', variant: 'tertiary' })} ${ACTION_BUTTON_LAYOUT_CLASS}`}
title="更多操作"
>
<More className={ACTION_ICON_CLASS} size={16} aria-hidden="true" />
</Dropdown.Trigger>
<Dropdown.Popover placement="bottom end">
<Dropdown.Menu aria-label="更多操作" onAction={(key) => overflowActions[Number(key)]?.onClick?.(context)}>
{overflowActions.map((action, index) => {
const Icon = action.icon;
return (
<Dropdown.Item
id={String(index)}
isDisabled={isActionDisabled(action, context)}
key={action.key}
textValue={action.label}
variant={isDestructiveAction(action) ? 'danger' : undefined}
>
<span className="flex items-center gap-2">
{Icon ? <Icon className={ACTION_ICON_CLASS} size={16} aria-hidden="true" /> : null}
<Label>{action.label}</Label>
</span>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
) : null}
</div> </div>
); );
} }
...@@ -106,8 +148,8 @@ function WorkbenchPagination({ pagination }) { ...@@ -106,8 +148,8 @@ function WorkbenchPagination({ pagination }) {
const pageWindowEnd = totalRecords ? Math.min(totalPages, pageWindowStart + 5) : pageWindowStart; const pageWindowEnd = totalRecords ? Math.min(totalPages, pageWindowStart + 5) : pageWindowStart;
const isDisabled = Boolean(pagination.disabled); const isDisabled = Boolean(pagination.disabled);
return ( return (
<div className="pm-pagination-bar"> <div className="min-w-0">
<Pagination aria-label="列表分页" className="w-full" size="sm"> <Pagination aria-label="列表分页" size="sm">
<Pagination.Summary> <Pagination.Summary>
共 {totalRecords} 条,第 {totalRecords ? currentPage + 1 : 0} / {totalRecords ? totalPages : 0} 页 共 {totalRecords} 条,第 {totalRecords ? currentPage + 1 : 0} / {totalRecords ? totalPages : 0} 页
</Pagination.Summary> </Pagination.Summary>
...@@ -128,19 +170,6 @@ function WorkbenchPagination({ pagination }) { ...@@ -128,19 +170,6 @@ function WorkbenchPagination({ pagination }) {
</Pagination.Item> </Pagination.Item>
</Pagination.Content> </Pagination.Content>
</Pagination> </Pagination>
<div className="pm-pagination-controls">
<Select aria-label="每页条数" className="pm-pagination-page-size-select" isDisabled={isDisabled} value={String(pageSize)} variant="secondary" onChange={(key) => pagination.onPageSizeChange?.(Number(key))}>
<Select.Trigger>
<span className="pm-pagination-page-size-copy"><span className="pm-pagination-page-size-label">每页</span><Select.Value className="pm-pagination-page-size-value">{pageSize} 条/页</Select.Value></span>
<Select.Indicator className="pm-pagination-page-size-indicator"><ChevronDown size={14} /></Select.Indicator>
</Select.Trigger>
<Select.Popover className="rc-form-select__popover rc-filter-select__popover" placement="bottom start">
<ListBox aria-label="每页条数">
{PAGE_SIZE_OPTIONS.map((option) => <ListBox.Item className="rc-form-select__option rc-filter-select__option" id={String(option)} key={option} textValue={`${option} 条/页`}><Label>{option} 条/页</Label><ListBox.ItemIndicator /></ListBox.Item>)}
</ListBox>
</Select.Popover>
</Select>
</div>
</div> </div>
); );
} }
...@@ -192,13 +221,14 @@ export default function RecordWorkbench({ ...@@ -192,13 +221,14 @@ export default function RecordWorkbench({
availableSelectionActions.filter((action) => !isActionHidden(action, baseContext)), availableSelectionActions.filter((action) => !isActionHidden(action, baseContext)),
); );
const hasDetailView = Boolean(detailPanel?.render); const hasDetailView = Boolean(detailPanel?.render);
const totalRowActionCount = rowActions.length + (hasDetailView ? 1 : 0);
const columnWidths = useMemo(() => columns.map((column, index) => { const columnWidths = useMemo(() => columns.map((column, index) => {
const configuredWidth = Number(column.width || 180); const configuredWidth = Number(column.width || 180);
return index === 0 && hasDetailView return index === 0 && hasDetailView
? Math.max(Math.min(Math.max(configuredWidth, 220), 320), estimatePrimaryColumnWidth(records, column)) ? Math.max(Math.min(Math.max(configuredWidth, 220), 320), estimatePrimaryColumnWidth(records, column))
: Math.min(Math.max(configuredWidth, 150), 360); : Math.min(Math.max(configuredWidth, 150), 360);
}), [columns, hasDetailView, records]); }), [columns, hasDetailView, records]);
const actionColumnWidth = rowActions.length > 0 ? Math.min(240, Math.max(112, rowActions.length * 40)) : 0; const actionColumnWidth = resolveActionColumnWidth(totalRowActionCount);
const gridMinimumWidth = columnWidths.reduce((total, width) => total + width, 0) + actionColumnWidth + (onSelectionChange ? 44 : 0); const gridMinimumWidth = columnWidths.reduce((total, width) => total + width, 0) + actionColumnWidth + (onSelectionChange ? 44 : 0);
useLayoutEffect(() => { useLayoutEffect(() => {
...@@ -218,48 +248,64 @@ export default function RecordWorkbench({ ...@@ -218,48 +248,64 @@ export default function RecordWorkbench({
id: String(column.field), id: String(column.field),
header: column.label, header: column.label,
isRowHeader: index === 0, isRowHeader: index === 0,
allowsSorting: true,
sortFn: (left, right) => {
const leftValue = formatFieldValue(getFieldValue(left?.raw || left, column.field), column.fieldDef) || '';
const rightValue = formatFieldValue(getFieldValue(right?.raw || right, column.field), column.fieldDef) || '';
return String(leftValue).localeCompare(String(rightValue), 'zh-CN', { numeric: true });
},
allowsResizing: true, allowsResizing: true,
minWidth: width, minWidth: width,
maxWidth: hasHorizontalOverflow ? width : undefined, maxWidth: hasHorizontalOverflow ? width : undefined,
width, width: !hasHorizontalOverflow && index === 0 ? '1fr' : width,
pinned: hasHorizontalOverflow && index === 0 ? 'start' : hasHorizontalOverflow && (column.lockPosition === 'right' || column.pinned === 'right') ? 'end' : undefined, pinned: hasHorizontalOverflow && index === 0 ? 'start' : hasHorizontalOverflow && (column.lockPosition === 'right' || column.pinned === 'right') ? 'end' : undefined,
cell: (record) => { cell: (record) => {
const custom = renderCell?.({ record, column, value: getFieldValue(record?.raw || record, column.field), openDetail }); const custom = renderCell?.({ record, column, value: getFieldValue(record?.raw || record, column.field), openDetail });
if (custom !== undefined && custom !== null) return custom; if (custom !== undefined && custom !== null) return custom;
const value = formatFieldValue(getFieldValue(record?.raw || record, column.field), column.fieldDef) || '-'; const value = formatFieldValue(getFieldValue(record?.raw || record, column.field), column.fieldDef) || '-';
if (index === 0 && hasDetailView) { if (index === 0) {
return ( return (
<Tooltip delay={0}> <Tooltip delay={0}>
<Button aria-label={`查看${String(value)}详情`} className="rc-data-grid-primary-action" size="sm" variant="ghost" onPress={() => openDetail(record)}> <Tooltip.Trigger className="block min-w-0" role="presentation">
<span className="rc-data-grid-text rc-data-grid-primary">{value}</span> <span className="block max-w-80 truncate font-medium">{value}</span>
</Button> </Tooltip.Trigger>
<Tooltip.Content>{String(value)}</Tooltip.Content> <Tooltip.Content>{String(value)}</Tooltip.Content>
</Tooltip> </Tooltip>
); );
} }
return <span className={clsx('rc-data-grid-text', index === 0 && 'rc-data-grid-primary')}>{value}</span>; return <span className="block max-w-80 truncate">{value}</span>;
}, },
}; };
}); });
if (rowActions.length > 0) { if (totalRowActionCount > 0) {
mapped.push({ mapped.push({
id: 'actions', id: 'actions',
header: '操作', header: '操作',
align: 'end', align: 'start',
headerClassName: 'rc-data-grid-actions-header',
cellClassName: 'rc-data-grid-actions-cell',
minWidth: actionColumnWidth, minWidth: actionColumnWidth,
maxWidth: actionColumnWidth,
width: actionColumnWidth, width: actionColumnWidth,
pinned: hasHorizontalOverflow ? 'end' : undefined, pinned: hasHorizontalOverflow ? 'end' : undefined,
cell: (record) => { cell: (record) => {
const context = { ...baseContext, record }; const context = { ...baseContext, record };
const actions = rowActions.filter((action) => !isActionHidden(action, context)); const configuredActions = rowActions.filter((action) => !isActionHidden(action, context));
const actions = hasDetailView
? [
{
key: '__preview',
label: '预览',
icon: Eye,
onClick: ({ record: currentRecord }) => openDetail(currentRecord),
},
...configuredActions.filter((action) => !String(action.action || action.key).endsWith('.viewDetail')),
]
: configuredActions;
return <RowActions actions={actions} context={context} />; return <RowActions actions={actions} context={context} />;
}, },
}); });
} }
return mapped; return mapped;
}, [actionColumnWidth, baseContext, columnWidths, columns, hasDetailView, hasHorizontalOverflow, renderCell, rowActions]); }, [actionColumnWidth, baseContext, columnWidths, columns, hasDetailView, hasHorizontalOverflow, renderCell, rowActions, totalRowActionCount]);
const handleSelectionChange = (selection) => { const handleSelectionChange = (selection) => {
if (selection === 'all') onSelectionChange?.(records.map(getRecordId)); if (selection === 'all') onSelectionChange?.(records.map(getRecordId));
...@@ -271,14 +317,14 @@ export default function RecordWorkbench({ ...@@ -271,14 +317,14 @@ export default function RecordWorkbench({
const hasViewSwitch = views.length > 1; const hasViewSwitch = views.length > 1;
return ( return (
<div className="rc-workbench"> <div className="flex min-w-0 flex-col gap-6">
{hasViewSwitch ? ( {hasViewSwitch ? (
<div className="rc-workbench-toolbar"> <div className="min-w-0 max-w-full">
<Tabs className="filter-tabs-wrap" selectedKey={activeViewName} onSelectionChange={(key) => onViewChange?.(String(key))}> <Tabs className="w-fit max-w-full min-w-0" selectedKey={activeViewName} onSelectionChange={(key) => onViewChange?.(String(key))}>
<Tabs.ListContainer> <Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="数据视图"> <Tabs.List aria-label="数据视图" className="w-max min-w-max">
{views.map((view) => ( {views.map((view) => (
<Tabs.Tab id={view.name} key={view.name}> <Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={view.name} key={view.name}>
<span>{getViewDisplayLabel(view)}</span> <span>{getViewDisplayLabel(view)}</span>
<Tabs.Indicator /> <Tabs.Indicator />
</Tabs.Tab> </Tabs.Tab>
...@@ -290,67 +336,64 @@ export default function RecordWorkbench({ ...@@ -290,67 +336,64 @@ export default function RecordWorkbench({
) : null} ) : null}
{filterCardSchema.length > 0 ? ( {filterCardSchema.length > 0 ? (
<Card className="rc-filter-card"> <Surface className="p-6">
<Card.Content className="rc-filter-card__content"> <div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="rc-filter-primary-row"> <div className="grid min-w-0 flex-1 gap-3 md:grid-cols-3">
<div className="rc-filter-primary-fields">
{filterCardSchema.map((field) => field.type === 'text' ? ( {filterCardSchema.map((field) => field.type === 'text' ? (
<SearchField aria-label={field.label} className="rc-filter-search" fullWidth key={field.key} value={filters[field.key] || ''} onChange={(value) => onFiltersChange?.(field.key, value)}> <SearchField aria-label={field.label} fullWidth key={field.key} value={filters[field.key] || ''} onChange={(value) => onFiltersChange?.(field.key, value)}>
<SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder={field.placeholder} /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group> <SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder={field.placeholder} /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group>
</SearchField> </SearchField>
) : null)} ) : null)}
</div> </div>
{onClearFilters ? ( {onClearFilters ? (
<div className="rc-filter-actions"> <div className="flex shrink-0 items-center">
<Tooltip delay={0}> <Tooltip delay={0}>
<Button isIconOnly aria-label="重置筛选" className="rc-filter-reset-button" size="sm" variant="ghost" onPress={onClearFilters}><Reload size={16} /></Button> <Button isIconOnly aria-label="重置筛选" size="sm" variant="ghost" onPress={onClearFilters}><Reload size={16} /></Button>
<Tooltip.Content>重置筛选</Tooltip.Content> <Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip> </Tooltip>
</div> </div>
) : null} ) : null}
</div> </div>
</Card.Content> </Surface>
</Card>
) : null} ) : null}
<section className="rc-list-section"> <section className="flex min-w-0 flex-col gap-3">
{listSearchField || visibleToolbarActions.length > 0 ? ( {listSearchField || visibleToolbarActions.length > 0 ? (
<header className="rc-list-header"> <header className="flex min-w-0 flex-col gap-3 md:flex-row md:items-center md:justify-between">
{listSearchField ? ( {listSearchField ? (
<SearchField aria-label={listSearchField.label} className="rc-toolbar-search" value={filters[listSearchField.key] || ''} onChange={(value) => onFiltersChange?.(listSearchField.key, value)}> <SearchField aria-label={listSearchField.label} className="w-full md:w-96" value={filters[listSearchField.key] || ''} onChange={(value) => onFiltersChange?.(listSearchField.key, value)}>
<SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder={listSearchField.placeholder} /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group> <SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input placeholder={listSearchField.placeholder} /><SearchField.ClearButton aria-label="清空搜索" /></SearchField.Group>
</SearchField> </SearchField>
) : null} ) : null}
{visibleToolbarActions.length > 0 ? <div className="rc-list-actions">{visibleToolbarActions.map((action) => <Button isDisabled={isActionDisabled(action, baseContext)} key={action.key} size="sm" variant={action.tone === 'primary' ? 'primary' : 'outline'} onPress={() => action.onClick?.(baseContext)}>{action.label}</Button>)}</div> : null} {visibleToolbarActions.length > 0 ? <div className="flex flex-wrap items-center justify-end gap-2">{visibleToolbarActions.map((action) => <Button isDisabled={isActionDisabled(action, baseContext)} key={action.key} size="sm" variant={action.tone === 'primary' ? 'primary' : 'secondary'} onPress={() => action.onClick?.(baseContext)}>{action.label}</Button>)}</div> : null}
</header> </header>
) : null} ) : null}
{states.error ? <div className="rc-context-alert" role="alert">{states.error}</div> : null} {states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className={clsx('rc-data-grid-wrap', records.length === 0 ? 'is-empty' : 'is-populated', hasHorizontalOverflow && 'is-wide')} ref={gridWrapRef} style={{ '--rc-data-grid-min-width': `${gridMinimumWidth}px` }}> <div className="relative min-w-0" ref={gridWrapRef}>
<DataGrid <DataGrid
showSelectionCheckboxes={Boolean(onSelectionChange)} showSelectionCheckboxes={Boolean(onSelectionChange)}
allowsColumnResize allowsColumnResize
aria-label={title || '业务数据'} aria-label={title || '业务数据'}
className="rc-data-grid" className="w-full"
columns={gridColumns} columns={gridColumns}
contentClassName={hasHorizontalOverflow ? 'rc-data-grid-content is-wide' : 'rc-data-grid-content'} contentClassName="min-w-full"
data={records} data={records}
getRowId={getRecordId} getRowId={getRecordId}
renderEmptyState={() => <DataGridEmpty loading={states.loading} title={states.emptyTitle || '暂无数据'} />} renderEmptyState={() => <DataGridEmpty loading={states.loading} title={states.emptyTitle || '暂无数据'} />}
scrollContainerClassName="rc-data-grid-scroll" scrollContainerClassName="overflow-x-auto"
selectedKeys={new Set(selectedIds)} selectedKeys={new Set(selectedIds)}
selectionMode={onSelectionChange ? 'multiple' : 'none'} selectionMode={onSelectionChange ? 'multiple' : 'none'}
variant="primary" variant="primary"
verticalAlign="middle" verticalAlign="middle"
onRowAction={(key) => { const record = records.find((item) => getRecordId(item) === String(key)); if (record) openDetail(record); }}
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined} onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
/> />
{states.loading && records.length > 0 ? <div className="rc-data-grid-busy"><Spinner size="sm" /></div> : null} {states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div> </div>
<WorkbenchPagination pagination={pagination} /> <WorkbenchPagination pagination={pagination} />
</section> </section>
<ActionBar aria-label="批量操作" className="rc-selection-action-bar" isOpen={selectedIds.length > 0}> <ActionBar aria-label="批量操作" isOpen={selectedIds.length > 0}>
<ActionBar.Prefix><span className="rc-selection-summary">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix> <ActionBar.Prefix><span className="whitespace-nowrap text-sm text-muted">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix>
{visibleSelectionActions.length > 0 ? ( {visibleSelectionActions.length > 0 ? (
<ActionBar.Content> <ActionBar.Content>
{visibleSelectionActions.map((action) => ( {visibleSelectionActions.map((action) => (
......
export const APPLICATION_DISPLAY_NAME = '廉洁从业管理';
export const APPLICATION_BASE_PATH = '/integrity_practice_management/';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/self_check_the_results_of_the_working_draft': '自查底稿填写表',
'/self_inspection_submission_form': '附件1:廉洁从业自查底稿',
'/statistical_table': '附件2:廉洁从业规定落实情况统计表',
'/integrity_practice_report': '廉洁从业报告生成',
'/self_check_the_draft_template': '自查底稿模版',
'/self_check_the_working_draft': '自查底稿项目',
'/self_inspection_annual_submission_materials': '历年报送材料',
});
.rc-app-shell {
position: fixed;
inset: 0;
display: flex;
overflow: hidden;
color: var(--rc-text-primary);
background: var(--rc-bg-canvas);
}
.rc-sidebar,
.rc-mobile-sidebar {
display: flex;
flex-direction: column;
color: var(--foreground);
background: var(--rc-bg-canvas);
}
.rc-sidebar {
position: relative;
z-index: 30;
width: 15rem;
height: 100%;
flex: none;
padding: var(--rc-space-4) var(--rc-space-3) var(--rc-space-3);
transition: width var(--rc-motion-normal) var(--rc-ease-standard);
}
.rc-sidebar.is-collapsed {
width: 4rem;
}
.rc-sidebar-brand {
width: 100%;
min-width: 0;
display: flex;
min-height: 3rem;
align-items: center;
gap: var(--rc-space-3);
padding: var(--rc-space-2) var(--rc-space-1);
color: var(--foreground);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-semibold);
}
.rc-sidebar-brand.is-collapsed {
justify-content: center;
padding-inline: 0;
}
.rc-sidebar-brand-mark {
display: flex;
width: 2.75rem;
height: 1.5rem;
flex: none;
align-items: center;
justify-content: center;
overflow: hidden;
}
.rc-sidebar-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.rc-sidebar-brand-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: var(--rc-space-2);
}
.rc-sidebar-app-icon {
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
flex: none;
align-items: center;
justify-content: center;
border-radius: var(--rc-radius-control);
color: var(--rc-brand-light);
background: rgb(255 255 255 / 8%);
}
.rc-sidebar-title {
min-width: 0;
overflow: hidden;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2rem;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-app-switcher {
flex: none;
}
.rc-sidebar-content {
min-height: 0;
flex: 1;
overflow-y: auto;
padding-top: var(--rc-space-4);
scrollbar-width: thin;
}
.rc-sidebar-group + .rc-sidebar-group {
margin-top: var(--rc-space-5);
}
.rc-sidebar-group-label {
margin: 0 0 var(--rc-space-2);
padding: 0 var(--rc-space-3);
color: var(--muted);
font-size: var(--rc-text-xs);
font-weight: var(--rc-font-medium);
line-height: var(--rc-line-normal);
}
.rc-sidebar-menu {
display: grid;
gap: var(--rc-space-1);
}
.rc-sidebar-item {
position: relative;
display: flex;
min-width: 0;
height: 2.5rem;
align-items: center;
gap: var(--rc-space-3);
padding: 0 var(--rc-space-3);
border-radius: var(--rc-radius-control);
color: var(--muted);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-medium);
text-decoration: none;
transition: color var(--rc-motion-fast) var(--rc-ease-standard),
background var(--rc-motion-fast) var(--rc-ease-standard);
}
.rc-sidebar-item:hover {
color: var(--foreground);
background: var(--default);
}
.rc-sidebar-item.is-active {
color: var(--foreground);
background: var(--default);
}
.rc-sidebar-item.is-active::before {
position: absolute;
inset-block: 0.5rem;
left: 0;
width: 0.1875rem;
border-radius: 0 999px 999px 0;
background: var(--rc-brand-primary);
content: '';
}
.rc-sidebar-item.is-collapsed {
justify-content: center;
padding-inline: 0;
}
.rc-sidebar-item-icon {
flex: none;
font-size: 1rem;
}
.rc-sidebar-item.is-active .rc-sidebar-item-icon {
color: var(--rc-brand-light);
}
.rc-sidebar-item-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-state {
margin: var(--rc-space-2);
padding: var(--rc-space-3);
border-radius: var(--rc-radius-control);
color: var(--muted);
background: var(--default);
font-size: var(--rc-text-xs);
}
.rc-sidebar-state.is-error {
color: rgb(254 202 202);
background: rgb(201 54 54 / 14%);
}
.rc-sidebar.is-collapsed .rc-sidebar-state {
display: none;
}
.rc-sidebar-user {
display: flex;
width: 100%;
min-height: 3rem;
align-items: center;
gap: var(--rc-space-3);
padding: 0.375rem var(--rc-space-2);
border: 0;
border-radius: var(--rc-radius-container);
color: var(--foreground);
background: transparent;
cursor: pointer;
text-align: left;
}
.rc-sidebar-user:hover,
.rc-sidebar-user:focus-visible {
background: rgb(255 255 255 / 7%);
}
.rc-sidebar-user:focus-visible,
.rc-sidebar-item:focus-visible {
outline: 2px solid var(--rc-brand-light);
outline-offset: 2px;
}
.rc-sidebar-user.is-collapsed {
justify-content: center;
padding-inline: 0;
}
.rc-sidebar-avatar {
flex: none;
background: var(--rc-brand-primary);
}
.rc-sidebar-user-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.rc-sidebar-user-copy strong,
.rc-sidebar-user-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-sidebar-user-copy strong {
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-medium);
}
.rc-sidebar-user-copy small {
margin-top: 0.125rem;
color: var(--muted);
font-size: var(--rc-text-xs);
}
.rc-sidebar-logout {
flex: none;
color: var(--muted);
font-size: 0.875rem;
}
.rc-app-main {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.rc-navbar {
flex: none;
height: 4rem;
min-height: 4rem;
max-height: 4rem;
border: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.rc-navbar .navbar__header {
width: 100%;
height: 4rem;
min-height: 4rem;
max-height: 4rem;
gap: var(--rc-space-3);
padding: 0 var(--rc-navbar-inline-padding);
}
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-trigger-size);
min-width: var(--rc-navbar-trigger-size);
max-width: var(--rc-navbar-trigger-size);
height: 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.toggle-button {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex: 0 0 var(--rc-navbar-icon-button-size);
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--foreground);
box-shadow: none;
line-height: 1;
}
.rc-navbar [data-slot='sidebar-trigger'] {
background: transparent !important;
}
.rc-navbar-icon-button.button,
.rc-navbar-icon-button.toggle-button {
background: var(--default) !important;
}
.rc-navbar [data-slot='sidebar-trigger']:hover,
.rc-navbar [data-slot='sidebar-trigger'][data-hovered='true'],
.rc-navbar [data-slot='sidebar-trigger']:focus-visible {
color: var(--foreground);
background: var(--default) !important;
}
.rc-navbar [data-slot='sidebar-trigger']:active,
.rc-navbar [data-slot='sidebar-trigger'][data-pressed='true'] {
background: var(--default-hover) !important;
}
.rc-navbar-icon-button.button:hover,
.rc-navbar-icon-button.button[data-hovered='true'],
.rc-navbar-icon-button.toggle-button:hover,
.rc-navbar-icon-button.toggle-button[data-hovered='true'] {
color: var(--foreground);
background: var(--default-hover) !important;
}
.rc-navbar [data-slot='sidebar-trigger'] > span,
.rc-navbar-icon-button > span,
.rc-navbar-icon-button .anticon {
display: grid;
width: 1rem;
height: 1rem;
place-items: center;
line-height: 1;
}
.rc-navbar [data-slot='sidebar-trigger'] > svg,
.rc-navbar [data-slot='sidebar-trigger'] > span > svg,
.rc-navbar-icon-button > svg,
.rc-navbar-icon-button .anticon > svg {
display: block;
width: 1rem;
height: 1rem;
margin: 0;
}
.rc-navbar-actions {
display: flex;
align-items: center;
gap: var(--rc-space-2);
}
.rc-navbar-title {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--foreground);
font-size: var(--rc-navbar-title-size);
font-weight: var(--rc-navbar-title-weight);
line-height: var(--rc-navbar-title-line-height);
letter-spacing: 0 !important;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-navbar-spacer {
flex: 1;
}
.app-shell-content-scroll {
min-width: 0;
background: var(--rc-bg-canvas);
overscroll-behavior: contain;
}
.rc-app-page {
width: 100%;
max-width: none;
min-width: 0;
min-height: 100%;
margin: 0;
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;
}
/* 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-chart-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.rc-chart-grid > * {
min-width: 0;
}
.rc-chart-grid > :only-child {
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
}
.rc-chart-interaction:focus-visible,
.rc-chart-interaction :focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--rc-related-item-gap);
}
@media (min-width: 48rem) {
.rc-kpi-grid:not([data-count='1']) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 80rem) {
.rc-kpi-grid[data-count='1'] {
grid-template-columns: minmax(0, 1fr);
}
.rc-kpi-grid[data-count='2'] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='3'],
.rc-kpi-grid[data-count='5'],
.rc-kpi-grid[data-count='6'],
.rc-kpi-grid[data-count='9'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='4'],
.rc-kpi-grid[data-count='7'],
.rc-kpi-grid[data-count='8'] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Shared HeroUI/HeroUI Pro business-card contract. */
.rc-card-surface {
border: 1px solid var(--rc-border-default);
background-color: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
/* Detail overlays use three explicit surface levels: dialog, detail well, and grid header. */
.rc-dialog-panel--4xl.modal__dialog {
width: calc(100vw - 2rem);
max-width: var(--rc-dialog-width-4xl);
}
.rc-detail-dialog.modal__dialog {
padding: 1.75rem;
border-radius: var(--rc-radius-modal);
background-color: var(--overlay);
box-shadow: var(--rc-shadow-overlay);
}
.rc-detail-dialog > .modal__close-trigger {
inset-inline-end: 1.75rem;
top: 1.75rem;
}
.rc-detail-dialog > .modal__header + .modal__body {
margin-top: 0.75rem;
}
.rc-detail-dialog > .modal__body + .modal__footer {
margin-top: 1.5rem;
}
.rc-detail-surface.surface--secondary {
border-radius: var(--rc-radius-detail-surface);
}
.rc-detail-data-grid.table-root--primary {
overflow: clip;
border-radius: var(--rc-radius-data-grid);
background-color: var(--surface-tertiary);
}
.rc-detail-data-grid :is(.table__header, .table__column),
.rc-detail-data-grid[data-slot='data-grid'] .table__column[data-pinned] {
background-color: var(--surface-tertiary);
}
.rc-detail-data-grid :is(.table__cell, .data-grid__selection-cell, .data-grid__drag-handle-cell) {
background-color: var(--surface);
}
/* Remote reference fields keep long selections compact and scroll inside the popup. */
.rc-reference-select-trigger.button {
width: 100%;
min-width: 0;
height: 2.5rem;
min-height: 2.5rem;
justify-content: flex-start;
gap: var(--rc-space-2);
padding-inline: var(--rc-space-3);
border-radius: var(--rc-radius-container);
color: var(--rc-text-primary);
font-weight: var(--rc-font-medium);
}
.rc-reference-select-trigger__value {
min-width: 0;
flex: 1;
overflow: hidden;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-reference-select-trigger__indicator,
.rc-reference-select-trigger__indicator > .anticon {
display: grid;
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
place-items: center;
color: var(--rc-text-muted);
line-height: 1;
}
.rc-reference-select-trigger__indicator > .anticon > svg {
display: block;
width: 1rem;
height: 1rem;
margin: 0;
}
.rc-reference-select-trigger[aria-expanded='true']
.rc-reference-select-trigger__indicator {
color: var(--rc-text-secondary);
}
.rc-reference-select-popover.popover {
width: min(24rem, calc(100vw - 1.5rem));
overflow: clip;
border: 1px solid var(--rc-border-default);
border-radius: var(--rc-radius-overlay);
background-color: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-raised);
}
.rc-reference-select-options.list-box {
max-height: 18rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.rc-reference-select-options .rc-reference-select-option.list-box-item {
min-height: 3rem;
border-radius: var(--rc-radius-container);
}
.rc-reference-select-option__content {
width: 100%;
max-width: 100%;
overflow: hidden;
}
.rc-reference-select-option__label {
display: block;
width: 100%;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-reference-select-options
.rc-reference-select-option.list-box-item:is(
[aria-selected='true'],
[data-selected='true']
) {
color: var(--rc-brand-primary);
background-color: var(--accent-soft);
}
@media (hover: hover) {
.rc-reference-select-options
.rc-reference-select-option.list-box-item:is(
[aria-selected='true'],
[data-selected='true']
):hover {
color: var(--rc-brand-dark);
background-color: var(--accent-soft-hover);
}
}
@media (min-width: 640px) {
.rc-dialog-panel--4xl.modal__dialog {
width: calc(100vw - 5rem);
}
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
display: grid;
width: 3rem;
min-width: 3rem;
max-width: 3rem;
height: 3rem;
min-height: 3rem;
max-height: 3rem;
flex: 0 0 3rem;
place-items: center;
border-radius: 1rem;
color: var(--rc-metric-icon-color);
background-color: var(--rc-metric-icon-background);
line-height: 1;
}
.rc-metric-icon-surface[data-tone='info'] {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-metric-icon-surface[data-tone='success'] {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-metric-icon-surface[data-tone='teal'] {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-metric-icon-surface[data-tone='danger'] {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-metric-icon-surface[data-tone='warning'] {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-metric-icon-surface[data-tone='violet'] {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-metric-icon-surface[data-tone='indigo'] {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-metric-icon-surface[data-tone='coral'] {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
/* Untoned KPI items receive a unique ordered tone for boards of up to nine. */
.rc-kpi-grid > :nth-child(1) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-kpi-grid > :nth-child(2) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-kpi-grid > :nth-child(3) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-kpi-grid > :nth-child(4) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-kpi-grid > :nth-child(5) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-kpi-grid > :nth-child(6) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-kpi-grid > :nth-child(7) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-kpi-grid > :nth-child(8) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
.rc-kpi-grid > :nth-child(9) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
}
.rc-metric-icon-surface > .anticon,
.rc-metric-icon-surface > svg {
display: block;
width: 1.25rem;
height: 1.25rem;
margin: 0;
color: inherit;
font-size: 1.25rem;
line-height: 1;
}
.rc-metric-icon-surface > .anticon > svg {
display: block;
width: 1em;
height: 1em;
margin: 0;
}
.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);
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rc-mobile-sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgb(15 23 42 / 32%);
transition: opacity var(--rc-motion-normal) var(--rc-ease-standard);
}
.rc-mobile-sidebar-panel {
position: fixed;
inset-block: 0;
left: 0;
z-index: 210;
width: min(19rem, calc(100vw - 3rem));
transition: transform var(--rc-motion-normal) var(--rc-ease-standard);
}
.rc-mobile-sidebar {
position: relative;
width: 100%;
height: 100%;
padding: var(--rc-space-4) var(--rc-space-3) var(--rc-space-3);
box-shadow: var(--rc-shadow-overlay);
}
.rc-mobile-sidebar-close.button {
position: absolute;
top: var(--rc-space-3);
right: var(--rc-space-3);
z-index: 1;
width: 2rem;
min-width: 2rem;
height: 2rem;
border-radius: 999px;
color: rgb(212 212 216);
}
@media (max-width: 1023px) {
.rc-app-main {
--rc-shell-inline-gutter: var(--rc-space-8);
}
.rc-sidebar {
display: none;
}
.rc-navbar {
min-height: 4rem;
}
}
@media (min-width: 768px) {
.rc-page-toolbar-responsive {
flex-direction: row;
align-items: center;
}
}
@media (max-width: 640px) {
.rc-app-main {
--rc-shell-inline-gutter: var(--rc-space-4);
--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 {
padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
}
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex-basis: var(--rc-navbar-icon-button-size);
}
}
/* HeroUI Pro offcanvas shell contract */
.rc-app-shell.sidebar__provider {
position: fixed;
inset: 0;
width: 100%;
height: 100dvh;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--rc-bg-canvas);
}
.rc-app-shell .sidebar__offcanvas-wrapper {
--sidebar-width: 15rem;
}
.rc-app-shell .rc-sidebar.sidebar,
.rc-mobile-sidebar {
width: 15rem;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
border-right: 1px solid var(--rc-border-default);
border-radius: 0;
background: var(--rc-bg-canvas);
box-shadow: none;
}
.rc-app-shell .sidebar__header,
.rc-mobile-sidebar .sidebar__header {
padding: var(--rc-space-2) var(--rc-space-4) var(--rc-space-2);
}
.rc-sidebar-brand {
min-height: 3rem;
gap: var(--rc-space-3);
padding: var(--rc-space-2) var(--rc-space-1);
}
.rc-sidebar-title {
min-width: 0;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2rem;
}
.rc-sidebar-app-trigger.button {
display: grid;
width: var(--rc-sidebar-app-trigger-size);
height: var(--rc-sidebar-app-trigger-size);
min-width: var(--rc-sidebar-app-trigger-size);
max-width: var(--rc-sidebar-app-trigger-size);
flex: 0 0 var(--rc-sidebar-app-trigger-size);
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
background: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
}
@media (hover: hover) {
.rc-sidebar-app-trigger.button:hover,
.rc-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background: var(--accent-soft);
}
}
.rc-sidebar-app-trigger.button[data-pressed='true'],
.rc-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background: var(--accent-soft);
}
.rc-sidebar-app-popover {
width: min(45rem, calc(100vw - 2rem));
padding: 0;
}
.rc-sidebar-app-dialog {
max-height: min(32rem, calc(100dvh - 7rem));
padding: 1.5rem;
overflow-y: auto;
outline: none;
}
.rc-sidebar-app-card {
min-width: 0;
cursor: pointer;
}
.rc-sidebar-app-card[aria-disabled='true'] {
cursor: default;
opacity: 0.6;
}
.rc-sidebar-app-logo,
.rc-sidebar-app-initial {
width: 2rem;
height: 2rem;
border-radius: var(--rc-radius-container);
object-fit: contain;
}
.rc-sidebar-app-initial {
display: grid;
place-items: center;
color: var(--foreground);
background: var(--default);
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-semibold);
}
.rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
padding: 0 var(--rc-space-3);
}
.rc-app-shell .sidebar__group,
.rc-mobile-sidebar .sidebar__group {
padding: 0;
}
.rc-app-shell .sidebar__group + .sidebar__group,
.rc-mobile-sidebar .sidebar__group + .sidebar__group {
margin-top: var(--rc-space-2);
}
.rc-app-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label {
padding: 0.375rem var(--rc-space-2);
color: var(--rc-text-muted);
}
.rc-app-shell .sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item-content {
min-height: 2.25rem;
border-radius: calc(var(--radius) * 2);
color: var(--foreground);
}
.rc-app-shell .sidebar__menu-icon,
.rc-app-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-label {
color: inherit;
}
.rc-app-shell
.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(--foreground);
background: var(--default);
}
.rc-app-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-app-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 {
color: var(--foreground);
background: var(--default);
}
.rc-app-shell .sidebar__footer,
.rc-mobile-sidebar .sidebar__footer {
padding: var(--rc-space-2) var(--rc-space-3) var(--rc-space-4);
}
.rc-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
justify-content: center;
gap: 0.625rem;
padding: 0.375rem 0.5rem;
border-radius: calc(var(--radius) * 2);
color: var(--foreground);
background: transparent;
text-align: left;
}
.rc-sidebar-user-trigger.dropdown__trigger:hover,
.rc-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.rc-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
.rc-sidebar-avatar {
width: 2rem;
height: 2rem;
min-width: 2rem;
flex: 0 0 2rem;
background: var(--default);
}
.rc-sidebar-user-copy {
gap: 0.125rem;
line-height: 1;
}
.rc-sidebar-user-menu-item {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: var(--rc-space-2);
}
.rc-app-main.sidebar__main {
height: 100dvh;
min-height: 0;
max-height: 100dvh;
margin: 0;
overflow: hidden;
border: 0;
border-radius: 0;
background: var(--rc-bg-canvas);
}
.dark .rc-app-main.sidebar__main,
[data-theme='dark'] .rc-app-main.sidebar__main,
.dark .app-shell-content-scroll,
[data-theme='dark'] .app-shell-content-scroll {
background: var(--rc-bg-canvas);
}
.rc-navbar {
flex: none;
height: 4rem;
min-height: 4rem;
max-height: 4rem;
border: 0;
background: transparent;
}
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-trigger-size);
min-width: var(--rc-navbar-trigger-size);
max-width: var(--rc-navbar-trigger-size);
height: var(--rc-navbar-trigger-size);
flex: 0 0 var(--rc-navbar-trigger-size);
padding: 0;
}
.rc-navbar-icon-button.button,
.rc-navbar-icon-button.toggle-button {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex: 0 0 var(--rc-navbar-icon-button-size);
padding: 0;
}
@media (max-width: 768px) {
.rc-navbar [data-slot='sidebar-trigger'] {
width: var(--rc-navbar-icon-button-size);
min-width: var(--rc-navbar-icon-button-size);
max-width: var(--rc-navbar-icon-button-size);
height: var(--rc-navbar-icon-button-size);
flex-basis: var(--rc-navbar-icon-button-size);
}
.rc-app-shell > .sidebar__offcanvas-wrapper {
display: none;
}
.rc-mobile-sidebar {
width: 100%;
border-right: 0;
}
}
@media (min-width: 769px) {
.rc-app-shell .rc-sidebar.sidebar {
display: flex;
}
}
.rc-login-page {
display: grid;
min-height: 100dvh;
place-items: center;
padding: var(--rc-space-6);
background: var(--app-background-gradient);
}
.rc-login-shell {
display: grid;
width: min(100%, var(--rc-login-panel-width));
gap: var(--rc-login-brand-gap);
}
.rc-login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--rc-space-3);
color: var(--rc-text-primary);
}
.rc-login-brand img {
width: auto;
max-width: 8rem;
height: 1.5rem;
filter: none;
object-fit: contain;
}
.rc-login-brand strong {
font-size: var(--rc-login-brand-size);
font-weight: var(--rc-font-semibold);
}
.rc-login-card.card {
width: 100%;
border-radius: var(--rc-radius-overlay);
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-raised);
}
.rc-login-form {
display: grid;
gap: var(--rc-space-5);
}
@media (max-width: 480px) {
.rc-login-page {
align-items: start;
padding: 12vh var(--rc-space-4) var(--rc-space-4);
}
.rc-login-brand {
align-items: flex-start;
flex-direction: column;
}
}
/* approved Xinyuan baseline Sidebar geometry and typography contract. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
width: 15rem;
height: 100%;
max-height: 100%;
margin: 0;
overflow: hidden;
border: 0;
border-right: 1px solid var(--rc-border-default);
border-radius: 0;
color: var(--foreground);
background: var(--rc-bg-canvas);
box-shadow: none;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
.template-dashboard-shell .sidebar__header,
.rc-mobile-sidebar .sidebar__header {
padding: 6px 1rem 0.5rem;
}
.app-sidebar-brand {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.25rem;
}
.app-sidebar-brand-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.5rem;
}
.app-sidebar-brand-mark {
display: grid;
width: 1.75rem;
height: 1.75rem;
flex: none;
place-items: center;
overflow: hidden;
}
.app-sidebar-brand-mark img {
grid-area: 1 / 1;
display: block;
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
}
.app-sidebar-title {
min-width: 0;
overflow: hidden;
color: var(--foreground);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.2rem;
letter-spacing: 0;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-sidebar-app-switcher {
flex: none;
}
.app-sidebar-app-trigger.button {
display: grid;
width: var(--rc-sidebar-app-trigger-size);
height: var(--rc-sidebar-app-trigger-size);
min-width: var(--rc-sidebar-app-trigger-size);
max-width: var(--rc-sidebar-app-trigger-size);
flex: none;
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
background-color: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
--button-fg: var(--foreground);
}
.app-sidebar-app-trigger .anticon {
display: grid;
width: var(--rc-sidebar-app-trigger-icon-size);
height: var(--rc-sidebar-app-trigger-icon-size);
place-items: center;
line-height: 1;
}
.app-sidebar-app-trigger .anticon > svg {
display: block;
width: 100%;
height: 100%;
}
@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);
}
}
.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);
}
.app-sidebar-app-dialog {
overflow: hidden;
outline: none;
}
.app-sidebar-app-scroll {
max-height: min(32rem, calc(100dvh - 7rem));
padding: 1.5rem;
overflow-y: auto;
}
.template-dashboard-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
padding-inline: 0.75rem;
}
.template-dashboard-shell .sidebar__group,
.rc-mobile-sidebar .sidebar__group {
padding: 0;
}
.template-dashboard-shell .sidebar__group + .sidebar__group,
.rc-mobile-sidebar .sidebar__group + .sidebar__group {
margin-top: 0.5rem;
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label {
padding: 0.375rem 0.5rem;
color: var(--rc-text-muted);
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
letter-spacing: 0;
}
.template-dashboard-shell .sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item-content {
min-height: 2.25rem;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-radius: calc(var(--radius) * 2);
color: var(--foreground);
font-weight: 400;
}
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon {
width: 1.25rem;
height: 1.25rem;
color: var(--muted);
}
.template-dashboard-shell .sidebar__menu-icon svg,
.rc-mobile-sidebar .sidebar__menu-icon svg {
width: 1rem;
height: 1rem;
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label {
color: var(--foreground);
font-size: 0.875rem;
font-weight: inherit;
line-height: 1.25rem;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.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[data-current='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 {
color: var(--foreground);
background: var(--default);
}
.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 {
font-weight: 500;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
:is(.sidebar__menu-icon, .sidebar__menu-label),
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
:is(.sidebar__menu-icon, .sidebar__menu-label) {
color: var(--foreground);
}
.template-dashboard-shell .sidebar__footer,
.rc-mobile-sidebar .sidebar__footer {
padding: 0.5rem 0.75rem 1rem;
}
.app-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
justify-content: flex-start;
gap: 0.625rem;
padding: 0.375rem 0.5rem !important;
border-radius: calc(var(--radius) * 2) !important;
color: var(--foreground);
background: transparent;
text-align: left;
}
@media (hover: hover) {
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--default);
}
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
.app-sidebar-user-avatar {
width: 2rem;
height: 2rem;
min-width: 2rem;
flex: 0 0 2rem;
background: var(--default);
}
.app-sidebar-user-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.125rem;
line-height: 1;
}
.app-sidebar-user-name,
.app-sidebar-user-description {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-sidebar-user-name {
color: var(--foreground);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.125rem;
}
.app-sidebar-user-description {
color: var(--muted);
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
}
@media (max-width: 768px) {
.rc-mobile-sidebar.app-sidebar-glass {
width: 100%;
border-right: 0;
}
}
/*
* 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.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--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);
--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);
/* 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.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 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);
--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);
}
:root, :root,
.light, .light,
[data-theme='light'] { .default,
--rc-brand-primary: #9e1b1b; [data-theme='light'],
--rc-brand-light: #b83a3a; [data-theme='default'],
--rc-brand-dark: #7e1515; .dark,
--rc-brand-primary-soft: rgb(158 27 27 / 8%); [data-theme='dark'] {
--rc-brand-primary-soft-hover: rgb(158 27 27 / 12%); /* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-on-primary: #fff; --rc-brand-primary: var(--accent);
--rc-color-success: #16845b; --rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-color-success-soft: rgb(22 132 91 / 9%); --rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
--rc-color-success-border: rgb(22 132 91 / 24%);
--rc-color-warning: #b46a00; --rc-bg-canvas: var(--background);
--rc-color-warning-soft: rgb(180 106 0 / 10%); --rc-bg-surface: var(--surface);
--rc-color-warning-border: rgb(180 106 0 / 25%); --rc-border-default: var(--border);
--rc-color-danger: #c93636; --rc-field-background: var(--field-background);
--rc-color-danger-soft: rgb(201 54 54 / 9%); --rc-field-border-focus: var(--focus);
--rc-color-danger-border: rgb(201 54 54 / 22%); --rc-button-secondary-border: color-mix(
--rc-bg-canvas: #f5f5f5; in oklab,
--rc-bg-surface: #fff; var(--foreground) 12%,
--rc-bg-subtle: #f8fafc; transparent
--rc-border-default: #e3e8ef; );
--rc-border-strong: #cbd3de; --rc-text-primary: var(--foreground);
--rc-field-background: #fff; --rc-text-secondary: color-mix(
--rc-field-border: #d9e1eb; in oklab,
--rc-field-border-hover: #b8c4d2; var(--foreground) 72%,
--rc-field-border-focus: var(--rc-brand-primary); var(--background)
--rc-button-secondary-border: rgb(9 9 11 / 12%); );
--rc-button-secondary-border-hover: rgb(9 9 11 / 20%); --rc-text-muted: var(--muted);
--rc-button-secondary-background-hover: rgb(9 9 11 / 4%);
--rc-text-primary: #172033; --rc-font-sans: var(--font-sans);
--rc-text-secondary: #4e5d73; --rc-font-medium: 500;
--rc-text-muted: #718096; --rc-font-semibold: 600;
--rc-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; --rc-line-normal: 1.5;
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%), 0 4px 12px rgb(15 23 42 / 2%); --rc-text-xs: 0.75rem;
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%), 0 8px 24px rgb(15 23 42 / 8%); --rc-text-sm: 0.875rem;
--rc-space-1: 0.25rem;
--rc-space-2: 0.5rem;
--rc-space-3: 0.75rem;
--rc-space-4: 1rem;
--rc-space-5: 1.25rem;
--rc-space-6: 1.5rem;
--rc-space-8: 2rem;
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--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);
--rc-page-padding-block-start: 1.5rem;
--rc-page-padding-block-end: 2rem;
--rc-page-section-gap: 2rem;
--rc-page-module-gap: 1.5rem;
--rc-related-item-gap: 1rem;
--rc-page-toolbar-gap: 1rem;
--rc-list-stack-gap: 1rem;
--rc-radius-control: 0.375rem;
--rc-radius-container: 0.5rem;
--rc-radius-overlay: 0.75rem;
--rc-radius-detail-surface: 1rem;
--rc-radius-data-grid: 1.25rem;
--rc-radius-modal: 1.5rem;
--rc-dialog-width-4xl: 68rem;
--rc-motion-fast: 120ms;
--rc-motion-normal: 180ms;
--rc-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--rc-button-radius: 1.5rem;
--rc-navbar-trigger-size: 2rem;
--rc-navbar-icon-button-size: 2.25rem;
--rc-navbar-title-size: 1.25rem;
--rc-navbar-title-weight: 600;
--rc-navbar-title-line-height: 1.75rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-login-panel-width: 28rem;
--rc-login-brand-size: 1.5rem;
--rc-login-brand-weight: var(--rc-font-semibold);
--rc-login-brand-gap: 1.5rem;
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%),
0 8px 24px rgb(15 23 42 / 8%);
--rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%); --rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%);
--rc-page-title-inset: 68px; --rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%),
--rc-login-brand-weight: 600; 0 4px 12px rgb(15 23 42 / 2%);
--rc-modal-radius: 24px;
--rc-modal-padding: 28px; /* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-modal-title-size: 16px; --rc-metric-tone-info: oklch(55% 0.14 255);
--rc-modal-title-weight: 500; --rc-metric-tone-success: oklch(54% 0.13 145);
--rc-modal-title-line-height: 24px; --rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-modal-section-gap: 12px; --rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-modal-footer-gap: 8px; --rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-modal-footer-margin: 24px; --rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-modal-backdrop-blur: 12px; --rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-modal-close-size: 24px; --rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-modal-close-inset: var(--rc-modal-padding); --rc-metric-tone-neutral: var(--muted);
--rc-modal-close-icon-size: 16px; --rc-metric-tone-info-soft: color-mix(
--rc-modal-width-xs: 320px; in oklab,
--rc-modal-width-sm: 384px; var(--rc-metric-tone-info) 9%,
--rc-modal-width-md: 480px; var(--surface)
--rc-modal-width-lg: 640px; );
--rc-modal-width-xl: 736px; --rc-metric-tone-success-soft: color-mix(
--rc-modal-width-2xl: 840px; in oklab,
--rc-modal-width-3xl: 960px; var(--rc-metric-tone-success) 9%,
--rc-modal-width-4xl: 1088px; var(--surface)
--rc-modal-width-5xl: 1216px; );
--background: var(--rc-bg-canvas); --rc-metric-tone-teal-soft: color-mix(
--surface: var(--rc-bg-surface); in oklab,
--surface-shadow: var(--rc-shadow-card); var(--rc-metric-tone-teal) 9%,
--surface-secondary: var(--rc-bg-subtle); var(--surface)
--overlay: var(--rc-bg-surface); );
--foreground: var(--rc-text-primary); --rc-metric-tone-danger-soft: color-mix(
--muted: var(--rc-text-muted); in oklab,
--muted-foreground: var(--rc-text-secondary); var(--rc-metric-tone-danger) 9%,
--accent: var(--rc-brand-primary); var(--surface)
--accent-foreground: var(--rc-brand-on-primary); );
--accent-hover: var(--rc-brand-light); --rc-metric-tone-warning-soft: color-mix(
--accent-soft: var(--rc-brand-primary-soft); in oklab,
--accent-soft-hover: var(--rc-brand-primary-soft-hover); var(--rc-metric-tone-warning) 9%,
--border: var(--rc-border-default); var(--surface)
--separator: var(--rc-border-default); );
--focus: var(--rc-brand-primary); --rc-metric-tone-violet-soft: color-mix(
--backdrop: rgb(0 0 0 / 50%); in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
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);
--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;
} }
.dark, .dark,
[data-theme='dark'] { [data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--rc-brand-primary-soft: rgb(158 27 27 / 16%); --rc-text-secondary: color-mix(
--rc-brand-primary-soft-hover: rgb(158 27 27 / 22%); in oklab,
--rc-color-success: #4fca92; var(--foreground) 76%,
--rc-color-success-soft: rgb(79 202 146 / 14%); var(--background)
--rc-color-success-border: rgb(79 202 146 / 30%); );
--rc-color-warning: #f0ad4e; --rc-button-secondary-border: color-mix(
--rc-color-warning-soft: rgb(240 173 78 / 15%); in oklab,
--rc-color-warning-border: rgb(240 173 78 / 32%); var(--foreground) 18%,
--rc-color-danger: #ff7272; transparent
--rc-color-danger-soft: rgb(255 114 114 / 14%); );
--rc-color-danger-border: rgb(255 114 114 / 30%); --rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
--rc-bg-canvas: #0f0f11; 0 8px 24px rgb(0 0 0 / 24%);
--rc-bg-surface: #18181b;
--rc-bg-subtle: #202024;
--rc-border-default: #303036;
--rc-border-strong: #45454d;
--rc-field-background: #202024;
--rc-field-border: #3b3b42;
--rc-field-border-hover: #56565f;
--rc-field-border-focus: var(--rc-brand-primary);
--rc-button-secondary-border: rgb(255 255 255 / 18%);
--rc-button-secondary-border-hover: rgb(255 255 255 / 28%);
--rc-button-secondary-background-hover: rgb(255 255 255 / 7%);
--rc-text-primary: #f4f4f5;
--rc-text-secondary: #d4d4d8;
--rc-text-muted: #a1a1aa;
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 18%), 0 4px 12px rgb(0 0 0 / 12%);
--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-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--surface-shadow: var(--rc-shadow-card); --rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
--default: #27272a; 0 4px 12px rgb(0 0 0 / 18%);
--default-hover: #3f3f46;
}
@media (max-width: 767px) {
:root { --rc-page-title-inset: 72px; }
} }
:root {
--pm-brand: var(--rc-brand-primary, #9e1b1b);
--pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5);
--pm-text: var(--rc-text-primary, #172033);
--pm-muted: var(--rc-text-muted, #718096);
}
html, html,
body, body,
#root { min-height: 100%; } #root {
min-height: 100%;
body {
margin: 0;
color: var(--rc-text-primary);
background: var(--rc-bg-canvas);
font-family: var(--rc-font-sans);
letter-spacing: 0;
} }
* { box-sizing: border-box; } body {
button, input, textarea, select { font: inherit; letter-spacing: 0; }
/* Keep icon geometry independent from the surrounding text baseline. */
.rc-app-icon.anticon {
display: inline-grid !important;
width: 1em;
height: 1em;
flex: 0 0 auto;
place-items: center;
margin: 0; margin: 0;
line-height: 0 !important; background: var(--rc-bg-canvas, #f5f5f5);
vertical-align: middle; color: var(--pm-text);
font-family:
"PingFang SC",
"Microsoft YaHei",
"Helvetica Neue",
Arial,
sans-serif;
} }
.rc-app-icon.anticon > svg { display: block; width: 100%; height: 100%; margin: 0; }
.rc-app-shell { position: fixed; inset: 0; display: flex; overflow: hidden; background: var(--rc-bg-canvas); color: var(--rc-text-primary); }
.rc-sidebar, .rc-mobile-sidebar { display: flex; flex-direction: column; color: var(--rc-text-primary); background: var(--rc-bg-canvas); }
.rc-sidebar { width: 15rem; padding: 6px 12px 16px; transition: width 180ms cubic-bezier(.2,0,0,1); }
.rc-sidebar[data-collapsed='true'] { width: 0; overflow: hidden; padding-inline: 0; }
.rc-sidebar .sidebar__header, .rc-mobile-sidebar .sidebar__header { padding: 0 4px; }
.rc-sidebar-brand { display: flex; min-height: 52px; align-items: center; gap: 10px; min-width: 0; }
.rc-sidebar-logo { width: 27px; height: 27px; object-fit: contain; flex: none; }
.rc-sidebar-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600; line-height: 20px; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
.rc-sidebar-app-trigger { width: 36px !important; height: 36px !important; min-width: 36px !important; padding: 0 !important; border-radius: 999px !important; }
.rc-sidebar-app-trigger .anticon { display: grid; width: 16px; height: 16px; place-items: center; margin: 0; line-height: 1; }
.rc-sidebar-app-trigger > .anticon > svg,
.rc-sidebar-app-trigger .anticon > svg { display: block; width: 100%; height: 100%; margin: 0; }
.rc-sidebar .sidebar__content, .rc-mobile-sidebar .sidebar__content { min-height: 0; padding: 0; }
.rc-sidebar .sidebar__group, .rc-mobile-sidebar .sidebar__group { padding: 0; margin-top: 8px; }
.rc-sidebar .sidebar__group-label, .rc-mobile-sidebar .sidebar__group-label { padding: 0 8px; font-size: 12px; font-weight: 500; color: var(--rc-text-muted); }
.rc-sidebar .sidebar__menu-item, .rc-mobile-sidebar .sidebar__menu-item { min-height: 36px; border-radius: 16px; padding-inline: 8px; gap: 12px; color: var(--rc-text-secondary); }
.rc-sidebar .sidebar__menu-item[data-current='true'], .rc-mobile-sidebar .sidebar__menu-item[data-current='true'] { color: var(--rc-text-primary); background: var(--default); }
.rc-sidebar .sidebar__footer, .rc-mobile-sidebar .sidebar__footer { padding: 8px 0 0; }
.rc-sidebar-user-trigger { display: flex !important; width: 100% !important; height: 48px !important; min-height: 48px !important; flex-direction: row !important; align-items: center !important; justify-content: flex-start !important; gap: 10px !important; padding: 6px 8px !important; border: 0 !important; border-radius: 12px !important; color: var(--rc-text-primary) !important; background: transparent !important; text-align: left !important; }
.rc-sidebar-user-trigger:hover, .rc-sidebar-user-trigger[data-hovered='true'], .rc-sidebar-user-trigger[aria-expanded='true'] { background: var(--default) !important; }
.rc-sidebar-avatar { display: grid; width: 32px; height: 32px; flex: none; place-items: center; border-radius: 50%; color: #fff; background: var(--rc-brand-primary); font-size: 13px; }
.rc-sidebar-avatar .avatar__fallback { width: 100%; height: 100%; color: #fff !important; background: var(--rc-brand-primary) !important; }
.rc-sidebar-user-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; text-align: left; }
.rc-sidebar-user-copy strong { overflow: hidden; text-overflow: ellipsis; font-size: 14px; font-weight: 500; line-height: 18px; white-space: nowrap; }
.rc-sidebar-user-copy small { overflow: hidden; text-overflow: ellipsis; color: var(--rc-text-muted); font-size: 12px; line-height: 16px; white-space: nowrap; }
.rc-app-main { display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; background: var(--rc-bg-canvas); }
.rc-navbar { flex: none; height: 64px; border: 0 !important; background: transparent !important; box-shadow: none !important; }
.rc-navbar .navbar__header { width: 100%; height: 64px; padding: 0 24px; gap: 12px; }
.rc-navbar-trigger { width: 32px !important; height: 32px !important; min-width: 32px !important; padding: 0 !important; border-radius: 999px !important; background: transparent !important; }
.rc-navbar-trigger:hover { background: var(--default) !important; }
.rc-navbar-trigger .anticon, .rc-navbar-trigger .anticon > svg { display: block; width: 16px; height: 16px; margin: 0; }
.rc-navbar-title { min-width: 0; overflow: hidden; color: var(--rc-text-primary); font-size: 20px; font-weight: 600; line-height: 28px; text-overflow: ellipsis; white-space: nowrap; }
.rc-navbar-icon-button { width: 36px !important; height: 36px !important; min-width: 36px !important; padding: 0 !important; border-radius: 999px !important; }
.rc-navbar-icon-button .anticon, .rc-navbar-icon-button .anticon > svg { display: block; width: 16px; height: 16px; margin: 0; }
.rc-app-page { min-height: 100%; padding: 16px var(--rc-page-title-inset) 40px; overflow-x: hidden; }
.app-switcher-root { position: relative; z-index: 180; }
.app-switcher-backdrop { position: fixed; inset: 0; background: var(--backdrop); backdrop-filter: blur(12px); transition: opacity 180ms; }
.app-switcher-backdrop[data-closed] { opacity: 0; }
.app-switcher-viewport { position: fixed; inset: 0; display: grid; place-items: center; overflow-y: auto; padding: 16px; }
.app-switcher-dialog { display: flex; width: min(46rem, calc(100vw - 32px)); max-height: min(42rem, calc(100dvh - 32px)); flex-direction: column; overflow: hidden; border: 1px solid var(--rc-border-default); border-radius: 16px; background: var(--rc-bg-surface); box-shadow: var(--rc-shadow-overlay); transition: opacity 180ms, transform 180ms; }
.app-switcher-dialog[data-closed] { opacity: 0; transform: translateY(8px) scale(.98); }
.app-switcher-header { display: flex; flex: none; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--rc-border-default); }
.app-switcher-header h2 { margin: 0; color: var(--rc-text-primary); font-size: 16px; font-weight: 600; line-height: 24px; }
.app-switcher-header p { margin: 4px 0 0; color: var(--rc-text-muted); font-size: 14px; line-height: 20px; }
.app-switcher-close { display: grid; width: 24px; height: 24px; flex: none; place-items: center; padding: 4px; border: 0; border-radius: 50%; color: var(--rc-text-secondary); background: var(--default); cursor: pointer; }
.app-switcher-close:hover { color: var(--rc-text-primary); background: var(--default-hover); }
.app-switcher-close .anticon, .app-switcher-close svg { display: block; width: 16px; height: 16px; margin: 0; }
.app-switcher-body { min-height: 0; flex: 1; overflow-y: auto; padding: 16px 20px; }
.app-switcher-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.app-switcher-card { display: flex; min-width: 0; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--rc-border-default); border-radius: 12px; color: inherit; background: var(--rc-bg-subtle); text-decoration: none; transition: background 120ms, border-color 120ms; }
.app-switcher-card:hover, .app-switcher-card:focus-visible { border-color: color-mix(in srgb, var(--rc-brand-primary) 28%, var(--rc-border-default)); background: var(--rc-brand-primary-soft); outline: none; }
.app-switcher-card__logo, .app-switcher-card__initial { display: grid; width: 36px; height: 36px; flex: none; place-items: center; object-fit: contain; border-radius: 10px; }
.app-switcher-card__initial { color: #fff; background: var(--rc-brand-primary); font-size: 14px; font-weight: 600; }
.app-switcher-card__copy { min-width: 0; flex: 1; }
.app-switcher-card__copy strong, .app-switcher-card__copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-switcher-card__copy strong { color: var(--rc-text-primary); font-size: 14px; font-weight: 500; line-height: 20px; }
.app-switcher-card__copy small { margin-top: 2px; color: var(--rc-text-muted); font-size: 12px; line-height: 16px; }
.app-switcher-card__current { flex: none; padding: 2px 8px; border-radius: 999px; color: var(--rc-brand-primary); background: var(--rc-brand-primary-soft); font-size: 12px; font-weight: 500; }
.app-switcher-state { margin: 32px 0; color: var(--rc-text-muted); text-align: center; font-size: 14px; }
@media (max-width: 767px) { * {
.rc-navbar .navbar__header { padding-inline: 24px; } box-sizing: border-box;
.rc-navbar-trigger { width: 36px !important; height: 36px !important; min-width: 36px !important; }
.rc-app-page { padding-top: 8px; padding-bottom: 24px; }
.app-switcher-grid { grid-template-columns: 1fr; }
} }
.login-page { display: grid; min-height: 100vh; place-items: center; padding: 24px 16px; background: var(--rc-bg-canvas); } a {
.login-card { width: min(28rem, 100%); border: 1px solid var(--rc-border-default) !important; border-radius: 24px !important; background: var(--rc-bg-surface) !important; box-shadow: var(--rc-shadow-raised) !important; } color: inherit;
.login-card [data-slot='card-content'] { padding: 36px !important; } text-decoration: none;
.login-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 32px; }
.login-brand img { width: 36px; height: 36px; object-fit: contain; }
.login-brand strong { color: var(--rc-text-primary); font-size: 32px; font-weight: var(--rc-login-brand-weight); line-height: 1.15; }
.login-form { display: flex; flex-direction: column; }
.login-field { display: flex; flex-direction: column; gap: 12px; }
.login-field + .login-field { margin-top: 24px; }
.login-field .label { color: var(--rc-text-secondary); font-size: 14px; font-weight: 500; }
.login-input, .login-field [data-slot='input'], .login-field [data-slot='input-wrapper'], .login-field [data-slot='control'] { height: 40px !important; min-height: 40px !important; }
.login-input { width: 100%; border-radius: 8px !important; }
.login-field[data-has-value='true'] .login-input,
.login-input[data-filled='true'],
.login-input:not(:placeholder-shown) { --input-bg: #fbf1f1 !important; --input-bg-hover: #faeaea !important; --input-bg-focus: #fbf1f1 !important; background: #fbf1f1 !important; box-shadow: inset 0 0 0 1000px #fbf1f1 !important; }
.login-input[data-filled='true']:focus, .login-input:not(:placeholder-shown):focus { border-color: var(--rc-brand-primary) !important; box-shadow: inset 0 0 0 1000px #fbf1f1, 0 0 0 2px rgb(158 27 27 / 16%) !important; }
.login-input:-webkit-autofill, .login-input:-webkit-autofill:hover, .login-input:-webkit-autofill:focus { -webkit-box-shadow: inset 0 0 0 1000px #fbf1f1, 0 0 0 2px rgb(158 27 27 / 16%) !important; box-shadow: inset 0 0 0 1000px #fbf1f1, 0 0 0 2px rgb(158 27 27 / 16%) !important; -webkit-text-fill-color: var(--rc-text-primary) !important; }
.login-captcha-row { display: grid; grid-template-columns: minmax(0, 1fr) 132px; gap: 12px; }
.login-captcha { display: grid; height: 40px; place-items: center; overflow: hidden; padding: 0 8px; border: 1px solid var(--rc-field-border); border-radius: 8px; color: var(--rc-text-muted); background: var(--rc-bg-surface); cursor: pointer; }
.login-captcha:hover { border-color: var(--rc-field-border-hover); color: var(--rc-brand-primary); }
.login-captcha span { display: grid; width: 100%; height: 100%; place-items: center; line-height: 0; }
.login-captcha svg { display: block; width: auto; height: auto; max-width: 100%; max-height: 30px; margin: auto; }
.login-error { margin-top: 20px; border-radius: 12px !important; }
.login-submit { height: 46px !important; min-height: 46px !important; margin-top: 24px !important; border-radius: 24px !important; }
.rc-workbench { display: flex; min-width: 0; flex-direction: column; gap: 16px; padding-bottom: 16px; }
.rc-workbench-toolbar { display: flex; min-width: 0; min-height: 40px; align-items: center; justify-content: space-between; gap: 16px; }
.filter-tabs-wrap { display: inline-flex !important; width: fit-content !important; max-width: 100%; align-self: flex-start; overflow-x: auto; scrollbar-width: none; }
.filter-tabs-wrap::-webkit-scrollbar { display: none; }
.filter-tabs-wrap .tabs__list-container { width: fit-content !important; max-width: 100%; min-width: 0 !important; flex: 0 0 auto; overflow: visible; border: 0 !important; border-radius: calc(var(--radius) * 3) !important; background: var(--default) !important; }
.filter-tabs-wrap .tabs__list-container__scroller { width: fit-content !important; max-width: 100%; min-width: 0 !important; overflow: visible !important; mask-image: none !important; -webkit-mask-image: none !important; }
.filter-tabs-wrap .tabs__list { width: max-content !important; min-width: 0 !important; min-height: 40px; gap: 0; padding: 0.25rem !important; }
.filter-tabs-wrap .tabs__tab { width: auto !important; min-width: 0 !important; height: 2rem; flex: 0 0 auto !important; padding-inline: var(--rc-space-3, 0.75rem); border-radius: calc(var(--radius) * 3) !important; color: var(--muted); font-size: 14px; font-weight: 500; white-space: nowrap; }
.filter-tabs-wrap .tabs__list-container__scroll-prev, .filter-tabs-wrap .tabs__list-container__scroll-next { display: none !important; }
.filter-tabs-wrap .tabs__tab[data-selected='true'] { color: var(--foreground) !important; }
.filter-tabs-wrap .tabs__indicator { inset: 0 !important; width: 100%; height: 100% !important; border-radius: calc(var(--radius) * 3) !important; background: var(--segment) !important; box-shadow: var(--surface-shadow) !important; }
.rc-toolbar-search { width: min(18rem, 40vw); min-width: 14rem; flex: 0 1 18rem; }
.rc-toolbar-search .search-field__group { width: 100%; height: 36px; min-height: 36px; border: 0 !important; border-radius: calc(var(--radius) * 1.5) !important; background: var(--default) !important; box-shadow: none !important; }
.rc-toolbar-search .search-field__group:hover, .rc-toolbar-search .search-field__group[data-hovered='true'] { background: var(--default-hover) !important; }
.rc-toolbar-search .search-field__group:focus-within, .rc-toolbar-search .search-field__group[data-focus-within='true'] { background: var(--rc-field-background) !important; box-shadow: 0 0 0 2px var(--rc-brand-primary-soft) !important; }
.rc-toolbar-search [data-slot='search-field-search-icon'], .rc-filter-search [data-slot='search-field-search-icon'] { width: 16px; height: 16px; flex: 0 0 16px; margin: 0 0 0 12px; color: var(--rc-text-muted); font-size: 16px; line-height: 1; }
.rc-toolbar-search [data-slot='search-field-search-icon'] svg, .rc-filter-search [data-slot='search-field-search-icon'] svg { display: block; width: 16px; height: 16px; margin: 0; }
.rc-toolbar-search [data-slot='search-field-input'] { height: 100%; min-width: 0; padding: 0 10px 0 8px; border: 0; color: var(--rc-text-primary); background: transparent !important; box-shadow: none; font-size: 14px !important; font-weight: 400; line-height: 20px; letter-spacing: 0; }
.rc-toolbar-search [data-slot='search-field-input']::placeholder { color: var(--rc-text-muted); opacity: 1; }
.rc-filter-card.card { overflow: visible; border: 1px solid var(--rc-border-default); border-radius: 16px; background: var(--rc-bg-surface); box-shadow: var(--rc-shadow-card); }
.rc-filter-card__content[data-slot='card-content'] { padding: 20px; }
.rc-filter-primary-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
.rc-filter-primary-fields { display: grid; width: min(28rem, 100%); min-width: 0; grid-template-columns: minmax(0, 1fr); }
.rc-filter-search { width: 100%; min-width: 0; }
.rc-filter-search .search-field__group { --field-background: var(--rc-field-background); --field-border: var(--rc-field-border); width: 100%; height: 36px; min-height: 36px; border: 1px solid var(--rc-field-border); border-radius: 8px; background: var(--rc-field-background); box-shadow: var(--field-shadow); }
.rc-filter-search .search-field__group:hover, .rc-filter-search .search-field__group[data-hovered='true'] { border-color: var(--rc-field-border-hover); }
.rc-filter-search .search-field__group:focus-within, .rc-filter-search .search-field__group[data-focus-within='true'] { border-color: var(--rc-field-border-focus); background: var(--rc-field-background); box-shadow: var(--field-shadow), 0 0 0 2px var(--rc-brand-primary-soft); }
.rc-filter-search [data-slot='search-field-input'] { height: 100%; min-width: 0; padding: 0 10px 0 8px; border: 0; color: var(--rc-text-primary); background: transparent; box-shadow: none; font-size: 14px !important; font-weight: 400; line-height: 20px; letter-spacing: 0; }
.rc-filter-search [data-slot='search-field-input']::placeholder { color: var(--rc-text-muted); opacity: 1; }
.rc-filter-actions { display: flex; flex: none; align-items: center; gap: 8px; }
.rc-filter-actions .rc-filter-reset-button.button { display: inline-grid; width: 32px !important; height: 32px !important; min-width: 32px !important; min-height: 32px !important; place-items: center; padding: 0 !important; border: 0 !important; border-radius: 999px !important; color: var(--rc-text-muted) !important; background: transparent !important; box-shadow: none !important; }
.rc-filter-actions .rc-filter-reset-button.button:hover, .rc-filter-actions .rc-filter-reset-button.button:focus-visible, .rc-filter-actions .rc-filter-reset-button.button[data-hovered='true'] { color: var(--rc-text-secondary) !important; background: var(--default) !important; }
.rc-filter-reset-button .anticon, .rc-filter-reset-button svg { display: block; width: 16px; height: 16px; margin: 0; }
.rc-list-section { min-width: 0; }
.rc-list-header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 16px; padding: 4px 0 12px; }
.rc-list-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.rc-context-alert { margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--rc-color-danger-border); border-radius: 12px; color: var(--rc-color-danger); background: var(--rc-color-danger-soft); font-size: 14px; }
.rc-data-grid-wrap { position: relative; width: 100%; min-width: 0; overflow: hidden; border: 0; border-radius: 20px; background: transparent; box-shadow: none; clip-path: inset(0 round 20px); }
.rc-data-grid-wrap.is-empty { min-height: 240px; }
.rc-data-grid-wrap.is-populated { min-height: 0; }
.rc-data-grid, .rc-data-grid .table-root, .rc-data-grid-scroll, .rc-data-grid .table__resizable-container { width: 100%; min-width: 0; min-height: 0; border: 0 !important; outline: 0; border-radius: 20px; background: transparent; box-shadow: none !important; }
.rc-data-grid-scroll { overflow: auto; }
.rc-data-grid-content, .rc-data-grid .table__content { width: 100%; min-width: 100%; }
.rc-data-grid-content.is-wide { min-width: max(100%, var(--rc-data-grid-min-width, 1080px)); }
.rc-data-grid .table__header { position: relative; z-index: 7; isolation: isolate; background: var(--rc-bg-subtle) !important; }
.rc-data-grid .table__content { background: var(--rc-bg-surface) !important; }
.rc-data-grid .table__column, .rc-data-grid .data-grid__selection-column { height: 36px; padding: 0 12px; color: var(--rc-text-muted); background: var(--rc-bg-subtle); font-size: 12px; font-weight: 500; line-height: 15px; letter-spacing: 0; }
.rc-data-grid .table__cell, .rc-data-grid .data-grid__selection-cell { padding: 10px 12px; border-color: var(--rc-border-default); color: var(--rc-text-secondary); background: var(--rc-bg-surface); font-size: 14px; font-weight: 400; line-height: 21px; }
.rc-data-grid .table__row:hover .table__cell, .rc-data-grid .table__row:hover .data-grid__selection-cell { background: color-mix(in srgb, var(--default) 35%, var(--rc-bg-surface)); }
.rc-data-grid .table__column[data-pinned] { z-index: 8; background: var(--rc-bg-subtle) !important; }
.rc-data-grid .table__cell[data-pinned] { z-index: 4; background: var(--rc-bg-surface) !important; }
.rc-data-grid .table__row:hover .table__cell[data-pinned] { background: color-mix(in srgb, var(--default) 35%, var(--rc-bg-surface)) !important; }
.rc-data-grid-text { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-data-grid-primary { color: var(--rc-text-primary); font-weight: 550; }
.rc-data-grid .rc-data-grid-primary-action.button { width: 100%; min-width: 0; max-width: 100%; height: auto; min-height: 21px; justify-content: flex-start; padding: 0; border: 0; border-radius: 0; color: var(--rc-text-primary); background: transparent; box-shadow: none; line-height: 21px; text-align: left; text-decoration: none; transition: color 160ms ease; }
.rc-data-grid .rc-data-grid-primary-action.button:hover, .rc-data-grid .rc-data-grid-primary-action.button[data-hovered='true'], .rc-data-grid .rc-data-grid-primary-action.button[data-focus-visible='true'] { color: var(--rc-brand-primary); background: transparent; text-decoration: none; }
.rc-data-grid .rc-data-grid-primary-action .rc-data-grid-primary { color: currentColor; }
.rc-data-grid-empty, .rc-data-grid-loading { display: grid; min-height: 240px; place-items: center; color: var(--rc-text-muted); }
.rc-data-grid-busy { position: absolute; inset: 0; display: grid; place-items: center; background: color-mix(in srgb, var(--rc-bg-surface) 76%, transparent); }
.rc-data-grid .rc-data-grid-actions-header { justify-content: center; text-align: center; }
.rc-data-grid .rc-data-grid-actions-cell { text-align: right; }
.rc-data-grid .rc-data-grid-actions-cell { min-width: 0; }
.rc-row-actions { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: flex-end; gap: 4px; }
.rc-row-action { display: inline-grid !important; width: 32px !important; height: 32px !important; min-width: 32px !important; padding: 0 !important; place-items: center; border: 0 !important; border-radius: 999px !important; color: var(--rc-text-secondary) !important; background: var(--default) !important; box-shadow: none !important; line-height: 0 !important; }
.rc-row-action:hover, .rc-row-action[data-hovered='true'], .rc-row-action:focus-visible, .rc-row-action[data-focus-visible='true'] { color: var(--rc-text-primary) !important; background: var(--default-hover) !important; }
.rc-row-action[disabled], .rc-row-action[data-disabled='true'] { opacity: 0.45; }
.rc-row-action.button--danger-soft { color: var(--rc-color-danger) !important; background: var(--rc-color-danger-soft) !important; }
.rc-row-action.button--danger-soft:hover, .rc-row-action.button--danger-soft[data-hovered='true'], .rc-row-action.button--danger-soft:focus-visible, .rc-row-action.button--danger-soft[data-focus-visible='true'] { color: var(--rc-color-danger) !important; background: color-mix(in srgb, var(--rc-color-danger) 16%, var(--rc-bg-surface)) !important; }
.rc-row-action svg { display: block; width: 16px; height: 16px; margin: 0; }
.rc-data-grid .checkbox__control[data-selected='true'], .rc-data-grid .checkbox__control[data-indeterminate='true'],
.rc-data-grid [data-selected='true'] .checkbox__control, .rc-data-grid [data-indeterminate='true'] .checkbox__control { border: 1px solid var(--rc-brand-primary) !important; border-color: var(--rc-brand-primary) !important; background: var(--rc-brand-primary) !important; box-shadow: none !important; filter: none !important; }
.rc-data-grid .checkbox__control[data-selected='true']::before, .rc-data-grid .checkbox__control[data-indeterminate='true']::before,
.rc-data-grid [data-selected='true'] .checkbox__control::before, .rc-data-grid [data-indeterminate='true'] .checkbox__control::before { background: var(--rc-brand-primary) !important; box-shadow: none !important; }
.rc-data-grid .checkbox__indicator, .rc-data-grid .checkbox__indicator svg { color: #fff !important; stroke: #fff !important; fill: none !important; }
.pm-pagination-bar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; margin-top: 8px; padding-top: 0; border: 0; background: transparent; }
.pm-pagination-bar > .pagination { width: 100%; min-width: 0; flex: 1; }
.pm-pagination-bar .pagination__summary { color: var(--rc-text-secondary); font-size: 12px; white-space: nowrap; }
.pm-pagination-bar .pagination__content { justify-content: flex-end; }
.pm-pagination-bar .pagination__link[data-active='true'] { border: 1px solid var(--rc-brand-primary) !important; border-radius: 999px !important; color: var(--rc-brand-primary) !important; background: transparent !important; }
.pm-pagination-controls { display: flex; flex: none; align-items: center; gap: 8px; }
.pm-pagination-page-size-select { width: auto; min-width: 160px; max-width: 100%; }
.pm-pagination-page-size-select .select__trigger { display: flex !important; height: 36px; min-height: 36px; align-items: center !important; padding-block: 0 !important; padding-inline: 16px; border: 0 !important; border-radius: 8px !important; background: var(--default) !important; box-shadow: none !important; }
.pm-pagination-page-size-select .select__trigger:hover, .pm-pagination-page-size-select .select__trigger[data-hovered='true'] { background: var(--default-hover) !important; }
.pm-pagination-page-size-copy { display: flex; min-width: 0; height: 100%; flex: 1; align-items: center; gap: 6px; }
.pm-pagination-page-size-label, .pm-pagination-page-size-value { color: var(--rc-text-primary) !important; font-size: 13px; font-weight: 500; line-height: 16px; white-space: nowrap; }
.pm-pagination-page-size-label::after { content: ':'; }
.pm-pagination-page-size-select .select__indicator, .pm-pagination-page-size-indicator { display: inline-grid !important; width: 16px; height: 16px; flex: none; align-self: center; place-items: center; color: var(--rc-text-muted); font-size: 14px; line-height: 0 !important; }
.pm-pagination-page-size-indicator :is(svg, .anticon), .pm-pagination-page-size-indicator svg, .pm-pagination-page-size-select .select__indicator svg { display: block !important; width: 1em !important; height: 1em !important; margin: 0 !important; color: currentColor !important; fill: currentColor !important; }
.rc-form-select { width: 100%; min-width: 0; }
.rc-form-select .select__trigger, .rc-form-select__trigger { display: flex !important; width: 100%; min-height: 40px; align-items: center !important; padding: 0 40px 0 12px !important; border: 1px solid var(--rc-field-border) !important; border-radius: 8px !important; color: var(--rc-text-primary); background: var(--rc-field-background) !important; box-shadow: var(--field-shadow) !important; }
.rc-form-select .select__trigger:hover, .rc-form-select__trigger:hover, .rc-form-select__trigger[data-hovered='true'] { border-color: var(--rc-field-border-hover) !important; }
.rc-form-select .select__trigger:focus-visible, .rc-form-select__trigger:focus-visible, .rc-form-select__trigger[data-focus-visible='true'] { border-color: var(--rc-field-border-focus) !important; box-shadow: var(--field-shadow), 0 0 0 2px var(--rc-brand-primary-soft) !important; }
.rc-form-select .select__value, .rc-form-select__value { display: flex; min-width: 0; min-height: 20px; flex: 1; align-items: center; color: var(--rc-text-primary); font-size: 14px; line-height: 20px; }
.rc-form-select__text { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-form-select__text.is-placeholder { color: var(--rc-text-muted); }
.rc-form-select .select__indicator, .rc-form-select__indicator { position: absolute !important; inset: 0 12px 0 auto !important; display: inline-grid !important; width: 16px; height: 16px; place-items: center; align-self: auto; margin: auto 0 !important; color: var(--rc-text-muted); line-height: 0 !important; transform: none; transform-origin: 50% 50%; transition: color 150ms ease, transform 150ms ease; }
.rc-form-select__indicator[data-open='true'], .rc-form-select__trigger[aria-expanded='true'] .rc-form-select__indicator { transform: rotate(180deg); }
.rc-form-select__indicator .anticon, .rc-form-select__indicator svg { display: block; width: 16px; height: 16px; margin: 0; }
.rc-form-select .popover, .rc-form-select [data-slot='popover'], .rc-form-select__popover { z-index: 210; min-width: var(--trigger-width); max-height: min(320px, calc(100dvh - 32px)); overflow-y: auto; padding: 4px !important; border: 1px solid var(--rc-border-default); border-radius: 12px !important; color: var(--rc-text-secondary); background: var(--rc-bg-surface) !important; box-shadow: var(--rc-shadow-overlay) !important; }
.rc-form-select__listbox { display: grid; gap: 2px; outline: none; }
.rc-form-select__option { display: flex; min-height: 40px; cursor: pointer; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 8px; color: var(--rc-text-secondary); font-size: 14px; line-height: 20px; }
.rc-form-select__option:hover, .rc-form-select__option[data-hovered='true'], .rc-form-select__option[data-focused='true'] { color: var(--rc-text-primary); background: var(--default); }
.rc-form-select__option[data-selected='true'] { color: var(--rc-brand-primary); background: var(--rc-brand-primary-soft); }
.rc-form-select__option-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-form-select__option-indicator { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--rc-brand-primary); }
.rc-dialog-backdrop { width: 100%; height: 100%; background: var(--backdrop); -webkit-backdrop-filter: blur(var(--rc-modal-backdrop-blur)); backdrop-filter: blur(var(--rc-modal-backdrop-blur)); }
.rc-dialog-panel { --rc-dialog-current-width: var(--rc-modal-width-lg); position: relative; display: flex; width: min(calc(100vw - 80px), var(--rc-dialog-current-width)) !important; max-width: calc(100vw - 80px) !important; max-height: calc(100dvh - 32px); flex-direction: column; padding: var(--rc-modal-padding); overflow: hidden; border: 0 !important; border-radius: var(--rc-modal-radius) !important; background: var(--overlay); box-shadow: var(--rc-shadow-overlay); outline: none; }
.rc-dialog-panel--xs { --rc-dialog-current-width: var(--rc-modal-width-xs); }
.rc-dialog-panel--sm { --rc-dialog-current-width: var(--rc-modal-width-sm); }
.rc-dialog-panel--md { --rc-dialog-current-width: var(--rc-modal-width-md); }
.rc-dialog-panel--lg { --rc-dialog-current-width: var(--rc-modal-width-lg); }
.rc-dialog-panel--xl { --rc-dialog-current-width: var(--rc-modal-width-xl); }
.rc-dialog-panel--2xl { --rc-dialog-current-width: var(--rc-modal-width-2xl); }
.rc-dialog-panel--3xl { --rc-dialog-current-width: var(--rc-modal-width-3xl); }
.rc-dialog-panel--4xl { --rc-dialog-current-width: var(--rc-modal-width-4xl); }
.rc-dialog-panel--5xl { --rc-dialog-current-width: var(--rc-modal-width-5xl); }
.rc-dialog-title { margin: 0; padding-right: 40px; color: var(--rc-text-primary); font-size: var(--rc-modal-title-size); font-weight: var(--rc-modal-title-weight); line-height: var(--rc-modal-title-line-height); letter-spacing: 0; text-wrap: balance; }
.rc-dialog-body { min-width: 0; min-height: 0; flex: 1; margin-top: var(--rc-modal-section-gap); margin-right: -3px; margin-left: -3px; padding: 3px; overflow-y: auto; overscroll-behavior: contain; color: var(--rc-text-secondary); font-size: 14px; line-height: 1.43; }
.rc-dialog-actions { display: flex; flex: none; flex-direction: row; align-items: center; justify-content: flex-end; gap: var(--rc-modal-footer-gap); margin-top: var(--rc-modal-footer-margin); }
.rc-dialog-actions .button[data-variant='outline'], .rc-dialog-actions .button.button--outline { border: 1px solid var(--rc-border-default) !important; background: var(--rc-bg-surface); color: var(--rc-text-primary); box-shadow: none; }
.rc-dialog-close { top: var(--rc-modal-close-inset); right: var(--rc-modal-close-inset); z-index: 2; display: inline-grid !important; width: var(--rc-modal-close-size) !important; height: var(--rc-modal-close-size) !important; min-width: var(--rc-modal-close-size) !important; padding: 4px !important; place-items: center; border: 0; border-radius: 999px !important; color: var(--rc-text-muted); background: var(--default); box-shadow: none; line-height: 0 !important; }
.rc-dialog-close:hover, .rc-dialog-close:focus-visible { color: var(--rc-text-primary); background: var(--default-hover); outline: none; }
.rc-dialog-close .anticon, .rc-dialog-close svg { display: block; width: var(--rc-modal-close-icon-size); height: var(--rc-modal-close-icon-size); margin: 0; }
.rc-record-detail-dialog__views { align-self: flex-start; margin-top: 12px; }
.rc-record-detail-dialog__body { margin-top: 12px; }
.rc-record-detail-dialog .rc-dialog-body { scrollbar-gutter: stable; }
.rc-record-detail-dialog__state, .rc-record-detail-dialog__error { padding: 48px 24px; border: 1px solid var(--rc-border-default); border-radius: 16px; color: var(--rc-text-muted); background: var(--rc-bg-subtle); text-align: center; font-size: 14px; }
.rc-record-detail-dialog__error { padding-block: 20px; border-color: var(--rc-color-danger-border); color: var(--rc-color-danger); background: var(--rc-color-danger-soft); text-align: left; }
.rc-user-assignment-fields { display: flex; flex-direction: column; gap: 24px; }
.rc-user-select { min-width: 0; }
.rc-user-select__label { margin-bottom: 8px; color: var(--rc-text-secondary); font-size: 14px; font-weight: 500; line-height: 20px; }
.rc-user-combobox__group { width: 100%; min-height: 40px; border: 1px solid var(--rc-field-border); border-radius: 8px; background: var(--rc-field-background); box-shadow: var(--field-shadow); }
.rc-user-combobox__group:hover { border-color: var(--rc-field-border-hover); }
.rc-user-combobox__group:focus-within { border-color: var(--rc-field-border-focus); box-shadow: var(--field-shadow), 0 0 0 2px var(--rc-brand-primary-soft); }
.rc-user-combobox__group .input { min-width: 0; flex: 1; border: 0 !important; background: transparent !important; box-shadow: none !important; }
.rc-user-combobox__popover { width: var(--trigger-width); max-height: min(288px, calc(100dvh - 32px)); overflow-y: auto; border: 1px solid var(--rc-border-default); border-radius: 12px; color: var(--rc-text-secondary); background: var(--rc-bg-surface); box-shadow: var(--rc-shadow-overlay); }
.rc-user-combobox__popover .list-box__item { min-height: 48px; }
.rc-user-combobox__loading { display: grid; min-height: 48px; place-items: center; }
.rc-user-select__input { width: 100%; min-height: 40px; padding-right: 42px !important; border-radius: 8px !important; }
.rc-user-select__trigger { position: absolute; top: 0; right: 0; display: grid; width: 40px; height: 40px; place-items: center; padding: 0; border: 0; color: var(--rc-text-muted); background: transparent; cursor: pointer; }
.rc-user-select__trigger:hover { color: var(--rc-text-primary); }
.rc-user-select__trigger svg { display: block; width: 18px; height: 18px; }
.rc-user-select__popover { position: absolute; z-index: 20; width: 100%; max-height: 256px; margin-top: 8px; overflow-y: auto; padding: 4px; border: 1px solid var(--rc-border-default); border-radius: 12px; color: var(--rc-text-secondary); background: var(--rc-bg-surface); box-shadow: var(--rc-shadow-overlay); }
.rc-user-select__option { display: flex; min-height: 48px; cursor: pointer; align-items: center; gap: 12px; padding: 6px 10px; border-radius: 8px; font-size: 14px; }
.rc-user-select__option[data-focus] { background: var(--rc-brand-primary-soft); }
.rc-user-select__check { display: grid; width: 16px; height: 16px; flex: none; place-items: center; border: 1px solid var(--rc-field-border); border-radius: 4px; color: transparent; background: var(--rc-field-background); }
.rc-user-select__check.is-selected { border-color: var(--rc-brand-primary); color: #fff; background: var(--rc-brand-primary); }
.rc-user-select__check svg { width: 12px; height: 12px; }
.rc-user-select__name, .rc-user-select__account { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-user-select__name { color: var(--rc-text-primary); font-size: 14px; font-weight: 550; line-height: 20px; }
.rc-user-select__account { margin-top: 2px; color: var(--rc-text-muted); font-size: 13px; font-weight: 400; line-height: 18px; }
.rc-user-select__state { padding: 12px; color: var(--rc-text-muted); font-size: 14px; }
.rc-user-select__chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.rc-user-select__chip { display: inline-flex; min-height: 26px; align-items: center; gap: 2px; padding: 2px 3px 2px 9px; border: 1px solid color-mix(in srgb, var(--rc-brand-primary) 18%, var(--rc-border-default)); border-radius: 999px; color: var(--rc-brand-primary); background: var(--rc-brand-primary-soft); font-size: 12px; font-weight: 500; line-height: 18px; }
.rc-user-select__chip > span:first-child { display: block; line-height: 18px; }
.rc-user-select__chip-remove { display: inline-grid !important; width: 20px !important; height: 20px !important; min-width: 20px !important; padding: 0 !important; place-items: center; border-radius: 999px !important; color: inherit !important; line-height: 0 !important; }
.rc-user-select__chip-remove .anticon, .rc-user-select__chip-remove svg { display: block; width: 12px; height: 12px; }
.rc-segmented-control { display: flex; width: max-content; max-width: 100%; min-height: 34px; gap: 2px; overflow-x: auto; padding: 3px; border-radius: 10px; background: var(--default); }
.rc-segmented-control__item { min-height: 28px; flex: 0 0 auto; padding: 0 12px; border: 0; border-radius: 8px; color: var(--rc-text-secondary); background: transparent; font-size: 14px; font-weight: 500; cursor: pointer; }
.rc-segmented-control__item:hover { color: var(--rc-text-primary); background: var(--default-hover); }
.rc-segmented-control__item.is-selected { color: var(--rc-brand-primary); background: var(--rc-bg-surface); box-shadow: var(--rc-shadow-card); }
.rc-subject-view { min-width: 0; background: var(--overlay); }
.rc-subject-view-empty { margin: 16px 0; padding: 48px 24px; border-radius: 16px; color: var(--rc-text-muted); background: var(--rc-bg-subtle); text-align: center; font-size: 14px; }
.rc-subject-view__fields { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 32px; }
.rc-subject-view__field { display: grid; min-width: 0; align-content: start; gap: 8px; padding: 18px 4px 20px; border-bottom: 1px solid var(--rc-border-default); }
.rc-subject-view__field.is-full-width { grid-column: 1 / -1; }
.rc-subject-view__label { color: var(--rc-text-muted); font-size: 13px; font-weight: 500; line-height: 20px; }
.rc-subject-view__value { min-width: 0; color: var(--rc-text-primary); }
.rc-subject-view.is-editing .rc-subject-view__fields { gap: 18px 24px; }
.rc-subject-view.is-editing .rc-subject-view__field { gap: 10px; padding: 0; border-bottom: 0; }
.rc-subject-view.is-editing .rc-subject-view__label { color: var(--rc-text-secondary); font-size: 14px; }
.rc-editable-field { position: relative; min-height: 32px; width: 100%; padding: 4px 0; border: 0; border-radius: 0; color: var(--rc-text-secondary); background: transparent; text-align: left; font-size: 14px; line-height: 22px; }
.rc-editable-field.is-editable { cursor: pointer; }
.rc-editable-field.is-editable:hover, .rc-editable-field.is-editable:focus-visible { border-color: color-mix(in srgb, var(--rc-brand-primary) 18%, var(--rc-border-default)); color: var(--rc-text-primary); background: var(--rc-brand-primary-soft); outline: none; }
.rc-editable-field.is-readonly { cursor: default; }
.rc-editable-field__permission { margin-top: 6px; color: var(--rc-color-warning); font-size: 12px; line-height: 18px; }
.rc-reference-field { min-width: 0; }
.rc-form-input, .rc-form-textarea, .rc-form-control { width: 100%; }
.rc-file-editor { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--rc-border-default); border-radius: 12px; background: var(--rc-bg-subtle); }
.rc-file-editor__list { display: grid; gap: 6px; }
.rc-file-editor__item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 8px; color: var(--rc-text-secondary); background: var(--rc-bg-surface); font-size: 13px; }
.rc-file-editor__item > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-file-upload .drop-zone__area { min-height: 144px; border-color: var(--rc-border-default); color: var(--rc-text-primary); background: var(--rc-field-background); }
.rc-file-upload .drop-zone__area:hover:not([data-disabled]) { border-color: var(--rc-border-strong); background: var(--rc-bg-subtle); }
.rc-file-upload .drop-zone__area[data-drop-target] { border-color: var(--rc-brand-primary); background: var(--rc-brand-primary-soft); }
.rc-file-upload .drop-zone__icon { color: var(--rc-brand-primary); }
.rc-file-upload .drop-zone__label { max-width: 100%; overflow: hidden; color: var(--rc-text-primary); text-overflow: ellipsis; white-space: nowrap; }
.rc-file-upload .drop-zone__description { color: var(--rc-text-muted); }
.rc-file-upload .drop-zone__trigger { min-height: 32px; border: 1px solid var(--rc-button-secondary-border); border-radius: 8px; color: var(--rc-text-primary); background: transparent; box-shadow: none; }
.rc-file-upload .drop-zone__trigger:hover, .rc-file-upload .drop-zone__trigger[data-hovered='true'] { border-color: var(--rc-button-secondary-border-hover); background: var(--rc-button-secondary-background-hover); }
.rc-file-editor .rc-file-upload { margin-top: 2px; }
.rc-form-date-picker { width: 100%; min-width: 0; }
.rc-form-date-picker__group, .rc-form-date-picker__time-group { position: relative !important; display: flex !important; width: 100% !important; min-height: 36px; align-items: center !important; border: 1px solid var(--rc-field-border) !important; border-radius: 8px !important; color: var(--rc-text-primary) !important; background: var(--rc-field-background) !important; box-shadow: var(--field-shadow) !important; }
.rc-form-date-picker__group:hover, .rc-form-date-picker__time-group:hover { border-color: var(--rc-field-border-hover) !important; }
.rc-form-date-picker[data-focus-within='true'] .rc-form-date-picker__group, .rc-form-date-picker__time-group:focus-within { border-color: var(--rc-field-border-focus) !important; box-shadow: var(--field-shadow), 0 0 0 2px var(--rc-brand-primary-soft) !important; }
.rc-form-date-picker__input { min-width: 0; flex: 1; padding-inline: 12px 42px !important; }
.rc-form-date-picker__segment { color: var(--rc-text-primary) !important; font-size: 14px; font-weight: 400; }
.rc-form-date-picker__segment[data-placeholder='true'] { color: var(--rc-text-muted) !important; }
.rc-form-date-picker__suffix { position: absolute !important; top: 50%; right: 2px; transform: translateY(-50%); }
.rc-form-date-picker__trigger { display: inline-grid !important; width: 32px !important; height: 32px !important; min-width: 32px !important; min-height: 32px !important; place-items: center; padding: 0 !important; border-radius: 999px !important; color: var(--rc-text-muted) !important; }
.rc-form-date-picker__trigger:hover, .rc-form-date-picker__trigger[data-hovered='true'] { color: var(--rc-text-primary) !important; background: var(--default) !important; }
.rc-form-date-picker__trigger :is(.anticon, svg), .rc-form-date-picker__trigger [data-slot='date-picker-trigger-indicator'] { display: grid !important; width: 16px !important; height: 16px !important; place-items: center; margin: 0 !important; }
.rc-form-date-picker__trigger .anticon > svg { display: block !important; width: 100% !important; height: 100% !important; }
.rc-form-date-picker__popover { z-index: 190; max-width: calc(100vw - 24px); max-height: min(384px, calc(100dvh - 24px)); overflow: auto; padding: 4px !important; border: 1px solid var(--rc-border-default) !important; border-radius: 12px !important; color: var(--rc-text-secondary) !important; background: var(--rc-bg-surface) !important; box-shadow: var(--rc-shadow-overlay) !important; }
.rc-form-date-picker__calendar { color: var(--rc-text-primary) !important; background: transparent !important; }
.rc-form-date-picker__popover [data-slot='calendar-cell'][data-selected='true'], .rc-form-date-picker__popover [data-slot='calendar-cell'][aria-selected='true'] { color: var(--rc-brand-primary) !important; background: var(--rc-brand-primary-soft) !important; }
.rc-form-date-picker__popover [data-slot='calendar-cell'][data-focus-visible='true'] { outline: 2px solid var(--rc-brand-primary) !important; outline-offset: -2px; }
.rc-form-date-picker__time-panel { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 4px; padding: 12px; border-top: 1px solid var(--rc-border-default); }
.rc-form-date-picker__time-panel .time-field { width: 136px; }
.rc-field-muted { color: var(--rc-text-muted); }
.rc-field-value { color: var(--rc-text-primary); }
.rc-field-chip { display: inline-flex; align-items: center; padding: 2px 8px; border: 1px solid var(--rc-border-default); border-radius: 999px; color: var(--rc-text-secondary); background: var(--rc-bg-subtle); font-size: 12px; font-weight: 500; line-height: 18px; }
.rc-field-chip.is-success { border-color: color-mix(in srgb, var(--rc-color-success, #26855b) 24%, var(--rc-border-default)); color: var(--rc-color-success, #26855b); }
.rc-field-chip.is-warning { border-color: color-mix(in srgb, var(--rc-color-warning, #a26600) 24%, var(--rc-border-default)); color: var(--rc-color-warning, #a26600); }
.rc-reference-table { min-width: 0; }
.rc-reference-table__toolbar { display: flex; min-height: 32px; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.rc-reference-table__summary { color: var(--rc-text-muted); font-size: 13px; font-weight: 400; line-height: 20px; }
.rc-reference-data-grid-wrap { max-height: none; }
.rc-reference-data-grid-wrap .rc-data-grid-scroll { max-height: none; overflow-x: auto; overflow-y: visible; }
.rc-reference-data-grid .table__resizable-container { min-height: 0; max-height: none; overflow-x: auto; overflow-y: visible; }
.rc-reference-data-grid .table__content { min-height: 0; }
.rc-reference-data-grid-wrap.is-single-column .rc-data-grid-scroll, .rc-reference-data-grid-wrap.is-single-column .rc-reference-data-grid .table__resizable-container { overflow-x: hidden; }
.rc-reference-data-grid .table__column { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: keep-all; }
.rc-reference-data-grid .table__cell { min-width: 0; overflow: hidden; }
.rc-reference-data-grid .table__cell > * { min-width: 0; max-width: 100%; }
.rc-reference-table__value { width: 100%; min-width: 0; max-width: 100%; color: var(--rc-text-secondary); cursor: default; font-size: 14px; font-weight: 400; line-height: 21px; }
.rc-reference-data-grid .rc-reference-table__primary-action.button { width: 100%; min-width: 0; max-width: 100%; height: auto; min-height: 21px; justify-content: flex-start; padding: 0; border: 0; border-radius: 0; color: var(--rc-text-secondary); background: transparent; box-shadow: none; text-align: left; }
.rc-reference-data-grid .rc-reference-table__primary-action.button:hover, .rc-reference-data-grid .rc-reference-table__primary-action.button[data-hovered='true'], .rc-reference-data-grid .rc-reference-table__primary-action.button[data-focus-visible='true'] { color: var(--rc-brand-primary); background: transparent; }
.rc-reference-data-grid .rc-reference-table__primary-action .rc-reference-table__value { color: currentColor; cursor: pointer; }
.rc-reference-data-grid-wrap.is-single-column .rc-reference-data-grid .table__cell { height: 48px; padding-block: 13px; }
.rc-reference-data-grid-wrap.is-single-column .rc-reference-table__value { color: var(--rc-text-secondary); font-weight: 400; }
.rc-reference-table__value:focus-visible { border-radius: 4px; outline: 2px solid var(--rc-brand-primary-soft-hover); outline-offset: 2px; }
.rc-reference-table__value.is-long-text { color: var(--rc-text-secondary); font-weight: 400; }
.rc-reference-table__tooltip { max-width: min(480px, calc(100vw - 32px)); white-space: pre-wrap; overflow-wrap: anywhere; }
.rc-reference-table__code { display: block; max-width: 28rem; overflow: hidden; color: var(--rc-text-secondary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.rc-reference-table__muted { color: var(--rc-text-muted); }
.rc-reference-table__warning { border: 1px solid var(--rc-color-warning-border); border-radius: 12px; color: var(--rc-color-warning); background: var(--rc-color-warning-soft); }
.rc-field-link { color: var(--rc-brand-primary); font-size: 13px; font-weight: 500; text-decoration: none; }
.rc-field-link:hover { color: var(--rc-brand-light); }
.rc-inline-editor { min-width: 0; }
.rc-inline-editor--boolean { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--rc-border-default); border-radius: 12px; background: var(--rc-bg-surface); }
.rc-inline-editor__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; }
.rc-field-error { color: var(--rc-color-danger); font-size: 13px; line-height: 18px; }
.rc-field-danger-action { flex: none; border: 0; color: var(--rc-color-danger); background: transparent; font-size: 13px; font-weight: 500; cursor: pointer; }
.rc-file-input { display: block; width: 100%; color: var(--rc-text-secondary); font-size: 13px; }
.rc-native-choice { display: flex; align-items: center; gap: 8px; color: var(--rc-text-secondary); font-size: 14px; }
.rc-choice-list { max-height: 224px; overflow-y: auto; padding: 6px; border: 1px solid var(--rc-border-default); border-radius: 12px; background: var(--rc-bg-subtle); }
.rc-choice-list__item { display: flex; width: 100%; min-height: 48px; cursor: pointer; align-items: center; gap: 12px; padding: 7px 10px; border-radius: 8px; color: var(--rc-text-secondary); background: var(--rc-bg-surface); font-size: 14px; }
.rc-choice-list__item :is(.checkbox__content, .radio__content) { width: 100%; align-items: center; gap: 12px; }
.rc-choice-list__item + .rc-choice-list__item { margin-top: 4px; }
.rc-choice-list__item:hover { background: var(--rc-brand-primary-soft); }
.rc-choice-list__empty { padding: 14px 12px; color: var(--rc-text-muted); font-size: 14px; }
.rc-selection-action-bar { position: fixed !important; right: auto !important; bottom: max(24px, env(safe-area-inset-bottom)) !important; left: calc(50% + 7.5rem) !important; z-index: 120 !important; width: max-content; max-width: calc(100vw - 32px); transform: translateX(-50%); }
.rc-selection-action-bar .action-bar__wrapper { min-width: 0; max-width: 100%; min-height: 44px; border: 1px solid var(--rc-border-default); border-radius: 12px; color: var(--rc-text-primary); background: var(--rc-bg-surface); box-shadow: var(--rc-shadow-overlay); }
.rc-selection-action-bar .action-bar__prefix, .rc-selection-action-bar .action-bar__suffix { flex: none; }
.rc-selection-action-bar .action-bar__content { min-width: 0; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
.rc-selection-action-bar .action-bar__content::-webkit-scrollbar { display: none; }
.rc-selection-action-bar .action-bar__content .button { flex: none; }
.rc-selection-summary { color: var(--rc-text-secondary); font-size: 13px; font-weight: 500; line-height: 20px; white-space: nowrap; }
.rc-app-shell[data-state='collapsed'] .rc-selection-action-bar { left: 50% !important; }
@media (max-width: 767px) {
.login-card [data-slot='card-content'] { padding: 28px 24px !important; }
.login-brand strong { font-size: 28px; }
.login-captcha-row { grid-template-columns: minmax(0, 1fr) 104px; }
.rc-workbench-toolbar { align-items: stretch; flex-direction: column; gap: 12px; }
.rc-toolbar-search { width: 100%; min-width: 0; flex-basis: auto; }
.rc-filter-card__content[data-slot='card-content'] { padding: 16px; }
.rc-filter-primary-row { align-items: stretch; }
.rc-filter-primary-fields { width: 100%; }
.rc-list-header { align-items: flex-start; flex-direction: column; }
.rc-dialog-panel { width: calc(100vw - 32px) !important; max-width: calc(100vw - 32px) !important; max-height: calc(100dvh - 32px); }
.rc-dialog-actions, .rc-dialog-footer { align-items: stretch; flex-direction: column-reverse; }
.rc-dialog-actions > *, .rc-dialog-footer .button { width: 100%; }
.rc-subject-view__fields { grid-template-columns: 1fr; }
.rc-subject-view__field, .rc-subject-view__field.is-full-width { grid-column: 1; padding-block: 16px; }
.rc-reference-table__toolbar { align-items: flex-start; flex-direction: column; }
.rc-reference-table__toolbar .button { width: 100%; }
.rc-file-upload .drop-zone__area { min-height: 128px; padding-inline: 16px; }
.rc-file-upload .drop-zone__trigger { min-height: 36px; }
.rc-selection-action-bar { right: 16px !important; left: 16px !important; width: auto !important; transform: none; }
.rc-selection-action-bar .action-bar__wrapper { width: 100%; max-width: 100%; }
.rc-selection-summary { display: none; }
.pm-pagination-bar { align-items: stretch; flex-direction: column; }
.pm-pagination-bar .pagination__content { max-width: 100%; justify-content: flex-start; overflow-x: auto; scrollbar-width: none; }
.pm-pagination-bar .pagination__content::-webkit-scrollbar { display: none; }
.pm-pagination-bar .pagination__summary { width: 100%; white-space: normal; }
.pm-pagination-controls { justify-content: flex-start; }
} }
/* Shared treatment for legacy Headless dialogs while business forms migrate incrementally. */
[data-headlessui-portal] { color: var(--rc-text-primary); }
[data-headlessui-portal] [role='dialog'] { color: var(--rc-text-primary); }
[data-headlessui-portal] [role='dialog']:not(.app-switcher-dialog) { border-color: transparent !important; border-radius: 24px !important; background: var(--rc-bg-surface) !important; background-image: none !important; box-shadow: var(--rc-shadow-overlay) !important; }
[data-headlessui-portal] [role='dialog'] :is(.bg-white, [class*='bg-white/'], .bg-zinc-50, [class*='bg-zinc-50/'], .bg-slate-50, [class*='bg-slate-50/']) { background-color: var(--rc-bg-surface) !important; background-image: none !important; }
[data-headlessui-portal] [role='dialog'] :is(.text-slate-950, .text-slate-900, .text-zinc-950, .text-zinc-900) { color: var(--rc-text-primary) !important; }
[data-headlessui-portal] [role='dialog'] :is(.text-slate-700, .text-slate-600, .text-slate-500, .text-zinc-700, .text-zinc-600, .text-zinc-500) { color: var(--rc-text-secondary) !important; }
[data-headlessui-portal] [role='dialog'] :is(.border-white, [class*='border-white/'], .border-zinc-100, .border-zinc-200) { border-color: var(--rc-border-default) !important; }
.dark [data-headlessui-portal] [role='dialog'] input, .dark [data-headlessui-portal] [role='dialog'] textarea { color: var(--rc-text-primary); background: var(--rc-field-background); }
import xyLogo from '@/assets/xy-logo_small.png'; import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import {
APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME,
} from '@/config/application';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, Spinner, TextField } from '@heroui/react'; import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi'; import { history, useSearchParams } from 'umi';
import { INTEGRITY_PRACTICE_APP_TITLE } from './integrity_practice_management/integrityPracticeConfig';
function buildPassword(password, siteKey) { function buildPassword(password, siteKey) {
if (!password) return password; if (!password) return password;
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password; return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
} }
function normalizeRedirect(value) {
if (!value) {
return '/';
}
try {
const target = new URL(value, window.location.origin);
if (target.origin !== window.location.origin) {
return '/';
}
const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
const targetPath = `${target.pathname}${target.search}${target.hash}`;
if (
applicationBasePath &&
(target.pathname === applicationBasePath ||
target.pathname.startsWith(`${applicationBasePath}/`))
) {
return targetPath.slice(applicationBasePath.length) || '/';
}
return targetPath;
} catch {
return '/';
}
}
export default function LoginPage() { export default function LoginPage() {
const site = useContext(SiteContext); const site = useContext(SiteContext);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
...@@ -23,12 +56,14 @@ export default function LoginPage() { ...@@ -23,12 +56,14 @@ export default function LoginPage() {
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/'; const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
setCaptchaLoading(true); setCaptchaLoading(true);
try { try {
setCaptchaData((await getCaptcha()) || null); const nextCaptcha = await getCaptcha();
setCaptchaData(nextCaptcha || null);
} catch (requestError) { } catch (requestError) {
setCaptchaData(null); setCaptchaData(null);
setError(requestError?.message || '验证码加载失败,请稍后重试'); setError(requestError?.message || '验证码加载失败,请稍后重试');
...@@ -38,7 +73,7 @@ export default function LoginPage() { ...@@ -38,7 +73,7 @@ export default function LoginPage() {
}; };
useEffect(() => { useEffect(() => {
document.title = INTEGRITY_PRACTICE_APP_TITLE; document.title = `${APPLICATION_DISPLAY_NAME} - 登录`;
refreshCaptcha(); refreshCaptcha();
}, []); }, []);
...@@ -46,6 +81,7 @@ export default function LoginPage() { ...@@ -46,6 +81,7 @@ export default function LoginPage() {
event.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const loginInfo = await userLogin({ const loginInfo = await userLogin({
identity, identity,
...@@ -54,6 +90,7 @@ export default function LoginPage() { ...@@ -54,6 +90,7 @@ export default function LoginPage() {
token: captchaData?.token, token: captchaData?.token,
type: 'password', type: 'password',
}); });
authService.setCurrentUser(loginInfo); authService.setCurrentUser(loginInfo);
if (loginInfo?.token) authService.setToken(loginInfo.token); if (loginInfo?.token) authService.setToken(loginInfo.token);
history.push(redirect); history.push(redirect);
...@@ -67,44 +104,131 @@ export default function LoginPage() { ...@@ -67,44 +104,131 @@ export default function LoginPage() {
}; };
return ( return (
<main className="login-page"> <main className="login-page rc-login-page">
<Card className="login-card"> <section className="login-shell rc-login-shell">
<Card className="login-card rc-login-card">
<Card.Content> <Card.Content>
<div className="login-brand"> <div className="login-brand login-card-brand rc-login-brand">
<img alt="鑫元基金" src={xyLogo} /> <span className="login-brand-logo">
<strong>{INTEGRITY_PRACTICE_APP_TITLE}</strong> <img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--dark"
src={xyLogoWhite}
/>
</span>
<strong>{APPLICATION_DISPLAY_NAME}</strong>
</div> </div>
<form className="login-form" onSubmit={handleSubmit}>
<TextField className="login-field" isDisabled={loading} isRequired value={identity} onChange={setIdentity}> <form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField
className="login-field"
data-has-value={identity ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
value={identity}
onChange={setIdentity}
>
<Label>账号</Label> <Label>账号</Label>
<Input autoComplete="username" className="login-input" data-filled={identity ? 'true' : undefined} placeholder="请输入账号" variant="secondary" /> <Input
autoComplete="username"
autoFocus
className="login-input"
data-filled={identity ? 'true' : undefined}
placeholder="请输入账号"
variant="secondary"
/>
</TextField> </TextField>
<TextField className="login-field" isDisabled={loading} isRequired value={password} onChange={setPassword}>
<TextField
className="login-field"
data-has-value={password ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
type="password"
value={password}
onChange={setPassword}
>
<Label>密码</Label> <Label>密码</Label>
<Input autoComplete="current-password" className="login-input" data-filled={password ? 'true' : undefined} placeholder="请输入密码" type="password" variant="secondary" /> <Input
autoComplete="current-password"
className="login-input"
data-filled={password ? 'true' : undefined}
placeholder="请输入密码"
variant="secondary"
/>
</TextField> </TextField>
<TextField className="login-field" isDisabled={loading} isRequired value={captchaCode} onChange={setCaptchaCode}>
<div
className="login-field"
data-has-value={captchaCode ? 'true' : undefined}
>
<Label>验证码</Label> <Label>验证码</Label>
<div className="login-captcha-row"> <div className="login-captcha-row">
<Input className="login-input" data-filled={captchaCode ? 'true' : undefined} placeholder="请输入验证码" variant="secondary" /> <Input
<button aria-label="验证码"
autoComplete="off"
className="login-input"
data-filled={captchaCode ? 'true' : undefined}
disabled={loading}
placeholder="请输入验证码"
required
value={captchaCode}
variant="secondary"
onChange={(event) => setCaptchaCode(event.target.value)}
/>
<Button
aria-label="刷新验证码" aria-label="刷新验证码"
className="login-captcha" className="login-captcha-button"
disabled={loading || captchaLoading} isDisabled={loading || captchaLoading}
onClick={() => { setError(''); refreshCaptcha(); }} variant="secondary"
type="button" onPress={() => {
setError('');
refreshCaptcha();
}}
> >
{captchaData?.image ? <span dangerouslySetInnerHTML={{ __html: captchaData.image }} /> : captchaLoading ? <Spinner size="sm" /> : '刷新验证码'} {captchaData?.image ? (
</button> <span
className="captcha-image"
dangerouslySetInnerHTML={{ __html: captchaData.image }}
/>
) : (
<span>{captchaLoading ? '加载中...' : '刷新验证码'}</span>
)}
</Button>
</div> </div>
</TextField> </div>
{error ? <Alert className="login-error" color="danger" role="alert">{error}</Alert> : null}
<Button className="login-submit" fullWidth isDisabled={captchaLoading} isPending={loading} type="submit" variant="primary"> {error ? (
{loading ? '正在登录...' : '登录'} <Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
isDisabled={
loading || captchaLoading || !identity || !password || !captchaCode
}
isPending={loading}
type="submit"
variant="primary"
>
{loading ? '登录中...' : '登录'}
</Button> </Button>
</form> </form>
</Card.Content> </Card.Content>
</Card> </Card>
</section>
</main> </main>
); );
} }
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { DataGrid } from '@heroui-pro/react/data-grid'; import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Button, Spinner, Tooltip } from '@heroui/react'; import { Alert, Button, Spinner, Surface, Tooltip } from '@heroui/react';
import { Eye } from '@/components/AppIcons';
import { getSubjectView, loadSubject, queryRecords } from '@/services/DataService'; import { getSubjectView, loadSubject, queryRecords } from '@/services/DataService';
import { import {
buildViewColumns, buildViewColumns,
...@@ -115,33 +116,20 @@ function sortRecordsByReferenceOrder(records, referenceIds) { ...@@ -115,33 +116,20 @@ function sortRecordsByReferenceOrder(records, referenceIds) {
}); });
} }
function CellValue({ value, fieldDef, primary = false, onPress }) { function CellValue({ value, fieldDef, primary = false }) {
const text = formatFieldValue(value, fieldDef); const text = formatFieldValue(value, fieldDef);
if (!text) { if (!text) {
return <span className="rc-data-grid-text rc-reference-table__muted">-</span>; return <span className="text-muted">-</span>;
} }
return ( return (
<Tooltip delay={300}> <Tooltip delay={300}>
{onPress ? ( <Tooltip.Trigger className="block min-w-0" role="presentation">
<Button <span className={primary ? 'block max-w-80 truncate font-medium' : 'block max-w-80 truncate'}>
aria-label={`查看${text}详情`}
className="rc-reference-table__primary-action"
size="sm"
variant="ghost"
onPress={onPress}
>
<span className={`rc-data-grid-text rc-reference-table__value${primary ? ' rc-data-grid-primary' : ''}${LONG_TEXT_TYPES.has(fieldDef?.type) ? ' is-long-text' : ''}`}>{text}</span>
</Button>
) : (
<span
className={`rc-data-grid-text rc-reference-table__value${primary ? ' rc-data-grid-primary' : ''}${LONG_TEXT_TYPES.has(fieldDef?.type) ? ' is-long-text' : ''}`}
tabIndex={0}
>
{text} {text}
</span> </span>
)} </Tooltip.Trigger>
<Tooltip.Content className="rc-reference-table__tooltip">{text}</Tooltip.Content> <Tooltip.Content>{text}</Tooltip.Content>
</Tooltip> </Tooltip>
); );
} }
...@@ -238,6 +226,7 @@ export default function ReferenceRecordsTable({ ...@@ -238,6 +226,7 @@ export default function ReferenceRecordsTable({
const columns = useMemo(() => buildColumns(subject, records, views), [records, subject, views]); const columns = useMemo(() => buildColumns(subject, records, views), [records, subject, views]);
const isSingleColumn = columns.length === 1; const isSingleColumn = columns.length === 1;
const hasPreviewAction = Boolean(subjectName && onOpenReference);
const columnWidths = useMemo( const columnWidths = useMemo(
() => columns.map((column, index) => { () => columns.map((column, index) => {
const type = column.fieldDef?.type; const type = column.fieldDef?.type;
...@@ -250,7 +239,7 @@ export default function ReferenceRecordsTable({ ...@@ -250,7 +239,7 @@ export default function ReferenceRecordsTable({
}), }),
[columns], [columns],
); );
const minimumGridWidth = columnWidths.reduce((total, width) => total + width, 0); const minimumGridWidth = columnWidths.reduce((total, width) => total + width, 0) + (hasPreviewAction ? 80 : 0);
useLayoutEffect(() => { useLayoutEffect(() => {
const wrapper = gridWrapRef.current; const wrapper = gridWrapRef.current;
...@@ -263,32 +252,59 @@ export default function ReferenceRecordsTable({ ...@@ -263,32 +252,59 @@ export default function ReferenceRecordsTable({
}, [isSingleColumn, minimumGridWidth]); }, [isSingleColumn, minimumGridWidth]);
const gridColumns = useMemo( const gridColumns = useMemo(
() => columns.map((column, index) => ({ () => [
...columns.map((column, index) => ({
id: column.field, id: column.field,
header: column.label, header: column.label,
isRowHeader: index === 0, isRowHeader: index === 0,
minWidth: columnWidths[index], minWidth: columnWidths[index],
maxWidth: hasHorizontalOverflow ? columnWidths[index] : undefined, maxWidth: hasHorizontalOverflow ? columnWidths[index] : undefined,
width: columnWidths[index], width: !hasHorizontalOverflow && index === 0 ? '1fr' : columnWidths[index],
pinned: hasHorizontalOverflow && index === 0 ? 'start' : undefined, pinned: hasHorizontalOverflow && index === 0 ? 'start' : undefined,
cell: (record) => ( cell: (record) => (
<CellValue <CellValue
fieldDef={column.fieldDef} fieldDef={column.fieldDef}
primary={index === 0 && !isSingleColumn} primary={index === 0 && !isSingleColumn}
value={getFieldValue(record, column.field)} value={getFieldValue(record, column.field)}
onPress={index === 0 && subjectName && onOpenReference ? () => onOpenReference({ recordName: normalizeRecordId(record), subjectName }) : undefined}
/> />
), ),
})), })),
[columnWidths, columns, hasHorizontalOverflow, isSingleColumn, onOpenReference, subjectName], ...(hasPreviewAction
? [{
id: 'actions',
header: '操作',
align: 'start',
minWidth: 80,
maxWidth: 80,
width: 80,
pinned: hasHorizontalOverflow ? 'end' : undefined,
cell: (record) => (
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="预览"
className="grid !size-8 shrink-0 place-items-center p-0"
size="sm"
variant="tertiary"
onPress={() => onOpenReference({ recordName: normalizeRecordId(record), subjectName })}
>
<Eye className="!grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4" size={16} aria-hidden="true" />
</Button>
<Tooltip.Content>预览</Tooltip.Content>
</Tooltip>
),
}]
: []),
],
[columnWidths, columns, hasHorizontalOverflow, hasPreviewAction, isSingleColumn, onOpenReference, subjectName],
); );
const configurationMissing = referenceIds.length > 0 && !subjectName && populatedRecords.length === 0; const configurationMissing = referenceIds.length > 0 && !subjectName && populatedRecords.length === 0;
return ( return (
<div className="rc-reference-table"> <div className="space-y-2">
<div className="rc-reference-table__toolbar"> <div className="flex items-center justify-between gap-3 text-xs text-muted">
<span className="rc-reference-table__summary">共 {loading ? referenceIds.length : records.length} 条关联记录</span> <span>共 {loading ? referenceIds.length : records.length} 条关联记录</span>
{canEdit ? ( {canEdit ? (
<Button size="sm" variant="outline" onPress={onEdit}> <Button size="sm" variant="outline" onPress={onEdit}>
编辑关联记录 编辑关联记录
...@@ -296,42 +312,37 @@ export default function ReferenceRecordsTable({ ...@@ -296,42 +312,37 @@ export default function ReferenceRecordsTable({
) : null} ) : null}
</div> </div>
{error ? ( {error ? (
<div className="rc-reference-table__warning"> <Alert status="warning"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
{error}
</div>
) : null} ) : null}
{configurationMissing ? ( {configurationMissing ? (
<div className="rc-reference-table__warning"> <Alert status="warning"><Alert.Content><Alert.Description>引用字段缺少目标 Subject 配置,暂不能加载关联明细。</Alert.Description></Alert.Content></Alert>
引用字段缺少目标 Subject 配置,暂不能加载关联明细。
</div>
) : null} ) : null}
<div <div
className={`rc-data-grid-wrap rc-reference-data-grid-wrap ${records.length === 0 ? 'is-empty' : 'is-populated'}${hasHorizontalOverflow ? ' is-wide' : ''}${isSingleColumn ? ' is-single-column' : ''}`} className="relative min-w-0"
ref={gridWrapRef} ref={gridWrapRef}
style={{ '--rc-data-grid-min-width': `${minimumGridWidth}px` }}
> >
<DataGrid <DataGrid
aria-label="关联记录" aria-label="关联记录"
className="rc-data-grid rc-reference-data-grid" className="rc-detail-data-grid w-full"
columns={gridColumns} columns={gridColumns}
contentClassName={hasHorizontalOverflow ? 'rc-data-grid-content is-wide' : 'rc-data-grid-content'} contentClassName="min-w-full"
data={records} data={records}
getRowId={(record) => normalizeRecordId(record)} getRowId={(record) => normalizeRecordId(record)}
renderEmptyState={() => loading ? ( renderEmptyState={() => loading ? (
<div className="rc-data-grid-loading"><Spinner size="sm" /></div> <div className="flex min-h-32 items-center justify-center"><Spinner size="sm" /></div>
) : ( ) : (
<EmptyState className="rc-data-grid-empty" size="sm"> <EmptyState size="sm">
<EmptyState.Header> <EmptyState.Header>
<EmptyState.Title>{referenceIds.length === 0 ? '暂无关联记录' : '暂无可展示的关联明细'}</EmptyState.Title> <EmptyState.Title>{referenceIds.length === 0 ? '暂无关联记录' : '暂无可展示的关联明细'}</EmptyState.Title>
</EmptyState.Header> </EmptyState.Header>
</EmptyState> </EmptyState>
)} )}
scrollContainerClassName="rc-data-grid-scroll" scrollContainerClassName="overflow-x-auto"
selectionMode="none" selectionMode="none"
variant="primary" variant="primary"
verticalAlign="middle" verticalAlign="middle"
/> />
{loading && records.length > 0 ? <div className="rc-data-grid-busy"><Spinner size="sm" /></div> : null} {loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div> </div>
</div> </div>
); );
......
import { useEffect, useMemo, useState } from 'react'; import { ChevronDown, Close, Search } from '@/components/AppIcons';
import { Checkbox, Input, Radio, RadioGroup } from '@heroui/react';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
import { import {
getReferenceSubjectName, getReferenceSubjectName,
...@@ -7,20 +6,40 @@ import { ...@@ -7,20 +6,40 @@ import {
normalizeRecordLabel, normalizeRecordLabel,
toArray, toArray,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import {
Alert,
Button,
Label,
ListBox,
Popover,
SearchField,
Spinner,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
export default function ReferenceSearchSelect({ function uniqueRecords(records) {
fieldDef, const seenIds = new Set();
value,
onChange, return records.filter((record) => {
placeholder, const recordId = normalizeRecordId(record);
}) { if (!recordId || seenIds.has(recordId)) return false;
seenIds.add(recordId);
return true;
});
}
export default function ReferenceSearchSelect({ fieldDef, value, onChange, placeholder }) {
const [isOpen, setIsOpen] = useState(false);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const subjectName = getReferenceSubjectName(fieldDef); const subjectName = getReferenceSubjectName(fieldDef);
const multiple = Boolean(fieldDef?.multiple); const multiple = Boolean(fieldDef?.multiple);
const selectedValues = useMemo(() => toArray(value).filter(Boolean), [value]);
const selectedIds = useMemo( const selectedIds = useMemo(
() => toArray(value).map(normalizeRecordId).filter(Boolean), () => selectedValues.map(normalizeRecordId).filter(Boolean),
[value], [selectedValues],
); );
useEffect(() => { useEffect(() => {
...@@ -32,6 +51,9 @@ export default function ReferenceSearchSelect({ ...@@ -32,6 +51,9 @@ export default function ReferenceSearchSelect({
return; return;
} }
setLoading(true);
setError('');
try { try {
const result = await queryRecords({ const result = await queryRecords({
subject: subjectName, subject: subjectName,
...@@ -41,83 +63,156 @@ export default function ReferenceSearchSelect({ ...@@ -41,83 +63,156 @@ export default function ReferenceSearchSelect({
mode: 'basic', mode: 'basic',
}); });
if (!cancelled) { if (!cancelled) setOptions(result?.records || []);
setOptions(result?.records || []);
}
} catch (requestError) { } catch (requestError) {
if (!cancelled) { if (!cancelled) {
setOptions([]); setOptions([]);
setError(requestError?.message || '引用记录加载失败');
} }
} finally {
if (!cancelled) setLoading(false);
} }
}; };
loadOptions(); loadOptions();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [keyword, subjectName]); }, [keyword, subjectName]);
const toggleRecord = (record, checked) => { const menuOptions = useMemo(
const recordId = normalizeRecordId(record); () => uniqueRecords([
if (!recordId) { ...selectedValues.filter((item) => typeof item === 'object'),
return; ...options,
} ]),
[options, selectedValues],
);
const selectedLabels = useMemo(
() => selectedIds.map((id) => {
const record = menuOptions.find((item) => normalizeRecordId(item) === id);
return record ? normalizeRecordLabel(record) || id : id;
}),
[menuOptions, selectedIds],
);
const triggerText = selectedIds.length === 0
? placeholder || '请选择引用记录'
: multiple
? `已选择 ${selectedIds.length} 项`
: selectedLabels[0];
const triggerTitle = selectedLabels.length > 0 ? selectedLabels.join('\n') : triggerText;
if (!multiple) { const handleSelectionChange = (keys) => {
onChange(checked ? recordId : null); const nextIds = keys === 'all'
return; ? menuOptions.map(normalizeRecordId).filter(Boolean)
} : Array.from(keys || []).map(String);
if (checked) { if (multiple) {
onChange(Array.from(new Set([...selectedIds, recordId]))); onChange(nextIds);
return; return;
} }
onChange(selectedIds.filter((item) => item !== recordId)); onChange(nextIds[0] || null);
setIsOpen(false);
setKeyword('');
};
const clearSelection = () => {
onChange(multiple ? [] : null);
setKeyword('');
}; };
if (!subjectName) { if (!subjectName) {
return ( return <Alert status="warning"><Alert.Content><Alert.Description>引用字段缺少目标 Subject 配置,暂不能搜索选择。</Alert.Description></Alert.Content></Alert>;
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
引用字段缺少目标 Subject 配置,暂不能搜索选择。
</div>
);
} }
return ( return (
<div className="space-y-3"> <Popover.Root
<Input isOpen={isOpen}
onOpenChange={(nextOpen) => {
setIsOpen(nextOpen);
if (!nextOpen) setKeyword('');
}}
>
<Button
aria-label={fieldDef?.label || '选择引用记录'}
className="rc-reference-select-trigger"
fullWidth
variant="secondary"
>
<span className="rc-reference-select-trigger__value" title={triggerTitle}>
{triggerText}
</span>
<span className="rc-reference-select-trigger__indicator">
<ChevronDown size={16} />
</span>
</Button>
<Popover.Content
className="rc-reference-select-popover"
placement="bottom start"
>
<Popover.Dialog
aria-label={fieldDef?.label || '选择引用记录'}
className="min-w-0 p-0"
>
<div className="flex items-center justify-between gap-3 px-3 pb-2 pt-3">
<span className="min-w-0 truncate text-sm text-muted">
{selectedIds.length > 0 ? `已选择 ${selectedIds.length} 项` : '选择引用记录'}
</span>
{selectedIds.length > 0 ? (
<Button size="sm" variant="tertiary" onPress={clearSelection}>清空</Button>
) : null}
</div>
<div className="px-2 pb-2">
<SearchField
aria-label="搜索引用记录"
fullWidth
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={placeholder || '搜索引用记录标题'}
variant="secondary" variant="secondary"
/> onChange={setKeyword}
<div className="rc-choice-list"> >
{options.length === 0 ? ( <SearchField.Group>
<div className="rc-choice-list__empty">暂无可选记录</div> <SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
) : multiple ? ( <SearchField.Input autoFocus placeholder={placeholder || '搜索引用记录标题'} />
options.map((record) => { <SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton>
</SearchField.Group>
</SearchField>
</div>
{loading ? (
<div className="grid min-h-24 place-items-center" role="status"><Spinner size="sm" /></div>
) : error ? (
<div className="grid min-h-24 place-items-center px-3 text-sm text-danger">{error}</div>
) : menuOptions.length === 0 ? (
<div className="grid min-h-24 place-items-center px-3 text-sm text-muted">暂无可选记录</div>
) : (
<ListBox
aria-label="引用记录候选项"
className="rc-reference-select-options"
items={menuOptions}
selectedKeys={new Set(selectedIds)}
selectionMode={multiple ? 'multiple' : 'single'}
onSelectionChange={handleSelectionChange}
>
{(record) => {
const recordId = normalizeRecordId(record); const recordId = normalizeRecordId(record);
const checked = selectedIds.includes(recordId); const label = normalizeRecordLabel(record) || recordId;
return ( return (
<Checkbox className="rc-choice-list__item" isSelected={checked} key={recordId} onChange={(nextChecked) => toggleRecord(record, nextChecked)}> <ListBox.Item
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control><span> className="rc-reference-select-option"
<span className="block font-medium text-zinc-950">{normalizeRecordLabel(record) || recordId}</span> id={recordId}
<span className="block text-xs text-zinc-500">{record.name || recordId}</span> textValue={label}
</span></Checkbox.Content> >
</Checkbox> <span className="rc-reference-select-option__content min-w-0 flex-1">
<Label className="rc-reference-select-option__label" title={label}>{label}</Label>
<span className="block truncate text-xs text-muted" title={record.name || recordId}>
{record.name || recordId}
</span>
</span>
<ListBox.ItemIndicator />
</ListBox.Item>
); );
}) }}
) : ( </ListBox>
<RadioGroup value={selectedIds[0] || ''} onChange={(nextId) => onChange(nextId || null)}>
{options.map((record) => {
const recordId = normalizeRecordId(record);
return <Radio className="rc-choice-list__item" key={recordId} value={recordId}><Radio.Content><Radio.Control><Radio.Indicator /></Radio.Control><span><span className="block font-medium text-zinc-950">{normalizeRecordLabel(record) || recordId}</span><span className="block text-xs text-zinc-500">{record.name || recordId}</span></span></Radio.Content></Radio>;
})}
</RadioGroup>
)} )}
</div> </Popover.Dialog>
</div> </Popover.Content>
</Popover.Root>
); );
} }
import clsx from 'clsx'; import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import TailwindEditableField from './TailwindEditableField'; import TailwindEditableField from './TailwindEditableField';
import { import {
buildViewColumns, buildViewColumns,
...@@ -34,15 +35,15 @@ export default function SubjectViewContent({ ...@@ -34,15 +35,15 @@ export default function SubjectViewContent({
if (!displayFields || displayFields.length === 0) { if (!displayFields || displayFields.length === 0) {
return ( return (
<div className="rc-subject-view-empty"> <EmptyState size="md">
暂无可显示的字段 <EmptyState.Header><EmptyState.Title>暂无可显示的字段</EmptyState.Title></EmptyState.Header>
</div> </EmptyState>
); );
} }
return ( return (
<section className={clsx('rc-subject-view', mode === 'edit' && 'is-editing')}> <section className="min-w-0">
<div className="rc-subject-view__fields"> <div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
{displayFields.map((field) => { {displayFields.map((field) => {
const value = getFieldValue(editableRecord, field.field); const value = getFieldValue(editableRecord, field.field);
const fieldDisabled = const fieldDisabled =
...@@ -57,12 +58,12 @@ export default function SubjectViewContent({ ...@@ -57,12 +58,12 @@ export default function SubjectViewContent({
<section <section
key={field.field} key={field.field}
className={clsx( className={clsx(
'rc-subject-view__field', 'min-w-0 py-3',
isFullWidth && 'is-full-width', isFullWidth && 'md:col-span-2',
)} )}
> >
<div className="rc-subject-view__label">{field.label}</div> <div className="text-sm font-medium text-muted">{field.label}</div>
<div className="rc-subject-view__value"> <div className="mt-1.5 min-w-0">
<TailwindEditableField <TailwindEditableField
value={value} value={value}
fieldDef={field.fieldDef} fieldDef={field.fieldDef}
......
import { useEffect, useMemo, useState } from 'react'; import { Calendar as CalendarIcon, ChevronDown, Close, Search, Upload } from '@/components/AppIcons';
import clsx from 'clsx';
import { Button, Checkbox, Input, Radio, RadioGroup, TextArea } from '@heroui/react';
import FileUploadField from '@/components/FileUploadField';
import FormDatePicker from '@/components/FormDatePicker';
import FormSelect from '@/components/FormSelect';
import { queryUsers, uploadFile } from '@/services/DataService'; import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from './ReferenceSearchSelect';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import { import {
dateInputValue, dateInputValue,
formatFieldValue, formatFieldValue,
...@@ -16,45 +9,54 @@ import { ...@@ -16,45 +9,54 @@ import {
normalizeUserLabel, normalizeUserLabel,
toArray, toArray,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { DropZone } from '@heroui-pro/react/drop-zone';
import {
Alert,
Autocomplete,
Calendar,
Checkbox,
Chip,
DateField,
DatePicker,
I18nProvider,
Input,
Label,
ListBox,
SearchField,
Select,
TextArea,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useEffect, useMemo, useState } from 'react';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import ReferenceSearchSelect from './ReferenceSearchSelect';
function asSaveValue(value, fieldDef) { function asSaveValue(value, fieldDef) {
if (fieldDef?.multiple) { if (fieldDef?.multiple) {
return toArray(value).filter((item) => item !== ''); return toArray(value).filter((item) => item !== '');
} }
return value === '' ? null : value;
if (value === '') {
return null;
}
return value;
} }
function getClearedValue(fieldDef) { function FieldError({ children }) {
return fieldDef?.multiple ? [] : null; if (!children) return null;
}
function Pill({ children, tone = 'zinc' }) {
const toneClass = tone === 'emerald' ? 'is-success' : tone === 'amber' ? 'is-warning' : '';
return ( return (
<span className={clsx('rc-field-chip', toneClass)}> <Alert status="danger">
{children} <Alert.Content><Alert.Description>{children}</Alert.Description></Alert.Content>
</span> </Alert>
); );
} }
function FieldValue({ value, fieldDef }) { function FieldValue({ value, fieldDef }) {
if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { const isEmpty = value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
return <span className="rc-field-muted">未填写</span>; if (isEmpty) return <span className="text-sm text-muted">未填写</span>;
}
if (fieldDef?.type === 'select') { if (fieldDef?.type === 'select') {
const text = formatFieldValue(value, fieldDef); const text = formatFieldValue(value, fieldDef);
return ( return (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{toArray(text.split('、')).filter(Boolean).map((item) => ( {toArray(text.split('、')).filter(Boolean).map((item) => <Chip key={item} size="sm" variant="secondary">{item}</Chip>)}
<Pill key={item}>{item}</Pill>
))}
</div> </div>
); );
} }
...@@ -62,11 +64,7 @@ function FieldValue({ value, fieldDef }) { ...@@ -62,11 +64,7 @@ function FieldValue({ value, fieldDef }) {
if (fieldDef?.type === 'user') { if (fieldDef?.type === 'user') {
return ( return (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{toArray(value).map((item, index) => ( {toArray(value).map((item, index) => <Chip color="success" key={`${normalizeUserLabel(item)}-${index}`} size="sm" variant="secondary">{normalizeUserLabel(item) || '-'}</Chip>)}
<Pill key={`${normalizeUserLabel(item)}-${index}`} tone="emerald">
{normalizeUserLabel(item) || '-'}
</Pill>
))}
</div> </div>
); );
} }
...@@ -76,162 +74,173 @@ function FieldValue({ value, fieldDef }) { ...@@ -76,162 +74,173 @@ function FieldValue({ value, fieldDef }) {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{toArray(value).map((file, index) => { {toArray(value).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`; const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
if (!file?.url) { return file?.url ? <a href={file.url} key={`${label}-${index}`} rel="noreferrer" target="_blank"><Chip color="accent" size="sm" variant="secondary">{label}</Chip></a> : <Chip key={`${label}-${index}`} size="sm" variant="secondary">{label}</Chip>;
return <Pill key={`${label}-${index}`}>{label}</Pill>;
}
return (
<a
key={`${label}-${index}`}
href={file.url}
target="_blank"
rel="noreferrer"
className="rc-field-chip rc-field-link"
>
{label}
</a>
);
})} })}
</div> </div>
); );
} }
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') { if (['object', 'form', 'subject'].includes(fieldDef?.type)) {
return ( return <pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs leading-5">{JSON.stringify(value, null, 2)}</pre>;
<pre className="max-h-48 overflow-auto rounded-2xl bg-zinc-950 px-4 py-3 text-xs leading-5 text-zinc-100">
{JSON.stringify(value, null, 2)}
</pre>
);
} }
const text = formatFieldValue(value, fieldDef); return <span className="whitespace-pre-wrap">{formatFieldValue(value, fieldDef) || '-'}</span>;
return <span className="whitespace-pre-wrap rc-field-value">{text || '-'}</span>;
} }
function UserSelector({ value, onChange, multiple }) { function UserSelector({ value, onChange, multiple }) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const selectedIds = useMemo( const selectedIds = useMemo(() => toArray(value).map((item) => item?._id || item).filter(Boolean).map(String), [value]);
() => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value],
);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const loadOptions = async () => { const loadOptions = async () => {
const filter = keyword.trim() const filter = keyword.trim() ? { $or: [
? {
$or: [
{ username: { $regex: keyword.trim(), $options: 'i' } }, { username: { $regex: keyword.trim(), $options: 'i' } },
{ display_name: { $regex: keyword.trim(), $options: 'i' } }, { display_name: { $regex: keyword.trim(), $options: 'i' } },
], ] } : {};
}
: {};
try { try {
const result = await queryUsers(0, 20, filter); const result = await queryUsers(0, 20, filter);
if (!cancelled) { if (!cancelled) setOptions(result?.users || []);
setOptions(result?.users || []); } catch {
} if (!cancelled) setOptions([]);
} catch (error) {
if (!cancelled) {
setOptions([]);
}
} }
}; };
loadOptions(); loadOptions();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [keyword]); }, [keyword]);
const toggleUser = (user, checked) => { const selectedOptions = options.filter((user) => selectedIds.includes(String(user._id)));
if (checked) { const displayText = selectedOptions.length > 0
onChange(Array.from(new Set([...selectedIds, user._id]))); ? selectedOptions.map((user) => user.display_name || user.username).join('、')
return; : selectedIds.length > 0 ? `已选择 ${selectedIds.length} 人` : '请选择用户';
}
onChange(selectedIds.filter((item) => item !== user._id));
};
return (
<div className="space-y-3 rc-inline-editor">
<Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名或显示名" variant="secondary" />
<div className="rc-choice-list">
{options.length === 0 ? (
<div className="rc-choice-list__empty">暂无用户</div>
) : multiple ? (
options.map((user) => {
const checked = selectedIds.includes(user._id);
return ( return (
<Checkbox className="rc-choice-list__item" isSelected={checked} key={user._id} onChange={(nextChecked) => toggleUser(user, nextChecked)}> <Autocomplete
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control><span> aria-label="选择用户"
<span className="block font-medium text-zinc-950">{user.display_name || user.username}</span> fullWidth
<span className="block text-xs text-zinc-500">{user.username}</span> selectedKey={multiple ? undefined : selectedIds[0] || null}
</span></Checkbox.Content> selectedKeys={multiple ? new Set(selectedIds) : undefined}
</Checkbox> selectionMode={multiple ? 'multiple' : 'single'}
); variant="secondary"
}) onSelectionChange={(selection) => {
) : ( if (multiple) {
<RadioGroup value={selectedIds[0] || ''} onChange={(nextId) => onChange(nextId || null)}> onChange(selection === 'all' ? options.map((user) => String(user._id)) : Array.from(selection || []).map(String));
{options.map((user) => <Radio className="rc-choice-list__item" key={user._id} value={user._id}><Radio.Content><Radio.Control><Radio.Indicator /></Radio.Control><span><span className="block font-medium text-zinc-950">{user.display_name || user.username}</span><span className="block text-xs text-zinc-500">{user.username}</span></span></Radio.Content></Radio>)} } else {
</RadioGroup> onChange(selection ? String(selection) : null);
)} }
</div> }}
</div> >
<Autocomplete.Trigger>
<Autocomplete.Value><span className="block min-w-0 truncate">{displayText}</span></Autocomplete.Value>
<Autocomplete.Indicator><ChevronDown size={16} /></Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start">
<Autocomplete.Filter filter={() => true} inputValue={keyword} onInputChange={setKeyword}>
<SearchField aria-label="搜索用户" fullWidth variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder="搜索用户名或显示名" />
<SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox aria-label="用户候选项" items={options} renderEmptyState={() => <div className="grid min-h-20 place-items-center p-4 text-sm text-muted">暂无用户</div>}>
{(user) => <ListBox.Item id={String(user._id)} textValue={user.display_name || user.username}><span className="min-w-0"><Label className="block truncate">{user.display_name || user.username}</Label><span className="block truncate text-xs text-muted">{user.username}</span></span><ListBox.ItemIndicator /></ListBox.Item>}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
); );
} }
function ControlledFieldEditor({ value, fieldDef, disabled, onChange }) { function FieldOptionSelect({ fieldDef, value, onChange }) {
const [error, setError] = useState(''); const options = getSelectOptions(fieldDef);
const [uploading, setUploading] = useState(false); const multiple = Boolean(fieldDef?.multiple);
const [jsonDraft, setJsonDraft] = useState(() => typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2)); const normalizedValue = multiple
const fieldType = fieldDef?.type || 'text'; ? toArray(value).map(String)
const isDateField = isDateFieldType(fieldType); : value === null || value === undefined || value === '' ? null : String(value);
const selectedValues = multiple ? normalizedValue : normalizedValue === null ? [] : [normalizedValue];
useEffect(() => { const displayText = options.filter((option) => selectedValues.includes(String(option.value))).map((option) => option.label).join('、') || '请选择';
setJsonDraft(typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2));
}, [value]);
if (disabled) {
return ( return (
<div className="rc-editable-field is-readonly"> <Select
<FieldValue value={value} fieldDef={fieldDef} /> aria-label={fieldDef?.label || fieldDef?.name || '请选择'}
<div className="rc-editable-field__permission">当前视图无编辑权限</div> fullWidth
</div> selectionMode={multiple ? 'multiple' : 'single'}
shouldCloseOnSelect={!multiple}
value={normalizedValue}
variant="secondary"
onChange={(nextValue) => onChange(multiple ? (Array.isArray(nextValue) ? nextValue.map(String) : []) : nextValue === null || nextValue === undefined || nextValue === '' ? null : String(nextValue))}
>
<Select.Trigger><Select.Value><span className="block min-w-0 truncate">{displayText}</span></Select.Value><Select.Indicator><ChevronDown size={16} /></Select.Indicator></Select.Trigger>
<Select.Popover placement="bottom start">
<ListBox aria-label={fieldDef?.label || '字段选项'}>
{options.map((option) => <ListBox.Item id={String(option.value)} isDisabled={Boolean(option.disabled)} key={String(option.value)} textValue={String(option.label)}><Label>{option.label}</Label><ListBox.ItemIndicator /></ListBox.Item>)}
</ListBox>
</Select.Popover>
</Select>
); );
} }
if (fieldType === 'boolean') { function parseDateValue(value, mode) {
return <Checkbox isSelected={Boolean(value)} onChange={onChange}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{value ? '是' : '否'}</Checkbox.Content></Checkbox>; if (!value) return null;
try {
return mode === 'datetime' ? parseDateTime(String(value).slice(0, 16)) : parseDate(String(value).slice(0, 10));
} catch {
return null;
} }
}
function DateEditor({ value, mode, onChange }) {
const parsedValue = useMemo(() => parseDateValue(value, mode), [mode, value]);
const timeValue = useMemo(() => {
if (!value || !String(value).includes('T')) return null;
try { return parseTime(String(value).slice(11, 16)); } catch { return null; }
}, [value]);
const emitDate = (nextValue) => {
if (!nextValue) return onChange(null);
const dateText = nextValue.toString().slice(0, 10);
const timeText = timeValue?.toString().slice(0, 5) || '00:00';
onChange(mode === 'datetime' ? `${dateText}T${timeText}` : dateText);
};
if (fieldType === 'select') {
const options = getSelectOptions(fieldDef);
return ( return (
<FormSelect <I18nProvider locale="zh-CN">
ariaLabel={fieldDef?.label || fieldDef?.name || '请选择'} <DatePicker aria-label="选择日期" granularity={mode === 'datetime' ? 'minute' : 'day'} value={parsedValue} onChange={emitDate}>
multiple={Boolean(fieldDef?.multiple)} <DateField.Group fullWidth variant="secondary">
options={options} <DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
value={value} <DateField.Suffix>
onChange={onChange} <DatePicker.Trigger aria-label="打开日期选择"><DatePicker.TriggerIndicator><CalendarIcon size={16} /></DatePicker.TriggerIndicator></DatePicker.Trigger>
/> </DateField.Suffix>
</DateField.Group>
<DatePicker.Popover placement="bottom start">
<Calendar>
<Calendar.Header><Calendar.NavButton slot="previous" /><Calendar.Heading /><Calendar.NavButton slot="next" /></Calendar.Header>
<Calendar.Grid><Calendar.GridHeader>{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}</Calendar.GridHeader><Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody></Calendar.Grid>
</Calendar>
{mode === 'datetime' ? (
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-sm text-muted">时间</span>
<TimeField aria-label="选择时间" granularity="minute" hideTimeZone hourCycle={24} value={timeValue} onChange={(nextTime) => {
const dateText = parsedValue?.toString().slice(0, 10) || String(value || '').slice(0, 10);
if (dateText) onChange(`${dateText}T${nextTime?.toString().slice(0, 5) || '00:00'}`);
}}>
<TimeField.Group fullWidth variant="secondary"><TimeField.Input>{(segment) => <TimeField.Segment segment={segment} />}</TimeField.Input></TimeField.Group>
</TimeField>
</div>
) : null}
</DatePicker.Popover>
</DatePicker>
</I18nProvider>
); );
} }
if (fieldType === 'user') {
return <UserSelector value={value} onChange={onChange} multiple={fieldDef?.multiple} />;
}
if (isReferenceFieldType(fieldType)) { function FileEditor({ value, fieldDef, onChange }) {
return <ReferenceSearchSelect fieldDef={fieldDef} value={value} onChange={onChange} />; const [error, setError] = useState('');
} const [uploading, setUploading] = useState(false);
if (fieldType === 'file') { const upload = async (files) => {
const handleUpload = async (event) => { const file = files?.[0];
const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
setError(''); setError('');
...@@ -245,378 +254,72 @@ function ControlledFieldEditor({ value, fieldDef, disabled, onChange }) { ...@@ -245,378 +254,72 @@ function ControlledFieldEditor({ value, fieldDef, disabled, onChange }) {
setUploading(false); setUploading(false);
} }
}; };
return ( return (
<div className="rc-file-editor rc-form-control"> <div className="space-y-3">
<div className="rc-file-editor__list"> {toArray(value).length > 0 ? (
{toArray(value).length === 0 ? <span className="rc-field-muted">未上传</span> : toArray(value).map((file, index) => { <DropZone.FileList>
{toArray(value).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`; const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return <div className="rc-file-editor__item" key={`${label}-${index}`}><span>{label}</span><Button size="sm" variant="danger-soft" onPress={() => onChange(fieldDef?.multiple ? toArray(value).filter((_, itemIndex) => itemIndex !== index) : null)}>删除</Button></div>; return <DropZone.FileItem key={`${label}-${index}`} status="complete"><DropZone.FileFormatIcon format={String(label).split('.').pop()?.slice(0, 4).toUpperCase()} /><DropZone.FileInfo><DropZone.FileName>{label}</DropZone.FileName><DropZone.FileMeta>已上传</DropZone.FileMeta></DropZone.FileInfo><DropZone.FileRemoveTrigger aria-label={`删除${label}`} onPress={() => onChange(fieldDef?.multiple ? toArray(value).filter((_, fileIndex) => fileIndex !== index) : null)}><Close size={14} /></DropZone.FileRemoveTrigger></DropZone.FileItem>;
})} })}
</div> </DropZone.FileList>
<FileUploadField disabled={uploading} multiple={Boolean(fieldDef?.multiple)} onChange={handleUpload} /> ) : null}
{uploading ? <span className="rc-field-muted">上传中...</span> : null} <DropZone>
{error ? <div className="rc-field-error">{error}</div> : null} <DropZone.Area aria-label="选择附件" isDisabled={uploading} onDrop={async (event) => {
const items = Array.from(event?.items || []).filter((item) => item?.kind === 'file' && typeof item.getFile === 'function');
upload(await Promise.all(items.map((item) => item.getFile())));
}}>
<DropZone.Input disabled={uploading} multiple={Boolean(fieldDef?.multiple)} onSelect={(files) => upload(Array.from(files || []))} />
<DropZone.Icon><Upload size={20} /></DropZone.Icon>
<DropZone.Label>{uploading ? '附件上传中' : '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>将文件拖到此处,或从本地选择。</DropZone.Description>
<DropZone.Trigger isDisabled={uploading}><Upload size={16} />选择附件</DropZone.Trigger>
</DropZone.Area>
</DropZone>
<FieldError>{error}</FieldError>
</div> </div>
); );
} }
if (fieldType === 'object') { function ControlledFieldEditor({ value, fieldDef, disabled, onChange }) {
return ( const [error, setError] = useState('');
<div> const [jsonDraft, setJsonDraft] = useState(() => typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2));
<TextArea const fieldType = fieldDef?.type || 'text';
className="rc-form-textarea"
rows={8} useEffect(() => {
value={jsonDraft} setJsonDraft(typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2));
onChange={(event) => { }, [value]);
if (disabled) return <div className="space-y-2"><FieldValue fieldDef={fieldDef} value={value} /><div className="text-xs text-warning">当前视图无编辑权限</div></div>;
if (fieldType === 'boolean') return <Checkbox isSelected={Boolean(value)} onChange={onChange}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{value ? '是' : '否'}</Checkbox.Content></Checkbox>;
if (fieldType === 'select') return <FieldOptionSelect fieldDef={fieldDef} onChange={onChange} value={value} />;
if (fieldType === 'user') return <UserSelector multiple={Boolean(fieldDef?.multiple)} onChange={onChange} value={value} />;
if (isReferenceFieldType(fieldType)) return <ReferenceSearchSelect fieldDef={fieldDef} onChange={onChange} value={value} />;
if (fieldType === 'file') return <FileEditor fieldDef={fieldDef} onChange={onChange} value={value} />;
if (['form', 'subject'].includes(fieldType)) return <div className="space-y-2"><FieldValue fieldDef={fieldDef} value={value} /><div className="text-xs text-muted">该结构字段仅支持查看</div></div>;
if (fieldType === 'object') return <div className="space-y-2"><TextArea fullWidth rows={8} value={jsonDraft} variant="secondary" onChange={(event) => {
const nextValue = event.target.value; const nextValue = event.target.value;
setJsonDraft(nextValue); setJsonDraft(nextValue);
try { try {
onChange(nextValue.trim() ? JSON.parse(nextValue) : null); onChange(nextValue.trim() ? JSON.parse(nextValue) : null);
setError(''); setError('');
} catch (parseError) { } catch {
setError('JSON 格式不正确'); setError('JSON 格式不正确');
} }
}} }} /><FieldError>{error}</FieldError></div>;
/> if (['long_text', 'rich_text'].includes(fieldType)) return <TextArea fullWidth rows={5} value={value || ''} variant="secondary" onChange={(event) => onChange(event.target.value)} />;
{error ? <div className="rc-field-error">{error}</div> : null} if (isDateFieldType(fieldType)) return <DateEditor mode={fieldType === 'date' ? 'date' : 'datetime'} onChange={onChange} value={fieldType === 'date' ? dateInputValue(value) : value} />;
</div> return <Input fullWidth type={fieldType === 'number' ? 'number' : 'text'} value={value ?? ''} variant="secondary" onChange={(event) => onChange(fieldType === 'number' ? event.target.value === '' ? '' : Number(event.target.value) : event.target.value)} />;
);
}
if (['form', 'subject'].includes(fieldType)) {
return <div className="rc-editable-field is-readonly"><FieldValue value={value} fieldDef={fieldDef} /><div className="rc-editable-field__permission">该结构字段仅支持查看</div></div>;
}
if (['long_text', 'rich_text'].includes(fieldType)) {
return <TextArea className="rc-form-textarea" rows={5} value={value || ''} onChange={(event) => onChange(event.target.value)} />;
}
if (isDateField) {
return <FormDatePicker
disabled={disabled}
mode={fieldType === 'date' ? 'date' : 'datetime'}
value={fieldType === 'date' ? dateInputValue(value) : value}
onChange={onChange}
/>;
}
return (
<Input
className="rc-form-input"
type={fieldType === 'number' ? 'number' : 'text'}
value={value ?? ''}
onChange={(event) => onChange(fieldType === 'number' ? event.target.value === '' ? '' : Number(event.target.value) : event.target.value)}
/>
);
} }
export default function TailwindEditableField({ export default function TailwindEditableField({ value, fieldDef, onSave, mode, readonly = false, disabled = false, onOpenReference }) {
value,
fieldDef,
onSave,
mode,
readonly = false,
disabled = false,
onOpenReference,
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
useEffect(() => {
setDraft(value);
}, [value]);
useEffect(() => {
if (readonly || disabled) {
setEditing(false);
}
}, [readonly, disabled]);
const canEdit = !readonly && !disabled;
const fieldType = fieldDef?.type || 'text'; const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
if (mode === 'view') { if (mode === 'view') {
if (isReferenceFieldType(fieldType)) { if (isReferenceFieldType(fieldType)) return <ReferenceRecordsTable fieldDef={fieldDef} onOpenReference={onOpenReference} value={value} />;
return <div className="rc-reference-field"><ReferenceRecordsTable fieldDef={fieldDef} value={value} onOpenReference={onOpenReference} /></div>; return <FieldValue fieldDef={fieldDef} value={value} />;
}
return <div className="rc-editable-field is-readonly"><FieldValue value={value} fieldDef={fieldDef} /></div>;
}
if (mode === 'edit') {
return <ControlledFieldEditor disabled={!canEdit} fieldDef={fieldDef} value={value} onChange={(nextValue) => onSave(asSaveValue(nextValue, fieldDef))} />;
}
const save = async (nextValue = draft) => {
setError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try {
nextValue = JSON.parse(nextValue);
} catch (parseError) {
setError('JSON 格式不正确,请检查后再保存。');
return;
}
}
try {
await onSave(asSaveValue(nextValue, fieldDef));
setEditing(false);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
}
};
const clearValue = async () => {
const clearedValue = getClearedValue(fieldDef);
setDraft(clearedValue);
await save(clearedValue);
};
const cancel = () => {
setDraft(value);
setError('');
setEditing(false);
};
const handleFileUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setUploading(true);
setError('');
try {
const uploaded = await uploadFile(file);
const fileValue = uploaded?.file || uploaded?.data || uploaded;
const nextValue = fieldDef?.multiple ? [...toArray(draft), fileValue] : fileValue;
setDraft(nextValue);
await onSave(nextValue);
setEditing(false);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
}
};
const handleRemoveFile = async (indexToRemove) => {
setError('');
const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove)
: null;
try {
setDraft(nextValue);
await onSave(nextValue);
if (!fieldDef?.multiple) {
setEditing(false);
}
} catch (saveError) {
setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。');
}
};
if (!editing) {
if (isReferenceFieldType(fieldType)) {
return (
<div className="rc-reference-field">
<ReferenceRecordsTable
canEdit={canEdit}
fieldDef={fieldDef}
onEdit={() => setEditing(true)}
onOpenReference={onOpenReference}
value={value}
/>
{disabled ? <div className="rc-editable-field__permission">当前视图无编辑权限</div> : null}
</div>
);
}
return (
<button
type="button"
disabled={!canEdit}
onClick={() => canEdit && setEditing(true)}
className={clsx(
'rc-editable-field group',
canEdit ? 'is-editable' : 'is-readonly',
)}
>
<FieldValue value={value} fieldDef={fieldDef} />
{disabled ? <div className="rc-editable-field__permission">当前视图无编辑权限</div> : null}
</button>
);
}
if (fieldType === 'boolean') {
return (
<div className="rc-inline-editor rc-inline-editor--boolean">
<Checkbox isSelected={Boolean(draft)} onChange={setDraft}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{draft ? '是' : '否'}</Checkbox.Content></Checkbox>
<div className="rc-inline-editor__actions"><Button size="sm" variant="danger-soft" onPress={clearValue}>清空</Button><Button size="sm" variant="outline" onPress={cancel}>取消</Button><Button size="sm" variant="primary" onPress={() => save(Boolean(draft))}>保存</Button></div>
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
);
}
if (fieldType === 'select') {
const options = getSelectOptions(fieldDef);
return (
<div className="space-y-3">
<FormSelect
ariaLabel={fieldDef?.label || fieldDef?.name || '请选择'}
multiple={Boolean(fieldDef?.multiple)}
options={options}
value={draft}
onChange={setDraft}
/>
<EditorActions onClear={clearValue} onCancel={cancel} onSave={() => save()} />
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
);
}
if (fieldType === 'user') {
return (
<div className="space-y-3">
<UserSelector value={draft} onChange={setDraft} multiple={fieldDef?.multiple} />
<EditorActions onClear={clearValue} onCancel={cancel} onSave={() => save()} />
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
);
}
if (isReferenceFieldType(fieldType)) {
return (
<div className="space-y-3">
<ReferenceSearchSelect
fieldDef={fieldDef}
value={draft}
onChange={setDraft}
/>
<EditorActions onClear={clearValue} onCancel={cancel} onSave={() => save()} />
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
);
}
if (fieldType === 'file') {
return (
<div className="rc-file-editor">
<div className="rc-file-editor__list">
{toArray(draft).length === 0 ? (
<span className="text-zinc-400">未上传</span>
) : (
toArray(draft).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return (
<div
key={`${label}-${index}`}
className="rc-file-editor__item"
>
<div className="min-w-0 flex-1">
{file?.url ? (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="rc-field-link"
>
{label}
</a>
) : (
<div>{label}</div>
)}
</div>
<button
type="button"
onClick={() => handleRemoveFile(index)}
className="rc-field-danger-action"
>
删除
</button>
</div>
);
})
)}
</div>
<FileUploadField disabled={uploading} multiple={Boolean(fieldDef?.multiple)} onChange={handleFileUpload} />
{error ? <div className="rc-field-error">{error}</div> : null}
<div className="flex justify-end gap-2">
<Button size="sm" variant="danger-soft" onPress={clearValue}>清空</Button>
<Button size="sm" variant="outline" onPress={cancel}>取消</Button>
<span className="rc-field-muted">{uploading ? '上传中...' : '选择文件后自动保存'}</span>
</div>
</div>
);
}
if (isJsonLike) {
const jsonValue = typeof draft === 'string' ? draft : JSON.stringify(draft ?? null, null, 2);
return (
<div className="space-y-3">
<TextArea rows={8} value={jsonValue} onChange={(event) => setDraft(event.target.value)} />
{error ? <div className="rc-field-error">{error}</div> : null}
<div className="flex justify-end gap-2">
<Button size="sm" variant="danger-soft" onPress={clearValue}>清空</Button>
<Button size="sm" variant="outline" onPress={cancel}>取消</Button>
<Button size="sm" variant="primary" onPress={() => (fieldType === 'object' ? save() : cancel())}>{fieldType === 'object' ? '保存' : '关闭'}</Button>
</div>
</div>
);
}
if (isLongText) {
return (
<div className="space-y-3">
<TextArea rows={5} value={draft || ''} onChange={(event) => setDraft(event.target.value)} autoFocus />
<EditorActions onClear={clearValue} onCancel={cancel} onSave={() => save()} />
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
);
}
if (isDateField) {
return (
<div className="space-y-3">
<FormDatePicker
mode={fieldType === 'date' ? 'date' : 'datetime'}
value={fieldType === 'date' ? dateInputValue(draft) : draft}
onChange={setDraft}
/>
<EditorActions onClear={clearValue} onCancel={cancel} onSave={() => save()} />
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
);
} }
return ( return <ControlledFieldEditor disabled={readonly || disabled} fieldDef={fieldDef} value={value} onChange={(nextValue) => onSave(asSaveValue(nextValue, fieldDef))} />;
<div className="space-y-3">
<Input
type={fieldType === 'number' ? 'number' : 'text'}
value={draft ?? ''}
onChange={(event) => {
const nextValue = fieldType === 'number'
? event.target.value === '' ? '' : Number(event.target.value)
: event.target.value;
setDraft(nextValue);
}}
autoFocus
/>
<EditorActions onClear={clearValue} onCancel={cancel} onSave={() => save()} />
{error ? <div className="rc-field-error">{error}</div> : null}
</div>
);
}
function EditorActions({ onClear, onCancel, onSave }) {
return <div className="rc-inline-editor__actions"><Button size="sm" variant="danger-soft" onPress={onClear}>清空</Button><Button size="sm" variant="outline" onPress={onCancel}>取消</Button><Button size="sm" variant="primary" onPress={onSave}>保存</Button></div>;
} }
import { Button, Toast, toast } from '@heroui/react'; import { Alert, Button, Modal, Surface, Tabs, Toast, toast } from '@heroui/react';
import React, { import React, {
forwardRef, forwardRef,
useEffect, useEffect,
...@@ -6,7 +6,7 @@ import React, { ...@@ -6,7 +6,7 @@ import React, {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { Dialog, DialogActions, DialogBody, DialogTitle } from '@/components/Dialog'; import { Close } from '@/components/AppIcons';
import { useSubjectRecord } from './hooks/useSubjectRecord'; import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent'; import SubjectViewContent from './components/SubjectViewContent';
import { import {
...@@ -179,59 +179,58 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -179,59 +179,58 @@ const RecordDetailDrawer = (props, ref) => {
}; };
return ( return (
<Dialog className="rc-record-detail-dialog" open={open} onClose={closeDrawer} size="4xl"> <Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && closeDrawer()}>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-detail-dialog rc-dialog-panel--4xl">
<Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{mode === 'edit' ? '编辑记录' : referenceStack.length > 0 ? '关联记录详情' : '记录详情'}</Modal.Heading>
</Modal.Header>
<Modal.Body>
<Toast.Provider placement="top" /> <Toast.Provider placement="top" />
<DialogTitle>{mode === 'edit' ? '编辑记录' : referenceStack.length > 0 ? '关联记录详情' : '记录详情'}</DialogTitle>
{detailViews.length > 1 ? ( {detailViews.length > 1 ? (
<div className="rc-record-detail-dialog__views rc-segmented-control"> <div className="max-w-full overflow-x-auto">
{detailViews.map((view) => { <Tabs className="w-fit max-w-full" selectedKey={activeView?.name} onSelectionChange={(key) => setActiveTabKey(String(key))}>
const selected = view.name === activeView?.name; <Tabs.ListContainer className="w-fit max-w-full">
return <button className={`rc-segmented-control__item${selected ? ' is-selected' : ''}`} key={view.name} onClick={() => setActiveTabKey(view.name)} type="button">{view.title || view.name}</button>; <Tabs.List aria-label="详情视图" className="min-w-max">
})} {detailViews.map((view) => <Tabs.Tab className="flex-none whitespace-nowrap" id={view.name} key={view.name}>{view.title || view.name}<Tabs.Indicator /></Tabs.Tab>)}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
</div> </div>
) : null} ) : null}
<DialogBody className="rc-record-detail-dialog__body">
{loading ? ( {loading ? (
<div className="rc-record-detail-dialog__state"> <div className="grid min-h-48 place-items-center text-sm text-muted">正在加载详情...</div>
正在加载详情...
</div>
) : error ? ( ) : error ? (
<div className="rc-record-detail-dialog__error"> <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
{error}
</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{isRecordLocked ? ( {isRecordLocked ? <Alert status="warning"><Alert.Content><Alert.Description>当前问题整改记录已锁定,仅可查看。如需修改,请先在问题清单中解锁。</Alert.Description></Alert.Content></Alert> : null}
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"> {mode === 'view' ? (
当前问题整改记录已锁定,仅可查看。如需修改,请先在问题清单中解锁。 <Surface className="rc-detail-surface p-5" variant="secondary">
</div> <SubjectViewContent view={activeView} subject={subject} editableRecord={editableRecord} onFieldUpdate={handleFieldUpdate} mode={mode} readonly={effectiveReadonly} isFieldDisabled={isDrawerFieldDisabled} onOpenReference={openReference} />
) : null} </Surface>
<SubjectViewContent ) : (
view={activeView} <SubjectViewContent view={activeView} subject={subject} editableRecord={editableRecord} onFieldUpdate={handleFieldUpdate} mode={mode} readonly={effectiveReadonly} isFieldDisabled={isDrawerFieldDisabled} onOpenReference={openReference} />
subject={subject} )}
editableRecord={editableRecord}
onFieldUpdate={handleFieldUpdate}
mode={mode}
readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled}
onOpenReference={openReference}
/>
</div> </div>
)} )}
</DialogBody> </Modal.Body>
<DialogActions> <Modal.Footer>
{mode === 'edit' ? ( {mode === 'edit' ? (
<> <><Button isDisabled={saving} size="sm" variant="secondary" onPress={cancelEdit}>取消</Button><Button isPending={saving} size="sm" variant="primary" onPress={saveChanges}>{saving ? '保存中...' : '保存'}</Button></>
<Button isDisabled={saving} size="sm" variant="outline" onPress={cancelEdit}>取消</Button>
<Button isPending={saving} size="sm" variant="primary" onPress={saveChanges}>{saving ? '保存中...' : '保存'}</Button>
</>
) : effectiveReadonly || referenceStack.length > 0 ? ( ) : effectiveReadonly || referenceStack.length > 0 ? (
<Button size="sm" variant="outline" onPress={closeDrawer}>{referenceStack.length > 0 ? '返回上一级' : '关闭'}</Button> <Button size="sm" variant="secondary" onPress={closeDrawer}>{referenceStack.length > 0 ? '返回上一级' : '关闭'}</Button>
) : ( ) : (
<Button size="sm" variant="primary" onPress={() => setMode('edit')}>编辑</Button> <Button size="sm" variant="primary" onPress={() => setMode('edit')}>编辑</Button>
)} )}
</DialogActions> </Modal.Footer>
</Dialog> </Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
); );
}; };
......
import xyLogoRed from '@/assets/xy-logo_small.png'; import AppShell from '@/components/AppShell';
import xyLogoWhite from '@/assets/xy-logo-white.png'; import { Archive, CheckSquare, FileDone, FileText, Settings } from '@/components/AppIcons';
import { AppGrid, Archive, CheckSquare, FileDone, FileText, LogOut, Moon, Settings, SidebarPanel, Sun } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { Navbar } from '@heroui-pro/react/navbar'; import { useContext, useEffect, useMemo, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Outlet, useLocation } from 'umi';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react'; import { INTEGRITY_PRACTICE_MENU } from './integrityPracticeConfig';
import { useContext, useLayoutEffect, useMemo, useState } from 'react';
import { Heading } from 'react-aria-components/Dialog';
import { history, Outlet, useLocation } from 'umi';
import { findMenuEntryByPath, INTEGRITY_PRACTICE_APP_TITLE, INTEGRITY_PRACTICE_MENU } from './integrityPracticeConfig';
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
const GROUP_ICONS = [CheckSquare, FileDone, Settings, Archive]; const GROUP_ICONS = [CheckSquare, FileDone, Settings, Archive];
function getInitialTheme() { const MENU_GROUPS = INTEGRITY_PRACTICE_MENU.map((group, index) => ({
if (typeof window === 'undefined') return 'light'; title: group.label,
try { return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light'; } catch { return 'light'; } items: group.items.map((item) => ({
} name: item.label,
path: item.path,
function SidebarUserMenu({ userInfo }) { icon: GROUP_ICONS[index] || FileText,
const displayName = userInfo?.display_name || userInfo?.username || '当前用户'; })),
return ( }));
<Dropdown>
<Dropdown.Trigger className="rc-sidebar-user-trigger" variant="ghost">
<Avatar className="rc-sidebar-avatar"><Avatar.Fallback>{displayName.slice(0, 1).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="rc-sidebar-user-copy" data-sidebar="label"><strong>{displayName}</strong><small>{userInfo?.username || '已登录'}</small></span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => { if (key === 'logout') userInfo?.logout?.(); }}>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger"><Label>退出登录</Label><LogOut size={14} /></Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ pathname, theme, userInfo, onSwitchApplication }) {
return (
<>
<Sidebar.Header>
<div className="rc-sidebar-brand">
<img alt="鑫元" className="rc-sidebar-logo" src={theme === 'dark' ? xyLogoWhite : xyLogoRed} />
<span className="rc-sidebar-title" data-sidebar="label">{INTEGRITY_PRACTICE_APP_TITLE}</span>
<span data-sidebar="label">
<Tooltip delay={0}>
<Button isIconOnly aria-label="切换应用" className="rc-sidebar-app-trigger" size="sm" variant="ghost" onPress={onSwitchApplication}><AppGrid size={16} /></Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</span>
</div>
</Sidebar.Header>
<Sidebar.Content>
{INTEGRITY_PRACTICE_MENU.map((group, index) => {
const Icon = GROUP_ICONS[index] || FileText;
return (
<Sidebar.Group key={group.path}>
<Sidebar.GroupLabel>{group.label}</Sidebar.GroupLabel>
<Sidebar.Menu aria-label={group.label} showGuideLines={false}>
{group.items.map((item) => (
<Sidebar.MenuItem href={item.path} isCurrent={pathname.endsWith(item.path)} key={item.path} textValue={item.label} tooltip={item.label}>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.label}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
))}
</Sidebar.Menu>
</Sidebar.Group>
);
})}
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} /></Sidebar.Footer>
</>
);
}
export default function IntegrityPracticeLayout() { export default function IntegrityPracticeLayout() {
const location = useLocation(); const location = useLocation();
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const [theme, setTheme] = useState(getInitialTheme); const [sidebarOpen, setSidebarOpen] = useState(true);
const [switcherOpen, setSwitcherOpen] = useState(false); const [switcherOpen, setSwitcherOpen] = useState(false);
const currentMenu = useMemo(() => findMenuEntryByPath(location.pathname), [location.pathname]); const pageTitle = useMemo(
const pageTitle = currentMenu?.item?.label || INTEGRITY_PRACTICE_APP_TITLE; () => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
[location.pathname],
);
useLayoutEffect(() => { useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark'); document.title = `${pageTitle} - ${APPLICATION_DISPLAY_NAME}`;
document.documentElement.classList.toggle('light', theme === 'light'); }, [pageTitle]);
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.title = `${pageTitle} - ${INTEGRITY_PRACTICE_APP_TITLE}`;
try { window.localStorage.setItem(THEME_STORAGE_KEY, theme); } catch {}
}, [pageTitle, theme]);
const sidebarProps = { pathname: location.pathname, theme, userInfo, onSwitchApplication: () => setSwitcherOpen(true) };
return ( return (
<Sidebar.Provider className={`rc-app-shell ${theme}`} collapsible="offcanvas" navigate={(href) => history.push(href)}> <>
<Sidebar className="rc-sidebar"><SidebarContents {...sidebarProps} /></Sidebar> <AppShell
<Sidebar.Mobile className="rc-mobile-sidebar"><Heading className="sr-only" slot="title">{INTEGRITY_PRACTICE_APP_TITLE}导航</Heading><SidebarContents {...sidebarProps} /></Sidebar.Mobile> menuGroups={MENU_GROUPS}
<Sidebar.Main className="rc-app-main"> onLogout={userInfo?.logout}
<Navbar className="rc-navbar" maxWidth="full"> onOpenApplicationSwitcher={() => setSwitcherOpen(true)}
<Navbar.Header> open={sidebarOpen}
<Sidebar.Trigger aria-label="打开或收起导航" className="rc-navbar-trigger"><SidebarPanel size={16} /></Sidebar.Trigger> pathname={location.pathname}
<h1 className="rc-navbar-title">{pageTitle}</h1> title={pageTitle}
<Navbar.Spacer /> userInfo={userInfo}
<Tooltip delay={0}> onOpenChange={setSidebarOpen}
<Button isIconOnly aria-label={theme === 'dark' ? '切换至浅色模式' : '切换至深色模式'} className="rc-navbar-icon-button" size="sm" variant="ghost" onPress={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}</Button> >
<Tooltip.Content>{theme === 'dark' ? '切换至浅色模式' : '切换至深色模式'}</Tooltip.Content> <Outlet />
</Tooltip> </AppShell>
</Navbar.Header>
</Navbar>
<div className="min-h-0 flex-1 overflow-y-auto"><div className="rc-app-page"><Outlet /></div></div>
</Sidebar.Main>
<ApplicationSwitcherDialog open={switcherOpen} onClose={() => setSwitcherOpen(false)} userInfo={userInfo} /> <ApplicationSwitcherDialog open={switcherOpen} onClose={() => setSwitcherOpen(false)} userInfo={userInfo} />
</Sidebar.Provider> </>
); );
} }
...@@ -18,7 +18,6 @@ import { ...@@ -18,7 +18,6 @@ import {
isConfiguredActionVisible, isConfiguredActionVisible,
loadSubjectRecords, loadSubjectRecords,
loadSubjectResources, loadSubjectResources,
PAGE_SIZE_OPTIONS,
} from './integrityPracticeWorkbenchService'; } from './integrityPracticeWorkbenchService';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
...@@ -53,11 +52,11 @@ function ActionConfirmationDialog({ state, onClose, onConfirm, submitting }) { ...@@ -53,11 +52,11 @@ function ActionConfirmationDialog({ state, onClose, onConfirm, submitting }) {
<AlertDialog isOpen={Boolean(state)} onOpenChange={(open) => { if (!open && !submitting) onClose(); }}> <AlertDialog isOpen={Boolean(state)} onOpenChange={(open) => { if (!open && !submitting) onClose(); }}>
<AlertDialog.Backdrop isKeyboardDismissDisabled={submitting}> <AlertDialog.Backdrop isKeyboardDismissDisabled={submitting}>
<AlertDialog.Container size="md"> <AlertDialog.Container size="md">
<AlertDialog.Dialog className="rc-dialog-panel"> <AlertDialog.Dialog>
<AlertDialog.Header><AlertDialog.Heading>确认{state?.label || '执行操作'}</AlertDialog.Heading></AlertDialog.Header> <AlertDialog.Header><AlertDialog.Heading>确认{state?.label || '执行操作'}</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body>{state?.description}</AlertDialog.Body> <AlertDialog.Body>{state?.description}</AlertDialog.Body>
<AlertDialog.Footer> <AlertDialog.Footer>
<Button isDisabled={submitting} variant="outline" onPress={onClose}>取消</Button> <Button isDisabled={submitting} variant="secondary" onPress={onClose}>取消</Button>
<Button isDisabled={submitting} isPending={submitting} variant={state?.actionName?.includes('deleteData') ? 'danger' : 'primary'} onPress={onConfirm}>{submitting ? '正在处理...' : state?.actionName?.includes('deleteData') ? '确认删除' : '确认执行'}</Button> <Button isDisabled={submitting} isPending={submitting} variant={state?.actionName?.includes('deleteData') ? 'danger' : 'primary'} onPress={onConfirm}>{submitting ? '正在处理...' : state?.actionName?.includes('deleteData') ? '确认删除' : '确认执行'}</Button>
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Dialog> </AlertDialog.Dialog>
...@@ -82,7 +81,7 @@ export default function SubjectWorkbench() { ...@@ -82,7 +81,7 @@ export default function SubjectWorkbench() {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState(''); const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const pageSize = DEFAULT_PAGE_SIZE;
const [pagination, setPagination] = useState(null); const [pagination, setPagination] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
...@@ -229,6 +228,7 @@ export default function SubjectWorkbench() { ...@@ -229,6 +228,7 @@ export default function SubjectWorkbench() {
const Icon = resolveActionIcon(action.action); const Icon = resolveActionIcon(action.action);
return { return {
key: action.action || `action-${index}`, key: action.action || `action-${index}`,
action: action.action,
label: action.name, label: action.name,
icon: Icon, icon: Icon,
tone: action.action?.includes('deleteData') ? 'danger' : undefined, tone: action.action?.includes('deleteData') ? 'danger' : undefined,
...@@ -311,7 +311,6 @@ export default function SubjectWorkbench() { ...@@ -311,7 +311,6 @@ export default function SubjectWorkbench() {
toolbarActions={toolbarActions} toolbarActions={toolbarActions}
selectionActions={selectionActions} selectionActions={selectionActions}
rowActions={rowActions} rowActions={rowActions}
actionColumnWidth={Math.min(440, Math.max(176, rowActions.length * 88))}
states={{ states={{
loading, loading,
error, error,
...@@ -337,12 +336,7 @@ export default function SubjectWorkbench() { ...@@ -337,12 +336,7 @@ export default function SubjectWorkbench() {
pagination pagination
? { ? {
...pagination, ...pagination,
pageSizeOptions: PAGE_SIZE_OPTIONS,
onPageChange: setPage, onPageChange: setPage,
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
},
} }
: null : null
} }
......
import { Close } from '@/components/AppIcons'; import { Close } from '@/components/AppIcons';
import { Dialog, DialogActions, DialogBody, DialogTitle } from '@/components/Dialog';
import { queryUsers } from '@/services/DataService'; import { queryUsers } from '@/services/DataService';
import { Button, ComboBox, Input, Label, ListBox, Spinner, Tooltip } from '@heroui/react'; import { Button, Chip, ComboBox, Input, Label, ListBox, Modal, Spinner, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
...@@ -32,8 +31,8 @@ function UserMultiSelect({ label, value, onChange }) { ...@@ -32,8 +31,8 @@ function UserMultiSelect({ label, value, onChange }) {
const availableOptions = options.filter((user) => !selectedIds.has(user._id)); const availableOptions = options.filter((user) => !selectedIds.has(user._id));
return ( return (
<div className="rc-user-select"> <div className="space-y-2">
<div className="rc-user-select__label">{label}</div> <Label>{label}</Label>
<ComboBox <ComboBox
fullWidth fullWidth
inputValue={query} inputValue={query}
...@@ -46,18 +45,18 @@ function UserMultiSelect({ label, value, onChange }) { ...@@ -46,18 +45,18 @@ function UserMultiSelect({ label, value, onChange }) {
setQuery(''); setQuery('');
}} }}
> >
<ComboBox.InputGroup className="rc-user-combobox__group"> <ComboBox.InputGroup variant="secondary">
<Input aria-label={label} placeholder={`搜索并选择${label}`} /> <Input aria-label={label} placeholder={`搜索并选择${label}`} />
<ComboBox.Trigger aria-label={`打开${label}列表`} /> <ComboBox.Trigger aria-label={`打开${label}列表`} />
</ComboBox.InputGroup> </ComboBox.InputGroup>
<ComboBox.Popover className="rc-user-combobox__popover" placement="bottom start"> <ComboBox.Popover placement="bottom start">
<ListBox aria-label={`${label}候选人`} items={availableOptions} renderEmptyState={() => <div className="rc-user-select__state">{loading ? '正在搜索...' : '没有匹配的用户'}</div>}> <ListBox aria-label={`${label}候选人`} items={availableOptions} renderEmptyState={() => <div className="grid min-h-20 place-items-center p-4 text-sm text-muted">{loading ? '正在搜索...' : '没有匹配的用户'}</div>}>
{(user) => <ListBox.Item id={user._id} textValue={user.display_name || user.username}><span className="rc-user-select__name">{user.display_name || user.username}</span><span className="rc-user-select__account">{user.username}</span><ListBox.ItemIndicator /></ListBox.Item>} {(user) => <ListBox.Item id={user._id} textValue={user.display_name || user.username}><span className="block min-w-0"><span className="block truncate font-medium text-foreground">{user.display_name || user.username}</span><span className="block truncate text-xs text-muted">{user.username}</span></span><ListBox.ItemIndicator /></ListBox.Item>}
</ListBox> </ListBox>
{loading ? <div className="rc-user-combobox__loading"><Spinner size="sm" /></div> : null} {loading ? <div className="grid place-items-center p-2"><Spinner size="sm" /></div> : null}
</ComboBox.Popover> </ComboBox.Popover>
</ComboBox> </ComboBox>
{value.length > 0 ? <div className="rc-user-select__chips">{value.map((user) => <span className="rc-user-select__chip" key={user._id}><span>{user.display_name || user.username}</span><Tooltip delay={0}><Button aria-label={`移除${user.display_name || user.username}`} className="rc-user-select__chip-remove" isIconOnly size="sm" variant="ghost" onPress={() => onChange(value.filter((item) => item._id !== user._id))}><Close size={12} /></Button><Tooltip.Content>移除</Tooltip.Content></Tooltip></span>)}</div> : null} {value.length > 0 ? <div className="flex flex-wrap gap-2">{value.map((user) => <div className="flex items-center gap-1" key={user._id}><Chip color="accent" size="sm">{user.display_name || user.username}</Chip><Tooltip delay={0}><Button aria-label={`移除${user.display_name || user.username}`} isIconOnly size="sm" variant="ghost" onPress={() => onChange(value.filter((item) => item._id !== user._id))}><Close size={12} /></Button><Tooltip.Content>移除</Tooltip.Content></Tooltip></div>)}</div> : null}
</div> </div>
); );
} }
...@@ -68,10 +67,18 @@ export default function UserAssignmentDialog({ open, onClose, onSubmit, submitti ...@@ -68,10 +67,18 @@ export default function UserAssignmentDialog({ open, onClose, onSubmit, submitti
useEffect(() => { if (!open) { setFillers(EMPTY_ARRAY); setAuditors(EMPTY_ARRAY); } }, [open]); useEffect(() => { if (!open) { setFillers(EMPTY_ARRAY); setAuditors(EMPTY_ARRAY); } }, [open]);
return ( return (
<Dialog className="rc-user-assignment-dialog" open={open} onClose={submitting ? undefined : onClose} size="lg"> <Modal.Root>
<DialogTitle>选择填报人和审核人</DialogTitle> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<DialogBody className="rc-user-assignment-fields"><UserMultiSelect label="填报人" value={fillers} onChange={setFillers} /><UserMultiSelect label="审核人" value={auditors} onChange={setAuditors} /></DialogBody> <Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && !submitting && onClose?.()}>
<DialogActions><Button isDisabled={submitting} variant="outline" onPress={onClose}>取消</Button><Button isDisabled={submitting || fillers.length === 0 || auditors.length === 0} isPending={submitting} variant="primary" onPress={() => onSubmit({ fillers, auditors })}>{submitting ? '正在推送...' : '确认推送'}</Button></DialogActions> <Modal.Container placement="center" scroll="inside" size="lg">
</Dialog> <Modal.Dialog>
<Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger>
<Modal.Header><Modal.Heading>选择填报人和审核人</Modal.Heading></Modal.Header>
<Modal.Body className="space-y-5"><UserMultiSelect label="填报人" value={fillers} onChange={setFillers} /><UserMultiSelect label="审核人" value={auditors} onChange={setAuditors} /></Modal.Body>
<Modal.Footer><Button isDisabled={submitting} variant="secondary" onPress={onClose}>取消</Button><Button isDisabled={submitting || fillers.length === 0 || auditors.length === 0} isPending={submitting} variant="primary" onPress={() => onSubmit({ fillers, auditors })}>{submitting ? '正在推送...' : '确认推送'}</Button></Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
); );
} }
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
- 应用基路径:`/integrity_practice_management/` - 应用基路径:`/integrity_practice_management/`
- 默认入口:`/self_check_the_results_of_the_working_draft` - 默认入口:`/self_check_the_results_of_the_working_draft`
- 页面菜单与 Subject 动作配置:`integrityPracticeConfig.js` - 页面菜单与 Subject 动作配置:`integrityPracticeConfig.js`
- 页面统一使用 Catalyst/Tailwind 风格的 `RecordWorkbench` 和 `RecordDetailDrawer` - 页面统一使用鑫元 HeroUI Pro Shell、`RecordWorkbench` 和 `RecordDetailDrawer`;Sidebar 为 offcanvas,移动端通过可访问性 Dialog 打开
- Subject 字段、Select 选项、列表视图和详情视图均从后端动态读取,页面不维护字段副本或硬编码选项 - Subject 字段、Select 选项、列表视图和详情视图均从后端动态读取,页面不维护字段副本或硬编码选项
| 菜单分组 | 页面 | Subject | | 菜单分组 | 页面 | Subject |
...@@ -25,22 +25,24 @@ ...@@ -25,22 +25,24 @@
3. 切换视图后,根据视图的 `settings.restrictions`、`settings.filters.conditions`、列排序配置调用 `POST /record/query`。 3. 切换视图后,根据视图的 `settings.restrictions`、`settings.filters.conditions`、列排序配置调用 `POST /record/query`。
4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。 4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。
5. 关键词查找通过 `/record/query` 的 `keyword` 查询标题;清空筛选后回到第 1 页。 5. 关键词查找通过 `/record/query` 的 `keyword` 查询标题;清空筛选后回到第 1 页。
6. 列定义、列顺序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定。 6. 列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。
7. 点击列表首列打开通用详情弹窗并进入只读查看态;点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。 7. DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。
8. 编辑态的单选和多选属性使用共享 HeroUI Select;选择后立即更新当前弹窗草稿。单选完成后关闭选项层,多选允许连续选择,最终仍由弹窗底部统一“保存”提交。 8. 点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
9. 编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为 `_id` 或 `_id[]`,不改变保存时机和 `metadata` 协议。
9. 编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
异常处理: 异常处理:
- Subject、视图或记录加载失败时,在工作台显示错误状态,不使用猜测字段兜底。 - Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。
- 后端没有可用 `grid` 视图时,使用“全部”临时视图并按 Subject 字段生成列。 - 后端没有可用 `grid` 视图时,使用“全部”临时视图并按 Subject 字段生成列。
- 配置动作执行期间禁用重复点击;成功后显示成功状态,失败时保留当前列表并显示服务端错误。 - 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
## 3. 记录选择与动作规则 ## 3. 记录选择与动作规则
- 行动作来自 `gridRecordConfig.menuConfig`,顶部动作来自 `gridRecordConfig.tabBarRightSlot`。 - 行动作来自 `gridRecordConfig.menuConfig`,顶部动作来自 `gridRecordConfig.tabBarRightSlot`。
- 已勾选记录时,动作处理全部勾选记录;未勾选时处理动作所在行。 - 已勾选记录时,动作处理全部勾选记录;未勾选时处理动作所在行。
- `visabled` 使用配置中的 `relationship`、`rules`、`field`、`comparator` 和 `value` 判断,不在页面硬编码材料类型。 - `visabled` 使用配置中的 `relationship`、`rules`、`field`、`comparator` 和 `value` 判断,不在页面硬编码材料类型。
- 删除、归档和创建底稿填写表在执行前显示确认弹窗。 - 删除、归档和创建底稿填写表在执行前显示 HeroUI AlertDialog;取消不会触发业务请求。
- 删除使用 `POST /record/batch/remove` 软删除,不执行物理删除。 - 删除使用 `POST /record/batch/remove` 软删除,不执行物理删除。
## 4. 自查底稿模版生成填写表 ## 4. 自查底稿模版生成填写表
...@@ -60,7 +62,7 @@ ...@@ -60,7 +62,7 @@
入口:自查底稿填写表 -> “推送自查底稿填写表”。 入口:自查底稿填写表 -> “推送自查底稿填写表”。
1. 通过用户搜索选择填报人和审核人;两组人员都不能为空,只保存用户 `_id[]`。 1. 通过 HeroUI Modal 中的远程 ComboBox 搜索选择填报人和审核人;两组人员都不能为空,只保存用户 `_id[]`。
2. 将填报人写入关联 `self_check_the_project_results.metadata.informant`,同时写入填写表 `metadata.editor`。 2. 将填报人写入关联 `self_check_the_project_results.metadata.informant`,同时写入填写表 `metadata.editor`。
3. 创建 `message_reference_id`,保存发送人、填报人、审核人、Subject、View、关联记录和限制条件。 3. 创建 `message_reference_id`,保存发送人、填报人、审核人、Subject、View、关联记录和限制条件。
4. 为每个填报人调用 `oa_notice:createProgress` 创建 OA 待办,并将任务 ID 写入 `metadata.progress_id_editor`。 4. 为每个填报人调用 `oa_notice:createProgress` 创建 OA 待办,并将任务 ID 写入 `metadata.progress_id_editor`。
...@@ -107,8 +109,8 @@ ...@@ -107,8 +109,8 @@
## 7.1 列表分页 ## 7.1 列表分页
- 本年度报送材料下的“附件1:廉洁从业自查底稿”“附件2:廉洁从业规定落实情况统计表”和“廉洁从业报告生成”统一使用服务端分页。 - 本年度报送材料下的“附件1:廉洁从业自查底稿”“附件2:廉洁从业规定落实情况统计表”和“廉洁从业报告生成”统一使用服务端分页。
- 默认每页 10 条,可切换为 10 / 20 / 50 条;切换每页条数后返回第 1 页。 - 固定每页 10 条;分页区仅显示 HeroUI Pagination,不显示页容量选择器。
- 翻页和切换每页条数保留当前搜索条件与数据视图,不改变记录操作、导出和归档协议。 - 翻页保留当前搜索条件与数据视图,不改变记录操作、导出和归档协议。
## 8. API 与数据协议 ## 8. API 与数据协议
......
...@@ -30,7 +30,6 @@ import JSZip from 'jszip'; ...@@ -30,7 +30,6 @@ import JSZip from 'jszip';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
export const DEFAULT_PAGE_SIZE = 10; export const DEFAULT_PAGE_SIZE = 10;
export const PAGE_SIZE_OPTIONS = [10, 20, 50];
function buildViewFilter(view) { function buildViewFilter(view) {
const restrictions = view?.settings?.restrictions || []; const restrictions = view?.settings?.restrictions || [];
......
@import 'tailwindcss'; @import "tailwindcss" source(none);
@import '@heroui/styles'; @import "@heroui/styles";
@import '@heroui-pro/react/css'; @import "@heroui-pro/react/css";
@import './design-system/tokens.css'; @import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
@source "./pages/**/*.{js,jsx,ts,tsx}";
@source "./wrapper/**/*.{js,jsx,ts,tsx}";
@theme { @theme {
--font-sans: var(--rc-font-sans); --font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
:root {
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
}
html {
color-scheme: light !important;
}
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
color: var(--rc-text-primary);
background: var(--rc-bg-canvas);
color-scheme: inherit;
}
body .login-page {
--rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem;
--rc-login-card-brand-margin-bottom: 2rem;
--rc-login-card-brand-title-size: 2rem;
--rc-login-card-brand-title-weight: var(--rc-login-brand-weight);
--rc-login-control-height: 2.5rem;
--rc-login-submit-height: 2.875rem;
--rc-login-submit-margin-top: 0.875rem;
--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%);
}
body .login-brand img {
width: var(--rc-login-brand-logo-size) !important;
height: var(--rc-login-brand-logo-size) !important;
filter: none !important;
}
body .login-card-brand {
justify-content: center;
gap: var(--rc-login-card-brand-gap) !important;
margin-bottom: var(--rc-login-card-brand-margin-bottom);
text-align: center;
}
body .login-card-brand strong {
color: var(--rc-text-primary);
font-size: var(--rc-login-card-brand-title-size);
font-weight: var(--rc-login-card-brand-title-weight);
line-height: 1.15;
letter-spacing: 0;
white-space: nowrap;
}
body .login-card [data-slot="card-header"] {
display: none !important;
}
body .login-card [data-slot="card-content"] {
padding: 2.25rem !important;
}
body
.login-form
.login-field
:is(
.input,
.input--secondary,
.login-input,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
) {
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
}
body
.login-form
.login-field
:is(input, .login-input, input.input--secondary, input[data-slot="input"]) {
line-height: var(--rc-login-control-height) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input input,
[data-slot="input-wrapper"] input,
[data-slot="control"] input
) {
background: transparent !important;
background-color: transparent !important;
}
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
border-color: transparent !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow: inset 0 0 0 1000px var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):hover,
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
)[data-hovered="true"] {
border-color: var(--rc-login-field-border-hover) !important;
background: var(--rc-login-field-filled-background-hover) !important;
background-color: var(--rc-login-field-filled-background-hover) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):focus,
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):focus-within,
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
)[data-focused="true"] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow:
var(--field-shadow, 0 1px 2px rgb(15 23 42 / 4%)),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow:
var(--field-shadow, 0 1px 2px rgb(15 23 42 / 4%)),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"]:focus,
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"][data-focused="true"],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"] {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
:is(.input, [data-slot="input-wrapper"], [data-slot="control"]):has(
input:-webkit-autofill
),
body
.login-form
:is(.input, [data-slot="input-wrapper"], [data-slot="control"]):has(
input:autofill
) {
--input-bg: var(--rc-login-field-autofill-background) !important;
--input-bg-hover: var(--rc-login-field-autofill-background) !important;
--input-bg-focus: var(--rc-login-field-autofill-background) !important;
background: var(--rc-login-field-autofill-background) !important;
background-color: var(--rc-login-field-autofill-background) !important;
}
body .login-form input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--rc-login-field-autofill-background)
inset !important;
-webkit-text-fill-color: var(--rc-text-primary) !important;
caret-color: var(--rc-text-primary);
}
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
)[data-filled="true"],
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
),
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus-visible,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"],
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill:hover,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill:focus,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):autofill,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):autofill:focus {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
appearance: none;
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
background-image: none !important;
background-clip: padding-box !important;
caret-color: var(--rc-text-primary);
box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-text-fill-color: var(--rc-text-primary) !important;
transition:
background-color 9999s ease-out,
color 9999s ease-out;
}
body .login-submit-button {
height: var(--rc-login-submit-height) !important;
min-height: var(--rc-login-submit-height) !important;
margin-top: var(--rc-login-submit-margin-top) !important;
border-radius: var(--rc-button-radius, 999px) !important;
}
.login-brand-logo {
display: grid;
width: var(--rc-login-brand-logo-size);
height: var(--rc-login-brand-logo-size);
place-items: center;
} }
@layer base { .login-brand-logo .rc-brand-logo,
a { .rc-sidebar-brand-mark .rc-sidebar-logo {
color: inherit; grid-area: 1 / 1;
text-decoration: none; object-fit: contain;
}
} }
:root { color-scheme: light; } .rc-brand-logo--dark,
html.dark, html[data-theme='dark'] { color-scheme: dark; } .rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: none;
}
.dark .rc-brand-logo--light,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--light {
display: none;
}
.dark .rc-brand-logo--dark,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: block;
}
body .login-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 0.75rem;
width: 100%;
}
body .login-captcha-button {
width: 100%;
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
border: 1px solid var(--rc-button-secondary-border) !important;
border-radius: var(--rc-radius-control) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
box-shadow: none !important;
}
body .login-captcha-button .captcha-image {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
place-items: center;
overflow: hidden;
}
body .login-captcha-button .captcha-image :is(svg, img) {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 1.875rem;
margin: 0;
}
.dark body .login-captcha-button,
body .dark .login-captcha-button,
body [data-theme="dark"] .login-captcha-button {
border-color: var(--rc-button-secondary-border) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
}
.dark .login-page,
body .dark .login-page,
body.dark .login-page,
body [data-theme="dark"] .login-page {
--rc-login-field-filled-background: color-mix(
in oklab,
var(--rc-brand-primary) 14%,
var(--rc-field-background)
);
--rc-login-field-filled-background-hover: color-mix(
in oklab,
var(--rc-brand-primary) 18%,
var(--rc-field-background)
);
--rc-login-field-autofill-background: color-mix(
in oklab,
var(--rc-brand-primary) 16%,
var(--rc-field-background)
);
--rc-login-field-border-hover: color-mix(
in oklab,
var(--rc-brand-primary) 42%,
var(--rc-border-default)
);
--rc-login-field-focus-ring: color-mix(
in oklab,
var(--rc-brand-primary) 22%,
transparent
);
}
import authService from '@/services/authService'; import authService from '@/services/authService';
import { APPLICATION_BASE_PATH } from '@/config/application';
import { getUserInfo, userLogout } from '@/services/DataService'; import { getUserInfo, userLogout } from '@/services/DataService';
import { createContext, useEffect, useMemo, useState } from 'react'; import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi'; import { history, Navigate, Outlet, useLocation } from 'umi';
export const UserContext = createContext(null); export const UserContext = createContext(null);
const LOGIN_PATH = `${INTEGRITY_PRACTICE_APP_BASE_PATH.replace(/\/$/, '')}/login`; const LOGIN_PATH = `${APPLICATION_BASE_PATH.replace(/\/$/, '')}/login`;
function getStoredUser() { function getStoredUser() {
const value = authService.getCurrentUser(); const value = authService.getCurrentUser();
......
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