Commit d04092d9 authored by Andy-bubu's avatar Andy-bubu

feat: migrate application to Xinyuan HeroUI design system

parent 39e13ac4
# UI 架构
## 分层
1. `src/services` 与 `integrityPartyService.js` 负责接口、Record/metadata 映射、Excel、日期和附件协议。
2. 页面负责权限、查询时机、筛选状态、表单 draft、选择状态和业务回调。
3. `src/components` 只提供业务无关的 HeroUI-native 组合,例如 DataGrid、选择字段、日期字段、上传字段与图标操作。
4. `src/design-system/theme.css` 是不可变 HeroUI 主题;`tokens.css` 只提供批准的扩展 Tokens,`shell.css` 只保留与“问题整改”相同的共享 Shell/布局模式。页面不得通过私有 CSS 改写组件外观。
字段后缀图标由 `src/components/AppIcons.jsx` 的 `FIELD_SUFFIX_ICON_STYLE` 统一提供语义颜色。`XinyuanAutocomplete` 和 `FormDatePicker` 直接把该样式绑定到 Ant 图标根节点,并在共享组件内部固定 16px 图标与 DatePicker 24px Trigger 几何;页面不得覆盖颜色、位置或内部 slot。
应用切换由 `ApplicationSwitcherDialog.jsx` 保留目标应用的数据归一化、当前项识别和跳转逻辑,但视觉结构严格继承“问题整改”:46rem/42rem Modal、手机一列、`sm` 起固定两列、12px gap,真实 Card 和 Loading Skeleton 使用同一网格,不设置桌面三列分支。
## 直接导入规则
- 基础组件从 `@heroui/react` 或明确子路径导入。
- Pro 组件从 `@heroui-pro/react/<component>` 导入,避免 barrel 引入无关可选依赖。
- 禁止 Catalyst、Headless UI、兼容原语、可见原生表单控件和旧 DOM 事件适配层。
- 共享组件使用 HeroUI 的 `selectedKey/onSelectionChange`、`value/onChange`、`isDisabled`、`onPress` 等当前契约。
## 数据与业务边界
- 服务层接口 URL 以 `src/services/record-api.md` 为准,不在 UI 中硬编码。
- Record 自定义字段只进入 `metadata`。
- 引用字段若未来接入,只向服务层传 ID;当前人员/部门/岗位均按业务文档保持普通文本。
- 日期 UI 值在页面/服务边界转换,网络载荷保持 ISO 8601。
- UI 迁移不得改变路由、权限、请求时机、Excel 映射、标题生成、批量处理或错误策略。
## 依赖来源
- 当前仓库通过官方 `hpsetup` 独立安装 HeroUI Pro,精确版本为 `1.0.0-beta.8`,npm lockfile 解析到官方 registry tarball。
- `@heroui/react`、`@heroui/styles`、React Aria 和 React 必须在应用构建中解析到一套兼容实例。
- 禁止恢复跨项目 `file:`、相邻 `node_modules`、软链接或绝对工作站路径。
- 仓库保留 npm 的 `package-lock.json`,不并存其它包管理器锁文件。
## 旧架构移除状态
已移除活动源码中的 `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 入口。运行态浏览器门禁仍需在浏览器实例可用后完成。
# 鑫元 UI 迁移清单
## 基础
- [x] 核对 Git、remote、当前分支、路由 base 与 npm 配置
- [x] 阅读 `record-api.md`、`subjectsDefinition.md` 和党风廉政业务流程
- [x] 建立设计规范、审计、迁移清单和架构文档
- [x] 同步权威 HeroUI 主题与扩展 Tokens
- [x] 通过官方 `hpsetup` 安装并精确锁定 HeroUI Pro 1.0.0-beta.8,移除本地 `file:` 镜像
- [x] 收敛应用与 Pro 使用的 HeroUI 3.2.2 / React Aria Components 1.19.0 运行时
- [x] 删除多余 lockfile、Catalyst/Headless 依赖与未引用旧组件
## 页面
- [x] Login:迁移 HeroUI 原生控件、验证码、filled/autofill 和响应式结构
- [x] Shell:迁移品牌、offcanvas、用户菜单、主题和 base-aware 导航
- [x] 应用切换:迁移 HeroUI Modal、真实应用和 36px identity well
- [x] 驾驶舱:移除 ECharts/手写 KPI,改用 HeroUI Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip
- [x] 党风廉政 Tabs、结构化筛选与透明工具栏 SearchField;无刷新/查询按钮并保持自动筛选
- [x] 党风廉政 DataGrid、选择、ActionBar、固定 10 条 Pagination
- [x] 新建/编辑、导入、批量修改、删除确认 Modal
- [x] DropZone 上传、附件维护、Pro DataGrid Excel 预览
## 业务回归
- [x] 静态核对 6 类 Subject 查询与 Tab 切换
- [x] 静态核对新建、更新、软删除 API
- [x] 静态核对关键词/年份/关键字段查询
- [x] 静态核对 Excel 模板、导入、全部导出、勾选导出
- [x] 静态核对批量修改与批量删除
- [x] 静态核对日期 ISO 8601、boolean 三态、附件数组协议
## 验收
- [x] `npm run build`(最终工作树重新执行)
- [x] `git diff --check`(最终工作树重新执行)
- [x] Xinyuan audit 脚本与旧 import 扫描
- [x] 页面私有视觉 CSS 扫描归零
- [x] `file:`、本机路径与授权值扫描归零
- [x] HeroUI / React Aria 依赖树检查
- [x] HeroUI / HeroUI Pro 官方 CSS 入口进入生产构建产物
- [x] Pro CSS 文件和 sidebar/navbar/data-grid/drop-zone/action-bar/empty-state ESM 子路径
- [ ] 1440x900、1280x720、768x1024、390x844
- [ ] 浅色/深色、打开态弹层、选择与 Checkbox、Modal、Sidebar
- [ ] 控制台无 runtime、slot、provider、重复 Context 错误
运行态验收说明:2026-08-19 本地开发服务已启动,但当前执行环境未暴露可连接的应用内浏览器或 Chrome 实例,以上三项浏览器门禁尚未执行,不能以静态检查替代。
# 鑫元设计系统实施规范
## 适用范围
本应用统一采用鑫元 HeroUI / HeroUI Pro 设计系统。品牌主色为 `#9E1B1B`,浅色和深色主题由根节点 `data-theme` 与 `.light` / `.dark` 驱动。
## 主题与身份
- CSS 加载顺序固定为 Tailwind、HeroUI、HeroUI Pro、`src/design-system/theme.css`、`tokens.css`、Shell 与应用样式。
- HeroUI 基础 CSS 使用 `@heroui/styles/css`,HeroUI Pro 使用 `@heroui-pro/react/css`;禁止用 `@heroui/styles` JavaScript 入口代替基础 CSS。
- `theme.css` 必须与鑫元技能基线逐字节一致;业务样式不得重定义其核心变量。
- `tokens.css` 仅定义 `--rc-*` 扩展、别名、图表色和组件几何。
- 浅色使用 `xy-logo-red.png`,深色使用 `xy-logo-white.png`,禁止使用 RSM 资产或 CSS 反色。
- 应用展示名统一为“党风廉政建设”。
## 组件边界
- 页面与共享组件直接导入 `@heroui/react`、`@heroui-pro/react` 或 Pro 子路径。
- 不使用 Catalyst、Headless UI、可见原生 Select、日期、文件控件或浏览器确认框。
- 选择字段使用 HeroUI Autocomplete;日期使用 DatePicker;上传使用 HeroUI Pro DropZone;列表使用 HeroUI Pro DataGrid;弹窗使用 HeroUI Modal。
- 共享组件只封装业务无关的 HeroUI 组合,使用当前 HeroUI 的值和事件模型,不模拟旧组件 API。
## 依赖安装
- 每个应用通过官方 `hpsetup` 独立安装 HeroUI Pro,授权仅通过当前进程的 `HEROUI_KEY` 传入。
- 当前批准版本为 `@heroui-pro/react@1.0.0-beta.8`,以 npm lockfile 复现。
- 禁止跨项目 `file:` 依赖、相邻 `node_modules`、软链接或提交授权值。
## 页面组合
- Shell:240px offcanvas Sidebar、64px Navbar、桌面/平板/手机分别使用 48/32/16px 对称 gutter。
- 列表顺序:页面 Tabs、结构化筛选 Surface、透明数据集标题与工具栏、DataGrid、Pagination。
- 筛选 Surface 最多首显三个结构化字段,溢出字段通过 HeroUI Disclosure 展开;不显示刷新或查询按钮,字段变化自动筛选,关键词 SearchField 约 300ms 延迟。
- 业务列表固定每页 10 条,不提供页容量选择器。
- Dashboard/KPI 外层圆角 24px,DataGrid 20px,字段 12px,Modal 24px。
- 例行成功反馈使用顶部居中的自动消失 Toast;需要处理的错误保留上下文 Alert。
- 应用切换 Modal 固定采用手机一列、`sm` 起两列的应用 Card 网格,间距 12px;加载骨架使用相同网格,桌面不得切成三列,长列表在 Modal Body 内滚动。
## 字段后缀图标
- Autocomplete 下拉箭头、选择清除图标、DatePicker 日历图标和日期清除图标统一使用 Ant Design 16px 图标。
- 所有后缀图标直接使用 `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 或字面颜色修正单个页面。
## 可访问性与验收
所有纯图标操作需要 Tooltip 和可访问名称;Modal 保持焦点约束;Popover、Dropdown、Tooltip 和 Modal 必须继承根主题。验收覆盖 1440x900、1280x720、768x1024、390x844,包含浅色、深色、Sidebar、打开态弹层、选择状态和移动端溢出。
# 鑫元 UI 初始审计
审计日期:2026-08-19
## 工程与路由
- 框架:Umi Max 4、React 19、Tailwind CSS 4。
- 包管理器:`.umirc.ts` 指定 npm;仓库初始同时存在 `package-lock.json` 与 `pnpm-lock.yaml`。
- base/publicPath:`/incorrupt_party_build/`。
- 路由:`/login`、`/resource/stat`、`/integrity-party`。
- 登录:`getCaptcha` + `userLogin`,密码保持现有 AES 加密;失败后刷新验证码。
- 登出:`userLogout`,随后清理本地认证并跳转到 base-aware 登录页。
## 业务保护项
- 查询:`POST /record/query`。
- 新建:`POST /record`。
- 更新:`PUT /record/:name`。
- 删除:`DELETE /record/:name`,默认软删除。
- 所有业务字段写入 `metadata`;日期保存为 ISO 8601;附件为 `{name,type,size,url}[]`。
- Subject:`integrity_party_training`、`integrity_party_talk`、`integrity_party_research`、`integrity_party_meeting`、`integrity_party_activity`、`integrity_party_holiday_reminder`。
- Excel 导入逐条新建且不做重复判断;附件不参与 Excel 导入。
## 初始 UI 与架构问题
- 缺失权威 `src/design-system/theme.css`,旧 `tokens.css` 重定义 HeroUI 核心变量。
- `integrity-party/index.jsx` 混用 Catalyst Button/Input/Dialog 与 HeroUI。
- 应用切换使用 Headless UI,应用 Logo 未使用统一 `Surface tertiary + Avatar soft` identity well。
- 登录页使用可见原生 input/button,未建立完整 HeroUI TextField 边界。
- Dropdown 选择仍通过兼容 `FormSelect` 模拟旧 `<option>` / DOM change 事件。
- 文件上传共享组件合成 `event.target.files`,不符合当前 HeroUI 文件模型。
- 分页带页容量 Select,且支持 10/20/50,偏离固定 10 条协议。
- `DataTable.jsx` 仍保留旧 Table 兼容解析器与页面私有分页窗口逻辑。
- Dashboard 使用 ECharts 与语义状态色混合作为分类色,含渐变条形色和非批准色值。
- 初始依赖树存在 HeroUI 3.2.2/3.2.3 与 React Aria 1.19/1.20 两套实例。
- HeroUI Pro 初始来源为相邻项目的本地镜像,无法跨机器复现。
- 初始仓库缺失四份鑫元迁移文档。
## 页面范围
| 页面/表面 | 初始状态 | 迁移目标 |
| --- | --- | --- |
| Login | 部分视觉迁移,原生控件 | HeroUI Card/TextField/Input/Button/Alert,保留验证码协议 |
| Shell | Pro Sidebar/Navbar 已接入,样式与名称需核验 | 原生 Pro Shell、对称 gutter、真实退出、根主题 |
| 应用切换 | Headless UI | HeroUI Modal 与统一 identity well |
| 驾驶舱 | HeroUI Card + ECharts 混合 | 鑫元 24px Card/KPI、批准图表色、暗色 Tooltip |
| 党风廉政台账 | Catalyst/HeroUI 混合 | HeroUI Tabs/Surface/Autocomplete/Modal、Pro DataGrid/DropZone/ActionBar |
## 初始依赖风险
迁移开始时使用相邻项目的本地文件依赖,不满足团队交付要求。该风险已在后续官方安装中消除。
## 当前迁移状态与阻断项
- 权威 `theme.css` 已同步并通过逐字节校验;旧 Tokens 不再重定义 HeroUI 核心变量。
- 已通过官方 `hpsetup` 在当前仓库独立安装 `@heroui-pro/react@1.0.0-beta.8`;`package.json`、lockfile 和安装目录均不再依赖相邻项目、绝对路径或软链接。
- HeroUI 基础样式入口已更正为 `@heroui/styles/css`。此前包根 JavaScript 入口导致 Tabs、Input、Button、Dropdown 和 DataGrid 缺失官方结构样式,是本次截图中纵向 Tabs、裸控件和尺寸异常的共同根因。
- Catalyst、Headless UI、旧 Select/Table 兼容层与多余 pnpm lockfile 已从活动源码移除。
- Login、Shell、应用切换、党风廉政筛选/DataGrid/Modal/DropZone 已完成原生 HeroUI/HeroUI Pro 结构迁移;与“问题整改”的浏览器逐项对照仍受浏览器不可用阻断。
- 党风廉政筛选对齐问题整改基线:Surface 内前三项等宽排列,溢出条件通过 Disclosure 展开;移除刷新和查询按钮,字段变化后自动请求,关键词采用约 300ms 延迟。
- 驾驶舱已移除 ECharts,KPI 使用 HeroUI Pro `KPI` compound API,图表使用 Pro `PieChart`、`BarChart`、`ComposedChart` 与 `ChartTooltipContent`。
- `src/xinyuan-portable.css` 已删除;页面目录不存在私有 CSS/Less/SCSS。`theme.css` 与 Skill 权威副本一致,`tokens.css`、`shell.css`、`tailwind.css`、`global.less` 与确认基准逐字节一致。
- 广义关键词已移到透明列表工具栏的 HeroUI `SearchField`,结构化筛选 Surface 仅保留年份和分类关键字段。
- 2026-08-19 最终静态门禁:`npm run build`、`git diff --check`、鑫元审计脚本均成功;旧组件、ECharts、页面私有视觉类、跨项目依赖、本机路径和授权值均无有效源码命中。
- HeroUI Pro 的 CSS、入口文件和 sidebar/navbar/data-grid/drop-zone/action-bar/empty-state ESM 导入均可用。
- 依赖树的应用与 Pro 主路径统一使用 HeroUI 3.2.2 / React Aria Components 1.19.0;Adobe Spectrum 的间接依赖仍包含 React Aria Components 1.20.0,不进入应用直接组件边界。
- 开发服务在 2026-08-19 启动于 `http://localhost:8003/incorrupt_party_build/`。当前执行环境没有可连接浏览器,因此桌面/平板/移动端、深浅主题、打开态弹层和控制台检查仍待浏览器门禁执行。
## 原始业务状态记录
本次指令到达时工作树已经包含未提交 UI 迁移,无法再通过直接启动工作树还原“未修改页面”。原始业务结构改由 Git `HEAD` 只读核对:驾驶舱通过 `loadIntegrityPartyDashboard` 加载真实统计数据;党风廉政页面保留六类 Subject、固定 10 条分页、Excel 导入导出、附件上传、新建/更新/软删除和批量操作。迁移不得改变这些协议和回调。
## 工具能力说明
当前会话未暴露 HeroUI Pro MCP 的 `list_components` / `get_component_docs` 工具。组件 API 以当前仓库已安装包的真实 exports、类型声明和确认基准实现为依据;不得猜测 API。浏览器门禁必须在可用的应用内浏览器实例出现后执行,期间不得标记完成。
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -9,24 +9,44 @@
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react",
"@heroui/react": "^3.2.3",
"@heroui/styles": "^3.2.3",
"@fontsource/inter": "5.3.0",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "3.2.2",
"@heroui/styles": "3.2.2",
"@number-flow/react": "^0.6.2",
"@react-aria/ssr": "^3.10.1",
"@react-aria/utils": "^3.34.1",
"@tailwindcss/postcss": "^4.2.0",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "^4.5.3",
"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",
"marked": "^18.0.10",
"motion": "^13.1.0",
"react": "^19.2.8",
"react-aria": "^3.51.0",
"react-aria-components": "^1.20.0",
"react-aria-components": "1.19.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
import '@/tailwind.css';
import '@heroui/styles';
import '@heroui-pro/react/css';
import '@/design-system/tokens.css';
import '@/design-system/shell.css';
import '@/xinyuan-portable.css';
import '@/global.less';
import { defineApp } from 'umi';
......
import { Button as HeroButton } from '@heroui/react/button';
import clsx from 'clsx';
import { forwardRef } from 'react';
import { Link } from './link';
function hasLegacyClass(className, token) {
return String(className || '')
.split(/\s+/)
.includes(token);
}
function resolveButtonIntent({ className, color, outline, plain }) {
const legacyPrimary = hasLegacyClass(className, 'button--primary');
const legacyDanger = hasLegacyClass(className, 'button--danger');
const legacyOutline = hasLegacyClass(className, 'button--outline');
const legacyPlain =
hasLegacyClass(className, 'button--plain') ||
hasLegacyClass(className, 'button--ghost');
const isPrimary = color === 'blue' || legacyPrimary;
const isDanger = color === 'red' || legacyDanger;
const isPlain = plain || legacyPlain;
const isDangerEntry = isDanger && (outline || isPlain);
const isOutline =
outline || legacyOutline || (!isPrimary && !isDanger && !isPlain);
return {
isDanger,
isDangerEntry,
isOutline,
isPlain,
isPrimary,
variant: isPrimary
? 'primary'
: isDanger && !isDangerEntry
? 'danger'
: isPlain
? 'tertiary'
: 'secondary',
};
}
export const Button = forwardRef(function Button(
{
color,
outline = false,
plain = false,
className,
children,
disabled,
href,
size = 'sm',
...props
},
ref,
) {
const intent = resolveButtonIntent({ className, color, outline, plain });
const classes = clsx(
'rc-button',
(intent.isPrimary || (intent.isDanger && !intent.isDangerEntry)) &&
'rc-button--solid',
intent.isPrimary && 'rc-button--primary',
intent.isOutline && 'rc-button--secondary rc-button--outline',
intent.isPlain && 'rc-button--secondary rc-button--plain',
intent.isDanger && 'rc-button--danger',
className,
);
if (typeof href === 'string') {
return (
<Link
{...props}
aria-disabled={disabled || undefined}
className={classes}
href={href}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</Link>
);
}
return (
<HeroButton
{...props}
className={classes}
isDisabled={disabled}
ref={ref}
size={size}
variant={intent.variant}
>
<TouchTarget>{children}</TouchTarget>
</HeroButton>
);
});
export function TouchTarget({ children }) {
return <>{children}</>;
}
import { Description } from '@heroui/react/description';
import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Close } from '../../components/AppIcons';
export function Dialog({ size = 'lg', className, children, open, onClose, ...props }) {
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
{...props}
className="rc-dialog-backdrop"
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size={size}>
<Modal.Dialog
className={clsx('rc-dialog-panel', `rc-dialog-panel--${size}`, className)}
>
<Modal.CloseTrigger
aria-label="关闭"
className="rc-dialog-close rc-dialog-close--automatic absolute inline-flex items-center justify-center rounded-full"
>
<Close size={16} />
</Modal.CloseTrigger>
{children}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export function DialogTitle({ className, ...props }) {
return (
<Modal.Header>
<Modal.Heading {...props} className={clsx('rc-dialog-title', className)} />
</Modal.Header>
);
}
export function DialogDescription({ className, ...props }) {
return <Description {...props} className={clsx('rc-dialog-description', className)} />;
}
export function DialogBody({ className, ...props }) {
return <Modal.Body {...props} className={clsx('rc-dialog-body', className)} />;
}
export function DialogActions({ className, ...props }) {
return <Modal.Footer {...props} className={clsx('rc-dialog-actions', className)} />;
}
import { Input as HeroInput } from '@heroui/react/input';
import clsx from 'clsx';
import { forwardRef } from 'react';
export function InputGroup({ children, className }) {
return (
<span data-slot="control" className={clsx('rc-input-group relative block', className)}>
{children}
</span>
);
}
export const Input = forwardRef(function Input(
{ className, disabled, invalid, ...props },
ref,
) {
return (
<HeroInput
{...props}
aria-invalid={invalid || undefined}
className={clsx('rc-form-input', className)}
disabled={disabled}
fullWidth
ref={ref}
variant="secondary"
/>
);
});
/**
* TODO: Update this component to use your client-side framework's link
* component. We've provided examples of how to do this for Next.js, Remix, and
* Inertia.js in the Catalyst documentation:
*
* https://catalyst.tailwindui.com/docs#client-side-router-integration
*/
import * as Headless from '@headlessui/react'
import React, { forwardRef } from 'react'
export const Link = forwardRef(function Link(props, ref) {
return (
<Headless.DataInteractive>
<a {...props} ref={ref} />
</Headless.DataInteractive>
)
})
import FormSelect from '@/components/FormSelect';
import { forwardRef } from 'react';
export const Select = forwardRef(function Select(props, _ref) {
return <FormSelect {...props} />;
});
'use client';
import clsx from 'clsx';
import { createContext, useContext, useState } from 'react';
import { Link } from './link';
const TableContext = createContext({
bleed: false,
dense: false,
grid: false,
striped: false,
});
export function Table({
bleed = false,
dense = false,
grid = false,
striped = false,
className,
children,
...props
}) {
return (
<TableContext.Provider value={{ bleed, dense, grid, striped }}>
<div className="flow-root">
<div
{...props}
className={clsx(
className,
'-mx-(--gutter) overflow-x-auto whitespace-nowrap',
)}
>
<div
className={clsx(
'inline-block min-w-full align-middle',
!bleed && 'sm:px-(--gutter)',
)}
>
<table className="min-w-full text-left text-sm/6 text-zinc-950">
{children}
</table>
</div>
</div>
</div>
</TableContext.Provider>
);
}
export function TableHead({ className, ...props }) {
return <thead {...props} className={clsx(className, 'text-zinc-500')} />;
}
export function TableBody(props) {
return <tbody {...props} />;
}
const TableRowContext = createContext({
href: undefined,
target: undefined,
title: undefined,
});
export function TableRow({ href, target, title, className, ...props }) {
let { striped } = useContext(TableContext);
return (
<TableRowContext.Provider value={{ href, target, title }}>
<tr
{...props}
className={clsx(
className,
href &&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-[var(--pm-brand)]',
striped && 'even:bg-zinc-950/2.5',
href && striped && 'hover:bg-zinc-950/5',
href && !striped && 'hover:bg-zinc-950/2.5',
)}
/>
</TableRowContext.Provider>
);
}
export function TableHeader({ className, ...props }) {
let { bleed, grid } = useContext(TableContext);
return (
<th
{...props}
className={clsx(
className,
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
/>
);
}
export function TableCell({ className, children, ...props }) {
let { bleed, dense, grid, striped } = useContext(TableContext);
let { href, target, title } = useContext(TableRowContext);
let [cellRef, setCellRef] = useState(null);
return (
<td
ref={href ? setCellRef : undefined}
{...props}
className={clsx(
className,
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
!striped && 'border-b border-zinc-950/5',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
dense ? 'py-2.5' : 'py-4',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
>
{href && (
<Link
data-row-link
href={href}
target={target}
aria-label={title}
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
className="absolute inset-0 focus:outline-hidden"
/>
)}
{children}
</td>
);
}
import clsx from 'clsx'
import { Link } from './link'
export function Text({ className, ...props }) {
return (
<p
data-slot="text"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6')}
/>
)
}
export function TextLink({ className, ...props }) {
return (
<Link
{...props}
className={clsx(
className,
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950'
)}
/>
)
}
export function Strong({ className, ...props }) {
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950')} />
}
export function Code({ className, ...props }) {
return (
<code
{...props}
className={clsx(
className,
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem]'
)}
/>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Textarea = forwardRef(function Textarea({ className, resizable = true, ...props }, ref) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Textarea
ref={ref}
{...props}
className={clsx([
// Basic layout
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'disabled:border-zinc-950/20',
// Resizable
resizable ? 'resize-y' : 'resize-none',
])}
/>
</span>
)
})
......@@ -42,6 +42,10 @@ function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
......
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import DashboardNavbar from './DashboardNavbar';
......@@ -70,6 +71,7 @@ export default function AppShell({
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={sidebarProps.userInfo}
/>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
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 {
Avatar,
Button,
Dropdown,
Label,
Tooltip,
} from '@heroui/react';
import { Heading } from 'react-aria-components/Dialog';
import { AppGrid, LogOut } from './AppIcons';
const APP_NAME = '党风廉政建设';
function getUserDisplay(userInfo) {
const displayName =
userInfo?.display_name || userInfo?.username || '当前用户';
......@@ -28,11 +27,9 @@ function SidebarUserMenu({ userInfo, onLogout }) {
return (
<Dropdown>
<Button
fullWidth
<Dropdown.Trigger
aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger"
size="sm"
variant="ghost"
>
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? (
......@@ -47,12 +44,12 @@ function SidebarUserMenu({ userInfo, onLogout }) {
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Button>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu
aria-label="用户菜单"
onAction={(key) => {
if (key === 'logout') onLogout();
if (key === 'logout') onLogout?.();
}}
>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
......@@ -106,6 +103,7 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
......@@ -116,6 +114,8 @@ function SidebarContents({
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
......@@ -131,7 +131,9 @@ function SidebarContents({
) : (
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;
......@@ -196,9 +198,9 @@ export default function DashboardSidebar(props) {
<SidebarContents {...props} />
</Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<Heading slot="title" className="sr-only">
党风廉政建设导航
</Heading>
<h2 slot="title" className="sr-only">
{APP_NAME}导航
</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
......
import {
Archive,
ChevronDown,
Close,
Database,
FileText,
} from '@/components/AppIcons';
import { Close } from '@/components/AppIcons';
import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Button,
Chip,
Label,
ListBox,
Pagination,
Select,
Separator,
Spinner,
Tooltip,
} from '@heroui/react';
import { Tooltip } from '@heroui/react/tooltip';
import {
Children,
Fragment,
isValidElement,
useCallback,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
export const DATA_TABLE_PAGE_SIZE_OPTIONS = Object.freeze([10, 20, 50]);
export function normalizeDataTablePageSize(
value,
fallback = DATA_TABLE_PAGE_SIZE_OPTIONS[0],
) {
const nextValue = Number(value);
return DATA_TABLE_PAGE_SIZE_OPTIONS.includes(nextValue)
? nextValue
: fallback;
}
export function DataGridEmptyIllustration() {
return (
<div className="data-grid-empty-illustration" aria-hidden="true">
<span className="data-grid-empty-illustration-back">
<Database size={28} />
</span>
<span className="data-grid-empty-illustration-front">
<FileText size={32} />
<span className="data-grid-empty-illustration-lines">
<i />
<i />
<i />
</span>
</span>
<span className="data-grid-empty-illustration-badge">
<Archive size={18} />
</span>
</div>
);
}
function normalizeDataTableEmptyTitle(title) {
function normalizeEmptyTitle(title) {
const text = String(title || '').trim();
if (!text) {
return '暂无数据';
}
if (/^(暂无数据){2,}$/.test(text)) {
return '暂无数据';
}
return title;
return text && !/^(暂无数据){2,}$/.test(text) ? text : '暂无数据';
}
export function DataTableEmptyState({ title = '暂无数据' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title);
function DataTableEmptyState({ title }) {
return (
<EmptyState className="data-grid-empty-state" size="md">
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Media>
<DataGridEmptyIllustration />
</EmptyState.Media>
<EmptyState.Title>{resolvedTitle}</EmptyState.Title>
<EmptyState.Title>{normalizeEmptyTitle(title)}</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
);
......@@ -92,7 +28,7 @@ export function DataTableEmptyState({ title = '暂无数据' }) {
function DataTableLoadingState() {
return (
<div className="data-grid-empty-loading" aria-label="数据加载中">
<div className="flex min-h-48 items-center justify-center" aria-label="数据加载中">
<Spinner size="sm" />
</div>
);
......@@ -100,60 +36,63 @@ function DataTableLoadingState() {
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;
}
const match = String(width || '').match(/(\d+)px/);
return match ? Number(match[1]) : undefined;
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;
return undefined;
}
export function DataTable({
ariaLabel = '数据表格',
className = '',
columns,
rows,
isLoading = false,
getRowKey,
selectedRowKeys,
onSelectedRowKeysChange,
disabledRowKeys,
emptyTitle = '暂无数据',
emptyDescription = '当前筛选条件下没有可显示的记录。',
className = '',
getRowKey,
isLoading = false,
isWide,
contentMinWidth,
disabledRowKeys,
onRowAction,
sortDescriptor,
onSelectedRowKeysChange,
onSortChange,
renderSelectionActions,
rows,
selectedRowKeys,
sortDescriptor,
}) {
const selectionEnabled = Boolean(selectedRowKeys && onSelectedRowKeysChange);
const selectedKeys = selectionEnabled ? new Set(selectedRowKeys) : undefined;
const wide = isWide ?? columns.length > 6;
const firstBusinessColumnIndex = columns.findIndex(
(column) => !column.isSelection && !column.isActions && column.key !== 'actions',
);
const gridColumns = columns.map((column, index) => {
const isActions = column.isActions || column.key === 'actions';
const isPrimary = column.isPrimary ?? (!isActions && index === 0);
const width = resolveColumnWidth(column.width);
const isPrimary = column.isPrimary ?? (!isActions && index === firstBusinessColumnIndex);
const configuredWidth = resolveColumnWidth(column.width);
const numericWidth = typeof configuredWidth === 'number' ? configuredWidth : undefined;
const pinned = wide
? column.pin || (isActions ? 'end' : undefined)
? column.pin || (isPrimary ? 'start' : isActions ? 'end' : undefined)
: undefined;
const minWidth =
column.minWidth || width || (isActions ? 104 : pinned ? 220 : 128);
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),
......@@ -162,88 +101,42 @@ 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 ? 'pm-data-grid-primary' : 'pm-data-grid-text'
}
/>
);
return <DataTableText text={value} className={isPrimary ? 'font-medium' : ''} />;
}
return value;
},
isRowHeader: index === 0,
isRowHeader: index === firstBusinessColumnIndex,
allowsSorting: column.sortable === true,
headerClassName: column.headerClassName,
cellClassName: [
'pm-data-grid-cell',
isPrimary ? 'pm-data-grid-cell--primary' : '',
column.cellClassName || '',
]
.filter(Boolean)
.join(' '),
cellClassName: column.cellClassName,
allowsResizing: true,
minWidth,
width: width || (pinned ? minWidth : undefined),
width,
pinned,
};
});
const resolvedContentMinWidth = wide
? Math.max(
Number(contentMinWidth) || 0,
gridColumns.reduce(
(total, column) => total + (column.width || column.minWidth || 128),
selectionEnabled ? 40 : 0,
),
)
: undefined;
const handleSelectionChange = (selection) => {
if (!onSelectedRowKeysChange) {
return;
}
if (!onSelectedRowKeysChange) return;
if (selection === 'all') {
const disabledKeySet = new Set(Array.from(disabledRowKeys || []).map(String));
onSelectedRowKeysChange(
rows.map((row, index) => resolveRowKey(row, index, getRowKey)),
rows
.map((row, index) => resolveRowKey(row, index, getRowKey))
.filter((key) => !disabledKeySet.has(key)),
);
return;
}
onSelectedRowKeysChange(Array.from(selection).map(String));
};
const emptyState = <DataTableEmptyState title={emptyTitle} />;
return (
<div
className={['pm-data-table-selection-scope', className]
.filter(Boolean)
.join(' ')}
>
<div
className={[
'pm-data-grid-wrap',
rows.length > 0 ? 'pm-data-grid-wrap--populated' : '',
!isLoading && rows.length === 0 ? 'pm-data-grid-wrap--empty' : '',
]
.filter(Boolean)
.join(' ')}
style={
resolvedContentMinWidth
? {
'--pm-data-grid-content-min-width': `${resolvedContentMinWidth}px`,
}
: undefined
}
>
<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="pm-data-grid-loading" aria-label="数据加载中">
<div className="absolute inset-0 z-10 grid place-items-center" aria-label="数据加载中">
<Spinner size="sm" />
</div>
) : null}
......@@ -251,27 +144,17 @@ export function DataTable({
showSelectionCheckboxes={selectionEnabled}
allowsColumnResize
aria-label={ariaLabel}
className="pm-data-grid"
columns={gridColumns}
contentClassName={
wide
? 'pm-data-grid-content pm-data-grid-content--wide'
: 'pm-data-grid-content'
}
contentClassName={wide ? 'min-w-max' : 'min-w-full'}
data={rows}
disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() =>
isLoading ? (
<DataTableLoadingState />
) : (
<span
className="pm-data-grid-native-empty-fallback"
aria-hidden="true"
/>
)
}
scrollContainerClassName="pm-data-grid-scroll"
renderEmptyState={() => (
isLoading
? <DataTableLoadingState />
: <DataTableEmptyState title={emptyTitle} />
)}
scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys}
selectionMode={selectionEnabled ? 'multiple' : 'none'}
variant="primary"
......@@ -279,630 +162,75 @@ export function DataTable({
onRowAction={onRowAction}
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={
selectionEnabled ? handleSelectionChange : undefined
}
onSelectionChange={selectionEnabled ? handleSelectionChange : undefined}
/>
{!isLoading && rows.length === 0 ? (
<div className="pm-data-grid-empty-overlay">{emptyState}</div>
) : null}
</div>
{selectionEnabled ? (
<DataTableActionBar
selectedCount={selectedRowKeys.length}
onClear={() => onSelectedRowKeysChange([])}
>
{renderSelectionActions ? (
renderSelectionActions(selectedRowKeys)
) : (
<span className="text-sm">已选择当前页记录</span>
)}
</DataTableActionBar>
) : null}
</div>
);
}
export function DataTableActionBar({
selectedCount,
onClear,
children,
ariaLabel = '批量操作',
}) {
return (
<ActionBar
className="pm-data-table-action-bar"
aria-label={ariaLabel}
isOpen={selectedCount > 0}
>
<ActionBar aria-label="批量操作" isOpen={selectedRowKeys.length > 0}>
<ActionBar.Prefix>
<Chip className="shrink-0 tabular-nums" size="sm">
{selectedCount}
</Chip>
<Chip className="shrink-0 tabular-nums" size="sm">{selectedRowKeys.length}</Chip>
</ActionBar.Prefix>
<Separator orientation="vertical" />
<ActionBar.Content>{children}</ActionBar.Content>
<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={onClear}
onPress={() => onSelectedRowKeysChange([])}
>
<Close size={16} />
</Button>
<Tooltip.Content>清空选择</Tooltip.Content>
</Tooltip>
</ActionBar.Suffix>
</ActionBar>
);
}
function flattenStructuralChildren(children) {
const flattened = [];
Children.forEach(children, (child) => {
if (child === null || child === undefined || typeof child === 'boolean') {
return;
}
if (isValidElement(child) && child.type === Fragment) {
flattened.push(...flattenStructuralChildren(child.props.children));
return;
}
flattened.push(child);
});
return flattened;
}
function parseTailwindWidth(className = '') {
const bracketMatch = className.match(
/(?:min-)?w-\[(\d+(?:\.\d+)?)(px|rem)\]/,
);
if (bracketMatch) {
const value = Number(bracketMatch[1]);
return bracketMatch[2] === 'rem' ? value * 16 : value;
}
const scaleMatch = className.match(/(?:min-)?w-(\d+)/);
return scaleMatch ? Number(scaleMatch[1]) * 4 : undefined;
}
function extractText(node) {
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (!isValidElement(node)) {
return '';
}
return flattenStructuralChildren(node.props.children)
.map(extractText)
.join('')
.trim();
}
function isSafeTooltipContent(node) {
if (node === null || node === undefined || typeof node !== 'object') {
return true;
}
if (!isValidElement(node)) {
return true;
}
if (node.type === Fragment) {
return flattenStructuralChildren(node.props.children).every(
isSafeTooltipContent,
);
}
if (typeof node.type !== 'string') {
return false;
}
if (['a', 'button', 'input', 'select', 'textarea'].includes(node.type)) {
return false;
}
return flattenStructuralChildren(node.props.children).every(
isSafeTooltipContent,
);
}
function normalizeGridDateText(value) {
const match = String(value || '')
.trim()
.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
if (!match) return '';
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(
2,
'0',
)}`;
}
function sanitizeLegacyTableClassName(className = '') {
return String(className)
.split(/\s+/)
.filter(
(token) =>
token && !/^(?:hidden|(?:sm|md|lg|xl|2xl):table-cell)$/.test(token),
)
.join(' ');
}
export function DataTableText({
children,
text,
className = '',
showTooltip = false,
}) {
const fullText = String(text ?? children ?? '').trim();
const trigger = showTooltip ? (
<DataTableTextTooltipTrigger
className={className}
text={fullText}
>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
) : (
<span
className={['pm-data-grid-truncate', className]
.filter(Boolean)
.join(' ')}
>
{children ?? (fullText || '-')}
</span>
);
if (
!fullText ||
fullText === '-' ||
(!showTooltip && fullText.length <= 18)
) {
return trigger;
}
return (
<DataTableTextTooltipTrigger className={className} text={fullText}>
{children ?? (fullText || '-')}
</DataTableTextTooltipTrigger>
);
}
function DataTableTextTooltipTrigger({
children,
className = '',
text,
truncate = true,
}) {
const triggerRef = useRef(null);
const [tooltip, setTooltip] = useState(null);
const showTooltip = useCallback(() => {
const element = triggerRef.current;
if (!element || typeof window === 'undefined') {
return;
}
const rect = element.getBoundingClientRect();
const viewportPadding = 16;
const maxWidth = Math.min(420, window.innerWidth - viewportPadding * 2);
const left = Math.min(
Math.max(rect.left, viewportPadding),
Math.max(viewportPadding, window.innerWidth - viewportPadding - maxWidth),
);
const shouldPlaceAbove = rect.bottom + 140 > window.innerHeight;
setTooltip({
left,
maxWidth,
placement: shouldPlaceAbove ? 'top' : 'bottom',
text,
top: shouldPlaceAbove ? rect.top - 8 : rect.bottom + 8,
});
}, [text]);
const hideTooltip = useCallback(() => {
setTooltip(null);
}, []);
return (
<>
<span
ref={triggerRef}
className={[
truncate ? 'pm-data-grid-truncate' : '',
'pm-data-grid-tooltip-trigger',
className,
]
.filter(Boolean)
.join(' ')}
tabIndex={0}
onBlur={hideTooltip}
onFocus={showTooltip}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
>
{children}
</span>
{tooltip && typeof document !== 'undefined'
? createPortal(
<div
className={[
'pm-data-grid-text-tooltip',
tooltip.placement === 'top'
? 'pm-data-grid-text-tooltip--top'
: '',
]
.filter(Boolean)
.join(' ')}
style={{
left: tooltip.left,
maxWidth: tooltip.maxWidth,
top: tooltip.top,
}}
>
{tooltip.text}
</div>,
document.body,
)
: null}
</>
);
}
export function DataTablePrimaryText({
children,
text,
className = '',
showTooltip = false,
}) {
return (
<DataTableText
text={text}
showTooltip={showTooltip}
className={['pm-data-grid-primary', 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 (
<Tooltip>
<Button
aria-label={ariaLabel || fullText}
className={['pm-data-grid-primary-action', className]
.filter(Boolean)
.join(' ')}
isDisabled={isDisabled || disabled}
size="sm"
variant="ghost"
onPress={handlePress}
>
<span className="pm-data-grid-primary pm-data-grid-truncate">
{children ?? fullText}
</span>
</Button>
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
);
}
export function DataTableSecondaryText({
children,
text,
className = '',
showTooltip = false,
}) {
return (
<DataTableText
text={text}
showTooltip={showTooltip}
className={['pm-data-grid-secondary', 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={['pm-data-grid-text-pair', className]
.filter(Boolean)
.join(' ')}
text={tooltipText}
truncate={false}
>
<span
className={['pm-data-grid-truncate pm-data-grid-primary', primaryClassName]
.filter(Boolean)
.join(' ')}
>
{primaryText}
</span>
{hasSecondary ? (
<span
className={[
'pm-data-grid-truncate pm-data-grid-secondary',
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={['pm-data-grid-person-identity', className]
.filter(Boolean)
.join(' ')}
/>
</div>
);
}
export function DataTableChip({ children, text, className = '' }) {
export function DataTableText({ children, className = '', showTooltip = false, text }) {
const fullText = String(text ?? children ?? '').trim();
const trigger = (
<span
className={['pm-data-grid-chip', className].filter(Boolean).join(' ')}
>
<span className="pm-data-grid-chip__label">
{children ?? (fullText || '-')}
</span>
</span>
);
if (!fullText || fullText === '-' || fullText.length <= 18) {
return trigger;
const content = children ?? (fullText || '-');
if (!showTooltip || !fullText || fullText === '-') {
return <span className={['block min-w-0 truncate', className].filter(Boolean).join(' ')}>{content}</span>;
}
return (
<Tooltip delay={0}>
{trigger}
<Tooltip>
<Tooltip.Trigger className={['block min-w-0 truncate', className].filter(Boolean).join(' ')}>
{content}
</Tooltip.Trigger>
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
);
}
function TableHead() {
return null;
}
function TableBody() {
return null;
}
function TableRow() {
return null;
}
function TableHeader() {
return null;
}
function TableCell() {
return null;
}
const PRIMARY_COLUMN_TITLES = new Set([
'项目',
'项目名称',
'预算标题',
'客户全称',
'姓名',
'项目执行情况',
'申请人',
]);
function getSemanticColumnWidth(titleText, { isActions, isSelection }) {
if (isSelection) return 56;
if (isActions) return 112;
if (PRIMARY_COLUMN_TITLES.has(titleText)) return 280;
if (/休假说明|事由|备注|地址|说明/.test(titleText)) return 320;
if (/工作内容/.test(titleText)) return 220;
if (/时间范围|日期范围/.test(titleText)) return 220;
if (/更新时间|创建时间/.test(titleText)) return 180;
if (/工作日期/.test(titleText)) return 180;
if (/开始时间|结束时间|日期/.test(titleText)) return 140;
if (/项目类型|业务类型|分类|背景|职级/.test(titleText)) return 200;
if (/邮箱/.test(titleText)) return 220;
if (/手机|电话/.test(titleText)) return 150;
if (/代码|编码/.test(titleText)) return 140;
if (/状态|是否|同步/.test(titleText)) return 130;
if (
/申请人|预订人|成员|经理|合伙人|创建人|人员|部门|分组|职位/.test(titleText)
)
return 180;
return 128;
}
export { TableBody, TableCell, TableHead, TableHeader, TableRow };
export function Table({ children, className = '', ...props }) {
const sections = flattenStructuralChildren(children);
const head = sections.find((child) => child?.type === TableHead);
const body = sections.find((child) => child?.type === TableBody);
const headerRow = flattenStructuralChildren(head?.props?.children).find(
(child) => child?.type === TableRow,
);
const headers = flattenStructuralChildren(headerRow?.props?.children).filter(
(child) => child?.type === TableHeader,
);
const bodyRows = flattenStructuralChildren(body?.props?.children).filter(
(child) => child?.type === TableRow,
);
const inferredWide =
headers.length > 6 || /min-w-\[(?:1\d{3,}|[2-9]\d{3,})px\]/.test(className);
const parsedRows = bodyRows.map((row, rowIndex) => {
const cells = flattenStructuralChildren(row.props.children).filter(
(child) => child?.type === TableCell,
);
return {
__key: String(row.key ?? rowIndex),
__cells: cells,
__rowClassName: row.props.className || '',
};
});
const emptyRow = parsedRows.find((row) => {
const cell = row.__cells[0];
return (
row.__cells.length === 1 &&
Number(cell?.props?.colSpan || 0) >= Math.max(headers.length, 1)
);
});
const emptyText = emptyRow ? extractText(emptyRow.__cells[0]) : '';
const dataRows = emptyRow
? parsedRows.filter((row) => row !== emptyRow)
: parsedRows;
const columns = headers.map((header, index) => {
const headerClassName = header.props.className || '';
const title = header.props.children;
const titleText = extractText(title);
const width = parseTailwindWidth(headerClassName);
const isActions = titleText === '操作';
const isSelection = index === 0 && !titleText;
let pin;
if (
inferredWide &&
(/sticky[^]*left-0|left-0[^]*sticky/.test(headerClassName) ||
isSelection ||
PRIMARY_COLUMN_TITLES.has(titleText))
) {
pin = 'start';
} else if (
inferredWide &&
(/sticky[^]*right-0|right-0[^]*sticky/.test(headerClassName) || isActions)
) {
pin = 'end';
}
return {
key: `column-${index}`,
title,
width,
minWidth:
width || getSemanticColumnWidth(titleText, { isActions, isSelection }),
pin,
isActions,
isPrimary: PRIMARY_COLUMN_TITLES.has(titleText),
headerClassName: sanitizeLegacyTableClassName(headerClassName),
render: (row) => {
const cell = row.__cells[index];
if (!cell) {
return '-';
}
const content = cell.props.children;
const fullText = extractText(content);
const normalizedDate = normalizeGridDateText(fullText);
const renderedContent = normalizedDate ? (
normalizedDate
) : fullText.length > 18 && isSafeTooltipContent(content) ? (
<DataTableText text={fullText}>{content}</DataTableText>
) : (
content
);
return (
<div
className={[
'pm-data-grid-cell-content',
PRIMARY_COLUMN_TITLES.has(titleText) &&
(typeof content === 'string' || typeof content === 'number')
? 'pm-data-grid-primary'
: '',
sanitizeLegacyTableClassName(cell.props.className),
]
.filter(Boolean)
.join(' ')}
>
{renderedContent}
</div>
);
},
};
});
return (
<DataTable
{...props}
ariaLabel={props['aria-label'] || '业务数据列表'}
className="pm-data-table-compat"
columns={columns}
rows={dataRows}
getRowKey={(row) => row.__key}
isLoading={emptyText.includes('加载中')}
isWide={inferredWide}
emptyTitle={
emptyText && !emptyText.includes('加载中') ? emptyText : '暂无数据'
}
emptyDescription="当前没有可显示的记录。"
/>
);
}
export function DataTablePagination({
isDisabled = false,
itemLabel = '条',
onPageChange,
page,
pageSize,
total,
onPageChange,
pageSizeOptions = DATA_TABLE_PAGE_SIZE_OPTIONS,
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 hasPageSizeControl =
pageSizeOptions.length > 0 && Boolean(onPageSizeChange);
const pageWindowEnd = total ? Math.min(totalPages, pageWindowStart + 5) : pageWindowStart;
return (
<div className="pm-pagination-bar">
<Pagination size="sm" className="w-full">
<Pagination className="w-full" size="sm">
<Pagination.Summary>
共 {total} {itemLabel},
{hasPageSizeControl ? null : <>每页 {pageSize} 条,</>}第{' '}
{total ? page + 1 : 0} / {total ? totalPages : 0} 页
共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
......@@ -914,9 +242,7 @@ export function DataTablePagination({
<span>上一页</span>
</Pagination.Previous>
</Pagination.Item>
{Array.from(
{ length: pageWindowEnd - pageWindowStart },
(_, index) => {
{Array.from({ length: pageWindowEnd - pageWindowStart }, (_, index) => {
const pageIndex = pageWindowStart + index;
return (
<Pagination.Item key={pageIndex}>
......@@ -929,8 +255,7 @@ export function DataTablePagination({
</Pagination.Link>
</Pagination.Item>
);
},
)}
})}
<Pagination.Item>
<Pagination.Next
isDisabled={isDisabled || page + 1 >= totalPages || total === 0}
......@@ -942,46 +267,5 @@ export function DataTablePagination({
</Pagination.Item>
</Pagination.Content>
</Pagination>
{hasPageSizeControl ? (
<div className="pm-pagination-controls">
<Select
aria-label="每页条数"
className="pm-pagination-page-size-select"
isDisabled={isDisabled}
selectedKey={String(pageSize)}
variant="secondary"
onSelectionChange={(key) => onPageSizeChange(Number(key))}
>
<Select.Trigger>
<span className="pm-pagination-page-size-copy">
<span className="pm-pagination-page-size-label">每页</span>
<Select.Value className="pm-pagination-page-size-value">
{pageSize} 条/页
</Select.Value>
</span>
<ChevronDown className="pm-pagination-page-size-indicator" />
</Select.Trigger>
<Select.Popover
className="rc-form-select__popover rc-filter-select__popover"
placement="bottom start"
>
<ListBox aria-label="每页条数">
{pageSizeOptions.map((option) => (
<ListBox.Item
key={option}
id={String(option)}
className="rc-form-select__option rc-filter-select__option"
textValue={`${option} 条/页`}
>
<Label>{option} 条/页</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
) : null}
</div>
);
}
......@@ -2,13 +2,6 @@ import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react';
function createFileChangeEvent(files) {
return {
target: { files },
currentTarget: { files },
};
}
export default function FileUploadField({
accept,
ariaLabel = '选择文件',
......@@ -16,16 +9,16 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。',
disabled = false,
fileName = '',
onChange,
onFilesChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback(
(files) => {
if (!disabled) {
onChange?.(createFileChangeEvent(files));
onFilesChange?.(Array.from(files || []));
}
},
[disabled, onChange],
[disabled, onFilesChange],
);
const handleDrop = useCallback(
......@@ -42,7 +35,7 @@ export default function FileUploadField({
);
return (
<DropZone className={`rc-file-upload ${className}`.trim()}>
<DropZone className={className}>
<DropZone.Area
aria-label={ariaLabel}
isDisabled={disabled}
......@@ -61,7 +54,6 @@ export default function FileUploadField({
{fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description>
<DropZone.Trigger isDisabled={disabled}>
<Upload aria-hidden="true" />
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area>
......
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
import { useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_filter_empty__';
function normalizeOption(option) {
if (option && typeof option === 'object') {
return {
label: String(option.label ?? option.value ?? ''),
value: String(option.value ?? ''),
disabled: Boolean(option.disabled),
};
}
return {
label: String(option ?? ''),
value: String(option ?? ''),
disabled: false,
};
}
export default function FilterSelect({
'aria-label': ariaLabel,
className,
disabled = false,
emptyText = '暂无可选项',
onChange,
options = [],
placeholder = '请选择',
value = '',
}) {
const normalizedOptions = useMemo(
() => options.map(normalizeOption),
[options],
);
const selectedValue = String(value ?? '');
const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue,
);
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue;
return (
<Select
aria-label={ariaLabel || placeholder}
className={clsx('rc-filter-select', className)}
fullWidth
isDisabled={disabled}
selectedKey={selectedKey}
variant="secondary"
onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue);
}}
>
<Select.Trigger className="rc-filter-select__trigger">
<Select.Value className="rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
!selectedOption && 'rc-filter-select__placeholder',
)}
title={selectedOption?.label || placeholder}
>
{selectedOption?.label || placeholder}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
</Select.Trigger>
<Select.Popover
className="rc-filter-select__popover"
placement="bottom start"
>
<ListBox
aria-label={ariaLabel || placeholder}
className="rc-filter-select__listbox"
>
{normalizedOptions.length > 0 ? (
normalizedOptions.map((option) => (
<ListBox.Item
key={option.value || EMPTY_OPTION_KEY}
id={option.value || EMPTY_OPTION_KEY}
className="rc-filter-select__option"
isDisabled={option.disabled}
textValue={option.label}
>
<Label
className="rc-filter-select__option-label"
title={option.label}
>
{option.label}
</Label>
<ListBox.ItemIndicator className="rc-filter-select__option-indicator" />
</ListBox.Item>
))
) : (
<div className="rc-filter-select__empty">{emptyText}</div>
)}
</ListBox>
</Select.Popover>
</Select>
);
}
import { Calendar as HeroCalendar } from '@heroui/react/calendar';
import { DateField } from '@heroui/react/date-field';
import { DatePicker } from '@heroui/react/date-picker';
import { TimeField } from '@heroui/react/time-field';
import {
Calendar as HeroCalendar,
CloseButton,
DateField,
DatePicker,
I18nProvider,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import clsx from 'clsx';
import { useMemo } from 'react';
import { Calendar as CalendarIcon } from './AppIcons';
import {
Calendar as CalendarIcon,
Close,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
function parseValue(value, mode) {
if (!value) {
......@@ -67,29 +74,21 @@ function combineDateAndTime(value, dateValue, timeValue) {
return `${dateText}T${formatTimeValue(timeValue)}`;
}
function emitDateChange(onChange, name, nextValue) {
onChange?.({
target: {
name,
value: nextValue,
},
});
}
export default function FormDatePicker({
'aria-label': ariaLabel,
className,
disabled = false,
granularity,
isClearable = true,
max,
min,
mode,
name,
onChange,
readOnly = false,
required = false,
type = 'date',
value,
variant = 'secondary',
...props
}) {
const resolvedMode =
......@@ -103,12 +102,14 @@ export default function FormDatePicker({
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
const canClear = isClearable && Boolean(value) && !disabled && !readOnly && !required;
return (
<I18nProvider locale="zh-CN">
<DatePicker
{...props}
aria-label={ariaLabel || '选择日期'}
className={clsx('rc-form-date-picker', className)}
className={['w-full', className].filter(Boolean).join(' ')}
granularity={resolvedGranularity}
isDisabled={disabled}
isReadOnly={readOnly}
......@@ -116,40 +117,42 @@ export default function FormDatePicker({
maxValue={maxValue || undefined}
minValue={minValue || undefined}
value={parsedValue}
onChange={(nextValue) =>
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
}
onChange={(nextValue) => onChange?.(formatValue(nextValue, resolvedMode))}
>
<DateField.Group
className="rc-form-date-picker__group"
fullWidth
variant="secondary"
<DateField.Group className="relative" fullWidth variant={variant}>
<DateField.Input className={canClear ? 'pe-14' : 'pe-8'}>
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
<DateField.Suffix className="absolute inset-y-0 end-1 mr-0 gap-0">
{canClear ? (
<CloseButton
aria-label={`清空${ariaLabel || '日期'}`}
onPress={() => onChange?.('')}
>
<DateField.Input className="rc-form-date-picker__input">
{(segment) => (
<DateField.Segment
className="rc-form-date-picker__segment"
segment={segment}
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
)}
</DateField.Input>
<DateField.Suffix className="rc-form-date-picker__suffix">
</CloseButton>
) : null}
<DatePicker.Trigger
className="rc-form-date-picker__trigger"
aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<DatePicker.TriggerIndicator>
<CalendarIcon />
<CalendarIcon
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</DatePicker.TriggerIndicator>
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover
className="rc-form-date-picker__popover"
placement="bottom start"
>
<HeroCalendar className="rc-form-date-picker__calendar">
<DatePicker.Popover placement="bottom start">
<HeroCalendar>
<HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading />
......@@ -165,34 +168,22 @@ export default function FormDatePicker({
</HeroCalendar.Grid>
</HeroCalendar>
{resolvedMode === 'datetime' ? (
<div className="rc-form-date-picker__time-panel">
<span className="rc-form-date-picker__time-label">时间</span>
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-sm text-muted">时间</span>
<TimeField
aria-label="选择时间"
className="rc-form-date-picker__time-field"
className="w-36"
granularity="minute"
hideTimeZone
hourCycle={24}
value={timeValue}
onChange={(nextTime) =>
emitDateChange(
onChange,
name,
combineDateAndTime(value, parsedValue, nextTime),
)
onChange?.(combineDateAndTime(value, parsedValue, nextTime))
}
>
<TimeField.Group
className="rc-form-date-picker__time-group"
variant="secondary"
>
<TimeField.Input className="rc-form-date-picker__time-input">
{(segment) => (
<TimeField.Segment
className="rc-form-date-picker__segment"
segment={segment}
/>
)}
<TimeField.Group fullWidth variant={variant}>
<TimeField.Input>
{(segment) => <TimeField.Segment segment={segment} />}
</TimeField.Input>
</TimeField.Group>
</TimeField>
......@@ -200,5 +191,6 @@ export default function FormDatePicker({
) : null}
</DatePicker.Popover>
</DatePicker>
</I18nProvider>
);
}
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
import { Children, Fragment, isValidElement, useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_form_select_empty__';
function extractText(node) {
if (node === null || node === undefined || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractText).join('');
}
if (isValidElement(node)) {
return extractText(node.props.children);
}
return '';
}
function normalizeOptions(children) {
const options = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment || child.type === 'optgroup') {
options.push(...normalizeOptions(child.props.children));
return;
}
if (child.type !== 'option') {
return;
}
options.push({
disabled: Boolean(child.props.disabled),
label: extractText(child.props.children),
value: String(child.props.value ?? ''),
});
});
return options;
}
function toOptionKey(value) {
return value === '' ? EMPTY_OPTION_KEY : String(value);
}
function fromOptionKey(key) {
return key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
}
export default function FormSelect({
'aria-label': ariaLabel,
children,
className,
disabled = false,
multiple = false,
name,
onChange,
placeholder,
required = false,
value,
...props
}) {
const options = useMemo(() => normalizeOptions(children), [children]);
const emptyOption = options.find((option) => option.value === '');
const resolvedPlaceholder = placeholder || emptyOption?.label || '请选择';
const selectedValues = multiple
? (Array.isArray(value) ? value : []).map(String)
: [String(value ?? '')];
const selectedOptions = options.filter(
(option) => option.value !== '' && selectedValues.includes(option.value),
);
const displayText = multiple
? selectedOptions.length <= 2
? selectedOptions.map((option) => option.label).join('、')
: `${selectedOptions
.slice(0, 2)
.map((option) => option.label)
.join('、')} 等 ${selectedOptions.length} 项`
: options.find((option) => option.value === selectedValues[0])?.label ||
resolvedPlaceholder;
const hasSelection = multiple
? selectedOptions.length > 0
: selectedValues[0] !== '';
const emitChange = (nextValues) => {
const normalizedValues = nextValues.map(fromOptionKey);
onChange?.({
target: {
name,
selectedOptions: normalizedValues.map((nextValue) => ({
value: nextValue,
})),
value: normalizedValues[0] || '',
},
});
};
const sharedProps = {
...props,
'aria-label': ariaLabel || resolvedPlaceholder,
className: clsx('rc-form-select rc-filter-select', className),
fullWidth: true,
isDisabled: disabled,
isRequired: required,
selectionMode: multiple ? 'multiple' : 'single',
variant: 'secondary',
};
if (multiple) {
sharedProps.selectedKeys = new Set(selectedValues.map(toOptionKey));
sharedProps.onSelectionChange = (keys) => {
const nextKeys =
keys === 'all' ? options.map((option) => option.value) : [...keys];
emitChange(nextKeys);
};
} else {
sharedProps.selectedKey = toOptionKey(selectedValues[0]);
sharedProps.onSelectionChange = (key) => emitChange([key]);
}
return (
<Select {...sharedProps}>
<Select.Trigger className="rc-form-select__trigger rc-filter-select__trigger">
<Select.Value className="rc-form-select__value rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
!hasSelection && 'rc-filter-select__placeholder',
)}
title={displayText}
>
{displayText}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
</Select.Trigger>
<Select.Popover
className="rc-form-select__popover rc-filter-select__popover"
placement="bottom start"
>
<ListBox
aria-label={ariaLabel || resolvedPlaceholder}
className="rc-filter-select__listbox"
selectionMode={multiple ? 'multiple' : 'single'}
>
{options.map((option) => (
<ListBox.Item
key={toOptionKey(option.value)}
id={toOptionKey(option.value)}
className="rc-form-select__option rc-filter-select__option"
isDisabled={option.disabled}
textValue={option.label}
>
<Label
className="rc-filter-select__option-label"
title={option.label}
>
{option.label}
</Label>
<ListBox.ItemIndicator className="rc-filter-select__option-indicator" />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
);
}
......@@ -13,55 +13,7 @@ import { Button, Dropdown, Label, Tooltip } from '@heroui/react';
import clsx from 'clsx';
import { Children, Fragment, isValidElement } from 'react';
const STICKY_ACTION_BASE_HEADER_CLASS =
'sticky right-0 z-10 bg-[var(--rc-data-grid-header-background)] text-left';
const STICKY_ACTION_BASE_CELL_CLASS =
'sticky right-0 z-[1] bg-[var(--rc-bg-surface)] group-hover:bg-[var(--rc-data-grid-row-hover-background)] hover:z-40 focus-within:z-40';
const STICKY_FIRST_COLUMN_BASE_HEADER_CLASS =
'sticky left-0 z-20 bg-[var(--rc-data-grid-header-background)] text-left';
const STICKY_FIRST_COLUMN_BASE_CELL_CLASS =
'sticky left-0 z-[2] bg-[var(--rc-bg-surface)] group-hover:bg-[var(--rc-data-grid-row-hover-background)] hover:z-30 focus-within:z-30';
const ACTION_COLUMN_WIDTHS = {
icon: 'w-[4.5rem] min-w-[4.5rem]',
single: 'w-[5rem] min-w-[5rem]',
double: 'w-[7rem] min-w-[7rem]',
compact: 'w-[9rem] min-w-[9rem]',
medium: 'w-[11rem] min-w-[11rem]',
wide: 'w-[14rem] min-w-[14rem]',
xwide: 'w-[18rem] min-w-[18rem]',
};
export const PROJECT_MANAGER_LIST_TABLE_CLASS =
'rounded-lg border border-zinc-200 bg-white text-sm text-zinc-700 [&_thead]:bg-zinc-50/70 [&_thead_th]:border-b [&_thead_th]:border-zinc-200 [&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-sm [&_thead_th]:font-semibold [&_thead_th]:text-zinc-500 [&_tbody_td]:px-4 [&_tbody_td]:text-sm [&_tbody_td]:text-zinc-700';
export const PROJECT_MANAGER_LIST_ROW_CLASS =
'group border-b border-zinc-100 transition-colors [&>td]:bg-zinc-50 [&>td]:py-3.5 hover:[&>td]:bg-zinc-100';
export const PROJECT_MANAGER_LIST_EDITABLE_CONTROL_CLASS =
'[&_input]:h-9 [&_input]:rounded-md [&_input]:border-zinc-200 [&_input]:bg-zinc-50/60 [&_input]:text-sm [&_input]:font-normal [&_input]:text-zinc-600 [&_input]:placeholder-zinc-400 [&_input]:shadow-none [&_input:disabled]:bg-zinc-50 [&_input:disabled]:text-zinc-400 [&_select]:h-9 [&_select]:rounded-md [&_select]:border-zinc-200 [&_select]:bg-zinc-50/60 [&_select]:text-sm [&_select]:text-zinc-600 [&_select]:shadow-none [&_select:disabled]:bg-zinc-50 [&_select:disabled]:text-zinc-400';
const ACTION_TONE_CLASSES = {
approve: 'pm-table-action--approve',
delete: 'pm-table-action--danger',
edit: 'pm-table-action--default',
history: 'pm-table-action--default',
reject: 'pm-table-action--danger',
revoke: 'pm-table-action--warning',
submit: 'pm-table-action--primary',
view: 'pm-table-action--default',
};
const ACTION_COLOR_TONE = {
blue: 'submit',
green: 'approve',
red: 'delete',
};
function resolveActionTone({ color, icon, label, outline, tone }) {
function resolveActionTone({ icon, tone }) {
if (tone) {
return tone;
}
......@@ -74,37 +26,7 @@ function resolveActionTone({ color, icon, label, outline, tone }) {
return iconTone;
}
if (/删除|移除|作废|清退|delete|remove|trash/i.test(String(label || ''))) {
return 'delete';
}
if (/驳回|拒绝|reject/i.test(String(label || ''))) {
return 'reject';
}
return ACTION_COLOR_TONE[color] || (outline ? 'edit' : 'view');
}
export function getStickyActionHeaderClass(size = 'medium') {
return clsx(
STICKY_ACTION_BASE_HEADER_CLASS,
ACTION_COLUMN_WIDTHS[size] || ACTION_COLUMN_WIDTHS.medium,
);
}
export function getStickyActionCellClass(size = 'medium') {
return clsx(
STICKY_ACTION_BASE_CELL_CLASS,
ACTION_COLUMN_WIDTHS[size] || ACTION_COLUMN_WIDTHS.medium,
);
}
export function getStickyFirstColumnHeaderClass(className) {
return clsx(STICKY_FIRST_COLUMN_BASE_HEADER_CLASS, className);
}
export function getStickyFirstColumnCellClass(className) {
return clsx(STICKY_FIRST_COLUMN_BASE_CELL_CLASS, className);
return 'view';
}
export const TABLE_ACTION_ICONS = {
......@@ -143,30 +65,31 @@ export function TableActionGroup({ className, children, ...props }) {
<div
{...props}
className={clsx(
'icon-actions flex flex-nowrap items-center justify-end gap-1.5 whitespace-nowrap',
'flex flex-nowrap items-center justify-start gap-2 whitespace-nowrap',
className,
)}
>
{visibleActions}
{overflowActions.length > 0 ? (
<Dropdown>
<Dropdown.Trigger>
<Tooltip delay={0}>
<Button
isIconOnly
size="sm"
variant="ghost"
className="pm-table-action pm-table-action--default inline-flex size-8 items-center justify-center rounded-full"
variant="tertiary"
className="grid place-items-center p-0"
aria-label="更多操作"
title="更多操作"
>
<More size={16} />
</Button>
<Tooltip.Content>更多操作</Tooltip.Content>
</Tooltip>
<Dropdown.Popover className="rc-dropdown-popover min-w-36" placement="bottom end">
</Dropdown.Trigger>
<Dropdown.Popover className="min-w-36" placement="bottom end">
<Dropdown.Menu
aria-label="更多操作"
onAction={(key) => overflowActions[Number(key)]?.props?.onClick?.()}
onAction={(key) => overflowActions[Number(key)]?.props?.onPress?.()}
>
{overflowActions.map((action, index) => {
const label = action?.props?.label || `操作 ${index + 3}`;
......@@ -174,9 +97,8 @@ export function TableActionGroup({ className, children, ...props }) {
return (
<Dropdown.Item
id={String(index)}
isDisabled={action?.props?.disabled}
isDisabled={action?.props?.isDisabled}
key={`${label}-${index}`}
className="rc-dropdown-option"
textValue={label}
>
<span className="flex items-center gap-2">
......@@ -196,22 +118,23 @@ export function TableActionGroup({ className, children, ...props }) {
export function TableActionButton({
className,
color,
disabled,
isDisabled,
icon: Icon,
label,
onClick,
outline = false,
onPress,
tone,
...props
}) {
const resolvedTone = resolveActionTone({
color,
icon: Icon,
label,
outline,
tone,
});
const variant =
resolvedTone === 'delete' || resolvedTone === 'reject'
? 'danger-soft'
: resolvedTone === 'approve' || resolvedTone === 'submit'
? 'secondary'
: 'tertiary';
return (
<Tooltip delay={0}>
......@@ -219,16 +142,11 @@ export function TableActionButton({
{...props}
isIconOnly
aria-label={label}
title={label}
isDisabled={disabled}
isDisabled={isDisabled}
size="sm"
variant="ghost"
onClick={onClick}
className={clsx(
'pm-table-action inline-flex size-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50',
ACTION_TONE_CLASSES[resolvedTone] || ACTION_TONE_CLASSES.view,
className,
)}
variant={variant}
onPress={onPress}
className={clsx('grid place-items-center p-0', className)}
>
<Icon size={16} />
</Button>
......
import {
ChevronDown,
Close,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
SearchField,
} from '@heroui/react';
function filterOption(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
export default function XinyuanAutocomplete({
ariaLabel,
className,
emptyText = '暂无可选项',
isDisabled = false,
isRequired = false,
onSelectionChange,
options = [],
placeholder = '请选择',
selectedKey,
variant = 'secondary',
}) {
const selectedOption = options.find(
(option) => String(option.value) === String(selectedKey ?? ''),
);
return (
<Autocomplete
aria-label={ariaLabel || placeholder}
className={className}
fullWidth
isDisabled={isDisabled}
isRequired={isRequired}
selectedKey={selectedKey || null}
variant={variant}
onClear={() => onSelectionChange?.('')}
onSelectionChange={(key) => onSelectionChange?.(key ? String(key) : '')}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span
className="block min-w-0 truncate"
title={selectedOption?.label || placeholder}
>
{selectedOption?.label || placeholder}
</span>
</Autocomplete.Value>
{selectedKey ? (
<Autocomplete.ClearButton aria-label="清空选择">
<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}
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start">
<Autocomplete.Filter filter={filterOption}>
<SearchField
aria-label="搜索选项"
fullWidth
variant="secondary"
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder="输入关键词搜索" />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox
aria-label={ariaLabel || placeholder}
items={options}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4 text-sm text-muted">
{emptyText}
</div>
)}
>
{(option) => (
<ListBox.Item
id={String(option.value)}
isDisabled={option.disabled}
textValue={option.label}
>
<Label className="truncate" title={option.label}>
{option.label}
</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
import { Surface } from '@heroui/react';
export default function XinyuanFilterSurface({ children }) {
return (
<Surface className="rounded-3xl p-6" variant="default">
{children}
</Surface>
);
}
import { Close } from '@/components/AppIcons';
import { Modal } from '@heroui/react';
import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = {
xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]',
};
export default function XinyuanModal({
bodyClassName,
children,
dialogClassName,
footer,
isOpen,
onOpenChange,
size = 'lg',
title,
}) {
const modalSize = ['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'lg';
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={isOpen}
onOpenChange={onOpenChange}
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog
className={clsx(
DIALOG_WIDTH_CLASS[size] && 'p-7',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
>
<Modal.CloseTrigger aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
</Modal.Header>
<Modal.Body className={bodyClassName}>{children}</Modal.Body>
{footer ? <Modal.Footer>{footer}</Modal.Footer> : null}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export const APP_NAME = '党风廉政建设';
export const APP_ROUTE_NAME = 'incorrupt_party_build';
export const APP_BASE_PATH = `/${APP_ROUTE_NAME}`;
export const LOGIN_PATH = '/login';
export function getBaseAwareLoginPath(redirect = '') {
const loginPath = `${APP_BASE_PATH}${LOGIN_PATH}`;
return redirect
? `${loginPath}?redirect=${encodeURIComponent(redirect)}`
: loginPath;
}
export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
'var(--rc-chart-4)',
'var(--rc-chart-5)',
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
primary: 'var(--rc-chart-primary)',
risk: 'var(--rc-chart-risk)',
warning: 'var(--rc-chart-warning)',
positive: 'var(--rc-chart-positive)',
comparison: 'var(--rc-chart-comparison)',
neutral: 'var(--rc-chart-neutral)',
});
function normalizeChartKey(key) {
return String(key ?? '').trim();
}
function hashChartKey(key) {
return Array.from(key).reduce(
(value, character) => ((value * 31) + character.codePointAt(0)) >>> 0,
0,
);
}
export function 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 = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
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 (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
colorMap.set(key, XINYUAN_CHART_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';
}
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,
};
});
}
......@@ -35,7 +35,7 @@
display: flex;
min-height: 3rem;
align-items: center;
gap: 0.625rem;
gap: var(--rc-space-3);
padding: var(--rc-space-2) var(--rc-space-1);
color: var(--foreground);
font-size: var(--rc-text-sm);
......@@ -49,8 +49,8 @@
.rc-sidebar-brand-mark {
display: flex;
width: 1.75rem;
height: 1.75rem;
width: 2.75rem;
height: 1.5rem;
flex: none;
align-items: center;
justify-content: center;
......@@ -69,7 +69,7 @@
min-width: 0;
flex: 1;
align-items: center;
gap: 0.5rem;
gap: var(--rc-space-2);
}
.rc-sidebar-app-icon {
......@@ -98,29 +98,10 @@
white-space: nowrap;
}
.rc-sidebar-brand-mark .rc-sidebar-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.rc-sidebar-app-switcher {
flex: none;
}
.rc-sidebar-app-popover {
width: min(24rem, calc(100vw - 2rem));
padding: 0;
border-radius: var(--rc-radius-overlay);
}
.rc-sidebar-app-dialog {
max-height: min(28rem, calc(100dvh - 7rem));
padding: 0.75rem;
overflow-y: auto;
outline: none;
}
.rc-sidebar-content {
min-height: 0;
flex: 1;
......@@ -291,9 +272,6 @@
}
.rc-app-main {
--rc-page-title-inset: calc(
var(--rc-space-6) + var(--rc-navbar-trigger-size) + var(--rc-space-3)
);
display: flex;
min-width: 0;
min-height: 0;
......@@ -319,7 +297,7 @@
min-height: 4rem;
max-height: 4rem;
gap: var(--rc-space-3);
padding: 0 var(--rc-space-6);
padding: 0 var(--rc-navbar-inline-padding);
}
.rc-navbar [data-slot='sidebar-trigger'] {
......@@ -328,6 +306,7 @@
max-width: var(--rc-navbar-trigger-size);
height: var(--rc-navbar-trigger-size);
flex: 0 0 var(--rc-navbar-trigger-size);
margin-inline-start: 0;
}
.rc-navbar-icon-button.button,
......@@ -431,10 +410,257 @@
min-width: 0;
min-height: 100%;
margin: 0;
padding: var(--rc-space-4) var(--rc-page-title-inset) var(--rc-space-5);
padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
padding-inline-start: var(--rc-page-inline-start);
padding-inline-end: var(--rc-page-inline-end);
overflow-x: clip;
}
/* Shared page rhythm. These classes control layout only, never HeroUI internals. */
.rc-page-stack,
.rc-page-section,
.rc-list-stack,
.rc-list-data-stack {
display: flex;
min-width: 0;
flex-direction: column;
}
.rc-page-stack {
gap: var(--rc-page-section-gap);
}
.rc-page-section {
gap: var(--rc-page-module-gap);
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
}
.rc-chart-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.rc-chart-grid > * {
min-width: 0;
}
.rc-chart-grid > :only-child {
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
}
.rc-chart-interaction:focus-visible,
.rc-chart-interaction :focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: var(--rc-related-item-gap);
}
@media (min-width: 48rem) {
.rc-kpi-grid:not([data-count='1']) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 80rem) {
.rc-kpi-grid[data-count='1'] {
grid-template-columns: minmax(0, 1fr);
}
.rc-kpi-grid[data-count='2'] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='3'],
.rc-kpi-grid[data-count='5'],
.rc-kpi-grid[data-count='6'],
.rc-kpi-grid[data-count='9'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='4'],
.rc-kpi-grid[data-count='7'],
.rc-kpi-grid[data-count='8'] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Shared HeroUI/HeroUI Pro business-card contract. */
.rc-card-surface {
border: 1px solid var(--rc-border-default);
background-color: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
display: grid;
width: 3rem;
min-width: 3rem;
max-width: 3rem;
height: 3rem;
min-height: 3rem;
max-height: 3rem;
flex: 0 0 3rem;
place-items: center;
border-radius: 1rem;
color: var(--rc-metric-icon-color);
background-color: var(--rc-metric-icon-background);
line-height: 1;
}
.rc-metric-icon-surface[data-tone='info'] {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-metric-icon-surface[data-tone='success'] {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-metric-icon-surface[data-tone='teal'] {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-metric-icon-surface[data-tone='danger'] {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-metric-icon-surface[data-tone='warning'] {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-metric-icon-surface[data-tone='violet'] {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-metric-icon-surface[data-tone='indigo'] {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-metric-icon-surface[data-tone='coral'] {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
/* Untoned KPI items receive a unique ordered tone for boards of up to nine. */
.rc-kpi-grid > :nth-child(1) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-kpi-grid > :nth-child(2) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-kpi-grid > :nth-child(3) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-kpi-grid > :nth-child(4) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-kpi-grid > :nth-child(5) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-kpi-grid > :nth-child(6) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-kpi-grid > :nth-child(7) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-kpi-grid > :nth-child(8) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
.rc-kpi-grid > :nth-child(9) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
}
.rc-metric-icon-surface > .anticon,
.rc-metric-icon-surface > svg {
display: block;
width: 1.25rem;
height: 1.25rem;
margin: 0;
color: inherit;
font-size: 1.25rem;
line-height: 1;
}
.rc-metric-icon-surface > .anticon > svg {
display: block;
width: 1em;
height: 1em;
margin: 0;
}
.rc-page-toolbar {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--rc-page-toolbar-gap);
}
.rc-page-toolbar-responsive {
flex-direction: column;
align-items: stretch;
}
.rc-list-stack,
.rc-list-data-stack {
gap: var(--rc-list-stack-gap);
}
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rc-mobile-sidebar-backdrop {
position: fixed;
inset: 0;
......@@ -473,6 +699,10 @@
}
@media (max-width: 1023px) {
.rc-app-main {
--rc-shell-inline-gutter: var(--rc-space-8);
}
.rc-sidebar {
display: none;
}
......@@ -482,15 +712,25 @@
}
}
@media (min-width: 768px) {
.rc-page-toolbar-responsive {
flex-direction: row;
align-items: center;
}
}
@media (max-width: 640px) {
.rc-app-main {
--rc-page-title-inset: calc(
var(--rc-space-6) + var(--rc-navbar-icon-button-size) + var(--rc-space-3)
);
--rc-shell-inline-gutter: var(--rc-space-4);
--rc-page-padding-block-start: var(--rc-space-4);
--rc-page-padding-block-end: var(--rc-space-6);
--rc-page-section-gap: var(--rc-space-6);
--rc-page-module-gap: var(--rc-space-4);
}
.rc-app-page {
padding: var(--rc-space-4) var(--rc-page-title-inset) var(--rc-space-5);
padding-block: var(--rc-page-padding-block-start)
var(--rc-page-padding-block-end);
}
.rc-navbar [data-slot='sidebar-trigger'] {
......@@ -540,7 +780,7 @@
.rc-sidebar-brand {
min-height: 3rem;
gap: 0.625rem;
gap: var(--rc-space-3);
padding: var(--rc-space-2) var(--rc-space-1);
}
......@@ -548,7 +788,7 @@
min-width: 0;
color: var(--foreground);
font-size: 0.95rem;
font-weight: var(--rc-font-semibold);
font-weight: 650;
line-height: 1.2rem;
}
......@@ -584,14 +824,13 @@
}
.rc-sidebar-app-popover {
width: min(24rem, calc(100vw - 2rem));
width: min(45rem, calc(100vw - 2rem));
padding: 0;
border-radius: var(--rc-radius-overlay);
}
.rc-sidebar-app-dialog {
max-height: min(28rem, calc(100dvh - 7rem));
padding: 0.75rem;
max-height: min(32rem, calc(100dvh - 7rem));
padding: 1.5rem;
overflow-y: auto;
outline: none;
}
......@@ -623,31 +862,6 @@
font-weight: var(--rc-font-semibold);
}
.rc-sidebar-app-current {
display: inline-flex;
height: 1.25rem;
align-items: center;
border: 1px solid var(--rc-brand-primary-soft-border);
border-radius: 999px;
background: var(--rc-brand-primary-soft);
padding: 0 0.5rem;
color: var(--rc-brand-primary);
font-size: var(--rc-text-xs);
font-weight: var(--rc-font-medium);
line-height: 1;
white-space: nowrap;
}
.rc-sidebar-app-status {
border-radius: var(--rc-radius-control);
background: var(--rc-color-warning-soft);
padding: 0.5rem 0.625rem;
color: var(--rc-color-warning);
font-size: var(--rc-text-xs);
font-weight: var(--rc-font-medium);
line-height: 1rem;
}
.rc-app-shell .sidebar__content,
.rc-mobile-sidebar .sidebar__content {
min-height: 0;
......@@ -711,7 +925,7 @@
padding: var(--rc-space-2) var(--rc-space-3) var(--rc-space-4);
}
.rc-sidebar-user-trigger.button {
.rc-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
......@@ -726,9 +940,9 @@
text-align: left;
}
.rc-sidebar-user-trigger.button:hover,
.rc-sidebar-user-trigger.button[data-hovered='true'],
.rc-sidebar-user-trigger.button[aria-expanded='true'] {
.rc-sidebar-user-trigger.dropdown__trigger:hover,
.rc-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.rc-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
......@@ -764,6 +978,13 @@
background: var(--rc-bg-canvas);
}
.dark .rc-app-main.sidebar__main,
[data-theme='dark'] .rc-app-main.sidebar__main,
.dark .app-shell-content-scroll,
[data-theme='dark'] .app-shell-content-scroll {
background: var(--rc-bg-canvas);
}
.rc-navbar {
flex: none;
height: 4rem;
......@@ -843,8 +1064,7 @@
width: auto;
max-width: 8rem;
height: 1.5rem;
filter: brightness(0) saturate(100%) invert(13%) sepia(17%) saturate(1318%)
hue-rotate(181deg) brightness(91%) contrast(92%);
filter: none;
object-fit: contain;
}
......@@ -854,6 +1074,7 @@
}
.rc-login-card.card {
width: 100%;
border-radius: var(--rc-radius-overlay);
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-raised);
......@@ -919,19 +1140,19 @@
}
.app-sidebar-brand-mark {
display: flex;
display: grid;
width: 1.75rem;
height: 1.75rem;
flex: none;
align-items: center;
justify-content: center;
place-items: center;
overflow: hidden;
}
.app-sidebar-brand-mark img {
grid-area: 1 / 1;
display: block;
width: 100%;
height: 100%;
width: 1.75rem;
height: 1.75rem;
object-fit: contain;
}
......@@ -940,7 +1161,7 @@
overflow: hidden;
color: var(--foreground);
font-size: 0.95rem;
font-weight: var(--rc-font-semibold);
font-weight: 650;
line-height: 1.2rem;
letter-spacing: 0;
font-synthesis: none;
......@@ -1007,8 +1228,8 @@
}
.app-sidebar-app-scroll {
max-height: min(28rem, calc(100dvh - 7rem));
padding: 0.75rem;
max-height: min(32rem, calc(100dvh - 7rem));
padding: 1.5rem;
overflow-y: auto;
}
......@@ -1110,13 +1331,13 @@
padding: 0.5rem 0.75rem 1rem;
}
.app-sidebar-user-trigger.button {
.app-sidebar-user-trigger.dropdown__trigger {
display: flex;
width: 100%;
min-width: 0;
min-height: 3rem;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 0.625rem;
padding: 0.375rem 0.5rem !important;
border-radius: calc(var(--radius) * 2) !important;
......@@ -1126,13 +1347,13 @@
}
@media (hover: hover) {
.app-sidebar-user-trigger.button:hover,
.app-sidebar-user-trigger.button[data-hovered='true'] {
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--default);
}
}
.app-sidebar-user-trigger.button[aria-expanded='true'] {
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default);
}
......
/*
* HeroUI Theme Customization
* Add this to your global.css after importing @heroui/styles
* Only includes variables users need to customize
* @see https://heroui.com/docs/react/getting-started/theming
*/
:root,
.light,
.default,
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
/* Border Radius */
--radius: 0.5rem;
--field-radius: 0.75rem;
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
}
......@@ -2,76 +2,39 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'] {
/* Xinyuan brand and semantic colors */
--rc-brand-primary: #9e1b1b;
--rc-brand-light: #b83a3a;
--rc-brand-dark: #7e1515;
--rc-brand-primary-hover: var(--rc-brand-light);
--rc-brand-primary-pressed: var(--rc-brand-dark);
--rc-brand-primary-soft: rgb(158 27 27 / 8%);
--rc-brand-primary-soft-hover: rgb(158 27 27 / 12%);
--rc-brand-glass: rgb(158 27 27 / 4%);
--rc-brand-on-primary: #ffffff;
--rc-color-success: #16845b;
--rc-color-warning: #b46a00;
--rc-color-danger: #c93636;
--rc-color-info: var(--rc-brand-primary);
--rc-color-teal: #0f766e;
--rc-color-favorite: #d68a00;
--rc-color-favorite-soft: rgb(214 138 0 / 10%);
--rc-color-success-soft: rgb(22 132 91 / 9%);
--rc-color-success-border: rgb(22 132 91 / 24%);
--rc-color-warning-soft: rgb(180 106 0 / 10%);
--rc-color-warning-border: rgb(180 106 0 / 25%);
--rc-color-danger-soft: rgb(201 54 54 / 9%);
--rc-color-danger-border: rgb(201 54 54 / 22%);
--rc-color-info-soft: var(--rc-brand-primary-soft);
--rc-color-info-border: color-mix(in srgb, var(--rc-brand-primary) 24%, transparent);
--rc-color-teal-soft: rgb(15 118 110 / 9%);
--rc-color-teal-border: rgb(15 118 110 / 24%);
--rc-color-neutral-soft: #f4f4f5;
--rc-color-neutral-border: #e4e4e7;
[data-theme='default'],
.dark,
[data-theme='dark'] {
/* 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);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
/* Surfaces, borders and text */
--rc-bg-canvas: #f5f5f5;
--rc-bg-surface: #ffffff;
--rc-bg-subtle: #f8fafc;
--rc-layout-background: #f4f6f9;
--rc-sidebar-glass: rgb(255 255 255 / 42%);
--rc-main-glass: rgb(255 255 255 / 82%);
--rc-shell-glass: rgb(255 255 255 / 78%);
--rc-glass-border: rgb(255 255 255 / 60%);
--rc-sidebar-border: rgb(226 232 240 / 55%);
--rc-border-default: #e3e8ef;
--rc-border-strong: #cbd3de;
--rc-field-background: #ffffff;
--rc-field-border: #d9e1eb;
--rc-field-border-hover: #b8c4d2;
--rc-field-border-focus: var(--rc-brand-primary);
--rc-button-secondary-border: rgb(9 9 11 / 12%);
--rc-button-secondary-border-hover: rgb(9 9 11 / 20%);
--rc-button-secondary-background-hover: rgb(9 9 11 / 4%);
--rc-text-primary: #172033;
--rc-text-secondary: #4e5d73;
--rc-text-muted: #718096;
--rc-bg-canvas: var(--background);
--rc-bg-surface: var(--surface);
--rc-border-default: var(--border);
--rc-field-background: var(--field-background);
--rc-field-border-focus: var(--focus);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 12%,
transparent
);
--rc-text-primary: var(--foreground);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 72%,
var(--background)
);
--rc-text-muted: var(--muted);
/* Typography */
--rc-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
--rc-font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
--rc-text-md: 1rem;
--rc-text-lg: 1.25rem;
--rc-text-xl: 1.5rem;
--rc-font-regular: 400;
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
--rc-font-semibold: 600;
--rc-line-compact: 1.25;
--rc-line-normal: 1.5;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
/* Spacing, radius and motion */
--rc-space-1: 0.25rem;
--rc-space-2: 0.5rem;
--rc-space-3: 0.75rem;
......@@ -79,7 +42,19 @@
--rc-space-5: 1.25rem;
--rc-space-6: 1.5rem;
--rc-space-8: 2rem;
--rc-space-10: 2.5rem;
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
--rc-page-padding-block-start: 1.5rem;
--rc-page-padding-block-end: 2rem;
--rc-page-section-gap: 2rem;
--rc-page-module-gap: 1.5rem;
--rc-related-item-gap: 1rem;
--rc-page-toolbar-gap: 1rem;
--rc-list-stack-gap: 1rem;
--rc-radius-control: 0.375rem;
--rc-radius-container: 0.5rem;
--rc-radius-overlay: 0.75rem;
......@@ -87,230 +62,116 @@
--rc-motion-normal: 180ms;
--rc-ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* Component contracts */
--rc-filter-radius: 0.75rem;
--rc-button-radius: 1.5rem;
--rc-button-sm-height: 2rem;
--rc-button-md-height: 2.25rem;
--rc-button-lg-height: 2.5rem;
--rc-table-action-width: 9rem;
--rc-table-action-gap: 0.5rem;
--rc-table-action-padding-inline: 0.75rem;
--rc-table-action-idle-opacity: 0.55;
--rc-navbar-trigger-size: 2rem;
--rc-navbar-icon-button-size: 2.25rem;
--rc-segment-track-padding: 0.125rem;
--rc-segment-track-radius: 0.75rem;
--rc-segment-item-height: 1.75rem;
--rc-segment-item-radius: 0.625rem;
--rc-segment-item-padding-inline: 0.75rem;
--rc-segment-item-gap: 0.5rem;
--rc-segment-font-size: var(--rc-text-sm);
--rc-segment-font-weight: var(--rc-font-medium);
--rc-segment-line-height: 1.25rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-navbar-border-width: 0px;
--rc-navbar-title-size: 1.25rem;
--rc-navbar-page-title-size: var(--rc-navbar-title-size);
--rc-navbar-title-weight: 600;
--rc-navbar-title-line-height: 1.75rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-login-panel-width: 28rem;
--rc-login-brand-size: 1.5rem;
--rc-login-brand-weight: var(--rc-font-semibold);
--rc-login-brand-gap: 1.5rem;
--rc-toolbar-filter-background: var(--default);
--rc-toolbar-filter-background-hover: var(--default-hover);
--rc-toolbar-filter-foreground: var(--foreground);
--rc-toolbar-filter-icon: var(--muted);
--rc-toolbar-filter-radius: 999px;
--rc-toolbar-filter-content-gap: 0.5rem;
--rc-data-grid-radius: calc(var(--radius) * 2.5);
--rc-data-grid-header-background: var(--surface-secondary);
--rc-data-grid-header-height: 2.25rem;
--rc-data-grid-header-font-size: var(--rc-text-xs);
--rc-data-grid-header-font-weight: var(--rc-font-medium);
--rc-data-grid-header-line-height: var(--rc-line-compact);
--rc-data-grid-header-color: var(--rc-text-muted);
--rc-data-grid-body-font-size: var(--rc-text-sm);
--rc-data-grid-body-font-weight: var(--rc-font-regular);
--rc-data-grid-body-line-height: var(--rc-line-normal);
--rc-data-grid-body-color: var(--rc-text-secondary);
--rc-data-grid-primary-font-size: var(--rc-text-sm);
--rc-data-grid-primary-font-weight: 550;
--rc-data-grid-primary-line-height: var(--rc-line-normal);
--rc-data-grid-primary-color: var(--rc-text-primary);
--rc-data-grid-secondary-font-size: 0.8125rem;
--rc-data-grid-secondary-font-weight: var(--rc-font-regular);
--rc-data-grid-secondary-line-height: var(--rc-line-compact);
--rc-data-grid-secondary-color: #7a8596;
--rc-data-grid-text-pair-gap: 0.25rem;
--rc-data-grid-chip-font-size: var(--rc-text-xs);
--rc-data-grid-chip-font-weight: var(--rc-font-medium);
--rc-data-grid-chip-line-height: var(--rc-line-compact);
--rc-data-grid-row-hover-background: color-mix(
in oklab,
var(--default) 35%,
var(--surface)
);
--rc-modal-radius: 1.5rem;
--rc-modal-padding: 1.75rem;
--rc-modal-title-size: var(--rc-text-md);
--rc-modal-title-weight: var(--rc-font-medium);
--rc-modal-title-line-height: 1.5rem;
--rc-modal-section-gap: 0.75rem;
--rc-modal-footer-gap: 0.5rem;
--rc-modal-footer-margin: 1.5rem;
--rc-modal-backdrop-blur: 0.75rem;
--rc-modal-close-size: 1.5rem;
--rc-modal-close-inset: var(--rc-modal-padding);
--rc-modal-close-icon-size: 1rem;
--rc-modal-width-xs: 20rem;
--rc-modal-width-sm: 24rem;
--rc-modal-width-md: 30rem;
--rc-modal-width-lg: 40rem;
--rc-modal-width-xl: 46rem;
--rc-modal-width-2xl: 52.5rem;
--rc-modal-width-3xl: 60rem;
--rc-modal-width-4xl: 68rem;
--rc-modal-width-5xl: 76rem;
--rc-data-grid-action-size: 2rem;
--rc-data-grid-action-radius: 999px;
--rc-list-pagination-gap: 0.5rem;
--rc-checkbox-size: 1rem;
--rc-checkbox-radius: 0.25rem;
--rc-checkbox-background: var(--rc-field-background);
--rc-checkbox-border: var(--rc-field-border);
--rc-checkbox-border-hover: var(--rc-field-border-hover);
--rc-checkbox-disabled-background: #f3f5f8;
--rc-checkbox-disabled-border: #d8dee7;
--rc-checkbox-disabled-opacity: 0.56;
--rc-checkbox-checked-background: var(--rc-brand-primary);
--rc-checkbox-checked-border: var(--rc-brand-dark);
--rc-checkbox-check-color: var(--rc-brand-on-primary);
--rc-kbd-background: #f4f6f9;
--rc-content-max-width: 80rem;
--rc-page-gutter: 1.25rem;
/* Elevation */
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%), 0 4px 12px rgb(15 23 42 / 2%);
--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-brand-glow: 0 4px 14px rgb(158 27 27 / 15%);
--rc-shadow-main-glass: 0 8px 28px rgb(148 163 184 / 14%),
inset 0 0 0 1px rgb(255 255 255 / 30%);
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%),
0 4px 12px rgb(15 23 42 / 2%);
/* HeroUI semantic bridge */
--accent: var(--rc-brand-primary);
--accent-foreground: var(--rc-brand-on-primary);
--accent-hover: var(--rc-brand-primary-hover);
--accent-soft: var(--rc-brand-primary-soft);
--accent-soft-hover: var(--rc-brand-primary-soft-hover);
--accent-soft-foreground: color-mix(
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
var(--surface)
);
--rc-metric-tone-success-soft: color-mix(
in oklab,
var(--rc-metric-tone-success) 9%,
var(--surface)
);
--rc-metric-tone-teal-soft: color-mix(
in oklab,
var(--rc-metric-tone-teal) 9%,
var(--surface)
);
--rc-metric-tone-danger-soft: color-mix(
in oklab,
var(--rc-metric-tone-danger) 9%,
var(--surface)
);
--rc-metric-tone-warning-soft: color-mix(
in oklab,
var(--rc-metric-tone-warning) 9%,
var(--surface)
);
--rc-metric-tone-violet-soft: color-mix(
in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-brand-primary) 82%,
var(--rc-text-primary) 18%
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
var(--surface)
);
--background: var(--rc-bg-canvas);
--surface: var(--rc-bg-surface);
--surface-shadow: var(--rc-shadow-card);
--foreground: var(--rc-text-primary);
--muted: var(--rc-text-muted);
--muted-foreground: var(--rc-text-secondary);
--separator: var(--rc-border-default);
--focus: var(--rc-brand-primary);
--app-background-gradient: radial-gradient(
circle at 0% 0%,
rgb(158 27 27 / 5%) 0%,
rgb(244 246 249 / 0%) 50%
),
var(--rc-layout-background);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-brand-primary-soft: rgb(158 27 27 / 16%);
--rc-brand-primary-soft-hover: rgb(158 27 27 / 22%);
--rc-brand-glass: rgb(158 27 27 / 10%);
--rc-color-success: #4fca92;
--rc-color-warning: #f0ad4e;
--rc-color-danger: #ff7272;
--rc-color-info: #f0a3a3;
--rc-color-teal: #5eead4;
--rc-color-favorite: #f2b84b;
--rc-color-favorite-soft: rgb(242 184 75 / 14%);
--rc-color-success-soft: rgb(79 202 146 / 14%);
--rc-color-success-border: rgb(79 202 146 / 30%);
--rc-color-warning-soft: rgb(240 173 78 / 15%);
--rc-color-warning-border: rgb(240 173 78 / 32%);
--rc-color-danger-soft: rgb(255 114 114 / 14%);
--rc-color-danger-border: rgb(255 114 114 / 30%);
--rc-color-info-soft: rgb(158 27 27 / 18%);
--rc-color-info-border: rgb(184 58 58 / 42%);
--rc-color-teal-soft: rgb(94 234 212 / 14%);
--rc-color-teal-border: rgb(94 234 212 / 30%);
--rc-color-neutral-soft: #27272a;
--rc-color-neutral-border: #3f3f46;
--rc-bg-canvas: #0f0f11;
--rc-bg-surface: #18181b;
--rc-bg-subtle: #202024;
--rc-layout-background: #0f0f11;
--rc-sidebar-glass: #0f0f11;
--rc-main-glass: #0f0f11;
--rc-shell-glass: #0f0f11;
--rc-glass-border: #303036;
--rc-sidebar-border: #303036;
--rc-border-default: #303036;
--rc-border-strong: #45454d;
--rc-field-background: #202024;
--rc-field-border: #3b3b42;
--rc-field-border-hover: #56565f;
--rc-button-secondary-border: rgb(255 255 255 / 18%);
--rc-button-secondary-border-hover: rgb(255 255 255 / 28%);
--rc-button-secondary-background-hover: rgb(255 255 255 / 7%);
--rc-text-primary: #f4f4f5;
--rc-text-secondary: #d4d4d8;
--rc-text-muted: #a1a1aa;
--rc-data-grid-secondary-color: #8b8b95;
--rc-checkbox-background: #202024;
--rc-checkbox-border: #56565f;
--rc-checkbox-border-hover: #6f6f7a;
--rc-checkbox-disabled-background: #1b1b1f;
--rc-checkbox-disabled-border: #36363d;
--rc-checkbox-disabled-opacity: 0.58;
--rc-kbd-background: #202024;
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 18%), 0 4px 12px rgb(0 0 0 / 12%);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%), 0 8px 24px rgb(0 0 0 / 24%);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-main-glass: none;
--accent: var(--rc-brand-primary);
--accent-foreground: var(--rc-brand-on-primary);
--accent-hover: var(--rc-brand-light);
--accent-soft: var(--rc-brand-primary-soft);
--accent-soft-hover: var(--rc-brand-primary-soft-hover);
--accent-soft-foreground: #f0a3a3;
--background: var(--rc-bg-canvas);
--surface: var(--rc-bg-surface);
--surface-secondary: var(--rc-bg-subtle);
--surface-shadow: var(--rc-shadow-card);
--overlay: var(--rc-bg-surface);
--overlay-foreground: var(--rc-text-primary);
--foreground: var(--rc-text-primary);
--muted: var(--rc-text-muted);
--muted-foreground: var(--rc-text-secondary);
--default: #27272a;
--default-hover: #3f3f46;
--border: var(--rc-border-default);
--separator: var(--rc-border-default);
--field: var(--rc-field-background);
--field-foreground: var(--rc-text-primary);
--focus: var(--rc-brand-primary);
--backdrop: rgb(0 0 0 / 50%);
--app-background-gradient: var(--rc-bg-canvas);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
}
:root {
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
--pm-surface: var(--rc-bg-surface);
--pm-bg: var(--rc-bg-canvas);
--pm-sidebar: var(--rc-bg-canvas);
--pm-sidebar-soft: var(--rc-bg-subtle);
--pm-text: var(--rc-text-primary);
--pm-muted: var(--rc-text-muted);
--rc-login-panel-width: 28rem;
--rc-login-control-height: 2.5rem;
--rc-login-submit-height: 2.875rem;
--rc-login-submit-margin-top: 0.875rem;
--rc-login-brand-weight: var(--rc-font-semibold);
--rc-login-captcha-image-max-height: 1.875rem;
--pm-brand: var(--rc-brand-primary, #9e1b1b);
--pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5);
--pm-text: var(--rc-text-primary, #172033);
--pm-muted: var(--rc-text-muted, #718096);
}
html,
......@@ -23,7 +15,7 @@ body,
body {
margin: 0;
background: var(--rc-bg-canvas);
background: var(--rc-bg-canvas, #f5f5f5);
color: var(--pm-text);
font-family:
"PingFang SC",
......@@ -41,633 +33,3 @@ a {
color: inherit;
text-decoration: none;
}
.login-page {
min-height: 100vh;
background: var(--rc-bg-canvas);
color: var(--rc-text-primary);
}
.login-shell {
display: grid;
min-height: 100vh;
place-items: center;
padding: 2rem 1rem;
}
.login-card {
width: min(calc(100vw - 2rem), var(--rc-login-panel-width));
max-width: var(--rc-login-panel-width);
border: 1px solid var(--rc-border-default);
border-radius: 24px;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-overlay);
padding: 2.25rem;
}
.login-card.card {
width: min(calc(100vw - 2rem), var(--rc-login-panel-width));
max-width: var(--rc-login-panel-width);
border-color: var(--rc-border-default);
border-radius: 24px;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-overlay);
padding: 0;
}
.login-card > [data-slot='card-content'] {
padding: 2.25rem;
}
.login-card-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
margin-bottom: 32px;
text-align: center;
}
.login-brand-logo {
width: 36px;
height: 36px;
object-fit: contain;
}
.login-card-brand .login-brand-logo--dark {
display: none;
}
.dark .login-card-brand .login-brand-logo--light {
display: none;
}
.dark .login-card-brand .login-brand-logo--dark {
display: block;
}
.login-card-brand :is(h1, strong) {
margin: 0;
color: var(--rc-text-primary);
font-size: 32px;
font-weight: var(--rc-login-brand-weight);
letter-spacing: 0;
line-height: 1.15;
}
.login-form {
display: grid;
gap: 1rem;
}
.login-field {
display: grid;
gap: 0.5rem;
color: var(--rc-text-secondary);
font-size: 14px;
font-weight: 500;
}
.login-input,
.login-field :is(.input, .input--secondary, [data-slot='input'], [data-slot='input-wrapper'], [data-slot='control']) {
min-height: var(--rc-login-control-height);
height: var(--rc-login-control-height);
}
.login-input {
width: 100%;
border: 1px solid var(--rc-field-border);
border-radius: 8px;
background: var(--rc-field-background);
box-shadow: var(--field-shadow);
color: var(--rc-text-primary);
font: inherit;
outline: none;
padding: 0 0.875rem;
transition: border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease;
}
.login-input:hover {
border-color: var(--rc-field-border-hover);
}
.login-input:focus {
border-color: var(--rc-brand-primary);
box-shadow: var(--field-shadow), 0 0 0 2px rgb(158 27 27 / 14%);
}
.login-input[data-filled='true'],
.login-input:not(:placeholder-shown),
input.login-input.input--secondary:not(:placeholder-shown) {
background: #fbf1f1;
--input-bg: #fbf1f1;
--input-bg-hover: #faeaea;
--input-bg-focus: #fbf1f1;
}
.login-input[data-filled='true']:hover,
.login-input:not(:placeholder-shown):hover {
background: #faeaea;
}
.login-input:-webkit-autofill,
.login-input:-webkit-autofill:hover,
.login-input:-webkit-autofill:focus {
box-shadow: inset 0 0 0 1000px #fbf1f1, var(--field-shadow);
-webkit-box-shadow: inset 0 0 0 1000px #fbf1f1, var(--field-shadow);
-webkit-text-fill-color: var(--rc-text-primary);
}
.login-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 12px;
}
.login-captcha-button {
display: grid;
width: 100%;
height: var(--rc-login-control-height);
place-items: center;
border: 1px solid var(--rc-field-border);
border-radius: 8px;
background: var(--rc-bg-surface);
color: var(--rc-text-secondary);
cursor: pointer;
font-size: 12px;
}
.login-captcha-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.captcha-image {
display: grid;
width: 100%;
height: 100%;
place-items: center;
}
.captcha-image svg,
.captcha-image img {
width: auto;
max-width: 100%;
max-height: var(--rc-login-captcha-image-max-height);
}
.login-submit-button {
display: inline-flex;
width: 100%;
height: var(--rc-login-submit-height);
align-items: center;
justify-content: center;
margin-top: var(--rc-login-submit-margin-top);
border: 0;
border-radius: 24px;
background: var(--rc-brand-primary);
color: #fff;
cursor: pointer;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.login-submit-button:hover {
background: var(--rc-brand-primary-hover, var(--rc-brand-light));
}
.login-submit-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.rc-alert {
border-radius: 12px;
border: 1px solid var(--rc-border-default);
padding: 0.75rem 1rem;
font-size: 14px;
line-height: 1.5;
}
.rc-alert--danger {
border-color: var(--rc-color-danger-border);
background: var(--rc-color-danger-soft);
color: var(--rc-color-danger);
}
.rc-alert--success {
border-color: var(--rc-color-success-border);
background: var(--rc-color-success-soft);
color: var(--rc-color-success);
}
.rc-app-page > .space-y-5,
.rc-app-page > .space-y-6 {
width: 100%;
}
.dark body {
background: var(--rc-bg-canvas);
}
.dark .login-card {
background: var(--rc-bg-surface);
}
.dark .login-input[data-filled='true'],
.dark .login-input:not(:placeholder-shown) {
background: rgb(158 27 27 / 16%);
}
.rc-app-shell {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--rc-bg-canvas);
}
.rc-sidebar {
display: flex;
width: 240px;
flex: 0 0 240px;
flex-direction: column;
border-right: 1px solid var(--rc-border-default);
background: var(--rc-bg-canvas);
transition: width 180ms ease, flex-basis 180ms ease;
}
.rc-sidebar.is-collapsed {
width: 0;
flex-basis: 0;
overflow: hidden;
border-right: 0;
}
.app-sidebar-brand-mark .rc-sidebar-logo--dark {
display: none;
}
.dark .app-sidebar-brand-mark .rc-sidebar-logo--light {
display: none;
}
.dark .app-sidebar-brand-mark .rc-sidebar-logo--dark {
display: block;
}
.rc-sidebar-content {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 0 12px;
}
.rc-sidebar-menu {
display: grid;
gap: 4px;
}
.rc-sidebar-menu-item {
display: flex;
height: 36px;
align-items: center;
gap: 12px;
border-radius: 16px;
padding: 0 8px;
color: var(--rc-text-secondary);
font-size: 14px;
line-height: 20px;
}
.rc-sidebar-menu-item:hover,
.rc-sidebar-menu-item.is-current {
background: var(--default);
color: var(--rc-text-primary);
}
.rc-sidebar-menu-icon {
display: grid;
width: 20px;
place-items: center;
color: var(--rc-text-muted);
}
.rc-sidebar-menu-item.is-current .rc-sidebar-menu-icon {
color: var(--rc-text-primary);
}
.rc-sidebar-footer {
padding: 8px 12px 16px;
}
.rc-sidebar-user-trigger {
width: 100%;
}
.rc-sidebar-logout-icon {
margin-left: auto;
color: var(--rc-text-muted);
}
.rc-app-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
background: var(--rc-bg-canvas);
}
.rc-navbar {
height: 64px;
flex: 0 0 64px;
background: transparent;
}
.rc-navbar-header {
display: flex;
height: 64px;
align-items: center;
gap: 12px;
padding: 0 24px;
}
.rc-navbar-spacer {
flex: 1;
}
.rc-navbar-trigger,
.rc-navbar-icon-button,
.rc-sidebar-app-trigger {
display: inline-grid;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--rc-text-secondary);
cursor: pointer;
padding: 0;
}
.rc-navbar-trigger {
width: 32px;
height: 32px;
}
.rc-navbar-icon-button,
.rc-sidebar-app-trigger {
width: 36px;
height: 36px;
}
.rc-navbar-trigger:hover,
.rc-navbar-icon-button:hover,
.rc-sidebar-app-trigger:hover {
background: var(--default);
color: var(--rc-text-primary);
}
.rc-navbar-title {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--rc-text-primary);
font-size: 20px;
font-weight: 600;
letter-spacing: 0;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-file-upload {
display: block;
}
.rc-file-upload__area {
display: grid;
gap: 8px;
place-items: center;
border: 1px dashed var(--rc-border-default);
border-radius: 16px;
background: var(--rc-field-background);
color: var(--rc-text-secondary);
cursor: pointer;
padding: 24px;
}
.rc-file-upload__input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.rc-file-upload__icon {
color: var(--rc-brand-primary);
}
.rc-file-upload__label {
max-width: 100%;
overflow: hidden;
color: var(--rc-text-primary);
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-file-upload__description {
color: var(--rc-text-muted);
font-size: 13px;
}
.rc-file-upload__trigger {
display: inline-flex;
height: 32px;
align-items: center;
border: 1px solid var(--rc-button-secondary-border, var(--rc-border-default));
border-radius: 24px;
padding: 0 12px;
color: var(--rc-text-primary);
font-size: 14px;
font-weight: 500;
}
.pm-data-grid-wrap {
position: relative;
overflow: hidden;
border-radius: 20px;
background: var(--rc-bg-surface);
}
.pm-data-grid-scroll {
overflow-x: auto;
}
table.pm-data-grid {
width: 100%;
min-width: 1180px;
border-collapse: separate;
border-spacing: 0;
background: var(--rc-bg-surface);
color: var(--rc-text-secondary);
}
.pm-data-grid th {
height: 36px;
background: var(--rc-data-grid-header-background, var(--rc-bg-subtle));
color: var(--rc-text-muted);
font-size: 12px;
font-weight: 500;
padding: 0 16px;
text-align: left;
white-space: nowrap;
}
.pm-data-grid td {
border-top: 1px solid var(--rc-border-subtle, var(--rc-border-default));
color: var(--rc-text-secondary);
font-size: 14px;
font-weight: 400;
padding: 10px 16px;
vertical-align: middle;
}
.pm-data-grid tr:hover td {
background: var(--rc-data-grid-row-hover-background, var(--default));
}
.pm-data-grid-selection-cell {
width: 44px;
min-width: 44px;
}
.pm-data-grid-actions-cell {
width: 80px;
}
.pm-data-grid-truncate {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pm-data-grid-empty-overlay {
display: grid;
min-height: 12rem;
place-items: center;
color: var(--rc-text-muted);
}
.data-grid-empty-state {
display: grid;
gap: 12px;
place-items: center;
}
.data-grid-empty-title {
color: var(--rc-text-muted);
font-size: 14px;
}
.pm-data-table-action-bar {
position: sticky;
bottom: 24px;
z-index: 30;
display: flex;
width: max-content;
max-width: calc(100vw - 48px);
align-items: center;
gap: 8px;
margin: 12px auto 0;
border: 1px solid var(--rc-border-default);
border-radius: 999px;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-overlay);
padding: 8px;
}
.pm-data-table-action-bar > button {
display: inline-grid;
width: 32px;
height: 32px;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--rc-text-secondary);
}
.pm-data-grid-text-tooltip {
position: fixed;
z-index: 2000;
max-width: min(420px, calc(100vw - 32px));
border: 1px solid var(--rc-border-default);
border-radius: 12px;
background: var(--rc-bg-surface);
box-shadow: var(--rc-shadow-raised);
color: var(--rc-text-primary);
padding: 8px 10px;
pointer-events: none;
white-space: pre-wrap;
}
.app-switcher-card[data-current='true'] {
border-color: color-mix(
in oklab,
var(--rc-brand-primary) 34%,
var(--rc-border-default)
) !important;
background: var(--rc-brand-primary-soft) !important;
}
.app-switcher-card__logo {
display: block;
width: 2.25rem;
height: 2.25rem;
flex: none;
border-radius: var(--rc-radius-control);
object-fit: contain;
}
.app-switcher-card__initial {
display: inline-grid;
width: 2.25rem;
height: 2.25rem;
flex: none;
place-items: center;
border-radius: var(--rc-radius-control);
background: var(--rc-brand-primary);
color: #fff;
font-size: var(--rc-text-sm);
font-weight: var(--rc-font-semibold);
line-height: 1;
}
@media (max-width: 768px) {
.rc-navbar-trigger {
width: 36px;
height: 36px;
}
}
@media (max-width: 640px) {
.login-card {
width: min(calc(100vw - 2rem), var(--rc-login-panel-width));
padding: 0;
}
.login-card > [data-slot='card-content'] {
padding: 1.75rem;
}
.login-card-brand :is(h1, strong) {
font-size: 28px;
}
}
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_BASE_PATH, APP_NAME } from '@/config/ApplicationConfig';
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import { Card } from '@heroui/react';
import {
Alert,
Button,
Card,
Input,
Label,
TextField,
} from '@heroui/react';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
const APP_NAME = '党风廉政建设';
function buildPassword(password, siteKey) {
if (!password) {
return password;
......@@ -18,6 +24,28 @@ function buildPassword(password, siteKey) {
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
}
function normalizeRedirect(value) {
if (!value) return '/';
try {
const target = new URL(value, window.location.origin);
if (target.origin !== window.location.origin) return '/';
const applicationBasePath = APP_BASE_PATH.replace(/\/+$/, '');
const targetPath = `${target.pathname}${target.search}${target.hash}`;
if (
applicationBasePath
&& (target.pathname === applicationBasePath
|| target.pathname.startsWith(`${applicationBasePath}/`))
) {
return targetPath.slice(applicationBasePath.length) || '/';
}
return targetPath;
} catch {
return '/';
}
}
export default function LoginPage() {
const site = useContext(SiteContext);
const [searchParams] = useSearchParams();
......@@ -29,8 +57,7 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/';
const siteTitle = APP_NAME;
const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => {
setCaptchaLoading(true);
......@@ -47,7 +74,7 @@ export default function LoginPage() {
};
useEffect(() => {
document.title = siteTitle;
document.title = `${APP_NAME} - 登录`;
refreshCaptcha();
}, []);
......@@ -81,81 +108,94 @@ export default function LoginPage() {
};
return (
<main className="login-page">
<section className="login-shell">
<Card className="login-card">
<main className="login-page rc-login-page">
<section className="login-shell rc-login-shell">
<Card className="login-card rc-login-card">
<Card.Content>
<div className="login-brand login-card-brand">
<div className="login-brand login-card-brand rc-login-brand">
<span className="login-brand-logo">
<img
src={xyLogoRed}
alt="鑫元"
className="login-brand-logo login-brand-logo--light"
className="rc-brand-logo rc-brand-logo--light"
src={xyLogoRed}
/>
<img
src={xyLogoWhite}
alt="鑫元"
className="login-brand-logo login-brand-logo--dark"
className="rc-brand-logo rc-brand-logo--dark"
src={xyLogoWhite}
/>
<h1>{siteTitle}</h1>
</span>
<strong>{APP_NAME}</strong>
</div>
<form onSubmit={handleSubmit} className="login-form">
<label
<form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField
className="login-field"
data-has-value={identity ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
value={identity}
onChange={setIdentity}
>
<span>账号</span>
<input
className="login-input input input--secondary"
<Label>账号</Label>
<Input
autoComplete="username"
autoFocus
className="login-input"
data-filled={identity ? 'true' : undefined}
value={identity}
onChange={(event) => setIdentity(event.target.value)}
disabled={loading}
placeholder="请输入账号"
required
variant="secondary"
/>
</label>
</TextField>
<label
<TextField
className="login-field"
data-has-value={password ? 'true' : undefined}
>
<span>密码</span>
<input
fullWidth
isDisabled={loading}
isRequired
type="password"
className="login-input input input--secondary"
data-filled={password ? 'true' : undefined}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={loading}
onChange={setPassword}
>
<Label>密码</Label>
<Input
autoComplete="current-password"
className="login-input"
data-filled={password ? 'true' : undefined}
placeholder="请输入密码"
required
variant="secondary"
/>
</label>
</TextField>
<label
<div
className="login-field"
data-has-value={captchaCode ? 'true' : undefined}
>
<span>验证码</span>
<Label>验证码</Label>
<div className="login-captcha-row">
<input
className="login-input input input--secondary"
<Input
aria-label="验证码"
autoComplete="off"
className="login-input"
data-filled={captchaCode ? 'true' : undefined}
value={captchaCode}
onChange={(event) => setCaptchaCode(event.target.value)}
disabled={loading}
placeholder="请输入验证码"
required
value={captchaCode}
variant="secondary"
onChange={(event) => setCaptchaCode(event.target.value)}
/>
<button
type="button"
onClick={() => {
<Button
aria-label="刷新验证码"
className="login-captcha-button"
isDisabled={loading || captchaLoading}
variant="secondary"
onPress={() => {
setError('');
refreshCaptcha();
}}
disabled={loading || captchaLoading}
className="login-captcha-button"
>
{captchaData?.image ? (
<span
......@@ -165,23 +205,30 @@ export default function LoginPage() {
) : (
<span>{captchaLoading ? '加载中...' : '刷新验证码'}</span>
)}
</button>
</Button>
</div>
</div>
</label>
{error ? (
<div className="rc-alert rc-alert--danger" role="alert">
{error}
</div>
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<button
type="submit"
disabled={loading || captchaLoading}
<Button
fullWidth
className="login-submit-button"
isDisabled={
loading || captchaLoading || !identity || !password || !captchaCode
}
isPending={loading}
type="submit"
variant="primary"
>
{loading ? '登录中...' : '登录'}
</button>
</Button>
</form>
</Card.Content>
</Card>
......
import { APP_NAME, APP_ROUTE_NAME } from '@/config/ApplicationConfig';
import { getApplications } from '@/services/DataService';
import {
Close as XMarkIcon,
ExclamationTriangleIcon,
} from '@/components/AppIcons';
import * as Headless from '@headlessui/react';
import clsx from 'clsx';
import {
Alert,
Avatar,
Card,
Chip,
Description,
Modal,
Skeleton,
Surface,
} from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { useEffect, useMemo, useState } from 'react';
const APP_DISPLAY_NAME = '党风廉政建设';
const APP_DISPLAY_NAME = APP_NAME;
const APP_LOGIN_SUBTITLE = '党风廉政工作台';
const APP_ROUTE_NAME = 'incorrupt_party_build';
const CURRENT_APPLICATION = {
name: APP_ROUTE_NAME,
title: APP_DISPLAY_NAME,
......@@ -181,63 +190,62 @@ function markCurrentApplication(applications) {
function ApplicationCard({ application }) {
const current = application.current === true;
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
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}
target={target}
rel={target ? 'noreferrer' : undefined}
data-current={current ? 'true' : undefined}
className={clsx(
'app-switcher-card group flex min-w-0 items-center gap-3 rounded-xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-3 py-3 text-left transition hover:bg-[var(--accent-soft)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)]',
)}
target={target}
>
<Surface
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
variant="tertiary"
>
<Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent"
color="accent"
variant="soft"
>
{application.logo ? (
<img
src={application.logo}
alt={application.title}
className="app-switcher-card__logo"
/>
) : (
<span className="app-switcher-card__initial">
{application.title.slice(0, 1)}
</span>
)}
<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 text-[var(--rc-text-primary)]">
<span className="block break-words font-medium leading-5 text-foreground" title={application.title}>
{application.title}
</span>
<span className="mt-0.5 block truncate text-xs leading-4 text-[var(--rc-text-muted)]">
<span className="block truncate text-sm text-muted" title={application.description}>
{application.description || application.name || '应用'}
</span>
</span>
{current ? (
<span className="shrink-0 rounded-full bg-[var(--rc-brand-primary-soft)] px-2 py-0.5 text-xs font-medium text-[var(--rc-brand-primary)]">
当前
</span>
) : null}
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="rounded-xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-3 py-3"
>
<div className="animate-pulse">
<div className="flex items-center gap-3">
<div className="size-9 rounded-lg bg-zinc-200" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-zinc-200" />
<div className="h-3 w-full rounded-full bg-zinc-100" />
</div>
</div>
</div>
<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>
))}
</div>
);
......@@ -331,53 +339,52 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications, open]);
return (
<Headless.Dialog
open={open}
onClose={onClose}
className="relative z-[180]"
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-[rgba(15,23,42,0.42)] backdrop-blur-sm transition duration-200 ease-out data-closed:opacity-0"
/>
<div className="fixed inset-0 overflow-y-auto p-4">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
transition
className="app-switcher-dialog flex max-h-[min(42rem,calc(100dvh-2rem))] w-full max-w-[46rem] flex-col overflow-hidden rounded-2xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-surface)] shadow-[var(--rc-shadow-overlay)] transition duration-200 ease-out data-closed:translate-y-2 data-closed:scale-[0.98] data-closed:opacity-0"
<Modal.Container
className="w-full sm:w-full"
placement="center"
scroll="inside"
size="lg"
>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-[var(--rc-border-default)] px-5 py-4">
<Modal.Dialog
className="app-switcher-dialog max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
>
<Modal.CloseTrigger aria-label="关闭应用切换">
<XMarkIcon size={16} />
</Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0">
<Headless.DialogTitle className="text-base font-semibold leading-6 text-[var(--rc-text-primary)]">
应用切换
</Headless.DialogTitle>
<p className="mt-1 text-sm leading-5 text-[var(--rc-text-muted)]">
<Modal.Heading>应用切换</Modal.Heading>
<Description>
选择你可访问的应用
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭应用切换"
className="inline-grid size-6 shrink-0 place-items-center rounded-full bg-[var(--default)] p-1 text-[var(--rc-text-secondary)] transition hover:bg-[var(--default-hover)] hover:text-[var(--rc-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)] [&_.anticon]:grid [&_.anticon]:size-4 [&_.anticon]:place-items-center [&_.anticon]:leading-none [&_.anticon>svg]:block [&_.anticon>svg]:size-4 [&_svg]:m-0"
>
<XMarkIcon />
</button>
</Description>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 [scrollbar-gutter:stable]">
</Modal.Header>
<Modal.Body className="[scrollbar-gutter:stable]">
{status === 'error' && errorMessage ? (
<div className="mb-4 flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900">
<ExclamationTriangleIcon className="mt-0.5 size-5 shrink-0 text-amber-500" />
<div>{errorMessage}</div>
</div>
<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="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
{applications.map((application) => (
<ApplicationCard
key={application.name}
......@@ -386,19 +393,19 @@ export default function ApplicationSwitcherDialog({
))}
</div>
) : (
<div className="flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-6 text-center">
<div className="text-base font-semibold text-[var(--rc-text-primary)]">
暂无可切换应用
</div>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--rc-text-muted)]">
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</p>
</div>
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title>
<EmptyState.Description>
当前账号还没有分配其它应用权限。
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</div>
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export default function EmptyStatePage({ title, description }) {
return (
<section className="relative overflow-hidden rounded-[32px] border border-white/70 bg-white/90 p-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-sky-100 blur-3xl" />
<div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-cyan-50 blur-3xl" />
<div className="relative">
<h2 className="text-3xl font-semibold text-slate-900">{title}</h2>
{description ? (
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-500">
{description}
</p>
) : null}
</div>
</section>
);
}
......@@ -21,6 +21,7 @@
- 左侧菜单:`党风廉政建设`
- 页面内通过 Tab 切换 `培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒`
- 每个 Tab 对应独立台账对象和独立 Excel 模板
- 页面使用鑫元浅色/深色主题;筛选、表格、弹窗和上传控件统一采用 HeroUI / HeroUI Pro 交互
## 3. 核心业务对象
......@@ -40,19 +41,21 @@
1. 用户进入 `党风廉政建设`
2. 通过顶部 Tab 选择台账分类
3. 页面展示该分类的表格台账
4. 筛选区统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选
5. 用户点击 `查询` 后,前端调用 `POST /record/query`
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 关闭
### 4.2 手动新建台账
1. 用户点击 `新建记录`
2. 前端根据当前 Tab 字段配置生成表单
2. 前端根据当前 Tab 字段配置在 HeroUI Modal 中生成表单
3. 用户填写最小必填字段
4. 附件字段通过系统上传控件上传,一个字段可保存多个附件
4. 附件字段通过 DropZone 上传,一个字段可保存多个附件
5. 前端自动生成 `title`
6. 前端调用 `POST /record`
7. 保存成功后提示并刷新当前列表
......@@ -69,7 +72,7 @@
### 4.3 编辑台账
1. 用户在表格中点击 `编辑`
2. 页面打开详情弹窗,展示当前分类全部字段
2. 页面打开 HeroUI 详情弹窗,展示当前分类全部字段;必填、日期、布尔和选择字段沿用新建表单规则
3. 用户修改字段或上传、移除附件
4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新当前列表
......@@ -77,7 +80,7 @@
### 4.4 删除台账
1. 用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除
2. 页面弹出二次确认
2. 页面弹出受控 HeroUI 删除确认弹窗
3. 前端调用 `DELETE /record/:name`
4. 删除后刷新当前列表
5. 删除默认走软删除
......@@ -86,8 +89,8 @@
1. 用户在当前 Tab 点击 `下载模板`
2. 前端导出当前分类的独立模板
3. 用户维护 Excel 后点击 `Excel 导入`
4. 前端读取首个工作表并按当前 Tab 字段映射解析
3. 用户维护 Excel 后点击 `Excel 导入`,在导入弹窗的 DropZone 选择或拖入文件
4. 前端读取首个工作表并按当前 Tab 字段映射解析,并在弹窗中展示只读预览
5. 附件字段不参与导入
6. 前端逐条调用 `POST /record`
7. 当前阶段不做重复判断、不自动覆盖,导入全部新增
......
import {
ArrowPathIcon,
CheckCircleIcon,
Close,
ChevronDownIcon,
Delete,
Edit,
Search,
} from '@/components/AppIcons';
import {
DataTable,
......@@ -12,27 +12,27 @@ import {
} from '@/components/DataTable';
import FileUploadField from '@/components/FileUploadField';
import FormDatePicker from '@/components/FormDatePicker';
import FormSelect from '@/components/FormSelect';
import { TableActionButton, TableActionGroup } from '@/components/TableActionButton';
import { Tabs } from '@heroui/react/tabs';
import { TextArea } from '@heroui/react/textarea';
import { Tooltip } from '@heroui/react/tooltip';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '../../../catalyst-ui-kit/javascript/button';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanFilterSurface from '@/components/XinyuanFilterSurface';
import XinyuanModal from '@/components/XinyuanModal';
import {
Dialog,
DialogActions,
DialogBody,
DialogDescription,
DialogTitle,
} from '../../../catalyst-ui-kit/javascript/dialog';
import { Input } from '../../../catalyst-ui-kit/javascript/input';
Alert,
Button,
Description,
Disclosure,
Input,
SearchField,
Tabs,
TextArea,
Toast,
} from '@heroui/react';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
DEFAULT_INTEGRITY_PAGE_SIZE,
INTEGRITY_LEDGER_CONFIG_MAP,
INTEGRITY_LEDGER_CONFIGS,
INTEGRITY_PAGE_SIZE_OPTIONS,
createEmptyLedgerForm,
createLedgerRecord,
deleteLedgerRecord,
......@@ -48,22 +48,12 @@ import {
uploadLedgerAttachment,
} from '../integrityPartyService';
function Panel({ className, children }) {
return (
<section
className={clsx(
'rc-card-surface rc-workspace-module',
className,
)}
>
{children}
</section>
);
}
function LedgerCategoryTab({ tabKey, label }) {
return (
<Tabs.Tab id={tabKey}>
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={tabKey}
>
<span>{label}</span>
<Tabs.Indicator />
</Tabs.Tab>
......@@ -72,18 +62,18 @@ function LedgerCategoryTab({ tabKey, label }) {
function FieldLabel({ field }) {
return (
<label className="rc-filter-label mb-2 block">
<label className="mb-2 block text-sm font-medium">
{field.label}
{field.required ? <span className="ml-1 text-red-600">*</span> : null}
{field.required ? <span className="ml-1 text-danger">*</span> : null}
</label>
);
}
function FilterField({ label, children, className }) {
return (
<div className={clsx('rc-filter-control min-w-0', className)}>
<div className="rc-filter-label mb-2">{label}</div>
{children}
<div className={clsx('w-full min-w-0', className)}>
<div className="mb-1.5 text-sm font-medium leading-5">{label}</div>
<div className="min-w-0">{children}</div>
</div>
);
}
......@@ -163,8 +153,7 @@ function AttachmentField({ field, value = [], onChange, disabled }) {
const [uploading, setUploading] = useState(false);
const files = Array.isArray(value) ? value : [];
async function handleUpload(event) {
const selectedFiles = Array.from(event.target.files || []);
async function handleUpload(selectedFiles) {
if (selectedFiles.length === 0) {
return;
}
......@@ -178,7 +167,6 @@ function AttachmentField({ field, value = [], onChange, disabled }) {
onChange([...files, ...uploadedFiles]);
} finally {
setUploading(false);
event.target.value = '';
}
}
......@@ -189,17 +177,18 @@ function AttachmentField({ field, value = [], onChange, disabled }) {
ariaLabel={`上传${field.label}`}
disabled={disabled || uploading}
fileName={uploading ? '上传中...' : ''}
onChange={handleUpload}
onFilesChange={handleUpload}
triggerLabel={`上传${field.label}`}
/>
<span className="rc-attachment-count">已上传 {files.length} 个</span>
<Description>已上传 {files.length} 个</Description>
</div>
{files.length > 0 ? (
<div className="mt-3 space-y-2">
{files.map((file, index) => (
<div
<Surface
key={`${file.name || file.url}-${index}`}
className="rc-attachment-item"
className="flex min-w-0 items-center justify-between gap-3 p-3"
variant="secondary"
>
<div className="min-w-0">
{file.url ? (
......@@ -207,28 +196,29 @@ function AttachmentField({ field, value = [], onChange, disabled }) {
href={file.url}
target="_blank"
rel="noreferrer"
className="truncate font-medium text-[var(--pm-brand)] hover:underline"
className="block truncate underline"
>
{file.name || file.url}
</a>
) : (
<div className="rc-attachment-name">
<div className="truncate text-sm">
{file.name || '未命名附件'}
</div>
)}
<div className="rc-attachment-meta">
<Description>
{[file.type, formatFileSize(file.size)].filter(Boolean).join(' · ') || '系统附件'}
</div>
</Description>
</div>
<Button
type="button"
plain
onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
disabled={disabled}
isDisabled={disabled}
size="sm"
variant="danger-soft"
onPress={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
>
移除
</Button>
</div>
</Surface>
))}
</div>
) : null}
......@@ -237,39 +227,42 @@ function AttachmentField({ field, value = [], onChange, disabled }) {
}
function FieldControl({ field, value, onChange, compact = false }) {
const commonProps = {
value: value ?? '',
onChange: (event) => onChange(event.target.value),
};
if (field.type === 'select') {
return (
<FormSelect {...commonProps}>
<option value="">{field.emptyLabel || '请选择'}</option>
{(field.options || []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</FormSelect>
<XinyuanAutocomplete
ariaLabel={field.label}
isRequired={field.required}
options={(field.options || []).map((option) => ({
label: option,
value: option,
}))}
placeholder={field.emptyLabel || '请选择'}
selectedKey={value || ''}
onSelectionChange={onChange}
/>
);
}
if (field.type === 'boolean') {
return (
<FormSelect {...commonProps}>
<option value="">{field.emptyLabel || '未填写'}</option>
<option value="true">是</option>
<option value="false">否</option>
</FormSelect>
<XinyuanAutocomplete
ariaLabel={field.label}
options={[
{ label: '是', value: 'true' },
{ label: '否', value: 'false' },
]}
placeholder={field.emptyLabel || '未填写'}
selectedKey={value === true ? 'true' : value === false ? 'false' : value || ''}
onSelectionChange={onChange}
/>
);
}
if (field.type === 'long_text') {
return (
<TextArea
{...commonProps}
className="rc-form-textarea"
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
fullWidth
variant="secondary"
rows={compact ? 3 : 5}
......@@ -289,13 +282,16 @@ function FieldControl({ field, value, onChange, compact = false }) {
}
if (field.type === 'date') {
return <FormDatePicker {...commonProps} type="date" />;
return <FormDatePicker value={value ?? ''} onChange={onChange} type="date" />;
}
return (
<Input
{...commonProps}
fullWidth
type={field.type === 'number' ? 'number' : 'text'}
value={value ?? ''}
variant="secondary"
onChange={(event) => onChange(event.target.value)}
placeholder={`请输入${field.label}`}
/>
);
......@@ -355,12 +351,11 @@ function createEmptyFilters(config) {
export default function IntegrityPartyPage() {
const [activeKey, setActiveKey] = useState(INTEGRITY_LEDGER_CONFIGS[0].key);
const activeConfig = INTEGRITY_LEDGER_CONFIG_MAP[activeKey];
const [draftFilters, setDraftFilters] = useState(() => createInitialFilters());
const [appliedFilters, setAppliedFilters] = useState(() => createInitialFilters());
const [filters, setFilters] = useState(() => createInitialFilters());
const [records, setRecords] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_INTEGRITY_PAGE_SIZE);
const pageSize = DEFAULT_INTEGRITY_PAGE_SIZE;
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
......@@ -379,17 +374,15 @@ export default function IntegrityPartyPage() {
const [batchSaving, setBatchSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState({ open: false, mode: 'single' });
const [filtersExpanded, setFiltersExpanded] = useState(false);
const reloadRequestIdRef = useRef(0);
useEffect(() => {
if (!notice) {
return undefined;
}
const timer = window.setTimeout(() => {
Toast.toast.success(notice, { timeout: 3600 });
setNotice('');
}, 3600);
return () => window.clearTimeout(timer);
return undefined;
}, [notice]);
const fieldMap = useMemo(() => {
......@@ -403,14 +396,19 @@ export default function IntegrityPartyPage() {
[records, selectedIds],
);
async function reload(nextPage = page) {
async function reload(nextPage = page, nextFilters = filters[activeKey]) {
const requestId = reloadRequestIdRef.current + 1;
reloadRequestIdRef.current = requestId;
setLoading(true);
setError('');
const result = await loadLedgerPage(activeKey, {
...appliedFilters[activeKey],
...nextFilters,
page: nextPage,
pageSize,
});
if (requestId !== reloadRequestIdRef.current) {
return;
}
setRecords(result.records || []);
setTotal(result.total || 0);
setPage(result.page || 0);
......@@ -422,40 +420,22 @@ export default function IntegrityPartyPage() {
}
useEffect(() => {
reload(0);
const timer = window.setTimeout(() => {
reload(0, filters[activeKey]);
}, 300);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKey, pageSize, appliedFilters]);
}, [activeKey, filters]);
function updateDraftFilter(name, value) {
setDraftFilters((current) => ({
function updateFilter(name, value) {
setFilters((current) => ({
...current,
[activeKey]: {
...current[activeKey],
[name]: value,
},
}));
}
function applyFilters() {
setAppliedFilters((current) => ({
...current,
[activeKey]: {
...draftFilters[activeKey],
},
}));
setPage(0);
}
function resetFilters() {
const empty = createEmptyFilters(activeConfig);
setDraftFilters((current) => ({
...current,
[activeKey]: empty,
}));
setAppliedFilters((current) => ({
...current,
[activeKey]: empty,
}));
setPage(0);
}
......@@ -537,7 +517,7 @@ export default function IntegrityPartyPage() {
async function handleWorkbookExport() {
try {
const result = await exportLedgerWorkbook(activeKey, appliedFilters[activeKey]);
const result = await exportLedgerWorkbook(activeKey, filters[activeKey]);
setNotice(`已导出 ${result.count} 条记录到 ${result.fileName}。`);
} catch (exportError) {
setError(exportError?.message || '导出失败,请稍后重试。');
......@@ -635,27 +615,17 @@ export default function IntegrityPartyPage() {
}
: null;
const filterItems = [
{
key: 'keyword',
label: '关键词',
node: (
<Input
value={draftFilters[activeKey]?.keyword || ''}
onChange={(event) => updateDraftFilter('keyword', event.target.value)}
placeholder="搜索标题、主要内容、部门、人员等"
/>
),
className: 'md:col-span-2 xl:col-span-1',
},
{
key: 'year',
label: '年份',
node: (
<Input
fullWidth
type="number"
value={draftFilters[activeKey]?.year || ''}
onChange={(event) => updateDraftFilter('year', event.target.value)}
value={filters[activeKey]?.year || ''}
onChange={(event) => updateFilter('year', event.target.value)}
placeholder="例如:2026"
variant="secondary"
/>
),
},
......@@ -665,14 +635,16 @@ export default function IntegrityPartyPage() {
node: focusField ? (
<FieldControl
field={focusField}
value={draftFilters[activeKey]?.focusValue || ''}
onChange={(value) => updateDraftFilter('focusValue', value)}
value={filters[activeKey]?.focusValue || ''}
onChange={(value) => updateFilter('focusValue', value)}
/>
) : (
<Input
value={draftFilters[activeKey]?.focusValue || ''}
onChange={(event) => updateDraftFilter('focusValue', event.target.value)}
fullWidth
value={filters[activeKey]?.focusValue || ''}
onChange={(event) => updateFilter('focusValue', event.target.value)}
placeholder={`例如:${focusFilter.label}`}
variant="secondary"
/>
),
},
......@@ -682,8 +654,8 @@ export default function IntegrityPartyPage() {
node: (
<FieldControl
field={field}
value={draftFilters[activeKey]?.[field.name] || ''}
onChange={(value) => updateDraftFilter(field.name, value)}
value={filters[activeKey]?.[field.name] || ''}
onChange={(value) => updateFilter(field.name, value)}
compact
/>
),
......@@ -714,13 +686,13 @@ export default function IntegrityPartyPage() {
<TableActionButton
icon={Edit}
label="编辑"
onClick={() => openEditDialog(record)}
onPress={() => openEditDialog(record)}
tone="edit"
/>
<TableActionButton
icon={Delete}
label="删除"
onClick={() => openDeleteDialog(record)}
onPress={() => openDeleteDialog(record)}
tone="delete"
/>
</TableActionGroup>
......@@ -732,9 +704,10 @@ export default function IntegrityPartyPage() {
return (
<div className="space-y-5">
<section className="rc-list-toolbar">
<div className="filter-tabs-wrap">
<section>
<div className="min-w-0 max-w-full">
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={activeKey}
onSelectionChange={(key) => {
setActiveKey(String(key));
......@@ -742,8 +715,11 @@ export default function IntegrityPartyPage() {
setSelectedIds([]);
}}
>
<Tabs.ListContainer>
<Tabs.List aria-label="党风廉政台账分类">
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List
aria-label="党风廉政台账分类"
className="w-max min-w-max"
>
{INTEGRITY_LEDGER_CONFIGS.map((config) => (
<LedgerCategoryTab
key={config.key}
......@@ -757,92 +733,87 @@ export default function IntegrityPartyPage() {
</div>
</section>
{notice ? (
<div className="rc-toast-viewport" aria-live="polite">
<div className="rc-toast rc-toast--success" role="status">
<span className="rc-toast__icon" aria-hidden="true">
<CheckCircleIcon size={16} />
</span>
<span className="rc-toast__message">{notice}</span>
</div>
</div>
) : null}
{error ? (
<div className="rc-alert rc-alert--danger">
{error}
</div>
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<section className="rc-filter-card card p-5">
<div className="rc-filter-primary-row">
<div className="rc-filter-primary-fields">
<XinyuanFilterSurface>
<Disclosure
isExpanded={filtersExpanded}
onExpandedChange={setFiltersExpanded}
>
<div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{primaryFilterItems.map((item) => (
<FilterField key={item.key} label={item.label} className={item.className}>
{item.node}
</FilterField>
))}
</div>
<div className="rc-filter-actions">
<Tooltip>
<Button
type="button"
isIconOnly
outline
className="rc-filter-reset-button"
aria-label="重置筛选"
onClick={resetFilters}
>
<ArrowPathIcon size={16} />
</Button>
<Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip>
<Button
type="button"
color="blue"
className="rc-filter-query-button"
onClick={applyFilters}
>
查询
</Button>
{expandedFilterItems.length > 0 ? (
<Button
type="button"
plain
className="rc-filter-toggle-button"
aria-expanded={filtersExpanded}
onClick={() => setFiltersExpanded((current) => !current)}
>
{filtersExpanded ? '收起筛选' : '展开筛选'}
<ChevronDownIcon
className={clsx('rc-filter-toggle-icon', filtersExpanded && 'is-expanded')}
size={14}
/>
</Button>
) : null}
</div>
<div className="flex min-w-0 flex-wrap items-end gap-2 md:justify-end xl:flex-nowrap">
<Disclosure.Trigger className="button button--sm button--ghost">
<span>{filtersExpanded ? '收起' : '展开'}</span>
<Disclosure.Indicator>
<ChevronDownIcon size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
</div>
{filtersExpanded && expandedFilterItems.length > 0 ? (
<div className="rc-filter-expanded-grid">
) : null}
{expandedFilterItems.length > 0 ? (
<Disclosure.Content
className={clsx(
'min-h-0 min-w-0 xl:col-start-1 xl:col-end-2',
!filtersExpanded && 'hidden',
)}
>
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{expandedFilterItems.map((item) => (
<FilterField key={item.key} label={item.label}>
{item.node}
</FilterField>
))}
</div>
</Disclosure.Content>
) : null}
</section>
<section>
<div className="rc-list-action-bar">
<div className="rc-list-action-title">{activeConfig.label}</div>
<div className="rc-page-actions">
<Button type="button" outline onClick={handleTemplateExport}>
</div>
</Disclosure>
</XinyuanFilterSurface>
<section className="grid min-w-0 gap-4">
<div className="grid min-w-0 gap-4">
<h2 className="text-base font-medium">{activeConfig.label}</h2>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<SearchField
aria-label="搜索台账"
className="w-full sm:max-w-96"
fullWidth
value={filters[activeKey]?.keyword || ''}
onChange={(value) => updateFilter('keyword', value)}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="请输入关键词" />
<SearchField.ClearButton aria-label="清空关键词">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" size="sm" variant="secondary" onPress={handleTemplateExport}>
下载模板
</Button>
<Button
type="button"
outline
onClick={() => {
size="sm"
variant="secondary"
onPress={() => {
setImportOpen(true);
setImportFile(null);
setImportPreview(null);
......@@ -850,14 +821,15 @@ export default function IntegrityPartyPage() {
>
Excel 导入
</Button>
<Button type="button" outline onClick={handleWorkbookExport}>
<Button type="button" size="sm" variant="secondary" onPress={handleWorkbookExport}>
Excel 导出
</Button>
<Button type="button" color="blue" onClick={openCreateDialog}>
<Button type="button" size="sm" variant="primary" onPress={openCreateDialog}>
新建记录
</Button>
</div>
</div>
</div>
<DataTable
ariaLabel={activeConfig.label}
......@@ -871,17 +843,17 @@ export default function IntegrityPartyPage() {
isWide
renderSelectionActions={() => (
<>
<Button type="button" outline onClick={handleSelectedExport}>
<Button type="button" size="sm" variant="secondary" onPress={handleSelectedExport}>
导出勾选
</Button>
<Button type="button" outline onClick={openBatchDialog}>
<Button type="button" size="sm" variant="secondary" onPress={openBatchDialog}>
批量修改
</Button>
<Button
type="button"
outline
color="red"
onClick={() => setDeleteConfirm({ open: true, mode: 'batch' })}
size="sm"
variant="danger-soft"
onPress={() => setDeleteConfirm({ open: true, mode: 'batch' })}
>
批量删除
</Button>
......@@ -893,107 +865,113 @@ export default function IntegrityPartyPage() {
page={page}
pageSize={pageSize}
total={total}
pageSizeOptions={INTEGRITY_PAGE_SIZE_OPTIONS}
isDisabled={loading}
onPageChange={(nextPage) => reload(Math.max(nextPage, 0))}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
}}
/>
</section>
<Dialog open={detailOpen} onClose={setDetailOpen} size="5xl">
<DialogTitle>
{detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : '编辑台账记录'}
</DialogTitle>
<DialogBody>
<LedgerForm config={activeConfig} form={detailForm} setForm={setDetailForm} />
</DialogBody>
<DialogActions>
<Button type="button" outline onClick={() => setDetailOpen(false)}>
<XinyuanModal
isOpen={detailOpen}
onOpenChange={setDetailOpen}
size="4xl"
title={detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : '编辑台账记录'}
footer={
<>
<Button type="button" size="sm" variant="secondary" onPress={() => setDetailOpen(false)}>
取消
</Button>
<Button type="button" color="blue" onClick={saveDetail} disabled={saving}>
<Button type="button" isPending={saving} size="sm" variant="primary" onPress={saveDetail}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={setImportOpen} size="4xl">
<DialogTitle>Excel 导入{activeConfig.tabLabel}</DialogTitle>
<DialogBody>
</>
}
>
<LedgerForm config={activeConfig} form={detailForm} setForm={setDetailForm} />
</XinyuanModal>
<XinyuanModal
isOpen={importOpen}
onOpenChange={setImportOpen}
size="4xl"
title={`Excel 导入${activeConfig.tabLabel}`}
footer={
<>
<Button type="button" size="sm" variant="secondary" onPress={handleTemplateExport}>
下载模板
</Button>
<Button type="button" size="sm" variant="secondary" onPress={() => setImportOpen(false)}>
取消
</Button>
<Button
type="button"
isDisabled={!importFile}
isPending={importing}
size="sm"
variant="primary"
onPress={handleImportWorkbook}
>
{importing ? '导入中...' : '确认导入'}
</Button>
</>
}
>
<FileUploadField
accept=".xlsx,.xls,.csv"
ariaLabel="选择导入文件"
fileName={importFile?.name || ''}
onChange={(event) => handlePreviewImport(event.target.files?.[0] || null)}
onFilesChange={(files) => handlePreviewImport(files[0] || null)}
triggerLabel="选择文件"
/>
{importPreview ? (
<div className="rc-import-preview mt-5">
<div className="rc-import-preview-summary">
<div className="mt-5 grid gap-3">
<Description>
工作表:{importPreview.sheetName},识别到 {importPreview.totalRows} 行
</div>
<div className="rc-import-preview-scroll">
<table className="rc-import-preview-table">
<thead>
<tr>
<th>行号</th>
{activeConfig.fields
.filter((field) => field.type !== 'file')
.slice(0, 6)
.map((field) => (
<th key={field.name}>
{field.label}
</th>
))}
</tr>
</thead>
<tbody>
{importPreview.previewRows.map((row) => (
<tr key={row.rowNumber}>
<td>{row.rowNumber}</td>
{activeConfig.fields
</Description>
<DataTable
ariaLabel="Excel 导入预览"
columns={[
{ key: 'rowNumber', title: '行号', minWidth: 80 },
...activeConfig.fields
.filter((field) => field.type !== 'file')
.slice(0, 6)
.map((field) => (
<td key={field.name}>
{row.metadata[field.name] || '-'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
.map((field) => ({
key: field.name,
title: field.label,
minWidth: TABLE_COLUMN_WIDTHS[field.name] || DEFAULT_TABLE_COLUMN_WIDTH,
render: (row) => row.metadata[field.name] || '-',
})),
]}
getRowKey={(row) => row.rowNumber}
isWide
rows={importPreview.previewRows}
/>
</div>
) : null}
</DialogBody>
<DialogActions>
<Button type="button" outline onClick={handleTemplateExport}>
下载模板
</Button>
<Button type="button" outline onClick={() => setImportOpen(false)}>
</XinyuanModal>
<XinyuanModal
isOpen={batchOpen}
onOpenChange={setBatchOpen}
size="3xl"
title={`批量修改${activeConfig.tabLabel}`}
footer={
<>
<Button type="button" size="sm" variant="secondary" onPress={() => setBatchOpen(false)}>
取消
</Button>
<Button
type="button"
color="blue"
onClick={handleImportWorkbook}
disabled={importing || !importFile}
>
{importing ? '导入中...' : '确认导入'}
<Button type="button" isPending={batchSaving} size="sm" variant="primary" onPress={handleBatchSave}>
{batchSaving ? '更新中...' : '批量保存'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={batchOpen} onClose={setBatchOpen} size="3xl">
<DialogTitle>批量修改{activeConfig.tabLabel}</DialogTitle>
<DialogBody>
<div className="rc-dialog-notice mb-5">
</>
}
>
<Alert status="accent">
<Alert.Content>
<Alert.Description>
当前已勾选 {selectedRecords.length} 条记录。仅覆盖你填写的字段,留空字段保持原值。
</div>
</Alert.Description>
</Alert.Content>
</Alert>
<LedgerForm
config={activeConfig}
form={batchForm}
......@@ -1001,50 +979,37 @@ export default function IntegrityPartyPage() {
fields={activeConfig.batchFields.map((fieldName) => fieldMap[fieldName]).filter(Boolean)}
compact
/>
</DialogBody>
<DialogActions>
<Button type="button" outline onClick={() => setBatchOpen(false)}>
取消
</Button>
</XinyuanModal>
<XinyuanModal
isOpen={deleteConfirm.open}
onOpenChange={(open) => setDeleteConfirm((current) => ({ ...current, open }))}
size="md"
title={deleteConfirm.mode === 'batch' ? '确认批量删除' : '确认删除记录'}
footer={
<>
<Button
type="button"
color="blue"
onClick={handleBatchSave}
disabled={batchSaving}
size="sm"
variant="secondary"
onPress={() => setDeleteConfirm({ open: false, mode: 'single' })}
>
{batchSaving ? '更新中...' : '批量保存'}
取消
</Button>
</DialogActions>
</Dialog>
<Dialog
open={deleteConfirm.open}
onClose={(open) => setDeleteConfirm((current) => ({ ...current, open }))}
size="md"
<Button type="button" isPending={saving} size="sm" variant="danger" onPress={confirmDelete}>
确认删除
</Button>
</>
}
>
<DialogTitle>
{deleteConfirm.mode === 'batch' ? '确认批量删除' : '确认删除记录'}
</DialogTitle>
<DialogDescription>
<Description>
{deleteConfirm.mode === 'batch'
? `即将删除 ${selectedRecords.length} 条勾选记录,请确认是否继续。`
: activeRecord
? `即将删除:${activeRecord.title}`
: '未找到当前记录。'}
</DialogDescription>
<DialogActions>
<Button
type="button"
outline
onClick={() => setDeleteConfirm({ open: false, mode: 'single' })}
>
取消
</Button>
<Button type="button" color="red" onClick={confirmDelete} disabled={saving}>
确认删除
</Button>
</DialogActions>
</Dialog>
</Description>
</XinyuanModal>
</div>
);
}
......@@ -9,7 +9,6 @@ import {
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
export const INTEGRITY_PAGE_SIZE_OPTIONS = [10, 20, 50];
export const DEFAULT_INTEGRITY_PAGE_SIZE = 10;
export const TRAINING_CATEGORY_OPTIONS = [
......
......@@ -3,463 +3,239 @@ import {
CalendarDaysIcon,
ClipboardDocumentListIcon,
} from '@/components/AppIcons';
import { Card } from '@heroui/react';
import clsx from 'clsx';
import ReactECharts from 'echarts-for-react';
import { useEffect, useMemo, useState } from 'react';
import {
colorizeXinyuanChartTooltipPayload,
createXinyuanChartColorMap,
XINYUAN_CHART_SEMANTIC,
} from '@/design-system/chartPalette';
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';
function Panel({ className, children }) {
return (
<section
className={clsx(
'rc-card-surface rc-workspace-module',
className,
)}
>
{children}
</section>
);
}
const CHART_LEGEND_LABEL_STYLE = {
fontSize: 'var(--rc-chart-legend-font-size)',
lineHeight: 'var(--rc-chart-legend-line-height)',
};
function MetricCard({ icon: Icon, label, value, tone = 'info' }) {
const toneClass =
tone === 'success'
? 'rc-tone-surface--success'
: tone === 'warning'
? 'rc-tone-surface--warning'
: tone === 'danger'
? 'rc-tone-surface--danger'
: 'rc-tone-surface--info';
function formatCount(value) {
const numericValue = Number(value || 0);
return Number.isFinite(numericValue) ? `${numericValue}` : '0';
}
function MetricCard({ icon: Icon, label, value, tone }) {
const numericValue = Number(value || 0);
return (
<div className="rc-card-surface rc-dashboard-card p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-medium text-[var(--rc-text-muted)]">{label}</div>
<div className="mt-3 text-3xl font-semibold text-[var(--rc-text-primary)]">
{value}
</div>
</div>
<div
className={clsx(
'rc-metric-icon-surface',
toneClass,
)}
<KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header>
<KPI.Title>{label}</KPI.Title>
</KPI.Header>
<KPI.Content>
<KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0}>
{() => formatCount(value)}
</KPI.Value>
<KPI.Icon
aria-label={label}
className="rc-metric-icon-surface"
data-tone={tone}
>
<Icon size={20} />
</div>
</div>
</div>
</KPI.Icon>
</KPI.Content>
</KPI>
);
}
function SectionHeader({ title }) {
function Panel({ title, children }) {
return (
<div className="mb-5">
<div className="text-base font-semibold text-[var(--rc-text-primary)]">
{title}
</div>
</div>
<Card>
<Card.Header>
<Card.Title>{title}</Card.Title>
</Card.Header>
<Card.Content>{children}</Card.Content>
</Card>
);
}
function OverviewSection({ title, meta, children }) {
function EmptyBlock({ title }) {
return (
<section className="rc-module-overview-section">
<div className="rc-module-overview-section__header">
<div className="rc-module-overview-section__title">{title}</div>
{meta ? <div className="rc-module-overview-section__meta">{meta}</div> : null}
</div>
<div className="rc-module-overview-section__body">{children}</div>
</section>
<EmptyState className="min-h-80" size="md">
<EmptyState.Header>
<EmptyState.Title>{title}</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
);
}
const CHART_FONT_FAMILY =
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const FALLBACK_CHART_THEME = {
surface: '#ffffff',
subtle: '#f8fafc',
text: '#172033',
secondary: '#4a5568',
muted: '#718096',
border: '#e3e8ef',
brand: '#9E1B1B',
brandHover: '#B83A3A',
warning: '#B7791F',
success: '#6A7B5D',
danger: '#B83A3A',
teal: '#4E6E81',
splitLine: 'rgba(113, 128, 150, 0.18)',
axisLine: 'rgba(113, 128, 150, 0.32)',
axisPointer: 'rgba(158, 27, 27, 0.06)',
};
function token(name, fallback) {
if (typeof window === 'undefined') {
return fallback;
}
const rootValue = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
if (!rootValue) {
return fallback;
}
if (!rootValue.includes('var(') && !rootValue.includes('color-mix(')) {
return rootValue;
}
if (!document.body) {
return rootValue;
}
const probe = document.createElement('span');
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.pointerEvents = 'none';
probe.style.color = `var(${name})`;
document.body.appendChild(probe);
const resolvedValue = getComputedStyle(probe).color.trim();
probe.remove();
return resolvedValue || rootValue || fallback;
function XinyuanChartTooltipContent({ active, label, payload, colorForEntry }) {
return (
<ChartTooltipContent
active={active}
label={label}
payload={colorizeXinyuanChartTooltipPayload(payload, colorForEntry)}
valueFormatter={(value) => `${formatCount(value)} 条`}
/>
);
}
function withAlpha(color, alpha, fallback) {
if (!color) {
return fallback;
}
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hex) {
const value = hex[1].length === 3
? hex[1].split('').map((part) => part + part).join('')
: hex[1];
const integer = Number.parseInt(value, 16);
return `rgba(${(integer >> 16) & 255}, ${(integer >> 8) & 255}, ${integer & 255}, ${alpha})`;
}
const rgb = color.match(/^rgba?\((.+)\)$/i);
if (rgb) {
const channels = rgb[1]
.split(/[,\s/]+/)
.filter(Boolean)
.slice(0, 3)
.map((part) => Number.parseFloat(part));
if (channels.length === 3 && channels.every(Number.isFinite)) {
return `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${alpha})`;
}
}
return fallback;
}
function LedgerDistributionChart({ groups }) {
const data = groups.map((item) => ({
name: item.label,
value: Number(item.count || 0),
}));
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name));
function getChartThemeTokens() {
const muted = token('--rc-text-muted', FALLBACK_CHART_THEME.muted);
const brand = token('--rc-brand-primary', FALLBACK_CHART_THEME.brand);
if (data.length === 0) return <EmptyBlock title="暂无台账分类数据" />;
return {
surface: token('--rc-bg-surface', FALLBACK_CHART_THEME.surface),
subtle: token('--rc-bg-subtle', FALLBACK_CHART_THEME.subtle),
text: token('--rc-text-primary', FALLBACK_CHART_THEME.text),
secondary: token('--rc-text-secondary', FALLBACK_CHART_THEME.secondary),
muted,
border: token('--rc-border-default', FALLBACK_CHART_THEME.border),
brand,
brandHover: token('--rc-brand-primary-hover', FALLBACK_CHART_THEME.brandHover),
warning: token('--rc-color-warning', FALLBACK_CHART_THEME.warning),
success: token('--rc-color-success', FALLBACK_CHART_THEME.success),
danger: token('--rc-color-danger', FALLBACK_CHART_THEME.danger),
teal: token('--rc-color-teal', FALLBACK_CHART_THEME.teal),
splitLine: withAlpha(muted, 0.18, FALLBACK_CHART_THEME.splitLine),
axisLine: withAlpha(muted, 0.32, FALLBACK_CHART_THEME.axisLine),
axisPointer: withAlpha(brand, 0.06, FALLBACK_CHART_THEME.axisPointer),
};
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>
);
}
function useChartThemeTokens() {
const [themeTokens, setThemeTokens] = useState(getChartThemeTokens);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const updateThemeTokens = () => setThemeTokens(getChartThemeTokens());
const observer = new MutationObserver(updateThemeTokens);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'style'],
});
updateThemeTokens();
return () => observer.disconnect();
}, []);
function YearDistributionChart({ groups }) {
const data = groups.map((item) => ({
name: item.label,
count: Number(item.count || 0),
}));
return themeTokens;
}
if (data.length === 0) return <EmptyBlock title="暂无年度分布数据" />;
function getTooltipOption(theme) {
return {
backgroundColor: theme.surface,
borderColor: theme.border,
borderWidth: 1,
confine: true,
appendToBody: true,
renderMode: 'html',
extraCssText:
'border-radius:12px;box-shadow:var(--rc-shadow-overlay);padding:8px 10px;',
textStyle: {
color: theme.text,
fontFamily: CHART_FONT_FAMILY,
fontSize: 12,
fontWeight: 500,
},
};
}
function getChartPalette(theme) {
return [
theme.brand,
theme.success,
theme.warning,
theme.danger,
theme.teal,
'#8B5E5E',
];
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>
);
}
function getPieOption(groups = [], theme = FALLBACK_CHART_THEME) {
return {
backgroundColor: 'transparent',
color: getChartPalette(theme),
tooltip: {
trigger: 'item',
formatter: '{b}<br/>{c} 条 · {d}%',
...getTooltipOption(theme),
},
legend: {
bottom: 0,
icon: 'circle',
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: theme.secondary,
fontFamily: CHART_FONT_FAMILY,
fontSize: 12,
fontWeight: 500,
},
},
series: [
{
name: '分类',
type: 'pie',
radius: ['46%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
label: {
color: theme.text,
fontFamily: CHART_FONT_FAMILY,
fontSize: 12,
fontWeight: 500,
formatter: '{b}\n{c} 条',
lineHeight: 18,
},
labelLine: {
length: 10,
length2: 8,
},
itemStyle: {
borderColor: theme.subtle,
borderWidth: 3,
},
data: groups.map((item) => ({
function ModuleCountChart({ groups }) {
const data = groups.map((item) => ({
name: item.label,
value: item.count,
})),
},
],
};
}
value: Number(item.count || 0),
}));
function getBarOption(groups = [], { horizontal = false, theme = FALLBACK_CHART_THEME } = {}) {
const visibleGroups = groups.slice(0, 8);
const labels = visibleGroups.map((item) => item.label);
const values = visibleGroups.map((item) => item.count);
if (data.length === 0) return <EmptyBlock title="暂无年度开展次数数据" />;
return {
backgroundColor: 'transparent',
color: getChartPalette(theme),
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
shadowStyle: {
color: theme.axisPointer,
},
},
formatter: (params = []) => {
const item = params[0];
return item ? `${item.name}<br/>${item.value} 条` : '';
},
...getTooltipOption(theme),
},
grid: {
top: 20,
right: 18,
bottom: horizontal ? 20 : 44,
left: horizontal ? 84 : 42,
containLabel: true,
},
xAxis: horizontal
? {
type: 'value',
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
lineStyle: {
color: theme.splitLine,
type: 'solid',
},
},
axisLabel: {
color: theme.muted,
fontFamily: CHART_FONT_FAMILY,
fontSize: 12,
fontWeight: 500,
},
}
: {
type: 'category',
data: labels,
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: theme.axisLine,
},
},
axisLabel: {
color: theme.text,
fontFamily: CHART_FONT_FAMILY,
fontSize: 12,
fontWeight: 500,
interval: 0,
margin: 12,
rotate: labels.some((label) => label.length > 5) ? 28 : 0,
},
},
yAxis: horizontal
? {
type: 'category',
data: labels,
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
color: theme.secondary,
fontFamily: CHART_FONT_FAMILY,
fontSize: 12,
fontWeight: 500,
width: 74,
overflow: 'truncate',
},
}
: {
type: 'value',
splitLine: {
lineStyle: {
color: theme.splitLine,
type: 'solid',
},
},
axisLabel: {
color: theme.muted,
fontFamily: CHART_FONT_FAMILY,
fontSize: 12,
fontWeight: 500,
},
},
series: [
{
type: 'bar',
data: values,
barMaxWidth: horizontal ? 18 : 38,
showBackground: true,
backgroundStyle: {
color: withAlpha(theme.muted, 0.06, 'rgba(113, 128, 150, 0.06)'),
borderRadius: horizontal ? [0, 8, 8, 0] : [8, 8, 0, 0],
},
itemStyle: {
borderRadius: horizontal ? [0, 10, 10, 0] : [10, 10, 4, 4],
color: {
type: 'linear',
x: 0,
y: 0,
x2: horizontal ? 1 : 0,
y2: horizontal ? 0 : 1,
colorStops: [
{ offset: 0, color: theme.brand },
{ offset: 1, color: theme.brandHover },
],
},
},
},
],
};
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>
);
}
function ChartPanel({ title, groups, type = 'bar' }) {
const hasData = groups.length > 0;
const chartTheme = useChartThemeTokens();
const option = useMemo(
() =>
type === 'pie'
? getPieOption(groups, chartTheme)
: getBarOption(groups, {
horizontal: type === 'horizontalBar',
theme: chartTheme,
}),
[chartTheme, groups, type],
function TrainingOverview({ items }) {
return (
<Card>
<Card.Header>
<Card.Title>培训类</Card.Title>
<Card.Description>{items.length} 类</Card.Description>
</Card.Header>
<Card.Content className="grid gap-3">
{items.length > 0 ? items.map((item) => (
<div
key={item.key}
className="grid grid-cols-[minmax(0,1fr)_5rem_5rem] items-center gap-3"
>
<span className="truncate text-sm">{item.label}</span>
<span className="text-right text-sm tabular-nums">{item.sessionCount} 场</span>
<span className="text-right text-sm tabular-nums">{item.participantCount} 人</span>
</div>
)) : <EmptyBlock title="暂无培训类数据" />}
</Card.Content>
</Card>
);
}
function TalkOverview({ summary }) {
const items = [
['已谈话人数', summary.totalCount],
['投资序列', summary.investmentCount],
['管理序列', summary.managementCount],
];
return (
<Panel className="rc-chart-card">
<div className="rc-chart-card__header">
<div className="rc-chart-card__title">{title}</div>
</div>
{hasData ? (
<div className="rc-chart-card__body">
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: '100%', width: '100%' }}
/>
<Card>
<Card.Header>
<Card.Title>谈话类</Card.Title>
</Card.Header>
<Card.Content className="grid gap-3">
{items.map(([label, value]) => (
<div key={label} className="flex items-center justify-between gap-4">
<span className="text-sm">{label}</span>
<span className="text-sm tabular-nums">{formatCount(value)}</span>
</div>
) : (
<div className="rc-chart-card__empty">
暂无可展示的数据
</div>
)}
</Panel>
))}
</Card.Content>
</Card>
);
}
......@@ -470,31 +246,20 @@ export default function ResourceStatPage() {
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError('');
try {
const integrityResult = await loadIntegrityPartyDashboard();
if (cancelled) {
return;
}
if (cancelled) return;
setIntegrityDashboard(integrityResult);
setError((integrityResult.errors || []).filter(Boolean).join(';'));
} catch (loadError) {
if (cancelled) {
return;
}
if (cancelled) return;
setIntegrityDashboard(null);
setError(loadError?.message || '党风廉政驾驶舱加载失败');
} finally {
if (!cancelled) {
setLoading(false);
}
if (!cancelled) setLoading(false);
}
})();
......@@ -503,30 +268,7 @@ export default function ResourceStatPage() {
};
}, []);
return (
<div className="space-y-6">
{error ? (
<div
className="rc-alert rc-alert--danger"
>
{error}
</div>
) : null}
{loading ? (
<div className="rc-alert">
正在加载驾驶舱...
</div>
) : null}
<IntegrityDashboardView dashboard={integrityDashboard} loading={loading} />
</div>
);
}
function IntegrityDashboardView({ dashboard, loading }) {
const safeDashboard =
dashboard || {
const dashboard = integrityDashboard || {
totalCount: 0,
currentYearCount: 0,
recentCount: 0,
......@@ -535,141 +277,59 @@ function IntegrityDashboardView({ dashboard, loading }) {
moduleOverview: {
year: new Date().getFullYear(),
trainingCategories: [],
talkSummary: {
totalCount: 0,
investmentCount: 0,
managementCount: 0,
},
talkSummary: { totalCount: 0, investmentCount: 0, managementCount: 0 },
yearlyModuleCounts: [],
},
};
return (
<>
<div className="rc-stat-metric-grid grid gap-4 md:grid-cols-3" data-count="3">
<MetricCard
icon={ClipboardDocumentListIcon}
label="台账总数"
value={safeDashboard.totalCount}
tone="info"
/>
<MetricCard
icon={CalendarDaysIcon}
label="本年度记录"
value={safeDashboard.currentYearCount}
tone="success"
/>
<MetricCard
icon={ArrowTrendingUpIcon}
label="最近 30 天更新"
value={safeDashboard.recentCount}
tone="warning"
/>
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<ChartPanel title="台账分类分布" groups={safeDashboard.ledgerGroups} type="pie" />
<ChartPanel title="年度分布" groups={safeDashboard.yearGroups} />
</div>
<IntegrityModuleOverview overview={safeDashboard.moduleOverview} loading={loading} />
</>
);
}
function IntegrityModuleOverview({ overview, loading }) {
const safeOverview = overview || {
year: new Date().getFullYear(),
trainingCategories: [],
talkSummary: {
totalCount: 0,
investmentCount: 0,
managementCount: 0,
},
yearlyModuleCounts: [],
};
const talkItems = [
['已谈话人数', safeOverview.talkSummary.totalCount],
['投资序列', safeOverview.talkSummary.investmentCount],
['管理序列', safeOverview.talkSummary.managementCount],
];
<div className="rc-page-stack">
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
return (
<Card className="rc-card-surface rc-workspace-module rc-module-overview-card">
<Card.Content className="rc-module-overview-card__content">
<div className="rc-module-overview-heading">
{safeOverview.year} 年模块概览
</div>
{loading ? (
<div className="text-sm text-[var(--rc-text-muted)]">正在加载党风廉政统计...</div>
) : (
<div className="rc-module-overview-grid">
<OverviewSection
title="培训类"
meta={`${safeOverview.trainingCategories.length} 类`}
>
<div className="rc-stat-table">
<div className="rc-stat-table__head grid grid-cols-[minmax(0,1fr)_88px_88px]">
<div>培训类别</div>
<div className="text-right">场次</div>
<div className="text-right">人数</div>
</div>
{safeOverview.trainingCategories.map((item) => (
<div
key={item.key}
className="rc-stat-table__row grid grid-cols-[minmax(0,1fr)_88px_88px]"
>
<div className="rc-stat-table__primary">
{item.label}
</div>
<div className="rc-stat-table__number">
{item.sessionCount}
</div>
<div className="rc-stat-table__number">
{item.participantCount}
<div className="flex min-h-48 items-center justify-center gap-2" role="status">
<Spinner size="sm" />
<span className="text-sm text-muted">正在加载驾驶舱...</span>
</div>
) : (
<>
<section className="rc-page-section">
<div className="rc-kpi-grid" data-count="3">
<MetricCard icon={ClipboardDocumentListIcon} label="台账总数" tone="info" value={dashboard.totalCount} />
<MetricCard icon={CalendarDaysIcon} label="本年度记录" tone="teal" value={dashboard.currentYearCount} />
<MetricCard icon={ArrowTrendingUpIcon} label="最近 30 天更新" tone="warning" value={dashboard.recentCount} />
</div>
))}
</div>
</OverviewSection>
</section>
<OverviewSection title="谈话类" meta={`${talkItems.length} 项`}>
<div className="rc-stat-summary-list rc-stat-summary-list--compact">
{talkItems.map(([label, value]) => (
<div
key={label}
className="rc-stat-summary-row rc-stat-summary-row--compact"
>
<div className="rc-stat-summary-row__label">{label}</div>
<div className="rc-stat-summary-row__value">
{value}
</div>
</div>
))}
</div>
</OverviewSection>
<section className="rc-page-grid rc-chart-grid">
<Panel title="台账分类分布">
<LedgerDistributionChart groups={dashboard.ledgerGroups} />
</Panel>
<Panel title="年度分布">
<YearDistributionChart groups={dashboard.yearGroups} />
</Panel>
</section>
<OverviewSection
title="年度开展次数"
meta={`${safeOverview.yearlyModuleCounts.length} 项`}
>
<div className="rc-stat-count-list rc-stat-count-list--compact">
{safeOverview.yearlyModuleCounts.map((item) => (
<div
key={item.key}
className="rc-stat-count-row rc-stat-count-row--compact"
>
<div className="rc-stat-count-row__label">{item.label}</div>
<div className="rc-stat-count-row__value">
{item.count}
</div>
</div>
))}
</div>
</OverviewSection>
<section className="rc-page-section">
<h2 className="text-base font-medium text-foreground">
{dashboard.moduleOverview.year} 年模块概览
</h2>
<div className="rc-page-grid rc-chart-grid">
<TrainingOverview items={dashboard.moduleOverview.trainingCategories} />
<TalkOverview summary={dashboard.moduleOverview.talkSummary} />
</div>
<Panel title="年度开展次数">
<ModuleCountChart groups={dashboard.moduleOverview.yearlyModuleCounts} />
</Panel>
</section>
</>
)}
</Card.Content>
</Card>
</div>
);
}
@import 'tailwindcss';
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "tailwindcss";
@import "@heroui/styles";
@import "@heroui-pro/react/css";
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@theme {
--font-sans:
"PingFang SC",
"Microsoft YaHei",
"Helvetica Neue",
Arial,
sans-serif;
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
:root {
color-scheme: light;
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
}
html {
color-scheme: light !important;
}
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
color: var(--rc-text-primary);
background: var(--rc-bg-canvas);
color-scheme: inherit;
}
body .login-page {
--rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem;
--rc-login-card-brand-margin-bottom: 2rem;
--rc-login-card-brand-title-size: 2rem;
--rc-login-card-brand-title-weight: var(--rc-login-brand-weight);
--rc-login-control-height: 2.5rem;
--rc-login-submit-height: 2.875rem;
--rc-login-submit-margin-top: 0.875rem;
--rc-login-field-filled-background: #fbf1f1;
--rc-login-field-filled-background-hover: #faeaea;
--rc-login-field-autofill-background: #fbf1f1;
--rc-login-field-border-hover: rgb(158 27 27 / 24%);
--rc-login-field-focus-ring: rgb(158 27 27 / 16%);
}
body .login-brand img {
width: var(--rc-login-brand-logo-size) !important;
height: var(--rc-login-brand-logo-size) !important;
filter: none !important;
}
body .login-card-brand {
justify-content: center;
gap: var(--rc-login-card-brand-gap) !important;
margin-bottom: var(--rc-login-card-brand-margin-bottom);
text-align: center;
}
body .login-card-brand strong {
color: var(--rc-text-primary);
font-size: var(--rc-login-card-brand-title-size);
font-weight: var(--rc-login-card-brand-title-weight);
line-height: 1.15;
letter-spacing: 0;
white-space: nowrap;
}
body .login-card [data-slot="card-header"] {
display: none !important;
}
body .login-card [data-slot="card-content"] {
padding: 2.25rem !important;
}
body
.login-form
.login-field
:is(
.input,
.input--secondary,
.login-input,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
) {
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
}
body
.login-form
.login-field
:is(input, .login-input, input.input--secondary, input[data-slot="input"]) {
line-height: var(--rc-login-control-height) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input input,
[data-slot="input-wrapper"] input,
[data-slot="control"] input
) {
background: transparent !important;
background-color: transparent !important;
}
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
) {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
border-color: transparent !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow: inset 0 0 0 1000px var(--rc-login-field-filled-background) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):hover,
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
)[data-hovered="true"] {
border-color: var(--rc-login-field-border-hover) !important;
background: var(--rc-login-field-filled-background-hover) !important;
background-color: var(--rc-login-field-filled-background-hover) !important;
}
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):focus,
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
):focus-within,
body
.login-form
.login-field[data-has-value="true"]
:is(
.input,
.input--secondary,
[data-slot="input"],
[data-slot="input-wrapper"],
[data-slot="control"]
)[data-focused="true"] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow:
var(--field-shadow, 0 1px 2px rgb(15 23 42 / 4%)),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
.login-field
:is(input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"] {
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow:
var(--field-shadow, 0 1px 2px rgb(15 23 42 / 4%)),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"]:focus,
body
.login-form
.login-input:is(
.input,
.input--secondary,
[data-slot="input"]
)[data-filled="true"][data-focused="true"],
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
.login-input:is(.input, .input--secondary, [data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"] {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
}
body
.login-form
:is(.input, [data-slot="input-wrapper"], [data-slot="control"]):has(
input:-webkit-autofill
),
body
.login-form
:is(.input, [data-slot="input-wrapper"], [data-slot="control"]):has(
input:autofill
) {
--input-bg: var(--rc-login-field-autofill-background) !important;
--input-bg-hover: var(--rc-login-field-autofill-background) !important;
--input-bg-focus: var(--rc-login-field-autofill-background) !important;
background: var(--rc-login-field-autofill-background) !important;
background-color: var(--rc-login-field-autofill-background) !important;
}
body .login-form input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--rc-login-field-autofill-background)
inset !important;
-webkit-text-fill-color: var(--rc-text-primary) !important;
caret-color: var(--rc-text-primary);
}
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
)[data-filled="true"],
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
),
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
):focus-visible,
body
.login-form
:is(input.login-input, input.input--secondary, input[data-slot="input"]):not(
:placeholder-shown
)[data-focused="true"],
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill:hover,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):-webkit-autofill:focus,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):autofill,
body
.login-form
:is(
input.login-input,
input.input--secondary,
input[data-slot="input"]
):autofill:focus {
--input-bg: var(--rc-login-field-filled-background) !important;
--input-bg-hover: var(--rc-login-field-filled-background-hover) !important;
--input-bg-focus: var(--rc-login-field-filled-background) !important;
appearance: none;
border-color: var(--rc-field-border-focus) !important;
background: var(--rc-login-field-filled-background) !important;
background-color: var(--rc-login-field-filled-background) !important;
background-image: none !important;
background-clip: padding-box !important;
caret-color: var(--rc-text-primary);
box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-box-shadow:
inset 0 0 0 1000px var(--rc-login-field-filled-background),
0 0 0 2px var(--rc-login-field-focus-ring) !important;
-webkit-text-fill-color: var(--rc-text-primary) !important;
transition:
background-color 9999s ease-out,
color 9999s ease-out;
}
body .login-submit-button {
height: var(--rc-login-submit-height) !important;
min-height: var(--rc-login-submit-height) !important;
margin-top: var(--rc-login-submit-margin-top) !important;
border-radius: var(--rc-button-radius, 999px) !important;
}
.login-brand-logo {
display: grid;
width: var(--rc-login-brand-logo-size);
height: var(--rc-login-brand-logo-size);
place-items: center;
}
.login-brand-logo .rc-brand-logo,
.rc-sidebar-brand-mark .rc-sidebar-logo {
grid-area: 1 / 1;
object-fit: contain;
}
.rc-brand-logo--dark,
.rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: none;
}
.dark .rc-brand-logo--light,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--light {
display: none;
}
.dark .rc-brand-logo--dark,
.dark .rc-sidebar-brand-mark .rc-sidebar-logo--dark {
display: block;
}
body .login-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 0.75rem;
width: 100%;
}
body .login-captcha-button {
width: 100%;
height: var(--rc-login-control-height) !important;
min-height: var(--rc-login-control-height) !important;
border: 1px solid var(--rc-button-secondary-border) !important;
border-radius: var(--rc-radius-control) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
box-shadow: none !important;
}
body .login-captcha-button .captcha-image {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
place-items: center;
overflow: hidden;
}
body .login-captcha-button .captcha-image :is(svg, img) {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 1.875rem;
margin: 0;
}
.dark body .login-captcha-button,
body .dark .login-captcha-button,
body [data-theme="dark"] .login-captcha-button {
border-color: var(--rc-button-secondary-border) !important;
background: var(--rc-bg-surface) !important;
color: var(--rc-text-secondary) !important;
}
.dark .login-page,
body .dark .login-page,
body.dark .login-page,
body [data-theme="dark"] .login-page {
--rc-login-field-filled-background: color-mix(
in oklab,
var(--rc-brand-primary) 14%,
var(--rc-field-background)
);
--rc-login-field-filled-background-hover: color-mix(
in oklab,
var(--rc-brand-primary) 18%,
var(--rc-field-background)
);
--rc-login-field-autofill-background: color-mix(
in oklab,
var(--rc-brand-primary) 16%,
var(--rc-field-background)
);
--rc-login-field-border-hover: color-mix(
in oklab,
var(--rc-brand-primary) 42%,
var(--rc-border-default)
);
--rc-login-field-focus-ring: color-mix(
in oklab,
var(--rc-brand-primary) 22%,
transparent
);
}
import { extend } from 'umi-request';
import progressMiddleware from 'umi-request-progress';
import authService from '@/services/authService';
import { getBaseAwareLoginPath } from '@/config/ApplicationConfig';
// 使用 .umirc.ts 中定义的全局变量
const baseUrl = STREAMS_API_URL;
......@@ -40,7 +41,7 @@ function createRequestWithPrefix(prefix = baseUrl) {
console.warn('[request] authentication required (401)');
// 跳转到登录页(可选,根据业务需求调整)
location.href = '/login?redirect=' + encodeURIComponent(location.href);
location.href = getBaseAwareLoginPath(location.href);
} else if (response?.status === 403) {
authService.clearUser();
console.warn('[request] forbidden (403)');
......
import authService from '@/services/authService';
import { getBaseAwareLoginPath } from '@/config/ApplicationConfig';
import { getUserInfo, userLogout } from '@/services/DataService';
import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi';
export const UserContext = createContext(null);
const LOGIN_PATH = '/login';
function getStoredUser() {
const value = authService.getCurrentUser();
......@@ -78,7 +77,7 @@ export default function AuthWrapper() {
}
} finally {
authService.clearUser();
history.push(LOGIN_PATH);
window.location.assign(getBaseAwareLoginPath());
}
},
};
......
import { getSystemInfo } from '@/services/DataService';
import { APP_NAME } from '@/config/ApplicationConfig';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import localeData from 'dayjs/plugin/localeData';
......@@ -42,7 +43,7 @@ export default function SiteWrapper() {
setLoadError(error?.message || '加载站点配置失败');
setSiteInfo({
name: '项目管理系统',
name: APP_NAME,
});
} finally {
if (!cancelled) {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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