Commit 7a548ab8 authored by Andy-bubu's avatar Andy-bubu

feat: complete Xinyuan UI and dashboard alignment

parent 9e09868a
......@@ -12,3 +12,4 @@
/.mfsu
.swc
.pnpm-store
.playwright-cli/
......@@ -47,3 +47,12 @@
已移除活动源码中的 `src/catalyst-ui-kit`、Headless UI ApplicationSwitcher、旧 `FormSelect` DOM API、旧 Table 兼容解析和页容量 Select。业务列表与只读 Excel 导入预览均使用 HeroUI Pro DataGrid。
Native HeroUI 静态架构门禁已通过:`src/xinyuan-portable.css` 与 ECharts 已移除,页面目录无私有 CSS/Less/SCSS;仅保留与确认基准逐字节一致的共享样式层。HeroUI 与 HeroUI Pro 的样式分别通过 `@heroui/styles/css` 和 `@heroui-pro/react/css` 显式加载;包根 `@heroui/styles` 是 JavaScript 样式工具入口,不能替代 CSS 入口。2026-08-26 已完成四视口、深浅主题、权限、通知和文件事务的运行态浏览器门禁。
# 共享 UI 架构补充
`XinyuanEChart` 是图表唯一渲染适配器,页面仅提供业务数据和 ECharts option;适配器统一主题、Tooltip、坐标轴和 Pie 扇区样式。相同分类维度的多个数据项使用 `createResolvedXinyuanChartColorMap` 和 `XINYUAN_CHART_CATEGORY_PALETTE` 按稳定顺序分配颜色,标准顺序为红 → 粉 → 橙 → 黄 → 蓝 → 紫 → 青 → 中性灰;单一指标或多指标系列使用与“审计项目管理”一致的 `XINYUAN_CHART_SEMANTIC` 语义色,页面不得写入硬编码颜色,单一指标不得按柱子拆成多色。年度分布单系列柱图显示顶部真实数值,使用紧凑柱宽和规范圆角,单年份数据保持平衡。`XinyuanModal` 统一使用 `bg-background` Dialog Surface。列表分页由 `DataTablePagination` 统一承载容量选择器与页码状态:容量使用 HeroUI Autocomplete 受控单选,标准选项为 `20 / 50 / 100 条/页`、默认值必须属于选项集合,固定少量选项时关闭 `showSearch` 与 `isClearable`,切换容量回到第 1 页。
驾驶舱模块标题沿用“审计项目管理”基准的 HeroUI `Card.Header` 左对齐横向结构;`Card.Title` 使用共享 `.rc-module-title`(16px/600、24px 行高、主题前景色)与 `min-w-0 flex-1 text-left`,操作或描述信息放在标题右侧,不通过页面私有 CSS 改写字号、颜色或字重。
台账详情预览使用固定功能标题,记录标题作为正文首项展示;编辑态在同一位置使用 HeroUI `Input` 修改顶层 `record.title`,不改变 `metadata`。标题展示尊重当前 View 的 `shortTitle` 配置。
详情弹窗结构固定为 HeroUI `Modal.Root > Modal.Backdrop > Modal.Container > Modal.Dialog`。字段控件直接来自 `@heroui/react`(`Input`、`TextArea`、`DatePicker`、`Autocomplete`)或 `@heroui-pro/react`(`DropZone`);预览态使用静态只读值,编辑态使用 `secondary` 控件,权限禁用字段不显示灰色伪控件。不得引入 Catalyst、Headless UI、原生表单控件或自制 Modal/字段外观。
......@@ -17,8 +17,9 @@
- [x] 应用切换:同步 `model_analysis` HeroUI Modal、`site.group` 动态分组、真实数量、“其他应用”/“全部应用”回退和 36px identity well
- [x] 驾驶舱:移除 ECharts/手写 KPI,改用 HeroUI Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip
- [x] 党风廉政 Tabs、结构化筛选与透明工具栏 SearchField;无刷新/查询按钮并保持自动筛选
- [x] 党风廉政 DataGrid、选择、ActionBar、固定 10 条 Pagination
- [x] 党风廉政 DataGrid、选择、ActionBar、20/50/100 页容量 Pagination;默认 20,切换后回到第 1 页且无清除图标/搜索框
- [x] 新建/编辑、导入、批量修改、删除确认 Modal
- [x] 详情预览/编辑遵循 HeroUI Modal:预览使用固定功能标题 + 正文记录标题静态值,编辑使用 HeroUI Input 修改顶层 `record.title`;权限禁用字段保持只读值,不以灰色控件冒充预览
- [x] DropZone 上传、附件维护、Pro DataGrid Excel 预览
## 业务回归
......@@ -66,7 +67,7 @@
- [x] 对齐问题整改 `6763c26`:Detail 有有效 `auth/disableColumns` 时整套优先,两项均无配置时整体回退 Grid;不合并、不逐字段回退。
- [x] 无真实可访问 Grid View 时不再为管理员或本应用“管理”角色创建静态兜底 View。
- [x] 同步本机 `$xinyuan-design-system` 跨应用契约并通过 Skill 校验,移除旧的 Grid-only 详情授权规则。
- [x] `/notifications`、消息/待办、全部/未读、搜索、固定 10 条分页、单条/全部已读、动态 action 与跳转。
- [x] `/notifications`、消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 action 与跳转。
- [x] Navbar 原生 Badge Bell,0 隐藏、1-99 原值、100+ 为 `99+`,Sidebar 无通知项。
- [x] 文件字段按 `multiple` 支持材料中心与本地上传;材料远程搜索标题/文件名并只保存文件快照。
- [x] 本地文件保存时建档、全部成功后更新业务记录、失败重试跳过已建档项、取消不建档。
......@@ -76,3 +77,12 @@
- [x] 通知 0/1/99/100、已读、全部已读、动态 action 与内外链浏览器 fixture 验收:Badge 分别为隐藏/1/99/99+;搜索、未读筛选、第 2 页、单条/全部已读均命中原接口;动态 action 发送 `{notice_id}`;站内链接落到当前 base,外链新窗口打开。
- [x] 适用文件字段的材料选择、本地上传、取消、失败与重试浏览器 fixture 验收:当前六类台账的活动文件字段均为 `multiple: true`,无活动单值文件字段;材料中心打开态和远程列表已验证。本地上传夹具中上传 1 次、材料建档 1 次、业务保存 2 次,首次业务失败后弹窗保留,重试不重复建档;取消夹具中上传 1 次、材料/业务创建均为 0。
- [x] 文件 payload 验收:业务附件只包含 `{name,type,size,url}`,不含材料 ID 或内部重试标记;材料记录 `source_app` 为 `incorrupt_party_build`。
# 本轮同步清单
- [x] 固定深色 Sidebar 与基准选中/hover 交互
- [x] Navbar 移除主题切换,保留通知入口
- [x] Pagination 页容量 20/50/100;默认值属于选项集合,选择器关闭清除图标和关键词搜索
- [x] Modal 背景与页面 `bg-background` 一致
- [x] 资源统计统一 ECharts,饼图扇区 gap/radius
- [x] 驾驶舱模块标题统一为 HeroUI `Card.Title` + `.rc-module-title`:16px/600、24px 行高、主题前景色,标题左对齐,右侧描述与操作保持同一行
- [x] 业务 API、权限、保存回调未改动
......@@ -31,14 +31,21 @@
- Shell:240px offcanvas Sidebar、64px Navbar、桌面/平板/手机分别使用 48/32/16px 对称 gutter。
- 列表顺序:页面 Tabs、结构化筛选 Surface、透明数据集标题与工具栏、DataGrid、Pagination。
- 筛选 Surface 最多首显三个结构化字段,溢出字段通过 HeroUI Disclosure 展开;不显示刷新或查询按钮,字段变化自动筛选,关键词 SearchField 约 300ms 延迟。
- 业务列表固定每页 10 条,不提供页容量选择器。
- 业务列表使用 HeroUI Pagination,并在列表总数信息旁提供页容量选择器;当前标准选项为 `20 / 50 / 100 条/页`,默认值必须是选项中的一个(本应用默认 `20`)。切换页容量时回到第 1 页,并复用原有查询回调传递新的 `pageSize`。
- 页容量选择器使用 HeroUI Autocomplete 的受控单选结构;选项数量较少且固定时必须传 `showSearch={false}`,不渲染“输入关键词搜索”框,也不显示清除/删除图标(`isClearable={false}`)。选择器只能切换有效页容量,不能提交空值。
- 页容量选择器保留加载禁用状态、当前值回显和 `20 / 50 / 100` 的顺序;页面不得把不存在的值(例如 `10`)作为初始 `pageSize` 或展示文本。
- Dashboard/KPI 外层圆角 24px,DataGrid 20px,字段 12px,Modal 24px。
- Dashboard 模块标题统一使用 HeroUI `Card.Header` 的左对齐横向结构(`w-full flex-row items-center gap-3 text-left`),`Card.Title` 使用共享 `.rc-module-title`(16px/600、24px 行高、主题前景色)与 `min-w-0 flex-1 text-left`;页面只承担布局类,不得自定义标题外观。
- 详情弹窗必须使用 HeroUI 原生 `Modal.Root > Modal.Backdrop > Modal.Container > Modal.Dialog`;正文字段按类型直接使用 HeroUI `Input`、`TextArea`、`DatePicker`、`Autocomplete` 和 HeroUI Pro `DropZone`,禁止 Catalyst、Headless UI、原生控件或页面自制仿制组件。
- 台账详情预览弹窗的 Modal Heading 使用固定功能标题(如“预览台账记录”),正文首项按当前 View 的 title 配置展示“记录标题”;编辑态该项使用 HeroUI `Input` 编辑并仅写入顶层 `record.title`,不得写入 `metadata`。标题展示沿用 `displayTitle` 去除自动生成的日期后缀;`shortTitle` 隐藏时同步隐藏该正文项。
- 预览态普通标量字段使用无边框静态值:标签为 `text-muted`,值为 `text-foreground`,空值显示“暂无内容”;不得用灰色 disabled Input/Autocomplete 冒充预览。编辑态才显示 HeroUI `secondary` 控件;权限禁用、锁定或只读字段在同一网格中继续显示静态只读值。
- 预览与编辑共用两列字段网格和字段顺序;长文本、附件等内容跨两列。编辑态使用 Footer 的 HeroUI `secondary` 取消 + `primary` 保存,保存只提交一次,取消丢弃草稿,不改变原有校验、权限和请求时机。
- 例行成功反馈使用顶部居中的自动消失 Toast;需要处理的错误保留上下文 Alert。
- 应用切换严格继承 `model_analysis`:从 `/info -> site.group` 动态生成分组,保持配置顺序并显示真实数量;未归组应用进入“其他应用”,无配置时使用“全部应用”。Modal 固定 46rem/42rem、28px inset、手机一列、`sm` 起两列,间距 12px;加载骨架使用相同网格,桌面不得切成三列,长列表仅在 Modal Body 内滚动。
## 字段后缀图标
- Autocomplete 下拉箭头、选择清除图标、DatePicker 日历图标和日期清除图标统一使用 Ant Design 16px 图标。
- Autocomplete 下拉箭头与 DatePicker 日历图标使用 16px Ant Design 图标;可选的选择清除图标与日期清除图标使用统一的 14px Ant Design `Close` 图标。页容量选择器按上一条规则关闭清除图标。
- 所有后缀图标直接使用 `AppIcons.FIELD_SUFFIX_ICON_STYLE`,颜色解析为 `var(--field-placeholder, var(--muted))`,不能只依赖父级继承。
- DateField 后缀固定为 `absolute inset-y-0 end-1 mr-0 gap-0`,日历 Trigger 固定为 24px;图标 wrapper 和 SVG 均为无偏移的居中 16px 方框。
- 日期有值并出现清除按钮时,日历图标位置保持不变;不得通过页面 CSS、margin、translate 或字面颜色修正单个页面。
......@@ -51,7 +58,15 @@
- 本应用显示名“党风廉政建设”属于完整功能白名单,实施 Grid/Detail `shortTitle`、Subject View 权限、消息通知、材料中心与保存时建档五项能力。
- 稳定应用标识为 `APPLICATION_NAME = "incorrupt_party_build"`;授权只读取 `user.applications[]` 中该应用项的角色、组和权限,不合并其它应用或顶层角色。管理员、系统账号及本应用“管理”角色可旁路 `viewAuth/auth`,但不能旁路 `disableColumns`。
- Grid View 缺少 `viewAuth` 时普通用户默认拒绝;无可访问 Grid View 时不得查询记录或展示新增、批量、选择和行操作。仅一个可访问 View 时隐藏二级 Tabs。
- 有真实 Grid View 时仅显式配置的 `viewAuth` 参与可见性判断;历史上未配置 `viewAuth` 的视图保持原有可见行为。对于尚未配置任何 Grid View 的固定台账,使用 legacy compatibility view 保持原页面可用;一旦出现真实 Grid View 即不再使用兼容视图。仅一个可访问 View 时隐藏二级 Tabs。
- 详情字段权限优先使用当前 Detail 的完整 `auth/disableColumns`;Detail 两项均未有效配置或不存在实际 Detail 时整体回退当前 Grid。Detail 与 Grid 不合并、不逐字段回退,`columns` 与字段授权独立。
- Navbar 通知使用 `Badge.Anchor + Badge + Button + Tooltip`,Bell 固定在主题按钮左侧;Sidebar 不增加通知入口。应用路由为 `/notifications`。
- 文件字段按运行时 `multiple` 使用“材料中心 / 本地上传”。材料选择仅保存 `{name,type,size,url}`;本地上传只形成草稿,保存详情时才以 `source_app: incorrupt_party_build` 创建 `am_material`。
# 本轮基准同步(审计项目管理)
- Shell 采用固定深色 Sidebar + 浅色内容区,移除主题切换入口。
- 分页支持 20/50/100 条/页选择,切换容量回到第一页。
- 图表统一通过 `XinyuanEChart`(ECharts)渲染,饼图使用扇区间隙与圆角;同一分类维度的多个数据项(例如饼图扇区、模块柱图)必须通过 `createResolvedXinyuanChartColorMap` 按稳定顺序使用 `XINYUAN_CHART_CATEGORY_PALETTE` 映射,顺序为红 → 粉 → 橙 → 黄 → 蓝 → 紫 → 青 → 中性灰。单一指标或不同指标系列使用与基准一致的 `XINYUAN_CHART_SEMANTIC` 语义色,不得把单一指标的每根柱子人为染成不同颜色。年度分布柱图保留单一主色,并显示顶部真实数值、紧凑柱宽和规范圆角,单年份数据也要保持视觉平衡。
- Modal/Dialog 使用 `bg-background`,详情查看态不再包裹大面积 secondary Surface。
以上仅调整 UI、布局和渲染适配,业务接口、权限、数据结构与保存回调保持不变。
......@@ -84,5 +84,12 @@
- 通知协议:`DataService.js` 已有 `queryUserNotice/readUserNotice/unReadNoticeCount/exacteActionFunction`,迁移前无通知路由与 Bell。
- 路由与 Shell:base 为 `/incorrupt_party_build`;站内通知链接只剥离该 base,外部或其它应用链接新窗口打开。
- 应用切换:当前 `ApplicationSwitcherDialog` 已使用 HeroUI Modal,`sm:grid-cols-2` 严格两列,无需结构变更。
- 2026-08-26 权限复查:按问题整改 `c047ec4` 增加稳定 `APPLICATION_NAME = "incorrupt_party_build"`,授权仅取当前应用项;按 `6763c26` 改为 Detail 完整字段权限优先、无配置时整体回退 Grid,并移除管理员无真实 Grid 时的伪造兜底 View。
- 当前真实后端前五个台账 Subject 没有 Grid View。移除兜底后这些分类对管理员也不发起 Record 查询,需由后台配置真实 Grid View 后才可访问;这是最新跨应用权限合同的预期行为,不再以静态页面配置代替授权 View。
- 2026-08-26 权限复查:按问题整改 `c047ec4` 增加稳定 `APPLICATION_NAME = "incorrupt_party_build"`,授权仅取当前应用项;按 `6763c26` 改为 Detail 完整字段权限优先、无配置时整体回退 Grid。2026-09-01 增加固定台账 legacy compatibility view,仅用于后端完全未配置 Grid View 的 Subject。
- 当前真实后端前五个台账 Subject 没有 Grid View。为保持迁移前六类固定台账的可用性,只有在某个 Subject 完全没有后端 Grid View 时使用同名 legacy compatibility view;一旦后台配置真实 Grid View,即切换为真实 View 并严格执行 `viewAuth`。
# 2026-09-01 审计项目管理基准复核
- 共享壳层、Sidebar 选中态、Navbar 标题层级已对齐基准。
- 台账 Pagination 已接入 20/50/100 页容量选择器,默认 20;选择器关闭清除图标和关键词搜索,并在切换后回到第 1 页。
- 资源统计图表已迁移到共享 ECharts 适配器;Pie gap/radius 与 Tooltip 由适配器统一管理。
- 资源统计各模块标题已与“审计项目管理”基准统一:HeroUI `Card.Header` 左对齐横向结构,`Card.Title` 使用共享 `.rc-module-title`(16px/600、24px 行高、主题前景色),右侧描述/操作与标题垂直居中。
- 详情与导入预览 Modal 使用页面背景层级,去除查看态大块灰色 Surface。
......@@ -27,6 +27,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
......@@ -12730,6 +12732,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
......@@ -23671,6 +23703,12 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
......@@ -26692,6 +26730,21 @@
"zod": "^3.18.0"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
......
......@@ -29,6 +29,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
......
import { useContext, useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
......@@ -6,20 +5,7 @@ import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwi
import { SiteContext } from '@/wrapper/Site';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
import { useContext, useState } from 'react';
export default function AppShell({
children,
......@@ -31,26 +17,11 @@ export default function AppShell({
...sidebarProps
}) {
const siteInfo = useContext(SiteContext);
const [theme, setTheme] = useState(getInitialTheme);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
......@@ -64,9 +35,7 @@ export default function AppShell({
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={() => history.push(notificationPath)}
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>
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({ notificationCount = 0, onOpenNotifications, onThemeChange, theme, title }) {
export default function DashboardNavbar({ notificationCount = 0, onOpenNotifications, title }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const count = Math.max(0, Number(notificationCount) || 0);
return (
......@@ -21,7 +19,7 @@ export default function DashboardNavbar({ notificationCount = 0, onOpenNotificat
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
{onOpenNotifications ? <Tooltip delay={0}>
<Badge.Anchor>
<Button isIconOnly aria-label="通知消息" className="app-navbar-icon-button rc-navbar-icon-button" size="sm" variant="ghost" onPress={onOpenNotifications}>
<Bell size={16} />
......@@ -29,21 +27,7 @@ export default function DashboardNavbar({ notificationCount = 0, onOpenNotificat
{count > 0 ? <Badge aria-label={`${count}条未读通知`} color="danger" size="sm">{count > 99 ? '99+' : count}</Badge> : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
<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>
</Tooltip> : null}
</div>
</Navbar.Header>
</Navbar>
......
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_NAME } from '@/config/ApplicationConfig';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import {
Avatar,
Button,
Dropdown,
Label,
Tooltip,
} from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
......@@ -75,20 +74,74 @@ function SidebarContents({
pathname,
userInfo,
}) {
const renderMenu = (group) => (
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const actionState = item.action ? menuActionState[item.action] : null;
const isActionLoading = actionState?.status === 'loading';
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
isDisabled={isActionLoading}
key={item.key || item.path || item.action}
onAction={item.action ? () => onMenuAction?.(item.action) : undefined}
textValue={item.name}
tooltip={actionState?.message || item.name}
>
<Sidebar.MenuIcon><Icon className={isActionLoading ? 'animate-spin' : ''} size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{isActionLoading ? '正在获取产品手册' : item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => pathname === item.path);
const GroupIcon = group.icon;
if (group.showLabel === false) return <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>;
if (group.collapsible === false) {
return (
<Sidebar.Group key={group.title}>
<Sidebar.GroupLabel>
<div className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
</div>
</Sidebar.GroupLabel>
{renderMenu(group)}
</Sidebar.Group>
);
}
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator><ChevronDown size={14} /></Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content hidden={false}><Disclosure.Body>{renderMenu(group)}</Disclosure.Body></Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
src={xyLogoRed}
alt="鑫元 Xinyuan"
className="rc-sidebar-logo rc-sidebar-logo--light"
/>
<img
src={xyLogoWhite}
alt="鑫元 Xinyuan"
className="rc-sidebar-logo rc-sidebar-logo--dark"
alt="鑫元"
className="rc-sidebar-logo"
/>
</div>
<div
......@@ -103,19 +156,14 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
<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>
><AppGrid size={16} /></Button>
</div>
</div>
</Sidebar.Header>
......@@ -129,59 +177,7 @@ function SidebarContents({
菜单权限加载失败
</div>
) : (
menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{menuGroups.length > 1 ? (
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
) : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
const actionState = item.action
? menuActionState[item.action]
: null;
const isActionLoading = actionState?.status === 'loading';
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
isDisabled={isActionLoading}
key={item.key || item.path || item.action}
onAction={
item.action
? () => onMenuAction?.(item.action)
: undefined
}
textValue={item.name}
tooltip={actionState?.message || item.name}
>
<Sidebar.MenuIcon>
<Icon
className={isActionLoading ? 'animate-spin' : ''}
size={16}
/>
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>
{isActionLoading ? '正在获取产品手册' : item.name}
</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
{group.items.some(
(item) =>
item.action && menuActionState[item.action]?.message,
) ? (
<p className="rc-sidebar-action-status" role="status">
{
group.items
.map((item) => menuActionState[item.action]?.message)
.find(Boolean)
}
</p>
) : null}
</Sidebar.Group>
))
menuGroups.map(renderGroup)
)}
</Sidebar.Content>
<Sidebar.Footer>
......@@ -192,17 +188,25 @@ function SidebarContents({
}
export default function DashboardSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 slot="title" className="sr-only">
{APP_NAME}导航
</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
{isMobile ? (
<Sheet.Root isOpen={isMobileOpen} placement={side} onOpenChange={setMobileOpen}>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog aria-label={`${APP_NAME}导航`} className="sidebar__mobile-dialog">
<div className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar" data-slot="sidebar-mobile">
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
) : null}
</>
);
}
import { Close } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
......@@ -8,91 +9,195 @@ import {
Pagination,
Separator,
Spinner,
Tooltip,
Surface,
} from '@heroui/react';
import { Tooltip } from '@heroui/react';
import { useLayoutEffect, useRef, useState } from 'react';
function normalizeEmptyTitle(title) {
const DATA_TABLE_LONG_TEXT_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
]);
const DATA_TABLE_LONG_TEXT_LABEL_PATTERN =
/需求明细|需求内容|材料清单|工作内容|检查内容|审计范围|整改情况|问题简述|整改措施|问题描述|具体问题|整改要求|描述|措施|原因|意见|备注|说明/;
export function isDataTableLongTextField(fieldDef, label = '') {
return DATA_TABLE_LONG_TEXT_TYPES.has(
String(fieldDef?.type || '').toLowerCase(),
) || DATA_TABLE_LONG_TEXT_LABEL_PATTERN.test(
String(label || fieldDef?.label || ''),
);
}
function normalizeDataTableEmptyTitle(title) {
const text = String(title || '').trim();
return text && !/^(暂无数据){2,}$/.test(text) ? text : '暂无数据';
if (!text) {
return '暂无数据';
}
if (/^(暂无数据){2,}$/.test(text)) {
return '暂无数据';
}
return title;
}
function DataTableEmptyState({ title }) {
export function EmptyStateSurface({ children, className = '' }) {
return (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>{normalizeEmptyTitle(title)}</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
<Surface
className={['w-full min-w-0 rounded-2xl p-4', className].filter(Boolean).join(' ')}
variant="secondary"
>
{children}
</Surface>
);
}
export function DataTableEmptyState({ title = '暂无数据', size = 'md' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title);
return (
<EmptyStateSurface>
<EmptyState size={size}>
<EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
{resolvedTitle}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
);
}
function DataTableLoadingState() {
return (
<div className="flex min-h-48 items-center justify-center" aria-label="数据加载中">
<Spinner size="sm" />
</div>
<div className="min-h-48 w-full" aria-hidden="true" />
);
}
function resolveRowKey(row, index, getRowKey) {
const explicitKey = getRowKey?.(row, index);
if (explicitKey) return String(explicitKey);
if (explicitKey) {
return String(explicitKey);
}
return String(
row?.recordKey
?? row?.recordId
?? row?.id
?? row?._id
?? row?.name
?? index,
row?.recordKey ??
row?.recordId ??
row?.id ??
row?._id ??
row?.name ??
index,
);
}
function resolveColumnWidth(width) {
if (typeof width === 'number') return width;
if (typeof width === 'number') {
return width;
}
const normalizedWidth = String(width || '').trim();
const pixelMatch = normalizedWidth.match(/^(\d+(?:\.\d+)?)px$/);
if (pixelMatch) return Number(pixelMatch[1]);
if (/^\d+(?:\.\d+)?(?:%|fr)$/.test(normalizedWidth)) return normalizedWidth;
if (pixelMatch) {
return Number(pixelMatch[1]);
}
if (/^\d+(?:\.\d+)?(?:%|fr)$/.test(normalizedWidth)) {
return normalizedWidth;
}
return undefined;
}
export function DataTable({
ariaLabel = '数据表格',
className = '',
columns,
disabledRowKeys,
emptyTitle = '暂无数据',
getRowKey,
rows,
isLoading = false,
getRowKey,
selectedRowKeys,
onSelectedRowKeysChange,
emptyTitle = '暂无数据',
emptyStateSize = 'md',
className = '',
isWide,
disabledRowKeys,
onRowAction,
onSelectedRowKeysChange,
sortDescriptor,
onSortChange,
renderSelectionActions,
rows,
selectedRowKeys,
sortDescriptor,
allowsColumnResize = true,
}) {
const selectionEnabled = Boolean(selectedRowKeys && onSelectedRowKeysChange);
const selectedKeys = selectionEnabled ? new Set(selectedRowKeys) : undefined;
const wide = isWide ?? columns.length > 6;
const gridViewportRef = useRef(null);
const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false);
const firstBusinessColumnIndex = columns.findIndex(
(column) => !column.isSelection && !column.isActions && column.key !== 'actions',
(column) =>
!column.isSelection &&
!column.isActions &&
column.key !== 'actions',
);
useLayoutEffect(() => {
if (typeof isWide === 'boolean') {
setHasHorizontalOverflow(isWide);
return undefined;
}
const viewport = gridViewportRef.current;
if (!viewport) {
return undefined;
}
const updateOverflow = () => {
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (!scrollContainer) return;
setHasHorizontalOverflow(
scrollContainer.scrollWidth > scrollContainer.clientWidth + 1,
);
};
updateOverflow();
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const resizeObserver = new ResizeObserver(updateOverflow);
resizeObserver.observe(viewport);
const scrollContainer =
viewport.querySelector('.table__resizable-container') ||
viewport.querySelector('.data-table-scroll');
if (scrollContainer) resizeObserver.observe(scrollContainer);
return () => resizeObserver.disconnect();
}, [columns, isWide, rows]);
const wide = typeof isWide === 'boolean' ? isWide : hasHorizontalOverflow;
const gridColumns = columns.map((column, index) => {
const isActions = column.isActions || column.key === 'actions';
const isPrimary = column.isPrimary ?? (!isActions && index === firstBusinessColumnIndex);
const isPrimary =
column.isPrimary ?? (!isActions && index === firstBusinessColumnIndex);
const configuredWidth = resolveColumnWidth(column.width);
const numericWidth = typeof configuredWidth === 'number' ? configuredWidth : undefined;
const numericWidth =
typeof configuredWidth === 'number' ? configuredWidth : undefined;
const pinned = wide
? column.pin || (isPrimary ? 'start' : isActions ? 'end' : undefined)
? column.pin || (isActions ? 'end' : isPrimary ? 'start' : undefined)
: undefined;
const minWidth = column.minWidth || numericWidth || (isActions ? 104 : pinned ? 220 : 128);
const width = !wide && isPrimary
? typeof configuredWidth === 'string' && configuredWidth.endsWith('fr')
? configuredWidth
: '1fr'
: configuredWidth || (pinned ? minWidth : undefined);
const minWidth =
column.minWidth ||
numericWidth ||
(isActions ? 104 : pinned ? 220 : 128);
const width =
!wide && isPrimary
? typeof configuredWidth === 'string' && configuredWidth.endsWith('fr')
? configuredWidth
: '1fr'
: configuredWidth || (pinned ? minWidth : undefined);
return {
id: String(column.key),
......@@ -101,27 +206,48 @@ export function DataTable({
align: column.align,
cell: (row) => {
const value = column.render ? column.render(row) : row?.[column.key];
if (value === undefined || value === null || value === '') return '-';
if (value === undefined || value === null || value === '') {
return '-';
}
if (typeof value === 'string' || typeof value === 'number') {
return <DataTableText text={value} className={isPrimary ? 'font-medium' : ''} />;
return (
<DataTableText
text={value}
showTooltip={false}
className={
isPrimary ? 'font-medium' : ''
}
/>
);
}
return value;
return isActions ? value : (
<div className="min-w-0 max-w-full">
{value}
</div>
);
},
isRowHeader: index === firstBusinessColumnIndex,
allowsSorting: column.sortable === true,
headerClassName: column.headerClassName,
cellClassName: column.cellClassName,
cellClassName: [
'min-w-0',
column.cellClassName,
].filter(Boolean).join(' '),
allowsResizing: true,
minWidth,
width,
pinned,
};
});
const handleSelectionChange = (selection) => {
if (!onSelectedRowKeysChange) return;
if (!onSelectedRowKeysChange) {
return;
}
if (selection === 'all') {
const disabledKeySet = new Set(Array.from(disabledRowKeys || []).map(String));
const disabledKeySet = new Set(
Array.from(disabledRowKeys || []).map(String),
);
onSelectedRowKeysChange(
rows
.map((row, index) => resolveRowKey(row, index, getRowKey))
......@@ -129,31 +255,37 @@ export function DataTable({
);
return;
}
onSelectedRowKeysChange(Array.from(selection).map(String));
};
return (
<div className={['relative min-w-0 w-full', className].filter(Boolean).join(' ')}>
<div className="relative min-w-0 w-full">
{isLoading && rows.length > 0 ? (
<div className="absolute inset-0 z-10 grid place-items-center" aria-label="数据加载中">
<div ref={gridViewportRef} className="relative min-w-0 w-full">
{isLoading ? (
<div
className="absolute inset-0 z-10 grid place-items-center"
aria-label="数据加载中"
>
<Spinner size="sm" />
</div>
) : null}
<DataGrid
showSelectionCheckboxes={selectionEnabled}
allowsColumnResize
allowsColumnResize={allowsColumnResize}
aria-label={ariaLabel}
columns={gridColumns}
contentClassName={wide ? 'min-w-max' : 'min-w-full'}
data={rows}
disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() => (
isLoading
? <DataTableLoadingState />
: <DataTableEmptyState title={emptyTitle} />
)}
renderEmptyState={() =>
isLoading ? (
<DataTableLoadingState />
) : (
<DataTableEmptyState title={emptyTitle} size={emptyStateSize} />
)
}
scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys}
selectionMode={selectionEnabled ? 'multiple' : 'none'}
......@@ -162,110 +294,366 @@ export function DataTable({
onRowAction={onRowAction}
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={selectionEnabled ? handleSelectionChange : undefined}
onSelectionChange={
selectionEnabled ? handleSelectionChange : undefined
}
/>
</div>
{selectionEnabled ? (
<ActionBar aria-label="批量操作" isOpen={selectedRowKeys.length > 0}>
<ActionBar.Prefix>
<Chip className="shrink-0 tabular-nums" size="sm">{selectedRowKeys.length}</Chip>
</ActionBar.Prefix>
<Separator orientation="vertical" />
<ActionBar.Content>
{renderSelectionActions
? renderSelectionActions(selectedRowKeys)
: <span className="text-sm">已选择当前页记录</span>}
</ActionBar.Content>
<Separator orientation="vertical" />
<ActionBar.Suffix>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="清空选择"
size="sm"
variant="ghost"
onPress={() => onSelectedRowKeysChange([])}
>
<Close size={16} />
</Button>
<Tooltip.Content>清空选择</Tooltip.Content>
</Tooltip>
</ActionBar.Suffix>
</ActionBar>
<DataTableActionBar
selectedCount={selectedRowKeys.length}
onClear={() => onSelectedRowKeysChange([])}
>
{renderSelectionActions ? (
renderSelectionActions(selectedRowKeys)
) : (
<span className="text-sm">已选择当前页记录</span>
)}
</DataTableActionBar>
) : null}
</div>
);
}
export function DataTableText({ children, className = '', showTooltip = false, text }) {
export function DataTableActionBar({
selectedCount,
onClear,
children,
ariaLabel = '批量操作',
}) {
return (
<ActionBar
aria-label={ariaLabel}
className="rc-data-grid-action-bar"
isOpen={selectedCount > 0}
>
<ActionBar.Prefix>
<Chip className="shrink-0 tabular-nums" size="sm">
{selectedCount}
</Chip>
</ActionBar.Prefix>
<Separator orientation="vertical" />
<ActionBar.Content>{children}</ActionBar.Content>
<Separator orientation="vertical" />
<ActionBar.Suffix>
<Button
isIconOnly
aria-label="清空选择"
size="sm"
variant="ghost"
onPress={onClear}
>
<Close size={16} />
</Button>
</ActionBar.Suffix>
</ActionBar>
);
}
export function DataTableText({
children,
text,
className = '',
showTooltip = false,
maxLines = 1,
truncate = true,
}) {
const fullText = String(text ?? children ?? '').trim();
const content = children ?? (fullText || '-');
const multilineClassName =
maxLines > 1
? `overflow-hidden whitespace-normal break-words [display:-webkit-box] [-webkit-box-orient:vertical] ${
maxLines === 3 ? '[-webkit-line-clamp:3]' : '[-webkit-line-clamp:2]'
} [overflow-wrap:anywhere]`
: '';
const trigger = showTooltip ? (
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={truncate && maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
) : (
<span className={[
maxLines > 1 ? multilineClassName : truncate ? 'truncate' : 'whitespace-normal break-words',
'block min-w-0 max-w-full',
truncate ? 'overflow-hidden' : 'overflow-visible',
className,
].filter(Boolean).join(' ')}>
{children ?? (fullText || '-')}
</span>
);
if (!showTooltip || !fullText || fullText === '-') {
return <span className={['block min-w-0 truncate', className].filter(Boolean).join(' ')}>{content}</span>;
return trigger;
}
return (
<DataTableTextTooltipTrigger
className={[multilineClassName, className].filter(Boolean).join(' ')}
text={fullText}
truncate={truncate && maxLines === 1}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
);
}
function DataTableTextTooltipTrigger({
children,
className = '',
text,
truncate = true,
}) {
return (
<Tooltip>
<Tooltip.Trigger className={['block min-w-0 truncate', className].filter(Boolean).join(' ')}>
{content}
<Tooltip.Trigger
className={[
'block min-w-0 max-w-full overflow-hidden',
truncate ? 'min-w-0 truncate' : '',
className,
].filter(Boolean).join(' ')}
>
{children}
</Tooltip.Trigger>
<Tooltip.Content>
<span className="block max-w-[min(32rem,calc(100vw-2rem))] whitespace-pre-wrap break-words">
{text}
</span>
</Tooltip.Content>
</Tooltip>
);
}
export function DataTablePrimaryText({
children,
text,
className = '',
showTooltip = false,
}) {
return (
<DataTableText
text={text}
showTooltip={showTooltip}
className={['font-medium', className].filter(Boolean).join(' ')}
>
{children}
</DataTableText>
);
}
export function DataTablePrimaryAction({
children,
text,
className = '',
onPress,
onClick,
isDisabled = false,
disabled = false,
ariaLabel,
}) {
const fullText = String(text ?? children ?? '').trim() || '-';
const handlePress = onPress || onClick;
return (
<Button
aria-label={ariaLabel || fullText}
className={className}
isDisabled={isDisabled || disabled}
size="sm"
variant="ghost"
onPress={handlePress}
>
<span className="min-w-0 truncate">
{children ?? fullText}
</span>
</Button>
);
}
export function DataTableSecondaryText({
children,
text,
className = '',
showTooltip = false,
}) {
return (
<DataTableText
text={text}
showTooltip={showTooltip}
className={['text-sm text-muted', className]
.filter(Boolean)
.join(' ')}
>
{children}
</DataTableText>
);
}
export function DataTableTextPair({
primary,
secondary,
className = '',
primaryClassName = '',
secondaryClassName = '',
}) {
const primaryText = String(primary ?? '').trim() || '-';
const hasSecondary =
secondary !== undefined && secondary !== null && secondary !== '';
const secondaryText = hasSecondary ? String(secondary).trim() : '';
const tooltipText = [primaryText, secondaryText].filter(Boolean).join('\n');
return (
<DataTableTextTooltipTrigger
className={['flex min-w-0 flex-col', className]
.filter(Boolean)
.join(' ')}
text={tooltipText}
truncate={false}
>
<span
className={['min-w-0 truncate font-medium', primaryClassName]
.filter(Boolean)
.join(' ')}
>
{primaryText}
</span>
{hasSecondary ? (
<span
className={[
'min-w-0 truncate text-sm text-muted',
secondaryClassName,
]
.filter(Boolean)
.join(' ')}
>
{secondaryText}
</span>
) : null}
</DataTableTextTooltipTrigger>
);
}
export function DataTablePersonIdentity({ name, account, className = '' }) {
const primaryText = String(name || '').trim() || '-';
const secondaryText = String(account || '').trim();
return (
<DataTableTextPair
primary={primaryText}
secondary={secondaryText}
className={className}
/>
);
}
export function DataTableChip({ children, text, className = '' }) {
const fullText = String(text ?? children ?? '').trim();
const trigger = <Chip className={className} size="sm">{children ?? (fullText || '-')}</Chip>;
if (!fullText || fullText === '-' || fullText.length <= 18) {
return trigger;
}
return (
<Tooltip delay={0}>
{trigger}
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
);
}
export function DataTablePagination({
isDisabled = false,
itemLabel = '条',
onPageChange,
page,
pageSize,
pageSizeOptions = [],
total,
onPageChange,
onPageSizeChange,
isDisabled = false,
itemLabel = '条',
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageWindowStart = Math.max(0, Math.min(page - 2, totalPages - 5));
const pageWindowEnd = total ? Math.min(totalPages, pageWindowStart + 5) : pageWindowStart;
const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart;
const normalizedPageSizeOptions = pageSizeOptions
.map((value) => Number(value))
.filter((value, index, values) => Number.isFinite(value) && value > 0 && values.indexOf(value) === index);
const pageSizeOptionsForPicker = normalizedPageSizeOptions.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}));
const hasPageSizePicker =
pageSizeOptionsForPicker.length > 0 && typeof onPageSizeChange === 'function';
return (
<Pagination className="w-full" size="sm">
<Pagination.Summary>
共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
isDisabled={isDisabled || page <= 0}
onPress={() => onPageChange(page - 1)}
>
<Pagination.PreviousIcon />
<span>上一页</span>
</Pagination.Previous>
</Pagination.Item>
{Array.from({ length: pageWindowEnd - pageWindowStart }, (_, index) => {
const pageIndex = pageWindowStart + index;
return (
<Pagination.Item key={pageIndex}>
<Pagination.Link
isActive={pageIndex === page}
isDisabled={isDisabled}
onPress={() => onPageChange(pageIndex)}
>
{pageIndex + 1}
</Pagination.Link>
</Pagination.Item>
);
})}
<Pagination.Item>
<Pagination.Next
isDisabled={isDisabled || page + 1 >= totalPages || total === 0}
onPress={() => onPageChange(page + 1)}
>
<span>下一页</span>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 shrink-0 items-center gap-3">
<span className="min-w-0 text-sm text-muted">
共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
</span>
{hasPageSizePicker ? (
<XinyuanAutocomplete
aria-label="每页条数"
className="w-24 shrink-0"
isDisabled={isDisabled}
options={pageSizeOptionsForPicker}
placeholder="每页条数"
selectedKey={String(pageSize)}
showSearch={false}
valueText={`${pageSize} 条/页`}
variant="secondary"
onSelectionChange={(key) => {
const nextPageSize = Number(key);
if (normalizedPageSizeOptions.includes(nextPageSize)) {
onPageSizeChange(nextPageSize);
}
}}
/>
) : null}
</div>
<Pagination className="ml-auto w-auto shrink-0" size={hasPageSizePicker ? 'lg' : 'sm'}>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
isDisabled={isDisabled || page <= 0}
onPress={() => onPageChange(page - 1)}
>
<Pagination.PreviousIcon />
<span>上一页</span>
</Pagination.Previous>
</Pagination.Item>
{Array.from(
{ length: pageWindowEnd - pageWindowStart },
(_, index) => {
const pageIndex = pageWindowStart + index;
return (
<Pagination.Item key={pageIndex}>
<Pagination.Link
isActive={pageIndex === page}
isDisabled={isDisabled}
onPress={() => onPageChange(pageIndex)}
>
{pageIndex + 1}
</Pagination.Link>
</Pagination.Item>
);
},
)}
<Pagination.Item>
<Pagination.Next
isDisabled={isDisabled || page + 1 >= totalPages || total === 0}
onPress={() => onPageChange(page + 1)}
>
<span>下一页</span>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
</div>
);
}
import {
Calendar as HeroCalendar,
CloseButton,
DateField,
DatePicker,
I18nProvider,
......@@ -10,9 +9,9 @@ import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useMemo } from 'react';
import {
Calendar as CalendarIcon,
Close,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
import XinyuanClearButton from './XinyuanClearButton';
function parseValue(value, mode) {
if (!value) {
......@@ -125,16 +124,10 @@ export default function FormDatePicker({
</DateField.Input>
<DateField.Suffix className="absolute inset-y-0 end-1 mr-0 gap-0">
{canClear ? (
<CloseButton
aria-label={`清空${ariaLabel || '日期'}`}
<XinyuanClearButton
ariaLabel={`清空${ariaLabel || '日期'}`}
onPress={() => onChange?.('')}
>
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</CloseButton>
/>
) : null}
<DatePicker.Trigger
aria-label="打开日期选择"
......@@ -142,7 +135,7 @@ export default function FormDatePicker({
>
<DatePicker.TriggerIndicator>
<CalendarIcon
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
className="inline-flex size-4 items-center justify-center leading-none [&>svg]:m-0 [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
......
......@@ -146,7 +146,7 @@ export function TableActionButton({
size="sm"
variant={variant}
onPress={onPress}
className={clsx('grid place-items-center p-0', className)}
className={clsx('rc-table-action-button grid place-items-center p-0', className)}
>
<Icon size={16} />
</Button>
......
import { Check, ChevronDown, Close, FIELD_SUFFIX_ICON_STYLE, Search } from '@/components/AppIcons';
import { Autocomplete, Label, ListBox, SearchField, Spinner } from '@heroui/react';
import XinyuanClearButton from './XinyuanClearButton';
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
......@@ -15,6 +16,7 @@ export default function XinyuanAutocomplete({
onInputChange, onOpenChange, onSelectionChange, options = [], placeholder = '请选择', placement = 'bottom start',
popoverHeader, renderOption, searchPlaceholder = '输入关键词搜索', selectedKey, selectedKeys,
selectionMode = 'single', shouldFilter = true, valueText, variant = 'secondary',
showSearch = true, isClearable = false,
}) {
const effectiveAriaLabel = ariaLabelProp || ariaLabel || placeholder;
const selectedKeySet = selectionMode === 'multiple'
......@@ -23,7 +25,9 @@ export default function XinyuanAutocomplete({
const selectedKeyStringSet = new Set(Array.from(selectedKeySet).map(toSelectionKey));
const selectedOptions = options.filter((option) => selectedKeyStringSet.has(toSelectionKey(optionKey(option))));
const resolvedValueText = valueText ?? (selectedOptions.length ? selectedOptions.map((option) => option.label).join('、') : placeholder);
const rootSelectionProps = selectionMode === 'multiple' ? { selectedKeys } : { selectedKey: selectedKey || null };
const rootSelectionProps = selectionMode === 'multiple'
? { selectedKeys: selectedKeyStringSet }
: { selectedKey: selectedKey ? toSelectionKey(selectedKey) : null };
const listBoxSelectionProps = selectionMode === 'multiple' ? { selectionMode, selectedKeys: selectedKeyStringSet } : {};
const visibleOptions = isLoading || errorText ? [] : options;
......@@ -43,13 +47,13 @@ export default function XinyuanAutocomplete({
<Autocomplete {...rootSelectionProps} aria-label={effectiveAriaLabel} className={className} fullWidth={fullWidth} isDisabled={isDisabled} isOpen={isOpen} isRequired={isRequired} name={name} selectionMode={selectionMode} variant={variant} onOpenChange={onOpenChange} onSelectionChange={onSelectionChange}>
<Autocomplete.Trigger>
<Autocomplete.Value><span className="block min-w-0 truncate" title={resolvedValueText}>{resolvedValueText}</span></Autocomplete.Value>
{selectionMode === 'single' && selectedKey ? <Autocomplete.ClearButton aria-label="清空选择" onClick={() => onSelectionChange?.('')}><Close className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4" size={16} style={FIELD_SUFFIX_ICON_STYLE} /></Autocomplete.ClearButton> : null}
{isClearable && selectionMode === 'single' && selectedKey ? <XinyuanClearButton ariaLabel="清空选择" onPress={() => onSelectionChange?.('')} /> : null}
<Autocomplete.Indicator><ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} /></Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter filter={shouldFilter ? filter || defaultFilter : () => true} inputValue={inputValue} onInputChange={onInputChange}>
{popoverHeader}
<SearchField aria-label={searchPlaceholder} fullWidth variant={variant}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input autoFocus placeholder={searchPlaceholder} /><SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField>
{showSearch ? <SearchField aria-label={searchPlaceholder} fullWidth variant={variant}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input autoFocus placeholder={searchPlaceholder} /><SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField> : null}
<ListBox {...listBoxSelectionProps} aria-label={effectiveAriaLabel} items={visibleOptions} renderEmptyState={() => <div className="grid min-h-20 place-items-center px-3 py-4" data-slot="empty-state">{isLoading ? <Spinner size="sm" /> : errorText || emptyText}</div>}>
{(option) => {
const isSelected = selectedKeyStringSet.has(toSelectionKey(optionKey(option)));
......
import { Button } from '@heroui/react';
import { Close, FIELD_SUFFIX_ICON_STYLE } from './AppIcons';
export default function XinyuanClearButton({ ariaLabel, onPress, isDisabled = false }) {
return (
<Button
aria-label={ariaLabel}
className="size-5 min-w-5 p-0"
isDisabled={isDisabled}
isIconOnly
size="sm"
variant="ghost"
onClick={(event) => event.stopPropagation()}
onPress={onPress}
>
<Close
className="inline-flex size-3.5 items-center justify-center leading-none [&>svg]:m-0 [&>svg]:block [&>svg]:size-3.5"
size={14}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</Button>
);
}
import { getXinyuanEChartsTheme } from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: { color: theme.axis, fontFamily: theme.fontFamily, fontSize: 12, ...item?.axisLabel },
axisLine: { ...item?.axisLine, lineStyle: { color: theme.grid, ...item?.axisLine?.lineStyle } },
axisTick: { ...item?.axisTick, lineStyle: { color: theme.grid, ...item?.axisTick?.lineStyle } },
splitLine: { ...item?.splitLine, lineStyle: { color: theme.grid, ...item?.splitLine?.lineStyle } },
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
export default function XinyuanEChart({ ariaLabel, className = '', height = 320, option }) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' }), []);
useEffect(() => { window.addEventListener('resize', hideTooltip); return () => window.removeEventListener('resize', hideTooltip); }, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applySeries = (series) => series?.type === 'pie' ? ({ ...series, itemStyle: { borderColor: theme.sectorGap, borderRadius: theme.sectorRadius, borderWidth: 2, ...series.itemStyle }, padAngle: series.padAngle ?? 1 }) : series;
const series = Array.isArray(option?.series) ? option.series.map(applySeries) : applySeries(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: { enabled: true, decal: { show: false }, ...option?.aria },
color: option?.color || theme.palette,
textStyle: { color: theme.label, fontFamily: theme.fontFamily, ...option?.textStyle },
...option,
series,
legend: option?.legend ? { icon: 'circle', itemHeight: 8, itemWidth: 8, ...option.legend, textStyle: { color: theme.label, fontFamily: theme.fontFamily, fontSize: 12, lineHeight: 16, ...option.legend?.textStyle } } : undefined,
tooltip: { appendToBody: true, backgroundColor: theme.tooltipBackground, borderColor: theme.tooltipBorder, borderWidth: 1, className: 'rc-echarts-tooltip', confine: true, renderMode: 'html', extraCssText: `border-radius: 12px; box-shadow: ${theme.tooltipShadow};`, ...option?.tooltip, textStyle: { color: theme.foreground, fontFamily: theme.fontFamily, fontSize: 12, ...option?.tooltip?.textStyle } },
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
return <div aria-label={ariaLabel} className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()} role="img" tabIndex={0}><ReactECharts lazyUpdate notMerge onEvents={{ globalout: hideTooltip }} option={themedOption} opts={{ renderer: 'canvas' }} ref={chartRef} style={{ height, width: '100%' }} /></div>;
}
......@@ -36,7 +36,7 @@ export default function XinyuanModal({
<Modal.Dialog
aria-label={title}
className={clsx(
'xy-modal-dialog p-7',
'xy-modal-dialog bg-background p-7',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
......
......@@ -7,6 +7,35 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]);
// Approved categorical sequence for charts where each category is independently meaningful.
// Keep the semantic series palette above unchanged for single-metric charts.
export const XINYUAN_CHART_CATEGORY_PALETTE = Object.freeze([
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
'var(--rc-chart-4)',
'var(--rc-chart-9)',
'var(--rc-chart-11)',
'var(--rc-chart-7)',
'var(--rc-chart-16)',
'var(--rc-chart-5)',
'var(--rc-chart-6)',
'var(--rc-chart-8)',
'var(--rc-chart-10)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
......@@ -29,73 +58,115 @@ function hashChartKey(key) {
);
}
export function getXinyuanChartColor(key, fallbackIndex = 0) {
const stableKey = normalizeChartKey(key);
if (!stableKey) {
return XINYUAN_CHART_PALETTE[fallbackIndex % XINYUAN_CHART_PALETTE.length];
}
return XINYUAN_CHART_PALETTE[hashChartKey(stableKey) % XINYUAN_CHART_PALETTE.length];
}
export function createXinyuanChartColorMap(keys = [], options = {}) {
const sourceKeys = Array.isArray(keys) ? keys : [];
const order = Array.isArray(options?.order) ? options.order : [];
const palette = Array.isArray(options?.palette) && options.palette.length > 0
? options.palette
: XINYUAN_CHART_PALETTE;
const orderedKeys = [...new Set(sourceKeys.map(normalizeChartKey).filter(Boolean))];
const preferredOrder = new Map(
order.map(normalizeChartKey).filter(Boolean).map((key, index) => [key, index]),
);
const stableKeys = orderedKeys.sort((left, right) => {
const leftOrder = preferredOrder.has(left)
? preferredOrder.get(left)
: Number.MAX_SAFE_INTEGER;
const rightOrder = preferredOrder.has(right)
? preferredOrder.get(right)
: Number.MAX_SAFE_INTEGER;
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort();
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.localeCompare(right, 'zh-CN');
});
const unknownPositions = new Map(
stableKeys
.filter((key) => !preferredOrder.has(key))
.map((key, index) => [key, index]),
);
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
stableKeys.forEach((key) => {
const hasExplicitOrder = preferredOrder.size > 0;
let paletteIndex = hasExplicitOrder && preferredOrder.has(key)
? preferredOrder.get(key) % palette.length
: hasExplicitOrder
? preferredOrder.size + unknownPositions.get(key)
: hashChartKey(key) % palette.length;
if (keyIndex < XINYUAN_CHART_PALETTE.length) {
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % XINYUAN_CHART_PALETTE.length;
if (
paletteIndex >= palette.length
|| usedIndexes.has(paletteIndex)
) {
const preferredIndex = hashChartKey(key) % palette.length;
paletteIndex = preferredIndex;
for (let offset = 0; offset < palette.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % palette.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
colorMap.set(key, XINYUAN_CHART_PALETTE[paletteIndex]);
usedIndexes.add(paletteIndex);
colorMap.set(key, palette[paletteIndex]);
});
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') return false;
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
return window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim() || fallback;
}
export function colorizeXinyuanChartTooltipPayload(payload = [], resolveColor) {
return payload.map((entry, index) => {
const rechartsPayload = entry?.payload || {};
const sourceDatum = rechartsPayload?.payload || rechartsPayload;
const key = sourceDatum?.name
?? rechartsPayload?.name
?? entry?.name
?? entry?.dataKey
?? '';
const color = [
resolveColor?.({ entry, index, key, sourceDatum }),
rechartsPayload?.fill,
sourceDatum?.fill,
entry?.fill,
entry?.color,
entry?.stroke,
getXinyuanChartColor(key, index),
].find(isVisibleChartColor);
return {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
};
});
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match ? readRootToken(match[1], fallback) : String(color || fallback);
}
export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
return new Map(
Array.from(createXinyuanChartColorMap(keys, options)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
fontFamily: documentFont || 'Inter, sans-serif',
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')
)),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
sectorGap: readRootToken('--rc-chart-sector-gap', '#ffffff'),
sectorRadius: Number.parseFloat(
readRootToken('--rc-chart-sector-radius', '10'),
) || 10,
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
}
......@@ -435,6 +435,41 @@
gap: var(--rc-page-module-gap);
}
.rc-section-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1.125rem;
font-weight: var(--rc-font-bold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-module-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1rem;
font-weight: var(--rc-font-semibold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-modal-header-actions {
align-self: flex-start;
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-start: auto;
max-width: 100%;
}
@media (max-width: 40rem) {
.rc-modal-header-actions {
flex-basis: 100%;
}
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
......@@ -453,7 +488,7 @@
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
/* Charts keep keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
......@@ -465,6 +500,10 @@
outline-offset: 2px;
}
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
......@@ -509,10 +548,33 @@
box-shadow: var(--rc-shadow-card);
}
.rc-filter-surface {
border-radius: var(--rc-radius-overlay);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
.rc-workspace-module {
border-radius: 1.5rem;
border: 1px solid var(--border);
background: transparent;
box-shadow: none;
}
.rc-empty-state-message {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
.rc-empty-field-text {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
......@@ -650,28 +712,132 @@
align-items: stretch;
}
.xy-modal-dialog {
min-width: 0;
.rc-list-stack,
.rc-list-data-stack {
gap: var(--rc-list-stack-gap);
}
/* Keep long data sets inside the table while the column headings remain visible. */
.rc-data-table-sticky .data-table-scroll {
position: relative;
max-height: min(60dvh, 42rem);
overflow-x: auto;
overflow-y: auto;
overscroll-behavior: contain;
}
.xy-app-switcher-dialog {
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
/* DataGrid adds a non-scrolling resize wrapper. Let sticky headers use the
outer scroll container as their containing block instead of this wrapper. */
.rc-data-table-sticky .table__resizable-container {
overflow: visible;
}
.xy-app-identity-well,
.xy-app-identity-avatar {
border-radius: var(--rc-radius-control);
.rc-data-table-sticky [data-slot='table-header'] .table__column {
position: sticky;
top: 0;
inset-block-start: 0;
z-index: 4;
display: table-cell;
color: var(--muted);
background: var(--surface-secondary);
box-shadow: inset 0 -1px 0 var(--separator);
}
.xy-app-identity-avatar {
background: transparent;
.rc-data-table-sticky [data-slot='table-header'] .table__column.bg-accent-soft {
background: var(--accent-soft);
color: var(--accent-soft-foreground);
}
.rc-list-stack,
.rc-list-data-stack {
gap: var(--rc-list-stack-gap);
/* The leading pinned column must sit above scrolling body cells. Keep its
surface opaque so text from adjacent columns cannot bleed through while
horizontal or vertical scrolling. */
.rc-data-table-sticky [data-pinned='start'] {
position: sticky;
z-index: 6;
background: var(--surface);
}
.rc-data-table-sticky [data-slot='table-header'] [data-pinned='start'] {
z-index: 8;
background: var(--surface-secondary);
}
/* Mirror HeroUI's row states on the opaque pinned surface so the leading
column still responds to hover and selection instead of looking detached. */
@media (hover: hover) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
/* Keep the pinned cell opaque; transparent hover fills let scrolled
sibling-cell text show through the fixed column. */
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row:hover
[data-pinned='start'].table__cell,
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-hovered='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 40%,
var(--color-surface-secondary)
);
}
}
}
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: var(--color-surface-secondary);
}
@supports (color: color-mix(in oklab, white, black)) {
.rc-data-table-sticky [data-slot='data-grid']
.table__row[data-selected='true']
[data-pinned='start'].table__cell {
background-color: color-mix(
in oklab,
var(--color-surface) 10%,
var(--color-surface-secondary)
);
}
}
@media (max-width: 48rem) {
.rc-data-table-sticky .data-table-scroll {
max-height: min(54dvh, 32rem);
}
}
/* Table row actions stay circular and keep a stable hit target when the
* DataGrid calculates its action column width. */
.rc-table-action-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);
min-height: var(--rc-navbar-icon-button-size);
max-height: var(--rc-navbar-icon-button-size);
flex: 0 0 var(--rc-navbar-icon-button-size);
border-radius: 999px !important;
}
@media (min-width: 769px) {
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline: 0;
}
body:has(.rc-app-shell[data-state='expanded'])
.action-bar:has(.rc-data-grid-action-bar) {
inset-inline-start: var(--rc-sidebar-width);
}
}
@media (min-width: 80rem) {
......@@ -881,6 +1047,23 @@
font-weight: var(--rc-font-semibold);
}
.rc-application-identity-well {
display: grid;
width: 2.25rem;
height: 2.25rem;
flex: none;
place-items: center;
overflow: hidden;
border-radius: var(--rc-radius-control);
}
.rc-application-identity-avatar {
width: 100%;
height: 100%;
border-radius: var(--rc-radius-control);
background: transparent;
}
.rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
......@@ -1204,12 +1387,12 @@
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
color: var(--rc-sidebar-foreground);
background-color: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
--button-fg: var(--foreground);
--button-bg-hover: var(--rc-sidebar-item-selected);
--button-bg-pressed: var(--rc-sidebar-item-selected);
--button-fg: var(--rc-sidebar-item-selected-foreground);
}
.app-sidebar-app-trigger .anticon {
......@@ -1229,16 +1412,16 @@
@media (hover: hover) {
.app-sidebar-app-trigger.button:hover,
.app-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
}
.app-sidebar-app-trigger.button:active,
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
.app-sidebar-app-dialog {
......@@ -1422,3 +1605,89 @@
border-right: 0;
}
}
/* Fixed split-shell theme: dark navigation, permanently light content canvas. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
color: var(--rc-sidebar-foreground);
border-color: var(--rc-sidebar-border) !important;
background-color: var(--rc-sidebar-background) !important;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
background: var(--rc-sidebar-item-selected);
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content *,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content * {
color: var(--rc-sidebar-item-selected-foreground) !important;
}
.template-dashboard-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--rc-sidebar-item-hover);
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'],
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
background: var(--rc-sidebar-item-selected);
}
.app-sidebar-user-avatar,
.app-sidebar-app-trigger.button,
.app-sidebar-user-trigger.dropdown__trigger {
color: var(--rc-sidebar-foreground);
background: transparent;
}
.app-sidebar-user-avatar {
background: var(--rc-sidebar-item-hover);
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label,
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon,
.app-sidebar-user-description {
color: var(--rc-sidebar-muted);
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.app-sidebar-title,
.app-sidebar-user-name,
.app-sidebar-app-trigger.button {
color: var(--rc-sidebar-foreground);
}
.app-sidebar-user-trigger.dropdown__trigger:focus-visible,
.app-sidebar-app-trigger.button:focus-visible,
.template-dashboard-shell .sidebar__menu-item:focus-visible,
.rc-mobile-sidebar .sidebar__menu-item:focus-visible {
outline: 2px solid var(--rc-sidebar-focus);
outline-offset: 2px;
}
......@@ -11,38 +11,37 @@
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent: oklch(57.14% 0.2156 29.20);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(94.00% 0.0000 29.20);
--default-foreground: oklch(21.03% 0.0059 29.20);
--field-background: oklch(100.00% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
--field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: oklch(57.14% 0.2156 29.20);
--foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0000 29.20);
--overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
......@@ -51,42 +50,86 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: oklch(78% 0.105 29.20);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
--xinyuan-chart-1: #d92d20;
--xinyuan-chart-2: #ef4444;
--xinyuan-chart-3: #f97316;
--xinyuan-chart-4: #f59e0b;
--xinyuan-chart-5: #22c55e;
--xinyuan-chart-6: #14b87a;
--xinyuan-chart-7: #06b6d4;
--xinyuan-chart-8: #0ea5e9;
--xinyuan-chart-9: #3b82f6;
--xinyuan-chart-10: #6366f1;
--xinyuan-chart-11: #8b5cf6;
--xinyuan-chart-12: #7c3aed;
--xinyuan-chart-13: #b42318;
--xinyuan-chart-14: #84cc16;
--xinyuan-chart-15: #a855f7;
--xinyuan-chart-16: #64748b;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent: oklch(57.14% 0.2156 29.20);
--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);
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(27.40% 0.0000 29.20);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
--field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: oklch(57.14% 0.2156 29.20);
--foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0000 29.20);
--overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
......@@ -2,9 +2,7 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
[data-theme='default'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
......@@ -27,6 +25,17 @@
var(--background)
);
--rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
......@@ -45,6 +54,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
......@@ -78,8 +88,7 @@
--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-card: 0 1px 2px rgb(15 23 42 / 2%),
0 4px 12px rgb(15 23 42 / 2%);
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%), 0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
......@@ -138,22 +147,32 @@
);
/* 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-1: var(--xinyuan-chart-1);
--rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: var(--xinyuan-chart-7);
--rc-chart-8: var(--xinyuan-chart-8);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
--rc-chart-sector-gap: var(--surface);
--rc-chart-sector-radius: 10px;
}
.dark,
......
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import XinyuanModal from '@/components/XinyuanModal';
import { APP_NAME, APP_ROUTE_NAME } from '@/config/ApplicationConfig';
import xinyuanLogo from '@/assets/identity/xy-logo-red.png';
import {
APP_NAME,
APPLICATION_NAME,
} from '@/config/ApplicationConfig';
import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
ScrollShadow,
SearchField,
Skeleton,
Surface,
} from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { useEffect, useMemo, useState } from 'react';
const APP_DISPLAY_NAME = APP_NAME;
const APP_LOGIN_SUBTITLE = '党风廉政工作台';
const CURRENT_APPLICATION = {
name: APP_ROUTE_NAME,
title: APP_DISPLAY_NAME,
description: APP_LOGIN_SUBTITLE,
logo: xyLogoRed,
};
const CURRENT_APPLICATION_NAME = APPLICATION_NAME;
function unwrapApplications(response) {
const candidates = [
response,
response?.data,
response?.applications,
response?.apps,
response?.records,
response?.rows,
response?.list,
response?.result,
];
return candidates.find(Array.isArray) || [];
}
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
......@@ -49,45 +63,28 @@ function resolveApplicationUrl(application) {
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || application?.app_name || fallback?.name || '';
const isCurrentSystem = name === APP_ROUTE_NAME;
const name = application?.name || fallback?.name || '';
const current = name === CURRENT_APPLICATION_NAME;
return {
name,
title: isCurrentSystem
? APP_DISPLAY_NAME
: application?.title ||
application?.display_name ||
fallback?.title ||
application?.name ||
fallback?.name ||
'未命名应用',
description: isCurrentSystem
? APP_LOGIN_SUBTITLE
: application?.description ||
application?.display_name ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: isCurrentSystem
? xyLogoRed
: application?.logo ||
application?.logo_url ||
application?.icon ||
fallback?.logo ||
null,
title: current
? APP_NAME
: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: xinyuanLogo,
url: resolveApplicationUrl({ name }),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
roles: normalizeLabelList(application?.roles?.length ? application.roles : fallback?.roles),
groups: normalizeLabelList(application?.groups?.length ? application.groups : fallback?.groups),
};
}
......@@ -111,12 +108,18 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
return;
}
mergedMap.set(
application.name,
normalizeApplication(application, application),
);
mergedMap.set(application.name, normalizeApplication(application, application));
});
const currentApplication = mergedMap.get(CURRENT_APPLICATION_NAME);
mergedMap.set(
CURRENT_APPLICATION_NAME,
normalizeApplication(
{ name: CURRENT_APPLICATION_NAME },
currentApplication || { title: APP_NAME },
),
);
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
......@@ -127,28 +130,6 @@ function mergeApplications(fetchedApplications, fallbackApplications) {
});
}
function unwrapApplications(response) {
const candidates = [
response,
response?.data,
response?.applications,
response?.result,
response?.list,
response?.records,
response?.items,
response?.data?.applications,
response?.data?.list,
response?.data?.records,
response?.data?.items,
response?.result?.applications,
response?.result?.list,
response?.result?.records,
response?.result?.items,
];
return candidates.find(Array.isArray) || [];
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [{ key: 'all', label: '全部应用', applications }];
......@@ -201,6 +182,10 @@ function buildApplicationSections(applications, applicationGroups) {
}
function isCurrentApplication(application) {
if (application?.name === CURRENT_APPLICATION_NAME) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
......@@ -225,76 +210,78 @@ function isCurrentApplication(application) {
);
}
function markCurrentApplication(applications) {
if (!Array.isArray(applications) || applications.length === 0) {
return [];
}
const currentApplication = applications.find(isCurrentApplication);
return applications.map((application) => ({
...application,
current: currentApplication?.name === application.name,
}));
}
function ApplicationCard({ application }) {
const current = application.current === true;
function ApplicationCard({ application, current }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
const description = application.description?.trim();
const hasDistinctDescription =
description && description !== application.title.trim();
return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
href={application.url}
rel={target ? 'noreferrer' : undefined}
target={target}
>
<Surface aria-hidden="true" className="xy-app-identity-well grid size-9 shrink-0 place-items-center overflow-hidden" variant="tertiary">
<Avatar
className="xy-app-identity-avatar size-full"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image alt="" className="object-contain p-1.5" src={application.logo} />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-5" title={application.title}>
{application.title}
</span>
<Description className="mt-0.5 block truncate leading-4" title={application.description}>
{application.description || application.name || '应用'}
</Description>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
<ItemCard
aria-current={current ? 'page' : undefined}
className="h-full min-w-0 no-underline"
href={application.url}
rel={target ? 'noreferrer' : undefined}
render={(props) => <a {...props} />}
target={target}
variant={current ? 'outline' : 'default'}
>
<ItemCard.Icon aria-hidden="true">
<Surface variant="default" className="rc-application-identity-well">
<Avatar
className="rc-application-identity-avatar"
color="accent"
variant="soft"
>
<Avatar.Image
alt=""
className="object-contain p-1.5"
src={application.logo || xinyuanLogo}
/>
<Avatar.Fallback>
<img alt="" className="object-contain p-1.5" src={xinyuanLogo} />
</Avatar.Fallback>
</Avatar>
</Surface>
</ItemCard.Icon>
<ItemCard.Content>
<ItemCard.Title title={application.title}>
{application.title}
</ItemCard.Title>
{hasDistinctDescription ? (
<ItemCard.Description title={description}>
{description}
</ItemCard.Description>
) : null}
</ItemCard.Content>
{current ? (
<ItemCard.Action>
<Chip color="accent" size="sm">当前</Chip>
</ItemCard.Action>
) : null}
</ItemCard>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
<ItemCardGroup
aria-label="正在加载应用"
className="grid-cols-1 gap-3 sm:grid-cols-2"
columns={2}
layout="grid"
variant="transparent"
>
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
<ItemCard key={index} variant="default">
<ItemCard.Icon><Skeleton className="size-9" /></ItemCard.Icon>
<ItemCard.Content className="gap-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</ItemCard.Content>
</ItemCard>
))}
</div>
</ItemCardGroup>
);
}
......@@ -305,49 +292,66 @@ export default function ApplicationSwitcherDialog({
userInfo,
}) {
const fallbackApplications = useMemo(() => {
const userApplications = Array.isArray(userInfo?.applications)
? userInfo.applications
: [];
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return mergeApplications(
userApplications.map((application) =>
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
),
[CURRENT_APPLICATION],
);
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(() =>
markCurrentApplication(fallbackApplications),
mergeApplications([], fallbackApplications),
);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
}, [applications]);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
const filteredApplicationSections = useMemo(() => {
const keyword = applicationSearch.trim().toLocaleLowerCase();
if (!keyword) {
return applicationSections;
}
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) => [
application.title,
application.description,
application.name,
].some((value) => String(value || '').toLocaleLowerCase().includes(keyword))),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return markCurrentApplication(fallbackApplications);
});
setApplications((previous) =>
mergeApplications(previous, fallbackApplications),
);
}, [fallbackApplications]);
useEffect(() => {
if (!isOpen) {
setApplicationSearch('');
return;
}
......@@ -370,14 +374,14 @@ export default function ApplicationSwitcherDialog({
fallbackApplications,
);
setApplications(markCurrentApplication(mergedApplications));
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(markCurrentApplication(fallbackApplications));
setApplications(mergeApplications([], fallbackApplications));
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
......@@ -391,56 +395,110 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications, isOpen]);
return (
<XinyuanModal
description="选择你可访问的应用"
dialogClassName="xy-app-switcher-dialog"
isOpen={isOpen}
size="xl"
title="应用切换"
onOpenChange={onOpenChange}
>
{status === 'error' && errorMessage ? (
<Alert status="warning">
<Alert.Indicator>
<ExclamationTriangleIcon size={20} />
</Alert.Indicator>
<Alert.Content>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="space-y-6">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium leading-5">{section.label}</h3>
<Description>{section.applications.length} 个应用</Description>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={isOpen}
onOpenChange={(isOpen) => {
onOpenChange?.(isOpen);
}}
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog
aria-label="应用切换"
className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))',
}}
>
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<div className="flex w-full min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SearchField
aria-label="搜索应用"
className="w-full min-w-0 max-w-full sm:flex-1 sm:w-auto"
value={applicationSearch}
variant="secondary"
onChange={setApplicationSearch}
>
<SearchField.Group>
<SearchField.SearchIcon>
<MagnifyingGlassIcon size={16} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="搜索应用" />
<SearchField.ClearButton aria-label="清空应用搜索" />
</SearchField.Group>
</SearchField>
</div>
</section>
))}
</div>
) : (
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>
当前账号还没有分配其它应用权限。
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</XinyuanModal>
</Modal.Header>
<Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow
className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1"
hideScrollBar={false}
offset={12}
size={24}
>
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
<div className="space-y-6">
{filteredApplicationSections.map((section) => (
<ItemCardGroup
key={section.key}
className="grid-cols-1 gap-3 sm:grid-cols-2"
columns={2}
layout="grid"
variant="transparent"
>
<ItemCardGroup.Header className="flex items-center justify-between gap-3">
<ItemCardGroup.Title>{section.label}</ItemCardGroup.Title>
<ItemCardGroup.Description className="mt-0">
{section.applications.length} 个应用
</ItemCardGroup.Description>
</ItemCardGroup.Header>
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
current={application.name === currentApplicationName}
/>
))}
</ItemCardGroup>
))}
</div>
) : (
<EmptyStateSurface>
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>
{applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}
</EmptyState.Title>
<EmptyState.Description>
{applicationSearch.trim()
? '请尝试其他关键词。'
: '当前账号还没有分配其它应用权限。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)}
</ScrollShadow>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
......@@ -8,6 +8,8 @@ import { Outlet, useLocation } from 'umi';
const menuGroups = [
{
title: '党风廉政工作台',
collapsible: false,
showLabel: false,
items: [
{
key: 'dashboard',
......
......@@ -45,15 +45,16 @@
4. 筛选区使用可展开的 Surface,统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选
5. 选择类筛选通过 Autocomplete 完成;页面不显示刷新或查询按钮,筛选条件变化后自动调用 `POST /record/query`,关键词输入采用约 300ms 延迟以减少重复请求;筛选 Surface 收起时只占主筛选行高度,点击展开后才增加溢出字段区域
6. 列表按 `updated_at`、`created_at` 倒序展示
- 列表标题仅展示业务主题,不在标题末尾拼接日期;历史记录若标题带有自动生成的日期后缀,仅在列表展示时去除,原始字段保持不变
7. 表格仅展示核心字段和操作入口,6 类台账统一不展示“附件状态”和“更新时间”列;附件完整清单在编辑弹窗中维护,更新时间继续作为后台排序字段使用
8. 表格采用固定列宽布局:日期、状态类字段使用紧凑宽度,主题和长文本字段使用较宽列并单行省略,完整内容通过悬停提示查看;列数较多时允许在表格区域横向滚动,避免内容覆盖操作入口
9. 完整字段在编辑弹窗中维护
10. DataGrid 固定每页展示 10 条,不提供页容量切换;通过底部 Pagination 翻页
11. 勾选记录后显示 ActionBar,集中提供导出勾选、批量修改和批量删除入口;清空选择后 ActionBar 关闭
12. Grid View `shortTitle=true` 时只隐藏标题列,标题数据和操作列入口保持不变;只有一个可访问 View 时不显示二级 View Tabs
13. 普通用户缺少 View 授权时不查询数据,也不显示新增、批量、选择或行操作;系统管理员及本应用“管理”角色按规则旁路
13. 存在真实 Grid View 时,普通用户仅可访问明确授权的 View;未配置 `viewAuth` 的历史 View 保持可见。Subject 完全没有 Grid View 时使用 legacy compatibility view 保持固定台账可用;系统管理员及本应用“管理”角色按规则旁路
14. 权限只读取稳定应用标识 `incorrupt_party_build` 对应的 `user.applications[]` 项;其它应用的角色、组和权限不参与判断
15. 没有真实可访问 Grid View 时不建立兜底视图,即使是管理员也不查询该 Subject
15. 后端存在 Grid View 但当前用户均无权访问时不查询该 Subject;仅在后端完全没有 Grid View 的固定台账使用 compatibility view,不伪造或覆盖真实 View 权限
### 4.2 手动新建台账
......
......@@ -63,10 +63,77 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
const options = materials.map((item) => ({ ...item, label: item.materialTitle, textValue: `${item.materialTitle} ${item.fileAsset?.name || ''}` }));
return <div className="space-y-4">
{files.length ? <div className="space-y-2">{files.map((file, index) => <div className="flex min-w-0 items-center justify-between gap-3" key={`${fileKey(file)}-${index}`}><div className="min-w-0"><div className="truncate text-sm font-medium">{file.name || file.url}</div><div className="mt-1 text-xs text-muted">{localKeys.has(fileKey(file)) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}</div></div><Tooltip><Button isIconOnly aria-label={`删除附件:${file.name || index + 1}`} isDisabled={disabled || uploading} size="sm" variant="danger-soft" onPress={() => removeFile(file, index)}><Delete size={16} /></Button><Tooltip.Content>删除当前附件</Tooltip.Content></Tooltip></div>)}</div> : <span className="text-sm text-muted">未上传</span>}
<Tabs aria-label="附件来源" selectedKey={tab} variant="secondary" onSelectionChange={(key) => setTab(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full"><Tabs.List><Tabs.Tab id="material-center">材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="local-upload">本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id="material-center"><XinyuanAutocomplete ariaLabel="选择审计材料" emptyText="没有找到可选择的审计材料" inputValue={keyword} isDisabled={disabled || uploading} isLoading={loading} options={options} placeholder={files.length && multiple ? `已选 ${files.length} 个附件,可继续添加` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" shouldFilter={false} variant="secondary" onInputChange={setKeyword} onSelectionChange={selectMaterial} renderOption={(item) => <span className="min-w-0"><Label className="block truncate">{item.materialTitle}</Label><span className="mt-0.5 block truncate text-xs text-muted">{[item.fileAsset?.name, item.archiveCategory, item.archiveYear].filter(Boolean).join(' · ')}</span></span>} />{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetry((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert> : null}</Tabs.Panel>
<Tabs.Panel className="pt-3" id="local-upload"><FileUploadField description={multiple ? '可选择多个文件;保存详情时同步至审计材料中心。' : '保存详情时同步至审计材料中心。'} disabled={disabled || uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={uploadLocal} />{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}</Tabs.Panel>
<Tabs
aria-label="附件来源"
className="w-full min-w-0"
selectedKey={tab}
variant="primary"
onSelectionChange={(key) => setTab(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id="material-center">
材料中心
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id="local-upload">
本地上传
<Tabs.Indicator />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id="material-center">
<XinyuanAutocomplete
ariaLabel="选择审计材料"
className="w-full min-w-0"
emptyText="没有找到可选择的审计材料"
inputValue={keyword}
isDisabled={disabled || uploading}
isLoading={loading}
options={options}
placeholder={files.length && multiple ? `已选 ${files.length} 个附件,可继续添加` : '请选择审计材料'}
searchPlaceholder="搜索材料标题或文件名"
shouldFilter={false}
variant="secondary"
onInputChange={setKeyword}
onSelectionChange={selectMaterial}
renderOption={(item) => (
<span className="min-w-0">
<Label className="block truncate">{item.materialTitle}</Label>
<span className="mt-0.5 block truncate text-xs text-muted">
{[item.fileAsset?.name, item.archiveCategory, item.archiveYear].filter(Boolean).join(' · ')}
</span>
</span>
)}
/>
{error ? (
<Alert className="mt-3" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetry((value) => value + 1)}>
<ArrowPathIcon size={14} />
重试
</Button>
</Alert>
) : null}
</Tabs.Panel>
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id="local-upload">
<FileUploadField
description={multiple ? '可选择多个文件;保存详情时同步至审计材料中心。' : '保存详情时同步至审计材料中心。'}
disabled={disabled || uploading}
multiple={multiple}
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={uploadLocal}
/>
{error ? (
<Alert className="mt-3" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Tabs.Panel>
</Tabs>
</div>;
}
......@@ -3,6 +3,7 @@ import {
ChevronDownIcon,
Delete,
Edit,
Eye,
Search,
} from '@/components/AppIcons';
import {
......@@ -72,7 +73,7 @@ function LedgerCategoryTab({ tabKey, label }) {
function FieldLabel({ field }) {
return (
<label className="mb-2 block text-sm font-medium">
<label className="mb-2 block text-sm font-medium text-muted">
{field.label}
{field.required ? <span className="ml-1 text-danger">*</span> : null}
</label>
......@@ -111,6 +112,58 @@ function formatCellValue(field, value) {
return value === null || value === undefined || value === '' ? '-' : String(value);
}
function ReadonlyFieldValue({ field, value }) {
const values = Array.isArray(value) ? value.filter((item) => item !== null && item !== undefined && item !== '') : [];
const isEmpty = values.length === 0 && (value === null || value === undefined || String(value).trim() === '');
if (field.type === 'file') {
const files = Array.isArray(value) ? value.filter(Boolean) : value ? [value] : [];
if (files.length === 0) {
return <span className="text-sm text-muted">暂无内容</span>;
}
return (
<div className="flex min-w-0 flex-wrap gap-2">
{files.map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return file?.url ? (
<a
className="text-sm text-accent no-underline hover:underline"
href={file.url}
key={`${label}-${index}`}
rel="noreferrer"
target="_blank"
>
{label}
</a>
) : (
<span className="text-sm text-foreground" key={`${label}-${index}`}>
{label}
</span>
);
})}
</div>
);
}
if (isEmpty) {
return <span className="text-sm text-muted">暂无内容</span>;
}
let displayValue = value;
if (field.type === 'boolean') {
displayValue = value === true || value === 'true' ? '是' : value === false || value === 'false' ? '否' : value;
} else if (Array.isArray(value)) {
displayValue = values.join('、');
}
return (
<span className="whitespace-pre-wrap text-sm text-foreground">
{String(displayValue)}
</span>
);
}
const TABLE_COLUMN_WIDTHS = {
sequence_no: 72,
year: 88,
......@@ -146,7 +199,11 @@ const TABLE_COLUMN_WIDTHS = {
const DEFAULT_TABLE_COLUMN_WIDTH = 156;
function FieldControl({ field, value, onChange, compact = false, disabled = false, editSessionId, onPendingMaterialChange }) {
function FieldControl({ field, value, onChange, compact = false, disabled = false, readOnly = false, editSessionId, onPendingMaterialChange }) {
if (readOnly || disabled) {
return <ReadonlyFieldValue field={field} value={value} />;
}
if (field.type === 'select') {
return (
<XinyuanAutocomplete
......@@ -154,6 +211,7 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
isRequired={field.required}
isDisabled={disabled}
options={(field.options || []).map((option) => ({
id: option,
label: option,
value: option,
}))}
......@@ -170,8 +228,8 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
ariaLabel={field.label}
isDisabled={disabled}
options={[
{ label: '是', value: 'true' },
{ label: '否', value: 'false' },
{ id: 'true', label: '是', value: 'true' },
{ id: 'false', label: '否', value: 'false' },
]}
placeholder={field.emptyLabel || '未填写'}
selectedKey={value === true ? 'true' : value === false ? 'false' : value || ''}
......@@ -225,11 +283,28 @@ function FieldControl({ field, value, onChange, compact = false, disabled = fals
);
}
function LedgerForm({ config, form, setForm, fields = null, compact = false, isFieldDisabled = () => false, editSessionId, onPendingMaterialChange }) {
function LedgerForm({ config, form, setForm, fields = null, compact = false, readOnly = false, recordTitle = '', showRecordTitle = false, isRecordTitleDisabled = false, onRecordTitleChange, isFieldDisabled = () => false, editSessionId, onPendingMaterialChange }) {
const visibleFields = fields || config.fields;
return (
<div className={clsx('grid gap-5', compact ? 'md:grid-cols-2' : 'md:grid-cols-2')}>
{showRecordTitle ? (
<div className="min-w-0 md:col-span-2">
<FieldLabel field={{ label: '记录标题' }} />
{readOnly || isRecordTitleDisabled ? (
<span className="whitespace-pre-wrap text-sm text-foreground">
{recordTitle || '暂无内容'}
</span>
) : (
<Input
fullWidth
value={recordTitle}
variant="secondary"
onChange={(event) => onRecordTitleChange?.(event.target.value)}
/>
)}
</div>
) : null}
{visibleFields.map((field) => (
<div
key={field.name}
......@@ -245,6 +320,7 @@ function LedgerForm({ config, form, setForm, fields = null, compact = false, isF
value={form[field.name]}
compact={compact}
disabled={isFieldDisabled(field)}
readOnly={readOnly}
editSessionId={editSessionId}
onPendingMaterialChange={onPendingMaterialChange}
onChange={(nextValue) =>
......@@ -287,7 +363,7 @@ export default function IntegrityPartyPage() {
const [records, setRecords] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const pageSize = DEFAULT_INTEGRITY_PAGE_SIZE;
const [pageSize, setPageSize] = useState(DEFAULT_INTEGRITY_PAGE_SIZE);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
......@@ -295,6 +371,7 @@ export default function IntegrityPartyPage() {
const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState('create');
const [activeRecord, setActiveRecord] = useState(null);
const [detailTitle, setDetailTitle] = useState('');
const [detailForm, setDetailForm] = useState(() => createEmptyLedgerForm(activeKey));
const [saving, setSaving] = useState(false);
const [importOpen, setImportOpen] = useState(false);
......@@ -372,7 +449,7 @@ export default function IntegrityPartyPage() {
return () => { cancelled = true; };
}, [activeKey, userInfo]);
async function reload(nextPage = page, nextFilters = filters[activeKey]) {
async function reload(nextPage = page, nextFilters = filters[activeKey], nextPageSize = pageSize) {
if (!canAccessLedger) {
setRecords([]);
setTotal(0);
......@@ -386,7 +463,7 @@ export default function IntegrityPartyPage() {
const result = await loadLedgerPage(activeKey, {
...nextFilters,
page: nextPage,
pageSize,
pageSize: nextPageSize,
});
if (requestId !== reloadRequestIdRef.current) {
return;
......@@ -426,6 +503,7 @@ export default function IntegrityPartyPage() {
setDetailSessionId((value) => value + 1);
setDetailMode('create');
setActiveRecord(null);
setDetailTitle('');
setDetailForm(createEmptyLedgerForm(activeKey));
setDetailOpen(true);
}
......@@ -435,6 +513,20 @@ export default function IntegrityPartyPage() {
setDetailSessionId((value) => value + 1);
setDetailMode('edit');
setActiveRecord(record);
setDetailTitle(record.displayTitle || record.title || '');
setDetailForm({
...createEmptyLedgerForm(activeKey),
...record.metadata,
});
setDetailOpen(true);
}
function openPreviewDialog(record) {
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
setDetailMode('view');
setActiveRecord(record);
setDetailTitle(record.displayTitle || record.title || '');
setDetailForm({
...createEmptyLedgerForm(activeKey),
...record.metadata,
......@@ -459,10 +551,10 @@ export default function IntegrityPartyPage() {
}
}
if (detailMode === 'create') {
const created = await createLedgerRecord(activeKey, detailForm);
const created = await createLedgerRecord(activeKey, { ...detailForm, title: detailTitle });
setNotice(`记录“${created.title}”已创建。`);
} else {
const updated = await updateLedgerRecord(activeKey, activeRecord, detailForm);
const updated = await updateLedgerRecord(activeKey, activeRecord, { ...detailForm, title: detailTitle });
setNotice(`记录“${updated.title}”已保存。`);
}
setDetailOpen(false);
......@@ -683,8 +775,9 @@ export default function IntegrityPartyPage() {
? [{
key: 'title',
title: '标题',
minWidth: 220,
render: (record) => <DataTableText text={record.title} showTooltip />,
minWidth: 320,
width: 320,
render: (record) => <DataTableText text={record.displayTitle || record.title} showTooltip={false} truncate={false} />,
}]
: []),
...activeConfig.tableColumns.map((fieldName) => ({
......@@ -702,9 +795,16 @@ export default function IntegrityPartyPage() {
key: 'actions',
title: '操作',
isActions: true,
minWidth: 112,
render: (record) => (
<TableActionGroup>
width: 168,
minWidth: 168,
render: (record) => (
<TableActionGroup>
<TableActionButton
icon={Eye}
label="预览详情"
onPress={() => openPreviewDialog(record)}
tone="view"
/>
<TableActionButton
icon={Edit}
label="编辑"
......@@ -729,6 +829,7 @@ export default function IntegrityPartyPage() {
<section>
<div className="min-w-0 max-w-full">
<Tabs
variant="primary"
className="w-fit max-w-full min-w-0"
selectedKey={activeKey}
onSelectionChange={(key) => {
......@@ -764,7 +865,7 @@ export default function IntegrityPartyPage() {
) : null}
{gridViews.length > 1 ? (
<Tabs selectedKey={activeViewName} onSelectionChange={(key) => { setActiveViewName(String(key)); setPage(0); setSelectedIds([]); }}>
<Tabs variant="primary" selectedKey={activeViewName} onSelectionChange={(key) => { setActiveViewName(String(key)); setPage(0); setSelectedIds([]); }}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label={`${activeConfig.label}视图`} className="w-max min-w-max">
{gridViews.map((view) => <LedgerCategoryTab key={view.name} tabKey={view.name} label={view.title || view.name} />)}
......@@ -808,6 +909,7 @@ export default function IntegrityPartyPage() {
) : null}
{expandedFilterItems.length > 0 ? (
<Disclosure.Content
hidden={!filtersExpanded}
className={clsx(
'min-h-0 min-w-0 xl:col-start-1 xl:col-end-2',
!filtersExpanded && 'hidden',
......@@ -883,6 +985,7 @@ export default function IntegrityPartyPage() {
onSelectedRowKeysChange={setSelectedIds}
emptyTitle="暂无数据"
isWide
className="rc-data-table-sticky"
renderSelectionActions={() => (
<>
<Button type="button" size="sm" variant="secondary" onPress={handleSelectedExport}>
......@@ -906,9 +1009,15 @@ export default function IntegrityPartyPage() {
<DataTablePagination
page={page}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100]}
total={total}
isDisabled={loading}
onPageChange={(nextPage) => reload(Math.max(nextPage, 0))}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
reload(0, filters[activeKey], nextPageSize);
}}
/>
</section>
</>}
......@@ -917,37 +1026,38 @@ export default function IntegrityPartyPage() {
isOpen={detailOpen}
onOpenChange={handleDetailOpenChange}
size="4xl"
title={detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : '编辑台账记录'}
title={detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : detailMode === 'view' ? '预览台账记录' : '编辑台账记录'}
footer={
<>
<Button type="button" size="sm" variant="secondary" onPress={() => handleDetailOpenChange(false)}>
取消
</Button>
<Button type="button" isPending={saving} size="sm" variant="primary" onPress={saveDetail}>
{saving ? '保存中...' : '保存'}
</Button>
{detailMode !== 'view' ? (
<Button type="button" isPending={saving} size="sm" variant="primary" onPress={saveDetail}>
{saving ? '保存中...' : '保存'}
</Button>
) : null}
</>
}
>
{detailViews.length > 1 ? (
<Tabs className="mb-5" selectedKey={activeDetailViewName} onSelectionChange={(key) => setActiveDetailViewName(String(key))}>
<Tabs className="mb-5" variant="primary" selectedKey={activeDetailViewName} onSelectionChange={(key) => setActiveDetailViewName(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0"><Tabs.List aria-label="详情视图" className="w-max min-w-max">{detailViews.map((view) => <LedgerCategoryTab key={view.name} tabKey={view.name} label={view.title || view.name} />)}</Tabs.List></Tabs.ListContainer>
</Tabs>
) : null}
{detailMode === 'edit' && activeRecord && !shouldHideViewTitleColumn(detailDisplayView) ? (
<Surface className="mb-5 p-4" variant="secondary">
<Description>记录标题</Description>
<div className="mt-1 text-sm font-medium">{activeRecord.title}</div>
</Surface>
) : null}
<LedgerForm
config={activeConfig}
fields={detailFields}
form={detailForm}
readOnly={detailMode === 'view'}
recordTitle={detailTitle}
showRecordTitle={detailMode !== 'create' && !shouldHideViewTitleColumn(detailDisplayView)}
isRecordTitleDisabled={isFieldDisabledByView('title', detailAuthorizationView, userInfo)}
setForm={setDetailForm}
onRecordTitleChange={setDetailTitle}
editSessionId={detailSessionId}
onPendingMaterialChange={handlePendingMaterialChange}
isFieldDisabled={(field) => isFieldDisabledByView(`metadata.${field.name}`, detailAuthorizationView, userInfo)}
isFieldDisabled={(field) => detailMode === 'view' || isFieldDisabledByView(`metadata.${field.name}`, detailAuthorizationView, userInfo)}
/>
</XinyuanModal>
......
......@@ -12,10 +12,24 @@ import { isViewAccessible } from '@/utils/subjectView';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
export const DEFAULT_INTEGRITY_PAGE_SIZE = 10;
export const DEFAULT_INTEGRITY_PAGE_SIZE = 20;
const viewResourceCache = new Map();
function hasConfiguredViewAuthorization(view) {
const rule = view?.settings?.viewAuth;
if (typeof rule === 'string') {
return rule.trim().length > 0;
}
return Boolean(
rule &&
['group', 'role', 'permission'].includes(String(rule.type || '').trim()) &&
(Array.isArray(rule.groups) ? rule.groups.length > 0 :
Array.isArray(rule.roles) ? rule.roles.length > 0 :
Array.isArray(rule.permissions) ? rule.permissions.length > 0 : true),
);
}
export async function loadLedgerViewResources(configKey, user) {
const config = getConfig(configKey);
const cacheKey = config.subject;
......@@ -38,7 +52,29 @@ export async function loadLedgerViewResources(configKey, user) {
}
const resources = await viewResourceCache.get(cacheKey);
const configuredGridViews = resources.views.filter((view) => view?.type === 'grid');
const accessibleGridViews = configuredGridViews.filter((view) => isViewAccessible(view, user));
// The original ledger page shipped with six fixed business tabs and did not
// require backend Subject Views. Keep those tabs usable until a Subject has
// at least one real Grid View configured; explicit backend views continue to
// be the sole source of authorization once they exist.
if (configuredGridViews.length === 0) {
const compatibilityView = {
name: `legacy_${config.subject}`,
title: config.label,
type: 'grid',
settings: {},
};
return {
...resources,
allViews: resources.views,
views: [compatibilityView],
};
}
// Existing ledger views without an explicit viewAuth rule were public before
// Subject View authorization was introduced. Preserve that behavior while
// enforcing permissions for views that carry an actual rule.
const accessibleGridViews = configuredGridViews.filter((view) =>
!hasConfiguredViewAuthorization(view) || isViewAccessible(view, user),
);
return { ...resources, allViews: resources.views, views: accessibleGridViews };
}
......@@ -83,7 +119,6 @@ export const INTEGRITY_LEDGER_CONFIGS = [
compactTitle([
metadata.training_category,
metadata.lecture_topic,
formatDate(metadata.training_date),
]) || '未命名培训记录',
focusFilter: {
name: 'training_category',
......@@ -133,8 +168,6 @@ export const INTEGRITY_LEDGER_CONFIGS = [
buildTitle: (metadata) =>
compactTitle([
metadata.person_name,
metadata.year ? `${metadata.year}年` : '',
formatDate(metadata.planned_talk_date),
]) || '未命名谈话记录',
focusFilter: {
name: 'person_or_department',
......@@ -199,7 +232,7 @@ export const INTEGRITY_LEDGER_CONFIGS = [
yearField: 'research_date',
titleFallback: '未命名调研记录',
buildTitle: (metadata) =>
compactTitle([metadata.research_unit, formatDate(metadata.research_date)]) ||
compactTitle([metadata.research_unit]) ||
'未命名调研记录',
focusFilter: {
name: 'research_unit',
......@@ -237,7 +270,7 @@ export const INTEGRITY_LEDGER_CONFIGS = [
yearField: 'meeting_date',
titleFallback: '未命名会议记录',
buildTitle: (metadata) =>
compactTitle([metadata.meeting_topic, formatDate(metadata.meeting_date)]) ||
compactTitle([metadata.meeting_topic]) ||
'未命名会议记录',
focusFilter: {
name: 'meeting_topic',
......@@ -265,10 +298,7 @@ export const INTEGRITY_LEDGER_CONFIGS = [
yearField: 'year',
titleFallback: '未命名活动记录',
buildTitle: (metadata) =>
compactTitle([
metadata.activity_theme,
metadata.year ? `${metadata.year}年` : '',
]) || '未命名活动记录',
compactTitle([metadata.activity_theme]) || '未命名活动记录',
focusFilter: {
name: 'activity_theme',
label: '活动主题',
......@@ -297,7 +327,6 @@ export const INTEGRITY_LEDGER_CONFIGS = [
titleFallback: '未命名节前提醒',
buildTitle: (metadata) =>
compactTitle([
metadata.year ? `${metadata.year}年` : '',
metadata.holiday_name === '其他'
? metadata.holiday_other || metadata.holiday_name
: metadata.holiday_name,
......@@ -506,6 +535,12 @@ export function formatDate(value) {
].join('-');
}
function stripGeneratedTitleDate(title) {
return String(title || '')
.replace(/\s*[·•]\s*\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s*$/, '')
.trim();
}
function normalizeDateValue(value) {
const dateText = formatDate(value);
if (!dateText) {
......@@ -625,11 +660,13 @@ export function normalizeLedgerRecord(record = {}, configKey) {
(fieldConfig) => normalizeFileList(metadata[fieldConfig.name]).length > 0,
);
const title = record.title || config.buildTitle(normalizedMetadata);
const displayTitle = stripGeneratedTitleDate(title) || title;
return {
id: record._id || record.id || record.name || '',
recordName: record.name || record._id || record.id || '',
title: title || config.titleFallback,
displayTitle: displayTitle || config.titleFallback,
subject: config.subject,
ledgerKey: config.key,
ledgerLabel: config.label,
......@@ -696,7 +733,8 @@ export function buildLedgerPayload(configKey, values = {}) {
);
return accumulator;
}, {});
const title = config.buildTitle(displayMetadata);
const explicitTitle = String(values.title || '').trim();
const title = explicitTitle || config.buildTitle(displayMetadata);
return {
subject: config.subject,
......
......@@ -122,7 +122,7 @@ export default function NotificationListPage() {
return (
<div className="rc-page-stack">
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<Tabs selectedKey={category} onSelectionChange={(key) => { setCategory(String(key)); setPage(0); }}>
<Tabs variant="primary" selectedKey={category} onSelectionChange={(key) => { setCategory(String(key)); setPage(0); }}>
<Tabs.ListContainer><Tabs.List aria-label="通知分类">{CATEGORY_OPTIONS.map((option) => <Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs>
<Button isDisabled={loading || unread === 0} isPending={busyKey === 'all'} variant="secondary" onPress={() => run('all', markAllNotificationsRead, '全部通知已标记为已读')}>全部已读</Button>
......@@ -131,7 +131,7 @@ export default function NotificationListPage() {
<SearchField aria-label="搜索通知内容" className="w-full sm:w-80 lg:w-96" value={searchText} variant="primary" onChange={setSearchText}>
<SearchField.Group><SearchField.SearchIcon><MagnifyingGlassIcon size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索通知内容" /><SearchField.ClearButton aria-label="清空通知搜索"><Close size={12} /></SearchField.ClearButton></SearchField.Group>
</SearchField>
<Tabs selectedKey={readStatus} onSelectionChange={(key) => { setReadStatus(String(key)); setPage(0); }}>
<Tabs variant="primary" selectedKey={readStatus} onSelectionChange={(key) => { setReadStatus(String(key)); setPage(0); }}>
<Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_OPTIONS.map((option) => <Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs>
</div>
......
......@@ -4,26 +4,19 @@ import {
ClipboardDocumentListIcon,
} from '@/components/AppIcons';
import {
colorizeXinyuanChartTooltipPayload,
createXinyuanChartColorMap,
XINYUAN_CHART_CATEGORY_PALETTE,
createResolvedXinyuanChartColorMap,
getXinyuanEChartsTheme,
resolveXinyuanChartColor,
XINYUAN_CHART_SEMANTIC,
} from '@/design-system/chartPalette';
import XinyuanEChart from '@/components/XinyuanEChart';
import { Alert, Card, Spinner } from '@heroui/react';
import { BarChart } from '@heroui-pro/react/bar-chart';
import { ChartTooltipContent } from '@heroui-pro/react/chart-tooltip';
import { ComposedChart } from '@heroui-pro/react/composed-chart';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { PieChart } from '@heroui-pro/react/pie-chart';
import { Legend } from 'recharts';
import { useEffect, useState } from 'react';
import { loadIntegrityPartyDashboard } from '../integrityPartyService';
const CHART_LEGEND_LABEL_STYLE = {
fontSize: 'var(--rc-chart-legend-font-size)',
lineHeight: 'var(--rc-chart-legend-line-height)',
};
function formatCount(value) {
const numericValue = Number(value || 0);
return Number.isFinite(numericValue) ? `${numericValue}` : '0';
......@@ -55,8 +48,8 @@ function MetricCard({ icon: Icon, label, value, tone }) {
function Panel({ title, children }) {
return (
<Card>
<Card.Header>
<Card.Title>{title}</Card.Title>
<Card.Header className="w-full flex-row items-center gap-3 text-left">
<Card.Title className="rc-module-title min-w-0 flex-1 text-left">{title}</Card.Title>
</Card.Header>
<Card.Content>{children}</Card.Content>
</Card>
......@@ -73,50 +66,23 @@ function EmptyBlock({ title }) {
);
}
function XinyuanChartTooltipContent({ active, label, payload, colorForEntry }) {
return (
<ChartTooltipContent
active={active}
label={label}
payload={colorizeXinyuanChartTooltipPayload(payload, colorForEntry)}
valueFormatter={(value) => `${formatCount(value)} 条`}
/>
);
}
function LedgerDistributionChart({ groups }) {
const data = groups.map((item) => ({
name: item.label,
value: Number(item.count || 0),
}));
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name));
const colorMap = createResolvedXinyuanChartColorMap(data.map((item) => item.name), {
order: data.map((item) => item.name),
palette: XINYUAN_CHART_CATEGORY_PALETTE,
});
if (data.length === 0) return <EmptyBlock title="暂无台账分类数据" />;
return (
<PieChart className="rc-chart-interaction" height={320}>
<PieChart.Pie
data={data}
dataKey="value"
innerRadius="52%"
nameKey="name"
outerRadius="72%"
paddingAngle={2}
>
{data.map((item) => (
<PieChart.Cell key={item.name} fill={colorMap.get(item.name)} />
))}
</PieChart.Pie>
<PieChart.Tooltip
content={(
<XinyuanChartTooltipContent
colorForEntry={({ key }) => colorMap.get(String(key ?? '').trim())}
/>
)}
/>
<Legend iconSize={8} iconType="circle" labelStyle={CHART_LEGEND_LABEL_STYLE} />
</PieChart>
);
return <XinyuanEChart ariaLabel="台账分类分布图" option={{
color: data.map((item) => colorMap.get(item.name)),
legend: { bottom: 0, data: data.map((item) => item.name) },
tooltip: { trigger: 'item', formatter: ({ name, value, percent }) => `${name}<br/>数量:${value} 条(${percent}%)` },
series: [{ type: 'pie', radius: ['52%', '72%'], center: ['50%', '44%'], avoidLabelOverlap: true, data: data.map((item) => ({ name: item.name, value: item.value, itemStyle: { color: colorMap.get(item.name) } })), label: { alignTo: 'labelLine', distanceToLabelLine: 6, formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 条}`, lineHeight: 18, rich: { name: { fontSize: 13, fontWeight: 500, lineHeight: 18 }, value: { fontSize: 14, fontWeight: 600, lineHeight: 20 } } }, labelLine: { length: 20, length2: 22, lineStyle: { width: 1.5 } }, labelLayout: (params) => { const points = params?.labelLinePoints; if (!Array.isArray(points) || points.length < 3) return null; const endpoint = points[2]; const isRight = endpoint[0] >= points[0][0]; return { align: isRight ? 'left' : 'right', hideOverlap: true, moveOverlap: 'none', verticalAlign: 'middle', x: endpoint[0] + (isRight ? 8 : -8), y: endpoint[1] }; } }] }} height={320} />;
}
function YearDistributionChart({ groups }) {
......@@ -127,32 +93,47 @@ function YearDistributionChart({ groups }) {
if (data.length === 0) return <EmptyBlock title="暂无年度分布数据" />;
return (
<ComposedChart
className="rc-chart-interaction"
data={data}
height={320}
margin={{ left: 8, right: 16, top: 16 }}
>
<ComposedChart.Grid vertical={false} />
<ComposedChart.XAxis dataKey="name" />
<ComposedChart.YAxis allowDecimals={false} />
<ComposedChart.Tooltip
content={(
<XinyuanChartTooltipContent
colorForEntry={() => XINYUAN_CHART_SEMANTIC.primary}
/>
)}
/>
<ComposedChart.Bar
dataKey="count"
fill={XINYUAN_CHART_SEMANTIC.primary}
maxBarSize={34}
name="数量"
radius={[4, 4, 0, 0]}
/>
</ComposedChart>
);
const theme = getXinyuanEChartsTheme();
return <XinyuanEChart ariaLabel="年度分布图" option={{
tooltip: {
trigger: 'axis',
formatter: (params) => `${params?.[0]?.axisValue}<br/>数量:${params?.[0]?.value ?? 0} 条`,
},
grid: { left: 28, right: 16, top: 28, bottom: 32, containLabel: true },
xAxis: {
type: 'category',
data: data.map((item) => item.name),
axisTick: { show: false },
axisLabel: { margin: 12 },
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: { show: true, lineStyle: { type: 'dashed' } },
},
series: [{
type: 'bar',
name: '数量',
data: data.map((item) => item.count),
barMaxWidth: 44,
barCategoryGap: '42%',
itemStyle: {
color: resolveXinyuanChartColor(XINYUAN_CHART_SEMANTIC.primary),
borderRadius: [6, 6, 0, 0],
},
label: {
show: true,
position: 'top',
distance: 8,
color: theme.foreground,
fontSize: 14,
fontWeight: 600,
formatter: ({ value }) => `${value}`,
},
emphasis: { focus: 'series' },
}],
}} height={320} />;
}
function ModuleCountChart({ groups }) {
......@@ -160,43 +141,26 @@ function ModuleCountChart({ groups }) {
name: item.label,
value: Number(item.count || 0),
}));
const colorMap = createResolvedXinyuanChartColorMap(data.map((item) => item.name), {
order: data.map((item) => item.name),
palette: XINYUAN_CHART_CATEGORY_PALETTE,
});
if (data.length === 0) return <EmptyBlock title="暂无年度开展次数数据" />;
return (
<BarChart
className="rc-chart-interaction"
data={data}
height={260}
layout="vertical"
margin={{ left: 16, right: 24 }}
>
<BarChart.Grid horizontal={false} />
<BarChart.XAxis allowDecimals={false} type="number" />
<BarChart.YAxis dataKey="name" type="category" width={112} />
<BarChart.Tooltip
content={(
<XinyuanChartTooltipContent
colorForEntry={() => XINYUAN_CHART_SEMANTIC.comparison}
/>
)}
/>
<BarChart.Bar
dataKey="value"
fill={XINYUAN_CHART_SEMANTIC.comparison}
maxBarSize={28}
name="开展次数"
radius={[0, 4, 4, 0]}
/>
</BarChart>
);
return <XinyuanEChart ariaLabel="年度开展次数图" option={{
tooltip: { trigger: 'axis', formatter: (params) => `${params?.[0]?.axisValue}<br/>开展次数:${params?.[0]?.value ?? 0} 次` },
grid: { left: 112, right: 24, top: 12, bottom: 24, containLabel: true },
xAxis: { type: 'value', minInterval: 1 }, yAxis: { type: 'category', data: data.map((item) => item.name) },
series: [{ type: 'bar', name: '开展次数', data: data.map((item) => ({ value: item.value, itemStyle: { color: colorMap.get(String(item.name ?? '').trim()), borderRadius: [0, 4, 4, 0] } })), barMaxWidth: 28 }]
}} height={260} />;
}
function TrainingOverview({ items }) {
return (
<Card>
<Card.Header>
<Card.Title>培训类</Card.Title>
<Card.Header className="w-full flex-row items-center gap-3 text-left">
<Card.Title className="rc-module-title min-w-0 flex-1 text-left">培训类</Card.Title>
<Card.Description>{items.length} 类</Card.Description>
</Card.Header>
<Card.Content className="grid gap-3">
......@@ -224,8 +188,8 @@ function TalkOverview({ summary }) {
return (
<Card>
<Card.Header>
<Card.Title>谈话类</Card.Title>
<Card.Header className="w-full flex-row items-center gap-3 text-left">
<Card.Title className="rc-module-title min-w-0 flex-1 text-left">谈话类</Card.Title>
</Card.Header>
<Card.Content className="grid gap-3">
{items.map(([label, value]) => (
......
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "tailwindcss";
@import "tailwindcss" source(none);
@import "@heroui/styles";
@import "@heroui-pro/react/css";
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
/* Keep Tailwind's watcher scoped to authored source. Umi's generated .umi
* files and hot-update artifacts must not trigger a CSS rebuild loop. */
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
@source "./pages/**/*.{js,jsx,ts,tsx}";
@source "./wrapper/**/*.{js,jsx,ts,tsx}";
@theme {
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
......
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