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

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

parent 48dae1c1
......@@ -10,5 +10,7 @@
/.umi-test
/dist
/.mfsu
.playwright-cli/
/test-results/
.swc
.pnpm-store
......@@ -91,6 +91,9 @@ export default defineConfig({
}
},
esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
define: {
STREAMS_API_URL: '/api',
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 @@
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react",
"@heroui/react": "^3.2.2",
"@heroui/styles": "^3.2.2",
"@internationalized/date": "^3.12.2",
"@tailwindcss/postcss": "^4.2.0",
"@umijs/max": "^4.5.3",
"clsx": "^2.1.1",
......@@ -17,8 +22,12 @@
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"motion": "^12.23.11",
"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",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
......
import '@/global.less';
import '@/tailwind.css';
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 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([
LEGACY_FLOW_PATH,
`${appBasePath}${LEGACY_FLOW_PATH}`,
]);
if (typeof window !== 'undefined' && legacyFlowPaths.has(window.location.pathname)) {
if (typeof window !== 'undefined') {
if (appBasePath && window.location.pathname === LEGACY_LOGIN_PATH) {
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() {
......
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TouchTarget } from './button'
import { Link } from './link'
import { Button as HeroButton } from '@heroui/react/button';
import { Chip } from '@heroui/react/chip';
import clsx from 'clsx';
import { forwardRef } from 'react';
import { Link } from './link';
const colors = {
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25',
orange:
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25',
amber:
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30',
yellow:
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30',
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30',
green:
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25',
emerald:
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25',
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25',
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30',
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25',
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25',
indigo:
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25',
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',
}
red: 'rc-status-chip--danger',
orange: 'rc-status-chip--warning',
amber: 'rc-status-chip--warning',
yellow: 'rc-status-chip--warning',
lime: 'rc-status-chip--success',
green: 'rc-status-chip--success',
emerald: 'rc-status-chip--success',
teal: 'rc-status-chip--teal',
cyan: 'rc-status-chip--info',
sky: 'rc-status-chip--info',
blue: 'rc-status-chip--info',
indigo: 'rc-status-chip--info',
violet: 'rc-status-chip--info',
purple: 'rc-status-chip--info',
fuchsia: 'rc-status-chip--danger',
pink: 'rc-status-chip--danger',
rose: 'rc-status-chip--danger',
zinc: 'rc-status-chip--neutral',
};
export function Badge({ color = 'zinc', className, ...props }) {
export function Badge({ color = 'zinc', className, children, ...props }) {
return (
<span
<Chip
{...props}
className={clsx(
className,
'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',
colors[color]
)}
/>
)
className={clsx('rc-status-chip', colors[color], className)}
size="sm"
variant="soft"
>
<Chip.Label>{children}</Chip.Label>
</Chip>
);
}
export const BadgeButton = forwardRef(function BadgeButton(
{ color = 'zinc', className, children, ...props },
ref
{ color = 'zinc', className, children, disabled, href, ...props },
ref,
) {
let classes = clsx(
className,
'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 typeof props.href === 'string' ? (
<Link {...props} className={classes} ref={ref}>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
const content = <Badge color={color}>{children}</Badge>;
if (typeof href === 'string') {
return (
<Link {...props} className={className} href={href} ref={ref}>
{content}
</Link>
) : (
<Headless.Button {...props} className={classes} ref={ref}>
<TouchTarget>
<Badge color={color}>{children}</Badge>
</TouchTarget>
</Headless.Button>
)
})
);
}
return (
<HeroButton
{...props}
className={className}
isDisabled={disabled}
ref={ref}
variant="tertiary"
>
{content}
</HeroButton>
);
});
This diff is collapsed.
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Checkbox as HeroCheckbox } from '@heroui/react/checkbox';
import clsx from 'clsx';
export function CheckboxGroup({ className, ...props }) {
return (
<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'
)}
/>
)
return <div data-slot="control" {...props} className={clsx('space-y-3', className)} />;
}
export function CheckboxField({ className, ...props }) {
return (
<Headless.Field
<div
data-slot="field"
{...props}
className={clsx(
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'
)}
className={clsx('grid grid-cols-[1rem_1fr] items-start gap-x-3 gap-y-1', className)}
/>
)
);
}
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 (
<Headless.Checkbox
data-slot="control"
<HeroCheckbox
{...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]])}>
<svg
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
viewBox="0 0 14 14"
fill="none"
>
{/* 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>
)
<HeroCheckbox.Content>
<HeroCheckbox.Control>
<HeroCheckbox.Indicator />
</HeroCheckbox.Control>
</HeroCheckbox.Content>
</HeroCheckbox>
);
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Description } from '@heroui/react/description';
import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Text } from './text'
import { Close } from '../../components/AppIcons';
const sizes = {
xs: 'sm:max-w-xs',
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}
export function Dialog({ size = 'lg', className, children, ...props }) {
return (
<Headless.Dialog {...props}>
<Headless.DialogBackdrop
transition
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"
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto pt-6 sm:pt-0">
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
<Headless.DialogPanel
transition
className={clsx(
export function Dialog({
size = 'lg',
className,
sizes[size],
'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',
'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'
)}
backdropClassName,
children,
open,
onClose,
...props
}) {
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
{...props}
className={clsx('rc-dialog-backdrop', backdropClassName)}
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}
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
)
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export function DialogTitle({ className, ...props }) {
return (
<Headless.DialogTitle
{...props}
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6')}
/>
)
<Modal.Header>
<Modal.Heading {...props} className={clsx('rc-dialog-title', className)} />
</Modal.Header>
);
}
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 }) {
return <div {...props} className={clsx(className, 'mt-6')} />
return <Modal.Body {...props} className={clsx('rc-dialog-body', className)} />;
}
export function DialogActions({ className, ...props }) {
return (
<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'
)}
/>
)
return <Modal.Footer {...props} className={clsx('rc-dialog-actions', className)} />;
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import { Description as HeroDescription } from '@heroui/react/description';
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 }) {
return (
<Headless.Fieldset
{...props}
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
/>
)
return <HeroFieldset {...props} className={className} />;
}
export function Legend({ className, ...props }) {
return (
<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'
)}
/>
)
return <HeroFieldset.Legend {...props} className={className} />;
}
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 }) {
return (
<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'
)}
/>
)
return <div data-slot="field" {...props} className={clsx('rc-form-field', className)} />;
}
export function Label({ className, ...props }) {
return (
<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'
)}
/>
)
return <HeroLabel data-slot="label" {...props} className={className} />;
}
export function Description({ className, ...props }) {
return (
<Headless.Description
data-slot="description"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6')}
/>
)
return <HeroDescription data-slot="description" {...props} className={className} />;
}
export function ErrorMessage({ className, ...props }) {
return (
<Headless.Description
data-slot="error"
{...props}
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6')}
/>
)
return <FieldError data-slot="error" {...props} className={className} />;
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { Input as HeroInput } from '@heroui/react/input';
import clsx from 'clsx';
import { forwardRef } from 'react';
export function InputGroup({ children }) {
export function InputGroup({ children, className }) {
return (
<span
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'
)}
>
<span data-slot="control" className={clsx('rc-input-group relative block', className)}>
{children}
</span>
)
);
}
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
export const Input = forwardRef(function Input(
{ className, ...props },
ref
{ className, disabled, invalid, ...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-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}
<HeroInput
{...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
,
])}
aria-invalid={invalid || undefined}
className={clsx('rc-form-input', className)}
disabled={disabled}
fullWidth
ref={ref}
variant="secondary"
/>
</span>
)
})
);
});
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import FormSelect from '@/components/FormSelect';
import { forwardRef } from 'react';
export const Select = forwardRef(function Select({ className, multiple, ...props }, ref) {
return (
<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>
)
})
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,
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>
);
}
export {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/DataTable';
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { TextArea } from '@heroui/react/textarea';
import clsx from 'clsx';
import { 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-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}
<TextArea
{...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
className={clsx(
'rc-textarea min-h-24 w-full',
resizable ? 'resize-y' : 'resize-none',
])}
className,
)}
fullWidth
ref={ref}
variant="secondary"
/>
</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 {
--pm-brand: #009cd9;
--pm-brand-strong: #007bb8;
--pm-surface: #ffffff;
--pm-bg: #eef5f9;
--pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b;
--pm-text: #0f172a;
--pm-muted: #64748b;
--pm-brand: var(--rc-brand-primary, #9e1b1b);
--pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5);
--pm-text: var(--rc-text-primary, #172033);
--pm-muted: var(--rc-text-muted, #718096);
}
html,
......@@ -17,7 +15,7 @@ body,
body {
margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%);
background: var(--rc-bg-canvas, #f5f5f5);
color: var(--pm-text);
font-family:
"PingFang SC",
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -5,6 +5,7 @@ import localeData from 'dayjs/plugin/localeData';
import weekday from 'dayjs/plugin/weekday';
import { createContext, useEffect, useState } from 'react';
import { Outlet } from 'umi';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
dayjs.extend(weekday);
dayjs.extend(localeData);
......@@ -42,7 +43,7 @@ export default function SiteWrapper() {
setLoadError(error?.message || '加载站点配置失败');
setSiteInfo({
name: '项目管理系统',
name: APPLICATION_DISPLAY_NAME,
});
} finally {
if (!cancelled) {
......
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