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 diff is collapsed.
...@@ -9,24 +9,44 @@ ...@@ -9,24 +9,44 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@headlessui/react": "^2.2.10", "@fontsource/inter": "5.3.0",
"@heroicons/react": "^2.2.0", "@heroui-pro/react": "1.0.0-beta.8",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react", "@heroui/react": "3.2.2",
"@heroui/react": "^3.2.3", "@heroui/styles": "3.2.2",
"@heroui/styles": "^3.2.3", "@number-flow/react": "^0.6.2",
"@react-aria/ssr": "^3.10.1", "@react-aria/ssr": "^3.10.1",
"@react-aria/utils": "^3.34.1", "@react-aria/utils": "^3.34.1",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "^4.5.3", "@umijs/max": "^4.5.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0", "embla-carousel": "^8.6.0",
"echarts-for-react": "^3.0.6", "embla-carousel-react": "^8.6.0",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^13.1.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-aria": "^3.51.0", "react-aria": "^3.51.0",
"react-aria-components": "^1.20.0", "react-aria-components": "1.19.0",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
"umi-request": "^1.4.0", "umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0", "umi-request-progress": "^1.1.0",
......
This diff is collapsed.
import '@/tailwind.css'; 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 '@/global.less';
import { defineApp } from 'umi'; 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) { ...@@ -42,6 +42,10 @@ function iconStyle(size, style) {
return size === undefined ? style : { fontSize: 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) { function withSize(Icon) {
return function AppIcon({ size, style, ...props }) { return function AppIcon({ size, style, ...props }) {
return ( return (
......
import { useLayoutEffect, useState } from 'react'; import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi'; import { history } from 'umi';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
...@@ -70,6 +71,7 @@ export default function AppShell({ ...@@ -70,6 +71,7 @@ export default function AppShell({
onClose={() => setIsApplicationDialogOpen(false)} onClose={() => setIsApplicationDialogOpen(false)}
userInfo={sidebarProps.userInfo} userInfo={sidebarProps.userInfo}
/> />
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </Sidebar.Provider>
); );
} }
import xyLogoRed from '@/assets/identity/xy-logo-red.png'; import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png'; import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_NAME } from '@/config/ApplicationConfig';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { import {
Avatar, Avatar,
Button, Button,
Dropdown, Dropdown,
Label, Label,
Tooltip,
} from '@heroui/react'; } from '@heroui/react';
import { Heading } from 'react-aria-components/Dialog';
import { AppGrid, LogOut } from './AppIcons'; import { AppGrid, LogOut } from './AppIcons';
const APP_NAME = '党风廉政建设';
function getUserDisplay(userInfo) { function getUserDisplay(userInfo) {
const displayName = const displayName =
userInfo?.display_name || userInfo?.username || '当前用户'; userInfo?.display_name || userInfo?.username || '当前用户';
...@@ -28,11 +27,9 @@ function SidebarUserMenu({ userInfo, onLogout }) { ...@@ -28,11 +27,9 @@ function SidebarUserMenu({ userInfo, onLogout }) {
return ( return (
<Dropdown> <Dropdown>
<Button <Dropdown.Trigger
fullWidth aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger" className="app-sidebar-user-trigger rc-sidebar-user-trigger"
size="sm"
variant="ghost"
> >
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar"> <Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? ( {userInfo?.avatar ? (
...@@ -47,12 +44,12 @@ function SidebarUserMenu({ userInfo, onLogout }) { ...@@ -47,12 +44,12 @@ function SidebarUserMenu({ userInfo, onLogout }) {
<span className="app-sidebar-user-name">{user.displayName}</span> <span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span> <span className="app-sidebar-user-description">{user.username}</span>
</span> </span>
</Button> </Dropdown.Trigger>
<Dropdown.Popover placement="top start"> <Dropdown.Popover placement="top start">
<Dropdown.Menu <Dropdown.Menu
aria-label="用户菜单" aria-label="用户菜单"
onAction={(key) => { onAction={(key) => {
if (key === 'logout') onLogout(); if (key === 'logout') onLogout?.();
}} }}
> >
<Dropdown.Item id="logout" textValue="退出登录" variant="danger"> <Dropdown.Item id="logout" textValue="退出登录" variant="danger">
...@@ -106,6 +103,7 @@ function SidebarContents({ ...@@ -106,6 +103,7 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher" className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label" data-sidebar="label"
> >
<Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
aria-label="切换应用" aria-label="切换应用"
...@@ -116,6 +114,8 @@ function SidebarContents({ ...@@ -116,6 +114,8 @@ function SidebarContents({
> >
<AppGrid size={16} /> <AppGrid size={16} />
</Button> </Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div> </div>
</div> </div>
</Sidebar.Header> </Sidebar.Header>
...@@ -131,7 +131,9 @@ function SidebarContents({ ...@@ -131,7 +131,9 @@ function SidebarContents({
) : ( ) : (
menuGroups.map((group) => ( menuGroups.map((group) => (
<Sidebar.Group key={group.title}> <Sidebar.Group key={group.title}>
{menuGroups.length > 1 ? (
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
) : null}
<Sidebar.Menu aria-label={group.title}> <Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => { {group.items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
...@@ -196,9 +198,9 @@ export default function DashboardSidebar(props) { ...@@ -196,9 +198,9 @@ export default function DashboardSidebar(props) {
<SidebarContents {...props} /> <SidebarContents {...props} />
</Sidebar> </Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar"> <Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<Heading slot="title" className="sr-only"> <h2 slot="title" className="sr-only">
党风廉政建设导航 {APP_NAME}导航
</Heading> </h2>
<SidebarContents {...props} /> <SidebarContents {...props} />
</Sidebar.Mobile> </Sidebar.Mobile>
</> </>
......
This diff is collapsed.
...@@ -2,13 +2,6 @@ import { Upload } from '@/components/AppIcons'; ...@@ -2,13 +2,6 @@ import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone'; import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react'; import { useCallback } from 'react';
function createFileChangeEvent(files) {
return {
target: { files },
currentTarget: { files },
};
}
export default function FileUploadField({ export default function FileUploadField({
accept, accept,
ariaLabel = '选择文件', ariaLabel = '选择文件',
...@@ -16,16 +9,16 @@ export default function FileUploadField({ ...@@ -16,16 +9,16 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。', description = '将文件拖到此处,或从本地选择。',
disabled = false, disabled = false,
fileName = '', fileName = '',
onChange, onFilesChange,
triggerLabel = '选择文件', triggerLabel = '选择文件',
}) { }) {
const emitFiles = useCallback( const emitFiles = useCallback(
(files) => { (files) => {
if (!disabled) { if (!disabled) {
onChange?.(createFileChangeEvent(files)); onFilesChange?.(Array.from(files || []));
} }
}, },
[disabled, onChange], [disabled, onFilesChange],
); );
const handleDrop = useCallback( const handleDrop = useCallback(
...@@ -42,7 +35,7 @@ export default function FileUploadField({ ...@@ -42,7 +35,7 @@ export default function FileUploadField({
); );
return ( return (
<DropZone className={`rc-file-upload ${className}`.trim()}> <DropZone className={className}>
<DropZone.Area <DropZone.Area
aria-label={ariaLabel} aria-label={ariaLabel}
isDisabled={disabled} isDisabled={disabled}
...@@ -61,7 +54,6 @@ export default function FileUploadField({ ...@@ -61,7 +54,6 @@ export default function FileUploadField({
{fileName ? '已选择文件,可重新选择进行替换。' : description} {fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description> </DropZone.Description>
<DropZone.Trigger isDisabled={disabled}> <DropZone.Trigger isDisabled={disabled}>
<Upload aria-hidden="true" />
{triggerLabel} {triggerLabel}
</DropZone.Trigger> </DropZone.Trigger>
</DropZone.Area> </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 {
import { DateField } from '@heroui/react/date-field'; Calendar as HeroCalendar,
import { DatePicker } from '@heroui/react/date-picker'; CloseButton,
import { TimeField } from '@heroui/react/time-field'; DateField,
DatePicker,
I18nProvider,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date'; import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import clsx from 'clsx';
import { useMemo } from 'react'; 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) { function parseValue(value, mode) {
if (!value) { if (!value) {
...@@ -67,29 +74,21 @@ function combineDateAndTime(value, dateValue, timeValue) { ...@@ -67,29 +74,21 @@ function combineDateAndTime(value, dateValue, timeValue) {
return `${dateText}T${formatTimeValue(timeValue)}`; return `${dateText}T${formatTimeValue(timeValue)}`;
} }
function emitDateChange(onChange, name, nextValue) {
onChange?.({
target: {
name,
value: nextValue,
},
});
}
export default function FormDatePicker({ export default function FormDatePicker({
'aria-label': ariaLabel, 'aria-label': ariaLabel,
className, className,
disabled = false, disabled = false,
granularity, granularity,
isClearable = true,
max, max,
min, min,
mode, mode,
name,
onChange, onChange,
readOnly = false, readOnly = false,
required = false, required = false,
type = 'date', type = 'date',
value, value,
variant = 'secondary',
...props ...props
}) { }) {
const resolvedMode = const resolvedMode =
...@@ -103,12 +102,14 @@ export default function FormDatePicker({ ...@@ -103,12 +102,14 @@ export default function FormDatePicker({
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]); const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]); const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]); const timeValue = useMemo(() => parseTimeValue(value), [value]);
const canClear = isClearable && Boolean(value) && !disabled && !readOnly && !required;
return ( return (
<I18nProvider locale="zh-CN">
<DatePicker <DatePicker
{...props} {...props}
aria-label={ariaLabel || '选择日期'} aria-label={ariaLabel || '选择日期'}
className={clsx('rc-form-date-picker', className)} className={['w-full', className].filter(Boolean).join(' ')}
granularity={resolvedGranularity} granularity={resolvedGranularity}
isDisabled={disabled} isDisabled={disabled}
isReadOnly={readOnly} isReadOnly={readOnly}
...@@ -116,40 +117,42 @@ export default function FormDatePicker({ ...@@ -116,40 +117,42 @@ export default function FormDatePicker({
maxValue={maxValue || undefined} maxValue={maxValue || undefined}
minValue={minValue || undefined} minValue={minValue || undefined}
value={parsedValue} value={parsedValue}
onChange={(nextValue) => onChange={(nextValue) => onChange?.(formatValue(nextValue, resolvedMode))}
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
}
> >
<DateField.Group <DateField.Group className="relative" fullWidth variant={variant}>
className="rc-form-date-picker__group" <DateField.Input className={canClear ? 'pe-14' : 'pe-8'}>
fullWidth {(segment) => <DateField.Segment segment={segment} />}
variant="secondary" </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"> <Close
{(segment) => ( className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
<DateField.Segment size={16}
className="rc-form-date-picker__segment" style={FIELD_SUFFIX_ICON_STYLE}
segment={segment}
/> />
)} </CloseButton>
</DateField.Input> ) : null}
<DateField.Suffix className="rc-form-date-picker__suffix">
<DatePicker.Trigger <DatePicker.Trigger
className="rc-form-date-picker__trigger"
aria-label="打开日期选择" aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
> >
<DatePicker.TriggerIndicator> <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.TriggerIndicator>
</DatePicker.Trigger> </DatePicker.Trigger>
</DateField.Suffix> </DateField.Suffix>
</DateField.Group> </DateField.Group>
<DatePicker.Popover <DatePicker.Popover placement="bottom start">
className="rc-form-date-picker__popover" <HeroCalendar>
placement="bottom start"
>
<HeroCalendar className="rc-form-date-picker__calendar">
<HeroCalendar.Header> <HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" /> <HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading /> <HeroCalendar.Heading />
...@@ -165,34 +168,22 @@ export default function FormDatePicker({ ...@@ -165,34 +168,22 @@ export default function FormDatePicker({
</HeroCalendar.Grid> </HeroCalendar.Grid>
</HeroCalendar> </HeroCalendar>
{resolvedMode === 'datetime' ? ( {resolvedMode === 'datetime' ? (
<div className="rc-form-date-picker__time-panel"> <div className="flex items-center justify-between gap-3 p-3">
<span className="rc-form-date-picker__time-label">时间</span> <span className="text-sm text-muted">时间</span>
<TimeField <TimeField
aria-label="选择时间" aria-label="选择时间"
className="rc-form-date-picker__time-field" className="w-36"
granularity="minute" granularity="minute"
hideTimeZone hideTimeZone
hourCycle={24} hourCycle={24}
value={timeValue} value={timeValue}
onChange={(nextTime) => onChange={(nextTime) =>
emitDateChange( onChange?.(combineDateAndTime(value, parsedValue, nextTime))
onChange,
name,
combineDateAndTime(value, parsedValue, nextTime),
)
} }
> >
<TimeField.Group <TimeField.Group fullWidth variant={variant}>
className="rc-form-date-picker__time-group" <TimeField.Input>
variant="secondary" {(segment) => <TimeField.Segment segment={segment} />}
>
<TimeField.Input className="rc-form-date-picker__time-input">
{(segment) => (
<TimeField.Segment
className="rc-form-date-picker__segment"
segment={segment}
/>
)}
</TimeField.Input> </TimeField.Input>
</TimeField.Group> </TimeField.Group>
</TimeField> </TimeField>
...@@ -200,5 +191,6 @@ export default function FormDatePicker({ ...@@ -200,5 +191,6 @@ export default function FormDatePicker({
) : null} ) : null}
</DatePicker.Popover> </DatePicker.Popover>
</DatePicker> </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'; ...@@ -13,55 +13,7 @@ import { Button, Dropdown, Label, Tooltip } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Children, Fragment, isValidElement } from 'react'; import { Children, Fragment, isValidElement } from 'react';
const STICKY_ACTION_BASE_HEADER_CLASS = function resolveActionTone({ icon, tone }) {
'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 }) {
if (tone) { if (tone) {
return tone; return tone;
} }
...@@ -74,37 +26,7 @@ function resolveActionTone({ color, icon, label, outline, tone }) { ...@@ -74,37 +26,7 @@ function resolveActionTone({ color, icon, label, outline, tone }) {
return iconTone; return iconTone;
} }
if (/删除|移除|作废|清退|delete|remove|trash/i.test(String(label || ''))) { return 'view';
return 'delete';
}
if (/驳回|拒绝|reject/i.test(String(label || ''))) {
return 'reject';
}
return ACTION_COLOR_TONE[color] || (outline ? 'edit' : 'view');
}
export function getStickyActionHeaderClass(size = 'medium') {
return clsx(
STICKY_ACTION_BASE_HEADER_CLASS,
ACTION_COLUMN_WIDTHS[size] || ACTION_COLUMN_WIDTHS.medium,
);
}
export function getStickyActionCellClass(size = 'medium') {
return clsx(
STICKY_ACTION_BASE_CELL_CLASS,
ACTION_COLUMN_WIDTHS[size] || ACTION_COLUMN_WIDTHS.medium,
);
}
export function getStickyFirstColumnHeaderClass(className) {
return clsx(STICKY_FIRST_COLUMN_BASE_HEADER_CLASS, className);
}
export function getStickyFirstColumnCellClass(className) {
return clsx(STICKY_FIRST_COLUMN_BASE_CELL_CLASS, className);
} }
export const TABLE_ACTION_ICONS = { export const TABLE_ACTION_ICONS = {
...@@ -143,30 +65,31 @@ export function TableActionGroup({ className, children, ...props }) { ...@@ -143,30 +65,31 @@ export function TableActionGroup({ className, children, ...props }) {
<div <div
{...props} {...props}
className={clsx( className={clsx(
'icon-actions flex flex-nowrap items-center justify-end gap-1.5 whitespace-nowrap', 'flex flex-nowrap items-center justify-start gap-2 whitespace-nowrap',
className, className,
)} )}
> >
{visibleActions} {visibleActions}
{overflowActions.length > 0 ? ( {overflowActions.length > 0 ? (
<Dropdown> <Dropdown>
<Dropdown.Trigger>
<Tooltip delay={0}> <Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
size="sm" size="sm"
variant="ghost" variant="tertiary"
className="pm-table-action pm-table-action--default inline-flex size-8 items-center justify-center rounded-full" className="grid place-items-center p-0"
aria-label="更多操作" aria-label="更多操作"
title="更多操作"
> >
<More size={16} /> <More size={16} />
</Button> </Button>
<Tooltip.Content>更多操作</Tooltip.Content> <Tooltip.Content>更多操作</Tooltip.Content>
</Tooltip> </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 <Dropdown.Menu
aria-label="更多操作" aria-label="更多操作"
onAction={(key) => overflowActions[Number(key)]?.props?.onClick?.()} onAction={(key) => overflowActions[Number(key)]?.props?.onPress?.()}
> >
{overflowActions.map((action, index) => { {overflowActions.map((action, index) => {
const label = action?.props?.label || `操作 ${index + 3}`; const label = action?.props?.label || `操作 ${index + 3}`;
...@@ -174,9 +97,8 @@ export function TableActionGroup({ className, children, ...props }) { ...@@ -174,9 +97,8 @@ export function TableActionGroup({ className, children, ...props }) {
return ( return (
<Dropdown.Item <Dropdown.Item
id={String(index)} id={String(index)}
isDisabled={action?.props?.disabled} isDisabled={action?.props?.isDisabled}
key={`${label}-${index}`} key={`${label}-${index}`}
className="rc-dropdown-option"
textValue={label} textValue={label}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
...@@ -196,22 +118,23 @@ export function TableActionGroup({ className, children, ...props }) { ...@@ -196,22 +118,23 @@ export function TableActionGroup({ className, children, ...props }) {
export function TableActionButton({ export function TableActionButton({
className, className,
color, isDisabled,
disabled,
icon: Icon, icon: Icon,
label, label,
onClick, onPress,
outline = false,
tone, tone,
...props ...props
}) { }) {
const resolvedTone = resolveActionTone({ const resolvedTone = resolveActionTone({
color,
icon: Icon, icon: Icon,
label,
outline,
tone, tone,
}); });
const variant =
resolvedTone === 'delete' || resolvedTone === 'reject'
? 'danger-soft'
: resolvedTone === 'approve' || resolvedTone === 'submit'
? 'secondary'
: 'tertiary';
return ( return (
<Tooltip delay={0}> <Tooltip delay={0}>
...@@ -219,16 +142,11 @@ export function TableActionButton({ ...@@ -219,16 +142,11 @@ export function TableActionButton({
{...props} {...props}
isIconOnly isIconOnly
aria-label={label} aria-label={label}
title={label} isDisabled={isDisabled}
isDisabled={disabled}
size="sm" size="sm"
variant="ghost" variant={variant}
onClick={onClick} onPress={onPress}
className={clsx( className={clsx('grid place-items-center p-0', className)}
'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,
)}
> >
<Icon size={16} /> <Icon size={16} />
</Button> </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,
};
});
}
This diff is collapsed.
/*
* 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);
}
This diff is collapsed.
This diff is collapsed.
import xyLogoRed from '@/assets/identity/xy-logo-red.png'; import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.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 authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Card } from '@heroui/react'; import {
Alert,
Button,
Card,
Input,
Label,
TextField,
} from '@heroui/react';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi'; import { history, useSearchParams } from 'umi';
const APP_NAME = '党风廉政建设';
function buildPassword(password, siteKey) { function buildPassword(password, siteKey) {
if (!password) { if (!password) {
return password; return password;
...@@ -18,6 +24,28 @@ function buildPassword(password, siteKey) { ...@@ -18,6 +24,28 @@ function buildPassword(password, siteKey) {
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password; return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
} }
function normalizeRedirect(value) {
if (!value) return '/';
try {
const target = new URL(value, window.location.origin);
if (target.origin !== window.location.origin) return '/';
const applicationBasePath = 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() { export default function LoginPage() {
const site = useContext(SiteContext); const site = useContext(SiteContext);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
...@@ -29,8 +57,7 @@ export default function LoginPage() { ...@@ -29,8 +57,7 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/'; const redirect = normalizeRedirect(searchParams.get('redirect'));
const siteTitle = APP_NAME;
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
setCaptchaLoading(true); setCaptchaLoading(true);
...@@ -47,7 +74,7 @@ export default function LoginPage() { ...@@ -47,7 +74,7 @@ export default function LoginPage() {
}; };
useEffect(() => { useEffect(() => {
document.title = siteTitle; document.title = `${APP_NAME} - 登录`;
refreshCaptcha(); refreshCaptcha();
}, []); }, []);
...@@ -81,81 +108,94 @@ export default function LoginPage() { ...@@ -81,81 +108,94 @@ export default function LoginPage() {
}; };
return ( return (
<main className="login-page"> <main className="login-page rc-login-page">
<section className="login-shell"> <section className="login-shell rc-login-shell">
<Card className="login-card"> <Card className="login-card rc-login-card">
<Card.Content> <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 <img
src={xyLogoRed}
alt="鑫元" alt="鑫元"
className="login-brand-logo login-brand-logo--light" className="rc-brand-logo rc-brand-logo--light"
src={xyLogoRed}
/> />
<img <img
src={xyLogoWhite}
alt="鑫元" 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> </div>
<form onSubmit={handleSubmit} className="login-form"> <form className="login-form rc-login-form" onSubmit={handleSubmit}>
<label <TextField
className="login-field" className="login-field"
data-has-value={identity ? 'true' : undefined} data-has-value={identity ? 'true' : undefined}
fullWidth
isDisabled={loading}
isRequired
value={identity}
onChange={setIdentity}
> >
<span>账号</span> <Label>账号</Label>
<input <Input
className="login-input input input--secondary" autoComplete="username"
autoFocus
className="login-input"
data-filled={identity ? 'true' : undefined} data-filled={identity ? 'true' : undefined}
value={identity}
onChange={(event) => setIdentity(event.target.value)}
disabled={loading}
placeholder="请输入账号" placeholder="请输入账号"
required variant="secondary"
/> />
</label> </TextField>
<label <TextField
className="login-field" className="login-field"
data-has-value={password ? 'true' : undefined} data-has-value={password ? 'true' : undefined}
> fullWidth
<span>密码</span> isDisabled={loading}
<input isRequired
type="password" type="password"
className="login-input input input--secondary"
data-filled={password ? 'true' : undefined}
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={setPassword}
disabled={loading} >
<Label>密码</Label>
<Input
autoComplete="current-password"
className="login-input"
data-filled={password ? 'true' : undefined}
placeholder="请输入密码" placeholder="请输入密码"
required variant="secondary"
/> />
</label> </TextField>
<label <div
className="login-field" className="login-field"
data-has-value={captchaCode ? 'true' : undefined} data-has-value={captchaCode ? 'true' : undefined}
> >
<span>验证码</span> <Label>验证码</Label>
<div className="login-captcha-row"> <div className="login-captcha-row">
<input <Input
className="login-input input input--secondary" aria-label="验证码"
autoComplete="off"
className="login-input"
data-filled={captchaCode ? 'true' : undefined} data-filled={captchaCode ? 'true' : undefined}
value={captchaCode}
onChange={(event) => setCaptchaCode(event.target.value)}
disabled={loading} disabled={loading}
placeholder="请输入验证码" placeholder="请输入验证码"
required required
value={captchaCode}
variant="secondary"
onChange={(event) => setCaptchaCode(event.target.value)}
/> />
<button <Button
type="button" aria-label="刷新验证码"
onClick={() => { className="login-captcha-button"
isDisabled={loading || captchaLoading}
variant="secondary"
onPress={() => {
setError(''); setError('');
refreshCaptcha(); refreshCaptcha();
}} }}
disabled={loading || captchaLoading}
className="login-captcha-button"
> >
{captchaData?.image ? ( {captchaData?.image ? (
<span <span
...@@ -165,23 +205,30 @@ export default function LoginPage() { ...@@ -165,23 +205,30 @@ export default function LoginPage() {
) : ( ) : (
<span>{captchaLoading ? '加载中...' : '刷新验证码'}</span> <span>{captchaLoading ? '加载中...' : '刷新验证码'}</span>
)} )}
</button> </Button>
</div>
</div> </div>
</label>
{error ? ( {error ? (
<div className="rc-alert rc-alert--danger" role="alert"> <Alert status="danger">
{error} <Alert.Content>
</div> <Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null} ) : null}
<button <Button
type="submit" fullWidth
disabled={loading || captchaLoading}
className="login-submit-button" className="login-submit-button"
isDisabled={
loading || captchaLoading || !identity || !password || !captchaCode
}
isPending={loading}
type="submit"
variant="primary"
> >
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </Button>
</form> </form>
</Card.Content> </Card.Content>
</Card> </Card>
......
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 @@ ...@@ -21,6 +21,7 @@
- 左侧菜单:`党风廉政建设` - 左侧菜单:`党风廉政建设`
- 页面内通过 Tab 切换 `培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒` - 页面内通过 Tab 切换 `培训类 / 谈话类 / 调研类 / 专题会议类 / 廉洁主题活动 / 节前廉洁提醒`
- 每个 Tab 对应独立台账对象和独立 Excel 模板 - 每个 Tab 对应独立台账对象和独立 Excel 模板
- 页面使用鑫元浅色/深色主题;筛选、表格、弹窗和上传控件统一采用 HeroUI / HeroUI Pro 交互
## 3. 核心业务对象 ## 3. 核心业务对象
...@@ -40,19 +41,21 @@ ...@@ -40,19 +41,21 @@
1. 用户进入 `党风廉政建设` 1. 用户进入 `党风廉政建设`
2. 通过顶部 Tab 选择台账分类 2. 通过顶部 Tab 选择台账分类
3. 页面展示该分类的表格台账 3. 页面展示该分类的表格台账
4. 筛选区统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选 4. 筛选区使用可展开的 Surface,统一包含关键词、年份和当前分类关键字段;谈话类额外支持是否党员、是否领导干部、是否投资序列筛选
5. 用户点击 `查询` 后,前端调用 `POST /record/query` 5. 选择类筛选通过 Autocomplete 完成;页面不显示刷新或查询按钮,筛选条件变化后自动调用 `POST /record/query`,关键词输入采用约 300ms 延迟以减少重复请求;筛选 Surface 收起时只占主筛选行高度,点击展开后才增加溢出字段区域
6. 列表按 `updated_at`、`created_at` 倒序展示 6. 列表按 `updated_at`、`created_at` 倒序展示
7. 表格仅展示核心字段和操作入口,6 类台账统一不展示“附件状态”和“更新时间”列;附件完整清单在编辑弹窗中维护,更新时间继续作为后台排序字段使用 7. 表格仅展示核心字段和操作入口,6 类台账统一不展示“附件状态”和“更新时间”列;附件完整清单在编辑弹窗中维护,更新时间继续作为后台排序字段使用
8. 表格采用固定列宽布局:日期、状态类字段使用紧凑宽度,主题和长文本字段使用较宽列并单行省略,完整内容通过悬停提示查看;列数较多时允许在表格区域横向滚动,避免内容覆盖操作入口 8. 表格采用固定列宽布局:日期、状态类字段使用紧凑宽度,主题和长文本字段使用较宽列并单行省略,完整内容通过悬停提示查看;列数较多时允许在表格区域横向滚动,避免内容覆盖操作入口
9. 完整字段在编辑弹窗中维护 9. 完整字段在编辑弹窗中维护
10. DataGrid 固定每页展示 10 条,不提供页容量切换;通过底部 Pagination 翻页
11. 勾选记录后显示 ActionBar,集中提供导出勾选、批量修改和批量删除入口;清空选择后 ActionBar 关闭
### 4.2 手动新建台账 ### 4.2 手动新建台账
1. 用户点击 `新建记录` 1. 用户点击 `新建记录`
2. 前端根据当前 Tab 字段配置生成表单 2. 前端根据当前 Tab 字段配置在 HeroUI Modal 中生成表单
3. 用户填写最小必填字段 3. 用户填写最小必填字段
4. 附件字段通过系统上传控件上传,一个字段可保存多个附件 4. 附件字段通过 DropZone 上传,一个字段可保存多个附件
5. 前端自动生成 `title` 5. 前端自动生成 `title`
6. 前端调用 `POST /record` 6. 前端调用 `POST /record`
7. 保存成功后提示并刷新当前列表 7. 保存成功后提示并刷新当前列表
...@@ -69,7 +72,7 @@ ...@@ -69,7 +72,7 @@
### 4.3 编辑台账 ### 4.3 编辑台账
1. 用户在表格中点击 `编辑` 1. 用户在表格中点击 `编辑`
2. 页面打开详情弹窗,展示当前分类全部字段 2. 页面打开 HeroUI 详情弹窗,展示当前分类全部字段;必填、日期、布尔和选择字段沿用新建表单规则
3. 用户修改字段或上传、移除附件 3. 用户修改字段或上传、移除附件
4. 前端调用 `PUT /record/:name` 4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新当前列表 5. 保存成功后刷新当前列表
...@@ -77,7 +80,7 @@ ...@@ -77,7 +80,7 @@
### 4.4 删除台账 ### 4.4 删除台账
1. 用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除 1. 用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除
2. 页面弹出二次确认 2. 页面弹出受控 HeroUI 删除确认弹窗
3. 前端调用 `DELETE /record/:name` 3. 前端调用 `DELETE /record/:name`
4. 删除后刷新当前列表 4. 删除后刷新当前列表
5. 删除默认走软删除 5. 删除默认走软删除
...@@ -86,8 +89,8 @@ ...@@ -86,8 +89,8 @@
1. 用户在当前 Tab 点击 `下载模板` 1. 用户在当前 Tab 点击 `下载模板`
2. 前端导出当前分类的独立模板 2. 前端导出当前分类的独立模板
3. 用户维护 Excel 后点击 `Excel 导入` 3. 用户维护 Excel 后点击 `Excel 导入`,在导入弹窗的 DropZone 选择或拖入文件
4. 前端读取首个工作表并按当前 Tab 字段映射解析 4. 前端读取首个工作表并按当前 Tab 字段映射解析,并在弹窗中展示只读预览
5. 附件字段不参与导入 5. 附件字段不参与导入
6. 前端逐条调用 `POST /record` 6. 前端逐条调用 `POST /record`
7. 当前阶段不做重复判断、不自动覆盖,导入全部新增 7. 当前阶段不做重复判断、不自动覆盖,导入全部新增
......
...@@ -9,7 +9,6 @@ import { ...@@ -9,7 +9,6 @@ import {
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
export const INTEGRITY_PAGE_SIZE_OPTIONS = [10, 20, 50];
export const DEFAULT_INTEGRITY_PAGE_SIZE = 10; export const DEFAULT_INTEGRITY_PAGE_SIZE = 10;
export const TRAINING_CATEGORY_OPTIONS = [ export const TRAINING_CATEGORY_OPTIONS = [
......
This diff is collapsed.
import { extend } from 'umi-request'; import { extend } from 'umi-request';
import progressMiddleware from 'umi-request-progress'; import progressMiddleware from 'umi-request-progress';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getBaseAwareLoginPath } from '@/config/ApplicationConfig';
// 使用 .umirc.ts 中定义的全局变量 // 使用 .umirc.ts 中定义的全局变量
const baseUrl = STREAMS_API_URL; const baseUrl = STREAMS_API_URL;
...@@ -40,7 +41,7 @@ function createRequestWithPrefix(prefix = baseUrl) { ...@@ -40,7 +41,7 @@ function createRequestWithPrefix(prefix = baseUrl) {
console.warn('[request] authentication required (401)'); console.warn('[request] authentication required (401)');
// 跳转到登录页(可选,根据业务需求调整) // 跳转到登录页(可选,根据业务需求调整)
location.href = '/login?redirect=' + encodeURIComponent(location.href); location.href = getBaseAwareLoginPath(location.href);
} else if (response?.status === 403) { } else if (response?.status === 403) {
authService.clearUser(); authService.clearUser();
console.warn('[request] forbidden (403)'); console.warn('[request] forbidden (403)');
......
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getBaseAwareLoginPath } from '@/config/ApplicationConfig';
import { getUserInfo, userLogout } from '@/services/DataService'; import { getUserInfo, userLogout } from '@/services/DataService';
import { createContext, useEffect, useMemo, useState } from 'react'; import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi'; import { history, Navigate, Outlet, useLocation } from 'umi';
export const UserContext = createContext(null); export const UserContext = createContext(null);
const LOGIN_PATH = '/login';
function getStoredUser() { function getStoredUser() {
const value = authService.getCurrentUser(); const value = authService.getCurrentUser();
...@@ -78,7 +77,7 @@ export default function AuthWrapper() { ...@@ -78,7 +77,7 @@ export default function AuthWrapper() {
} }
} finally { } finally {
authService.clearUser(); authService.clearUser();
history.push(LOGIN_PATH); window.location.assign(getBaseAwareLoginPath());
} }
}, },
}; };
......
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import { APP_NAME } from '@/config/ApplicationConfig';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
...@@ -42,7 +43,7 @@ export default function SiteWrapper() { ...@@ -42,7 +43,7 @@ export default function SiteWrapper() {
setLoadError(error?.message || '加载站点配置失败'); setLoadError(error?.message || '加载站点配置失败');
setSiteInfo({ setSiteInfo({
name: '项目管理系统', name: APP_NAME,
}); });
} finally { } finally {
if (!cancelled) { if (!cancelled) {
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment