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

feat: 按鑫元设计系统统一 UI/UX

parent 48dae1c1
...@@ -10,5 +10,7 @@ ...@@ -10,5 +10,7 @@
/.umi-test /.umi-test
/dist /dist
/.mfsu /.mfsu
.playwright-cli/
/test-results/
.swc .swc
.pnpm-store .pnpm-store
...@@ -91,6 +91,9 @@ export default defineConfig({ ...@@ -91,6 +91,9 @@ export default defineConfig({
} }
}, },
esbuildMinifyIIFE: true, esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
define: { define: {
STREAMS_API_URL: '/api', STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser', STREAMS_PARSER_URL: '/parser',
......
This diff is collapsed.
# 问题整改系统 UI/UX 全局审计
审计日期:2026-08-10
目标分支:`codex/ui-ux-xinyuan-migration`
审计范围:登录、应用 Shell、驾驶舱、整改分析、问题整改列表、往期材料归档、外链流程、记录详情抽屉及共享 Catalyst 兼容层。
## 1. 迁移边界
本次只迁移 UI/UX,不改变以下行为:
- Umi `base/publicPath`:`/problem_rectification_new/`;
- 登录、验证码、退出登录、401/403 跳转和 redirect 参数;
- `/record/query`、`/record/count`、批量更新/删除、克隆、归档、导出和 OA 推送协议;
- Subject View 驱动的列、分组、筛选、权限、固定列与详情字段规则;
- 填报、复核、最终审核、延期申请、锁定/解锁等状态机;
- 页面现有 loading、disabled、权限、校验和失败回调。
接口和数据字段以 `src/services/record-api.md`、`src/services/subjectsDefinition.md` 与 `src/pages/problem_rectification/problem-rectification.business-flow.md` 为准。
## 2. 改造前基线
- 技术栈为 React 18、Umi 4、Tailwind CSS 4、Catalyst/Headless UI。
- 原始项目可在 `http://localhost:8000/problem_rectification_new/` 启动。
- 未登录访问业务页会正确跳转至带 base 的登录页,必须保留。
- 登录页桌面为营销式左右分栏,移动端隐藏左栏;不符合鑫元居中认证面板。
- 项目没有统一 Xinyuan Tokens、HeroUI Shell、根级暗色主题或持久化主题切换。
## 3. 全局发现
### P0:基础体系缺失
- 缺少 `src/design-system/tokens.css`、统一 Shell、图标适配层和语义状态组件。
- `src/global.less` 仍以蓝色 `--pm-brand: #009cd9` 和渐变页面背景为基础。
- HeroUI / HeroUI Pro 尚未接入;表格、下拉、弹窗和分页仍由 Catalyst/Headless UI 或页面本地实现。
- 暗色主题没有覆盖 body Portal、弹窗、下拉、日期、图表 Tooltip 和移动 offcanvas。
### P0:Shell 与身份不统一
- 登录页、侧栏、Navbar、应用切换和文档标题没有一个统一的应用展示名称来源。
- 侧栏为页面私有实现,折叠后仍保留窄轨,不符合 offcanvas 零宽要求。
- Navbar 触发器使用自制折叠/展开图标,不是 HeroUI Pro 默认 `LayoutSideContentLeft` 图形。
- 用户 footer、退出登录、应用切换弹窗、浅色红 Logo/深色白 Logo 需要作为一套迁移。
- 页面内容缺少桌面 68px / 移动 72px 的对称 `--rc-page-title-inset`。
### P0:列表与表单仍有旧控件
- 主工作台、详情引用表、驾驶舱明细和流程表仍使用 Catalyst Table,不是 HeroUI Pro DataGrid。
- 存在 Headless UI 页面私有下拉、自写分页和 10/20/50/100 页容量;鑫元规范要求共享 HeroUI 分页和 10/20/50。
- 存在可见原生 `date`、`file` 控件和 `window.confirm` 删除确认。
- 日期、上传、引用选择、枚举选择和页容量下拉的打开态尚未统一 Portal、选中态与暗色主题。
### P1:视觉语言分裂
- 页面同时使用 `blue/sky/cyan/rose/pink/orange`、硬编码 hex、品牌渐变和页面私有 focus ring。
- 登录页有英文眉题、重复登录标题、营销说明与装饰卡片;普通业务页存在重复大标题和说明段落。
- 常规按钮混入装饰图标、尺寸和圆角不一;危险入口与最终危险确认层级不清。
- Tag/Chip 存在蓝、青、玫红、方角、大胶囊和过重字重。
- 看板存在大号装饰图标、私有渐变、非 24px 模块圆角和多层卡片。
- 成功提示仍可能进入页面流,未统一为 3-4 秒顶部悬浮 Toast。
### P1:DataGrid 验收项缺失
- 表头/正文/主次字段/Chip 字体层级不统一。
- 固定列表头与数据层、20px 四角、空状态单一来源、选择 Checkbox 红色完整填充尚未建立。
- 批量操作没有统一视口固定 ActionBar;清除选择需要右侧关闭图标。
- 行操作图标家族、32px 圆形命中区、Tooltip、危险操作颜色和 4 个以上才折叠的规则未统一。
## 4. 迁移顺序
1. 安装本地 HeroUI Pro 镜像并验证子路径导出。
2. 引入 Xinyuan Tokens、Logo、图标适配层、根级主题与兼容样式。
3. 迁移 Login、AppShell、Sidebar、Navbar、应用切换和用户菜单。
4. 迁移 Button、Select、Reference、DatePicker、DropZone、Modal、Toast 等共享适配器。
5. 以 `RecordWorkbench` 为入口迁移 DataGrid、分页、ActionBar、空状态和行操作,再覆盖详情引用表及页面内明细表。
6. 统一驾驶舱、整改分析、归档和流程页模块表面、图表 Tooltip 与响应式。
7. 执行静态复审、构建、四视口和深浅色浏览器验收。
## 5. 完成门槛
- 可见业务控件中原生 Select/Date/File、Catalyst 弹层和页面私有分页数量为 0。
- 可见旧品牌色、营销式登录、重复页面标题、装饰按钮图标和 page-flow 成功提示为 0。
- 所有列表使用共享 DataGrid + Pagination;打开态控件、Modal、Tooltip 和图表 Tooltip 通过深浅色验收。
- 1440x900、1280x720、768x1024、390x844 无页面级横向溢出、文字遮挡或图标偏心。
- `npm run build`、鑫元审计脚本和 `git diff --check` 通过。
## 6. 本轮迁移结果
- 已接入本地 HeroUI Pro 镜像、Xinyuan Tokens、红/白 Logo、图标适配层、AppShell、Sidebar、Navbar 和根级深浅主题。
- 已将登录页改为居中认证面板;验证码、AES 密码、登录、redirect 和退出登录协议保持不变。
- Catalyst 的 Button、Input、Select、Textarea、Checkbox、Badge、Dialog、Table 已改为 HeroUI/HeroUI Pro 兼容层,旧页面回调签名继续可用。
- 主列表及页面内表格经共享 DataGrid 适配;分页、日期、上传、多选、远程人员搜索、详情 Drawer、应用切换器和删除确认已迁移。
- 驾驶舱、分析、流程、表单、列表和详情抽屉已移除营销渐变、装饰光斑、漂浮阴影和超大圆角,统一为鑫元中性企业应用表面。
- 清单和流程成功反馈已改为顶部 HeroUI Toast;错误仍保留在业务上下文中。
- 静态复审确认业务页面与共享组件内无 `window.confirm`、原生 date/file、页面渐变或直接 Headless UI 引用。
## 7. 验证记录
- `npm run build`:通过。
- `git diff --check`:通过。
- 本地镜像入口、CSS 和 Sidebar/Navbar/DataGrid/DropZone/ActionBar/EmptyState 子路径:通过 import 验证。
- 浏览器:1440x900 浅色、390x844 浅色/暗色登录页通过;暗色验证码对比度已修复。
- 鉴权:未登录业务路由仍跳转到 `/login?redirect=%2Fproblem_rectification`。
- 当前没有可用业务账号,因此带真实数据的 Shell、DataGrid、Drawer 和流程操作仍需在有效登录态下完成最终人工走查。
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -8,8 +8,13 @@ ...@@ -8,8 +8,13 @@
"start": "npm run dev" "start": "npm run dev"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2",
"@headlessui/react": "^2.2.10", "@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react",
"@heroui/react": "^3.2.2",
"@heroui/styles": "^3.2.2",
"@internationalized/date": "^3.12.2",
"@tailwindcss/postcss": "^4.2.0", "@tailwindcss/postcss": "^4.2.0",
"@umijs/max": "^4.5.3", "@umijs/max": "^4.5.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
...@@ -17,8 +22,12 @@ ...@@ -17,8 +22,12 @@
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"react": "^18.3.1", "motion": "^12.23.11",
"react-dom": "^18.3.1", "react": "^19.2.7",
"react-aria-components": "^1.19.0",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"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",
......
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { APPLICATION_BASE_PATH, THEME_STORAGE_KEY } from '@/config/application';
if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
const theme = storedTheme === 'dark' ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}
const LEGACY_FLOW_PATH = '/self_inspection_form'; const LEGACY_FLOW_PATH = '/self_inspection_form';
const appBasePath = String(PROBLEM_RECTIFICATION_APP_BASE_PATH || '/').replace(/\/+$/, ''); const LEGACY_LOGIN_PATH = '/login';
const appBasePath = String(
PROBLEM_RECTIFICATION_APP_BASE_PATH || APPLICATION_BASE_PATH || '/',
).replace(/\/+$/, '');
const legacyFlowPaths = new Set([ const legacyFlowPaths = new Set([
LEGACY_FLOW_PATH, LEGACY_FLOW_PATH,
`${appBasePath}${LEGACY_FLOW_PATH}`, `${appBasePath}${LEGACY_FLOW_PATH}`,
]); ]);
if (typeof window !== 'undefined' && legacyFlowPaths.has(window.location.pathname)) { if (typeof window !== 'undefined') {
window.location.replace( if (appBasePath && window.location.pathname === LEGACY_LOGIN_PATH) {
`${appBasePath}/problem_rectification_flow_table${window.location.search}${window.location.hash}`, window.location.replace(
); `${appBasePath}${LEGACY_LOGIN_PATH}${window.location.search}${window.location.hash}`,
);
} else if (legacyFlowPaths.has(window.location.pathname)) {
window.location.replace(
`${appBasePath}/problem_rectification_flow_table${window.location.search}${window.location.hash}`,
);
}
} }
export async function getInitialState() { export async function getInitialState() {
......
import * as Headless from '@headlessui/react' import { Button as HeroButton } from '@heroui/react/button';
import clsx from 'clsx' import { Chip } from '@heroui/react/chip';
import React, { forwardRef } from 'react' import clsx from 'clsx';
import { TouchTarget } from './button' import { forwardRef } from 'react';
import { Link } from './link'
import { Link } from './link';
const colors = { const colors = {
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25', red: 'rc-status-chip--danger',
orange: orange: 'rc-status-chip--warning',
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25', amber: 'rc-status-chip--warning',
amber: yellow: 'rc-status-chip--warning',
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30', lime: 'rc-status-chip--success',
yellow: green: 'rc-status-chip--success',
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30', emerald: 'rc-status-chip--success',
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30', teal: 'rc-status-chip--teal',
green: cyan: 'rc-status-chip--info',
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25', sky: 'rc-status-chip--info',
emerald: blue: 'rc-status-chip--info',
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25', indigo: 'rc-status-chip--info',
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25', violet: 'rc-status-chip--info',
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30', purple: 'rc-status-chip--info',
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25', fuchsia: 'rc-status-chip--danger',
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25', pink: 'rc-status-chip--danger',
indigo: rose: 'rc-status-chip--danger',
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25', zinc: 'rc-status-chip--neutral',
violet: };
'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25',
purple:
'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25',
fuchsia:
'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25',
pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25',
rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25',
zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20',
}
export function Badge({ color = 'zinc', className, ...props }) { export function Badge({ color = 'zinc', className, children, ...props }) {
return ( return (
<span <Chip
{...props} {...props}
className={clsx( className={clsx('rc-status-chip', colors[color], className)}
className, size="sm"
'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline', variant="soft"
colors[color] >
)} <Chip.Label>{children}</Chip.Label>
/> </Chip>
) );
} }
export const BadgeButton = forwardRef(function BadgeButton( export const BadgeButton = forwardRef(function BadgeButton(
{ color = 'zinc', className, children, ...props }, { color = 'zinc', className, children, disabled, href, ...props },
ref,
ref
) { ) {
let classes = clsx( const content = <Badge color={color}>{children}</Badge>;
className, if (typeof href === 'string') {
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500' return (
) <Link {...props} className={className} href={href} ref={ref}>
{content}
return typeof props.href === 'string' ? ( </Link>
<Link {...props} className={classes} ref={ref}> );
<TouchTarget> }
<Badge color={color}>{children}</Badge> return (
</TouchTarget> <HeroButton
</Link> {...props}
) : ( className={className}
<Headless.Button {...props} className={classes} ref={ref}> isDisabled={disabled}
<TouchTarget> ref={ref}
<Badge color={color}>{children}</Badge> variant="tertiary"
</TouchTarget> >
</Headless.Button> {content}
) </HeroButton>
}) );
});
This diff is collapsed.
import * as Headless from '@headlessui/react' import { Checkbox as HeroCheckbox } from '@heroui/react/checkbox';
import clsx from 'clsx' import clsx from 'clsx';
export function CheckboxGroup({ className, ...props }) { export function CheckboxGroup({ className, ...props }) {
return ( return <div data-slot="control" {...props} className={clsx('space-y-3', className)} />;
<div
data-slot="control"
{...props}
className={clsx(
className,
// Basic groups
'space-y-3',
// With descriptions
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/>
)
} }
export function CheckboxField({ className, ...props }) { export function CheckboxField({ className, ...props }) {
return ( return (
<Headless.Field <div
data-slot="field" data-slot="field"
{...props} {...props}
className={clsx( className={clsx('grid grid-cols-[1rem_1fr] items-start gap-x-3 gap-y-1', className)}
className,
// Base layout
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
// Control layout
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
// Label layout
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
// Description layout
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
// With description
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
)}
/> />
) );
}
const base = [
// Basic layout
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
// Background color when checked
'group-data-checked:before:bg-(--checkbox-checked-bg)',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Background color applied to control in dark mode
,
// Border
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
,
// Inner highlight shadow
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
,
// Focus ring
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state
'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
,
// Forced colors mode
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
,
]
const colors = {
'dark/zinc': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
,
],
'dark/white': [
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
,
],
white:
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
orange:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
amber:
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
yellow:
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
green:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
emerald:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
indigo:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
violet:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
purple:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
fuchsia:
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
} }
export function Checkbox({ color = 'dark/zinc', className, ...props }) { export function Checkbox({
checked,
className,
color = 'blue',
disabled,
indeterminate,
onChange,
...props
}) {
return ( return (
<Headless.Checkbox <HeroCheckbox
data-slot="control"
{...props} {...props}
className={clsx(className, 'group inline-flex focus:outline-hidden')} className={clsx('rc-checkbox', `rc-checkbox--${color}`, className)}
isDisabled={disabled}
isIndeterminate={indeterminate}
isSelected={checked}
onChange={onChange}
> >
<span className={clsx([base, colors[color]])}> <HeroCheckbox.Content>
<svg <HeroCheckbox.Control>
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5" <HeroCheckbox.Indicator />
viewBox="0 0 14 14" </HeroCheckbox.Control>
fill="none" </HeroCheckbox.Content>
> </HeroCheckbox>
{/* Checkmark icon */} );
<path
className="opacity-100 group-data-indeterminate:opacity-0"
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Indeterminate icon */}
<path
className="opacity-0 group-data-indeterminate:opacity-100"
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Headless.Checkbox>
)
} }
import * as Headless from '@headlessui/react' import { Description } from '@heroui/react/description';
import clsx from 'clsx' import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Text } from './text' import { Close } from '../../components/AppIcons';
const sizes = { export function Dialog({
xs: 'sm:max-w-xs', size = 'lg',
sm: 'sm:max-w-sm', className,
md: 'sm:max-w-md', backdropClassName,
lg: 'sm:max-w-lg', children,
xl: 'sm:max-w-xl', open,
'2xl': 'sm:max-w-2xl', onClose,
'3xl': 'sm:max-w-3xl', ...props
'4xl': 'sm:max-w-4xl', }) {
'5xl': 'sm:max-w-5xl',
}
export function Dialog({ size = 'lg', className, children, ...props }) {
return ( return (
<Headless.Dialog {...props}> <Modal.Root>
<Headless.DialogBackdrop <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
transition <Modal.Backdrop
className="fixed inset-0 z-50 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16" {...props}
/> className={clsx('rc-dialog-backdrop', backdropClassName)}
isOpen={open}
<div className="fixed inset-0 z-50 w-screen overflow-y-auto pt-6 sm:pt-0"> onOpenChange={(isOpen) => {
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4"> if (!isOpen) onClose?.();
<Headless.DialogPanel }}
transition variant="blur"
className={clsx( >
className, <Modal.Container placement="center" scroll="inside" size={size}>
sizes[size], <Modal.Dialog
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl forced-colors:outline', className={clsx('rc-dialog-panel', `rc-dialog-panel--${size}`, className)}
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
)}
> >
<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} {children}
</Headless.DialogPanel> </Modal.Dialog>
</div> </Modal.Container>
</div> </Modal.Backdrop>
</Headless.Dialog> </Modal.Root>
) );
} }
export function DialogTitle({ className, ...props }) { export function DialogTitle({ className, ...props }) {
return ( return (
<Headless.DialogTitle <Modal.Header>
{...props} <Modal.Heading {...props} className={clsx('rc-dialog-title', className)} />
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6')} </Modal.Header>
/> );
)
} }
export function DialogDescription({ className, ...props }) { export function DialogDescription({ className, ...props }) {
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} /> return <Description {...props} className={clsx('rc-dialog-description', className)} />;
} }
export function DialogBody({ className, ...props }) { export function DialogBody({ className, ...props }) {
return <div {...props} className={clsx(className, 'mt-6')} /> return <Modal.Body {...props} className={clsx('rc-dialog-body', className)} />;
} }
export function DialogActions({ className, ...props }) { export function DialogActions({ className, ...props }) {
return ( return <Modal.Footer {...props} className={clsx('rc-dialog-actions', className)} />;
<div
{...props}
className={clsx(
className,
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
)}
/>
)
} }
import * as Headless from '@headlessui/react' import { Description as HeroDescription } from '@heroui/react/description';
import clsx from 'clsx' import { FieldError } from '@heroui/react/field-error';
import { Fieldset as HeroFieldset } from '@heroui/react/fieldset';
import { Label as HeroLabel } from '@heroui/react/label';
import clsx from 'clsx';
export function Fieldset({ className, ...props }) { export function Fieldset({ className, ...props }) {
return ( return <HeroFieldset {...props} className={className} />;
<Headless.Fieldset
{...props}
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
/>
)
} }
export function Legend({ className, ...props }) { export function Legend({ className, ...props }) {
return ( return <HeroFieldset.Legend {...props} className={className} />;
<Headless.Legend
data-slot="legend"
{...props}
className={clsx(
className,
'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6'
)}
/>
)
} }
export function FieldGroup({ className, ...props }) { export function FieldGroup({ className, ...props }) {
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} /> return <HeroFieldset.Group {...props} className={clsx('space-y-8', className)} />;
} }
export function Field({ className, ...props }) { export function Field({ className, ...props }) {
return ( return <div data-slot="field" {...props} className={clsx('rc-form-field', className)} />;
<Headless.Field
{...props}
className={clsx(
className,
'[&>[data-slot=label]+[data-slot=control]]:mt-3',
'[&>[data-slot=label]+[data-slot=description]]:mt-1',
'[&>[data-slot=description]+[data-slot=control]]:mt-3',
'[&>[data-slot=control]+[data-slot=description]]:mt-3',
'[&>[data-slot=control]+[data-slot=error]]:mt-3',
'*:data-[slot=label]:font-medium'
)}
/>
)
} }
export function Label({ className, ...props }) { export function Label({ className, ...props }) {
return ( return <HeroLabel data-slot="label" {...props} className={className} />;
<Headless.Label
data-slot="label"
{...props}
className={clsx(
className,
'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6'
)}
/>
)
} }
export function Description({ className, ...props }) { export function Description({ className, ...props }) {
return ( return <HeroDescription data-slot="description" {...props} className={className} />;
<Headless.Description
data-slot="description"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6')}
/>
)
} }
export function ErrorMessage({ className, ...props }) { export function ErrorMessage({ className, ...props }) {
return ( return <FieldError data-slot="error" {...props} className={className} />;
<Headless.Description
data-slot="error"
{...props}
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6')}
/>
)
} }
import * as Headless from '@headlessui/react' import { Input as HeroInput } from '@heroui/react/input';
import clsx from 'clsx' import clsx from 'clsx';
import React, { forwardRef } from 'react' import { forwardRef } from 'react';
export function InputGroup({ children }) { export function InputGroup({ children, className }) {
return ( return (
<span <span data-slot="control" className={clsx('rc-input-group relative block', className)}>
data-slot="control"
className={clsx(
'relative isolate block',
'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
'*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
'[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
'*:data-[slot=icon]:text-zinc-500'
)}
>
{children} {children}
</span> </span>
) );
} }
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
export const Input = forwardRef(function Input( export const Input = forwardRef(function Input(
{ className, ...props }, { className, disabled, invalid, ...props },
ref,
ref
) { ) {
return ( return (
<span <HeroInput
data-slot="control" {...props}
className={clsx([ aria-invalid={invalid || undefined}
className, className={clsx('rc-form-input', className)}
// Basic layout disabled={disabled}
'relative block w-full', fullWidth
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode ref={ref}
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm', variant="secondary"
// 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-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Input
ref={ref}
{...props}
className={clsx([
// Date classes
props.type &&
dateTypes.includes(props.type) && [
'[&::-webkit-datetime-edit-fields-wrapper]:p-0',
'[&::-webkit-date-and-time-value]:min-h-[1.5em]',
'[&::-webkit-datetime-edit]:inline-flex',
'[&::-webkit-datetime-edit]:p-0',
'[&::-webkit-datetime-edit-year-field]:p-0',
'[&::-webkit-datetime-edit-month-field]:p-0',
'[&::-webkit-datetime-edit-day-field]:p-0',
'[&::-webkit-datetime-edit-hour-field]:p-0',
'[&::-webkit-datetime-edit-minute-field]:p-0',
'[&::-webkit-datetime-edit-second-field]:p-0',
'[&::-webkit-datetime-edit-millisecond-field]:p-0',
'[&::-webkit-datetime-edit-meridiem-field]:p-0',
],
// Basic layout
'relative block 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
'data-disabled:border-zinc-950/20',
// System icons
,
])}
/>
</span>
)
})
import * as Headless from '@headlessui/react' import FormSelect from '@/components/FormSelect';
import clsx from 'clsx' import { forwardRef } from 'react';
import React, { forwardRef } from 'react'
export const Select = forwardRef(function Select({ className, multiple, ...props }, ref) { export const Select = forwardRef(function Select(props, _ref) {
return ( return <FormSelect {...props} />;
<span });
data-slot="control"
className={clsx([
className,
// Basic layout
'group 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 has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Select
ref={ref}
multiple={multiple}
{...props}
className={clsx([
// Basic layout
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Horizontal padding
multiple
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
// Options (multi-select)
'[&_optgroup]:font-semibold',
// 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
'data-disabled:border-zinc-950/20 data-disabled:opacity-100',
])}
/>
{!multiple && (
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 forced-colors:stroke-[CanvasText]"
viewBox="0 0 16 16"
aria-hidden="true"
fill="none"
>
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
)}
</span>
)
})
'use client'; export {
Table,
import clsx from 'clsx'; TableBody,
import { createContext, useContext, useState } from 'react'; TableCell,
import { Link } from './link'; TableHead,
TableHeader,
const TableContext = createContext({ TableRow,
bleed: false, } from '@/components/DataTable';
dense: false,
grid: false,
striped: false,
});
export function Table({
bleed = false,
dense = false,
grid = false,
striped = false,
scrollable = true,
className,
children,
...props
}) {
return (
<TableContext.Provider value={{ bleed, dense, grid, striped }}>
<div className="flow-root">
<div
{...props}
className={clsx(
className,
'-mx-(--gutter)',
scrollable && '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-blue-500',
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 * as Headless from '@headlessui/react' import { TextArea } from '@heroui/react/textarea';
import clsx from 'clsx' import clsx from 'clsx';
import React, { forwardRef } from 'react' import { forwardRef } from 'react';
export const Textarea = forwardRef(function Textarea({ className, resizable = true, ...props }, ref) { export const Textarea = forwardRef(function Textarea({ className, resizable = true, ...props }, ref) {
return ( return (
<span <TextArea
data-slot="control" {...props}
className={clsx([ className={clsx(
'rc-textarea min-h-24 w-full',
resizable ? 'resize-y' : 'resize-none',
className, className,
// Basic layout )}
'relative block w-full', fullWidth
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode ref={ref}
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm', variant="secondary"
// 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-blue-500',
// 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>
)
})
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import DownOutlined from '@ant-design/icons/DownOutlined';
import ClockCircleOutlined from '@ant-design/icons/ClockCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import DollarOutlined from '@ant-design/icons/DollarOutlined';
import DatabaseOutlined from '@ant-design/icons/DatabaseOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import ProjectOutlined from '@ant-design/icons/ProjectOutlined';
import ReloadOutlined from '@ant-design/icons/ReloadOutlined';
import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
function withSize(Icon) {
return function AppIcon({ size, style, ...props }) {
return (
<Icon aria-hidden="true" {...props} style={iconStyle(size, style)} />
);
};
}
export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined);
export const ChevronRight = withSize(RightOutlined);
export const Clock = withSize(ClockCircleOutlined);
export const Close = withSize(CloseOutlined);
export const Copy = withSize(CopyOutlined);
export const Delete = withSize(DeleteOutlined);
export const Dollar = withSize(DollarOutlined);
export const Database = withSize(DatabaseOutlined);
export const Edit = withSize(EditOutlined);
export const Eye = withSize(EyeOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) {
return (
<span
aria-hidden="true"
role="img"
{...props}
style={iconStyle(size, style)}
className={['anticon', props.className].filter(Boolean).join(' ')}
>
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
fill="none"
xmlns="http://www.w3.org/2000/svg"
focusable="false"
aria-hidden="true"
>
<path
clipRule="evenodd"
d="M6 3.5h6A1.5 1.5 0 0 1 13.5 5v6a1.5 1.5 0 0 1-1.5 1.5H6zm-1.5 0H4A1.5 1.5 0 0 0 2.5 5v6A1.5 1.5 0 0 0 4 12.5h.5zM1 5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
</span>
);
}
export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined);
export const CalendarDaysIcon = Calendar;
export const ChartBarSquareIcon = BarChart;
export const ChartPieIcon = withSize(PieChartOutlined);
export const CheckCircleIcon = withSize(CheckCircleOutlined);
export const ChevronDownIcon = ChevronDown;
export const ChevronRightIcon = ChevronRight;
export const ClipboardDocumentListIcon = withSize(OrderedListOutlined);
export const DocumentDuplicateIcon = Copy;
export const ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
export const LockClosedIcon = Lock;
export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const UserGroupIcon = Team;
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react/toast';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({
children,
open,
onOpenChange,
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
theme={theme}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录';
return {
displayName,
username,
initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U',
};
}
function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Button
fullWidth
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 ? (
<Avatar.Image alt={user.displayName} src={userInfo.avatar} />
) : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span
className="app-sidebar-user-copy rc-sidebar-user-copy"
data-sidebar="label"
>
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Button>
<Dropdown.Popover placement="top start">
<Dropdown.Menu
aria-label="用户菜单"
onAction={(key) => {
if (key === 'logout') onLogout?.();
}}
>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item">
<Label>退出登录</Label>
<LogOut size={14} />
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({
menuGroups,
onLogout,
onOpenApplicationSwitcher,
pathname,
theme,
userInfo,
}) {
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
src={xyLogoWhite}
/>
</div>
<div
className="app-sidebar-brand-main rc-sidebar-brand-main"
data-sidebar="label"
>
<span className="app-sidebar-title rc-sidebar-title">
{APPLICATION_DISPLAY_NAME}
</span>
</div>
<div
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem
href={item.path}
isCurrent={pathname === item.path}
key={item.path}
textValue={item.name}
tooltip={item.name}
>
<Sidebar.MenuIcon>
<Icon size={16} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
))}
</Sidebar.Content>
<Sidebar.Footer>
<SidebarUserMenu userInfo={userInfo} onLogout={onLogout} />
</Sidebar.Footer>
</>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 slot="title" className="sr-only">
{APPLICATION_DISPLAY_NAME}导航
</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
);
}
This diff is collapsed.
import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react';
function createFileChangeEvent(files) {
return {
target: { files },
currentTarget: { files },
};
}
export default function FileUploadField({
accept,
ariaLabel = '选择文件',
className = '',
description = '将文件拖到此处,或从本地选择。',
disabled = false,
fileName = '',
onChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback(
(files) => {
if (!disabled) {
onChange?.(createFileChangeEvent(files));
}
},
[disabled, onChange],
);
const handleDrop = useCallback(
async (event) => {
const fileItems = Array.from(event?.items || []).filter(
(item) => item?.kind === 'file' && typeof item.getFile === 'function',
);
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(files.slice(0, 1));
},
[emitFiles],
);
return (
<DropZone className={`rc-file-upload ${className}`.trim()}>
<DropZone.Area
aria-label={ariaLabel}
isDisabled={disabled}
onDrop={handleDrop}
>
<DropZone.Input
accept={accept}
disabled={disabled}
onSelect={emitFiles}
/>
<DropZone.Icon>
<Upload aria-hidden="true" />
</DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description>
<DropZone.Trigger isDisabled={disabled}>
<Upload aria-hidden="true" />
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
}
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 { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { I18nProvider } from '@heroui/react';
import clsx from 'clsx';
import { useMemo } from 'react';
import { Calendar as CalendarIcon } from './AppIcons';
function parseValue(value, mode) {
if (!value) {
return null;
}
try {
if (mode === 'month') {
return parseDate(`${String(value).slice(0, 7)}-01`);
}
return mode === 'datetime'
? parseDateTime(String(value).slice(0, 16))
: parseDate(String(value).slice(0, 10));
} catch {
return null;
}
}
function formatValue(value, mode) {
if (!value) {
return '';
}
const rawValue = value.toString();
if (mode === 'month') {
return rawValue.slice(0, 7);
}
if (mode === 'date') {
return rawValue.slice(0, 10);
}
if (rawValue.includes('T')) {
return rawValue.slice(0, 16);
}
return `${rawValue.slice(0, 10)}T00:00`;
}
function parseTimeValue(value) {
if (!value || !String(value).includes('T')) {
return null;
}
try {
return parseTime(String(value).slice(11, 16));
} catch {
return null;
}
}
function formatTimeValue(value) {
return value ? value.toString().slice(0, 5) : '00:00';
}
function combineDateAndTime(value, dateValue, timeValue) {
const dateText = dateValue
? dateValue.toString().slice(0, 10)
: String(value || '').slice(0, 10);
if (!dateText) {
return '';
}
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,
max,
min,
mode,
name,
onChange,
readOnly = false,
required = false,
type = 'date',
value,
...props
}) {
const resolvedMode =
mode === 'month' || type === 'month'
? 'month'
: mode === 'datetime' || type === 'datetime-local'
? 'datetime'
: 'date';
const resolvedGranularity =
granularity ||
(resolvedMode === 'datetime'
? 'minute'
: resolvedMode === 'month'
? 'month'
: 'day');
const parsedValue = useMemo(
() => parseValue(value, resolvedMode),
[resolvedMode, value],
);
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
return (
<I18nProvider locale="zh-CN">
<DatePicker
{...props}
aria-label={ariaLabel || '选择日期'}
className={clsx('rc-form-date-picker', className)}
granularity={resolvedGranularity}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
maxValue={maxValue || undefined}
minValue={minValue || undefined}
value={parsedValue}
onChange={(nextValue) =>
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
}
>
<DateField.Group
className="rc-form-date-picker__group"
fullWidth
variant="secondary"
>
<DateField.Input className="rc-form-date-picker__input">
{(segment) => (
<DateField.Segment
className="rc-form-date-picker__segment"
segment={segment}
/>
)}
</DateField.Input>
<DateField.Suffix className="rc-form-date-picker__suffix">
<DatePicker.Trigger
className="rc-form-date-picker__trigger"
aria-label="打开日期选择"
>
<DatePicker.TriggerIndicator>
<CalendarIcon />
</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">
<HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading />
<HeroCalendar.NavButton slot="next" />
</HeroCalendar.Header>
<HeroCalendar.Grid>
<HeroCalendar.GridHeader>
{(day) => <HeroCalendar.HeaderCell>{day}</HeroCalendar.HeaderCell>}
</HeroCalendar.GridHeader>
<HeroCalendar.GridBody>
{(date) => <HeroCalendar.Cell date={date} />}
</HeroCalendar.GridBody>
</HeroCalendar.Grid>
</HeroCalendar>
{resolvedMode === 'datetime' ? (
<div className="rc-form-date-picker__time-panel">
<span className="rc-form-date-picker__time-label">时间</span>
<TimeField
aria-label="选择时间"
className="rc-form-date-picker__time-field"
granularity="minute"
hideTimeZone
hourCycle={24}
value={timeValue}
onChange={(nextTime) =>
emitDateChange(
onChange,
name,
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.Input>
</TimeField.Group>
</TimeField>
</div>
) : 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>
);
}
This diff is collapsed.
import { ChevronDown } from '@/components/AppIcons';
import { Button, Input, Label, ListBox, Popover } from '@heroui/react';
import { useCallback, useMemo, useState } from 'react';
export default function ResourceFilterMultiSelect({
options,
value,
onChange,
placeholder,
disabled = false,
searchable = false,
searchPlaceholder = '输入关键字搜索',
}) {
const [searchKeyword, setSearchKeyword] = useState('');
const [isOpen, setIsOpen] = useState(false);
const selectedValues = Array.isArray(value) ? value.filter(Boolean) : [];
const selectedOptions = options.filter((option) =>
selectedValues.includes(option.value),
);
const normalizedSearchKeyword = searchKeyword.trim().toLowerCase();
const visibleOptions = useMemo(() => {
if (!searchable || !normalizedSearchKeyword) return options;
return options.filter((option) =>
`${option.label || ''} ${option.value || ''}`
.trim()
.toLowerCase()
.includes(normalizedSearchKeyword),
);
}, [normalizedSearchKeyword, options, searchable]);
const summaryText =
selectedOptions.length === 0
? placeholder
: selectedOptions.length <= 2
? selectedOptions.map((option) => option.label).join('、')
: `${selectedOptions
.slice(0, 2)
.map((option) => option.label)
.join('、')} 等 ${selectedOptions.length} 项`;
const handleSelectionChange = useCallback(
(keys) => {
onChange?.(
keys === 'all' ? options.map((option) => option.value) : [...keys],
);
},
[onChange, options],
);
const handleClearSelection = useCallback(
(event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
if (disabled || selectedValues.length === 0) {
return;
}
setSearchKeyword('');
onChange?.([]);
},
[disabled, onChange, selectedValues.length],
);
const handleOpenChange = useCallback((nextOpen) => {
setIsOpen(nextOpen);
if (!nextOpen) {
setSearchKeyword('');
}
}, []);
return (
<span className="resource-filter-multi-select rc-filter-select block min-w-0 w-full">
<Popover isOpen={isOpen} onOpenChange={handleOpenChange}>
<Button
aria-expanded={isOpen}
aria-label={placeholder}
className="resource-filter-multi-select__trigger rc-filter-select__trigger"
data-disabled={disabled ? 'true' : undefined}
isDisabled={disabled}
type="button"
variant="tertiary"
>
<span className="rc-filter-select__value">
<span
className={
selectedOptions.length > 0
? 'block truncate'
: 'rc-filter-select__placeholder block truncate'
}
title={summaryText}
>
{summaryText}
</span>
</span>
<ChevronDown
className="rc-filter-select__indicator"
data-open={isOpen ? 'true' : undefined}
/>
</Button>
<Popover.Content
className="resource-filter-multi-select__popover rc-filter-options-popover"
placement="bottom start"
>
<Popover.Dialog>
<div className="flex items-center justify-between gap-3 px-2 py-1">
<span className="text-xs text-[var(--rc-text-muted)]">可多选</span>
<Button
isDisabled={disabled || selectedValues.length === 0}
size="sm"
type="button"
variant="tertiary"
onClick={handleClearSelection}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPress={handleClearSelection}
>
清空
</Button>
</div>
{searchable ? (
<div className="px-2 py-2">
<Input
aria-label={searchPlaceholder}
fullWidth
placeholder={searchPlaceholder}
type="search"
value={searchKeyword}
variant="secondary"
onChange={(event) => setSearchKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault();
}}
/>
</div>
) : null}
{visibleOptions.length > 0 ? (
<ListBox
aria-label={placeholder}
className="rc-filter-select__listbox"
selectedKeys={new Set(selectedValues)}
selectionMode="multiple"
onSelectionChange={handleSelectionChange}
>
{visibleOptions.map((option) => (
<ListBox.Item
id={option.value}
key={option.value}
className="rc-filter-option resource-filter-multi-select__option"
textValue={option.label}
>
<Label className="truncate" title={option.label}>
{option.label}
</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
) : (
<div className="rc-filter-options-popover__status">
没有匹配选项
</div>
)}
</Popover.Dialog>
</Popover.Content>
</Popover>
</span>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
export const APPLICATION_DISPLAY_NAME = '问题整改';
export const APPLICATION_BASE_PATH = '/problem_rectification_new/';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/integrity_risk_table/dashboard': '驾驶舱',
'/integrity_risk_table/business_dashboard': '整改分析',
'/problem_rectification': '问题整改',
'/issue_rectification_archive': '往期材料归档',
'/problem_rectification_form': '整改处理',
'/problem_rectification_flow_table': '整改流程',
});
const FALLBACK_TOKENS = {
brand: '#9e1b1b',
brandLight: '#b83a3a',
brandDark: '#7e1515',
success: '#16845b',
warning: '#b46a00',
danger: '#c93636',
teal: '#0f766e',
surface: '#ffffff',
subtle: '#f8fafc',
border: '#e3e8ef',
text: '#172033',
secondary: '#4e5d73',
muted: '#718096',
};
function readToken(styles, name, fallback) {
const value = styles?.getPropertyValue(name)?.trim();
return value || fallback;
}
export function getXinyuanChartTheme() {
if (typeof window === 'undefined') {
return FALLBACK_TOKENS;
}
const root = document.documentElement;
const styles = window.getComputedStyle(root);
const isDark = root.classList.contains('dark') || root.dataset.theme === 'dark';
const theme = {
brand: readToken(styles, '--rc-brand-primary', FALLBACK_TOKENS.brand),
brandLight: readToken(styles, '--rc-brand-light', FALLBACK_TOKENS.brandLight),
brandDark: readToken(styles, '--rc-brand-dark', FALLBACK_TOKENS.brandDark),
success: readToken(styles, '--rc-color-success', FALLBACK_TOKENS.success),
warning: readToken(styles, '--rc-color-warning', FALLBACK_TOKENS.warning),
danger: readToken(styles, '--rc-color-danger', FALLBACK_TOKENS.danger),
teal: readToken(styles, '--rc-color-teal', FALLBACK_TOKENS.teal),
surface: readToken(styles, '--rc-bg-surface', FALLBACK_TOKENS.surface),
subtle: readToken(styles, '--rc-bg-subtle', FALLBACK_TOKENS.subtle),
border: readToken(styles, '--rc-border-default', FALLBACK_TOKENS.border),
text: readToken(styles, '--rc-text-primary', FALLBACK_TOKENS.text),
secondary: readToken(styles, '--rc-text-secondary', FALLBACK_TOKENS.secondary),
muted: readToken(styles, '--rc-text-muted', FALLBACK_TOKENS.muted),
};
return {
...theme,
palette: [theme.brand, theme.brandLight, theme.warning, theme.success, theme.teal, theme.brandDark],
isDark,
grid: isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(23, 32, 51, 0.10)',
axis: isDark ? 'rgba(255, 255, 255, 0.22)' : 'rgba(23, 32, 51, 0.14)',
tooltipBackground: theme.surface,
tooltipShadow: 'var(--rc-shadow-overlay, 0 16px 40px rgb(15 23 42 / 14%))',
};
}
export function chartTooltip(theme, formatter) {
return {
trigger: 'item',
renderMode: 'html',
appendToBody: true,
confine: true,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.tooltipBackground,
shadowColor: 'transparent',
extraCssText: `box-shadow: ${theme.tooltipShadow}; border-radius: 10px; padding: 10px 12px;`,
textStyle: { color: theme.text, fontSize: 12 },
formatter,
};
}
export function chartAxis(theme) {
return {
axisLabel: { color: theme.muted, fontSize: 12 },
axisLine: { lineStyle: { color: theme.axis } },
axisTick: { show: false },
splitLine: { lineStyle: { color: theme.grid, width: 1 } },
};
}
This diff is collapsed.
This diff is collapsed.
:root { :root {
--pm-brand: #009cd9; --pm-brand: var(--rc-brand-primary, #9e1b1b);
--pm-brand-strong: #007bb8; --pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: #ffffff; --pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: #eef5f9; --pm-bg: var(--rc-bg-canvas, #f5f5f5);
--pm-sidebar: #0f172a; --pm-text: var(--rc-text-primary, #172033);
--pm-sidebar-soft: #1e293b; --pm-muted: var(--rc-text-muted, #718096);
--pm-text: #0f172a;
--pm-muted: #64748b;
} }
html, html,
...@@ -17,7 +15,7 @@ body, ...@@ -17,7 +15,7 @@ body,
body { body {
margin: 0; margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%); background: var(--rc-bg-canvas, #f5f5f5);
color: var(--pm-text); color: var(--pm-text);
font-family: font-family:
"PingFang SC", "PingFang SC",
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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