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,7 +156,6 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
......@@ -111,11 +163,7 @@ function SidebarContents({
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>
{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} />
</Sidebar.Mobile>
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
) : null}
</>
);
}
This diff is collapsed.
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];
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;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return XINYUAN_CHART_PALETTE[hashChartKey(stableKey) % XINYUAN_CHART_PALETTE.length];
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort();
return left.localeCompare(right, 'zh-CN');
});
const unknownPositions = new Map(
stableKeys
.filter((key) => !preferredOrder.has(key))
.map((key, index) => [key, index]),
);
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
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);
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 {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
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%)',
),
};
});
}
This diff is collapsed.
......@@ -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,
......
......@@ -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>;
}
......@@ -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