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

feat: unify UI with Xinyuan design system

parent 3c858d53
This diff is collapsed.
interface:
display_name: "鑫元设计系统"
short_description: "统一鑫元业务应用的布局、组件、视觉资产与交互实现规范"
brand_color: "#9E1B1B"
default_prompt: "Use $xinyuan-design-system to audit and migrate this Xinyuan application to the approved shared UI baseline without changing business logic."
import type { ComponentType, CSSProperties } from 'react';
import type { AntdIconProps } from '@ant-design/icons/lib/components/AntdIcon';
import {
AppstoreOutlined,
ArrowLeftOutlined,
ArrowUpOutlined,
BellOutlined,
BranchesOutlined,
CalendarOutlined,
CheckCircleOutlined,
CloudUploadOutlined,
CodeOutlined,
CloseOutlined,
CopyOutlined,
DatabaseOutlined,
DeleteOutlined,
DownOutlined,
DownloadOutlined,
EditOutlined,
ExclamationCircleOutlined,
ExpandAltOutlined,
ExpandOutlined,
FileAddOutlined,
FileDoneOutlined,
FileTextOutlined,
FolderOpenOutlined,
FilterOutlined,
HomeOutlined,
InboxOutlined,
LayoutOutlined,
LoadingOutlined,
LockOutlined,
LogoutOutlined,
MoreOutlined,
MoonOutlined,
PlusOutlined,
ProjectOutlined,
ReadOutlined,
ReloadOutlined,
RobotOutlined,
SafetyCertificateOutlined,
SearchOutlined,
SettingOutlined,
StarFilled,
StarOutlined,
SunOutlined,
TagsOutlined,
TeamOutlined,
UnorderedListOutlined,
UserOutlined,
} from '@ant-design/icons';
export type AppIconProps = Omit<AntdIconProps, 'icon'> & {
fill?: string;
size?: number | string;
};
type AntIcon = ComponentType<AntdIconProps>;
function iconStyle(size: number | string | undefined, style: CSSProperties | undefined) {
return size === undefined ? style : { fontSize: size, ...style };
}
function withSize(Icon: AntIcon) {
return function AppIcon({ size, style, ...props }: AppIconProps) {
return <Icon aria-hidden="true" {...props} style={iconStyle(size, style)} />;
};
}
export const AlertCircle = withSize(ExclamationCircleOutlined);
export const Archive = withSize(InboxOutlined);
export const ArrowDownToLine = withSize(DownloadOutlined);
export const ArrowLeft = withSize(ArrowLeftOutlined);
export const ArrowUp = withSize(ArrowUpOutlined);
export const Expand = withSize(ExpandOutlined);
export const Bell = withSize(BellOutlined);
export const Bot = withSize(RobotOutlined);
export const Briefcase = withSize(ProjectOutlined);
export const Calendar = withSize(CalendarOutlined);
export const ChevronDown = withSize(DownOutlined);
export const ExpandUpRight = withSize(ExpandAltOutlined);
export const CheckCircle2 = withSize(CheckCircleOutlined);
export const Copy = withSize(CopyOutlined);
export const DataElement = withSize(CodeOutlined);
export const Database = withSize(DatabaseOutlined);
export const Dots9 = withSize(AppstoreOutlined);
export const FileCheck = withSize(FileDoneOutlined);
export const FileLibrary = withSize(ReadOutlined);
export const FileText = withSize(FileTextOutlined);
export const Filter = withSize(FilterOutlined);
export const FolderKanban = withSize(FolderOpenOutlined);
export const Home = withSize(HomeOutlined);
export const Library = withSize(ReadOutlined);
export const ListChecks = withSize(UnorderedListOutlined);
export const Loader2 = withSize(LoadingOutlined);
export const LoaderCircle = withSize(LoadingOutlined);
export const LockKeyhole = withSize(LockOutlined);
export const LogOut = withSize(LogoutOutlined);
export const MoreHorizontal = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const PanelRight = withSize(LayoutOutlined);
export const PenLine = withSize(EditOutlined);
export const Plus = withSize(PlusOutlined);
export const RefreshCw = withSize(ReloadOutlined);
export const RuleBranch = withSize(BranchesOutlined);
export const Search = withSize(SearchOutlined);
export const Settings = withSize(SettingOutlined);
export const ShieldCheck = withSize(SafetyCertificateOutlined);
export const Sparkles = withSize(RobotOutlined);
export const Sun = withSize(SunOutlined);
export const SynonymTags = withSize(TagsOutlined);
export const TemplateText = withSize(FileAddOutlined);
export const Trash2 = withSize(DeleteOutlined);
export const UploadCloud = withSize(CloudUploadOutlined);
export const UserRound = withSize(UserOutlined);
export const Users = withSize(TeamOutlined);
export const X = withSize(CloseOutlined);
export function Star({ fill, size, style, ...props }: AppIconProps) {
const Icon = fill && fill !== 'none' ? StarFilled : StarOutlined;
return <Icon aria-hidden="true" {...props} style={iconStyle(size, style)} />;
}
import { useLayoutEffect, useState, type ReactNode } from 'react';
import { Sidebar } from '@heroui-pro/react';
import { DashboardNavbar } from './dashboard-navbar';
import { DashboardSidebar } from './dashboard-sidebar';
import { getBreadcrumbsForPath } from '../nav-items';
import type { StreamsUserInfo } from '../api/streamsApi';
export type XinyuanTheme = 'light' | 'dark';
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
function getInitialTheme(): XinyuanTheme {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light';
} catch {
return 'light';
}
}
export interface AppShellProps {
children: ReactNode;
pathname: string;
logoSrc: string;
reportTitle?: string;
currentUser: StreamsUserInfo | null;
onNavigate: (href: string) => void;
onLogout: () => void;
assistantOpen: boolean;
onAssistantOpenChange: (open: boolean) => void;
}
export function AppShell({
assistantOpen,
children,
currentUser,
logoSrc,
onAssistantOpenChange,
onLogout,
onNavigate,
pathname,
reportTitle,
}: AppShellProps) {
const breadcrumbs = getBreadcrumbsForPath(pathname, reportTitle);
const [theme, setTheme] = useState<XinyuanTheme>(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 ${theme}`}
collapsible="offcanvas"
data-scrollbar="thin"
navigate={onNavigate}
>
<DashboardSidebar
currentUser={currentUser}
logoSrc={logoSrc}
pathname={pathname}
onLogout={onLogout}
/>
<Sidebar.Main className="app-main-glass overflow-hidden">
<DashboardNavbar
assistantOpen={assistantOpen}
breadcrumbs={breadcrumbs}
theme={theme}
onAssistantOpenChange={onAssistantOpenChange}
onNavigate={onNavigate}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll">
{children}
</div>
</Sidebar.Main>
</Sidebar.Provider>
);
}
import type { ReactNode } from 'react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Archive, Database, FileText } from './app-icons';
export function DataGridEmptyIllustration() {
return (
<div className="data-grid-empty-illustration" aria-hidden="true">
<span className="data-grid-empty-illustration-back">
<Database size={28} />
</span>
<span className="data-grid-empty-illustration-front">
<FileText size={32} />
<span className="data-grid-empty-illustration-lines">
<i />
<i />
<i />
</span>
</span>
<span className="data-grid-empty-illustration-badge">
<Archive size={18} />
</span>
</div>
);
}
export function DataGridEmptyState({ title = '暂无数据' }: { title?: ReactNode }) {
return (
<EmptyState className="data-grid-empty-state" size="md">
<EmptyState.Header>
<EmptyState.Media>
<DataGridEmptyIllustration />
</EmptyState.Media>
<EmptyState.Title>{title}</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
);
}
import type { ComponentPropsWithRef, ReactNode } from 'react';
import { Button, Tooltip } from '@heroui/react';
type ButtonProps = ComponentPropsWithRef<typeof Button>;
export interface IconButtonProps extends Omit<ButtonProps, 'children' | 'isIconOnly'> {
label: string;
tooltip?: ReactNode;
children: ReactNode;
}
export function IconButton({ children, label, tooltip, ...buttonProps }: IconButtonProps) {
return (
<Tooltip>
<Button isIconOnly aria-label={label} {...buttonProps}>
{children}
</Button>
<Tooltip.Content>{tooltip ?? label}</Tooltip.Content>
</Tooltip>
);
}
import type { ReactNode } from 'react';
type ListSectionHeaderProps = {
actions?: ReactNode;
className?: string;
hint?: ReactNode;
title: ReactNode;
};
export function ListSectionHeader({
actions,
className = '',
hint,
title,
}: ListSectionHeaderProps) {
return (
<div
className={[
'rc-list-action-bar flex min-w-0 flex-wrap items-center justify-between gap-3',
className,
]
.filter(Boolean)
.join(' ')}
data-slot="list-section-header"
>
<div className="rc-list-title-group flex min-w-0 items-center gap-1.5">
<h2 className="truncate text-base font-semibold leading-6 text-zinc-900">
{title}
</h2>
{hint ? (
<div className="rc-list-title-hint inline-flex shrink-0 items-center">
{hint}
</div>
) : null}
</div>
{actions ? (
<div className="rc-list-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-3">
{actions}
</div>
) : null}
</div>
);
}
import { useState, type FormEvent } from 'react';
import { Alert, Button, Card, Checkbox, Input, Label, TextField } from '@heroui/react';
export type LoginFormValue = {
username: string;
password: string;
remember: boolean;
};
export interface LoginPageProps {
logoSrc: string;
error?: string;
isPending?: boolean;
onLogin: (value: LoginFormValue) => Promise<void> | void;
}
export function LoginPage({
error,
isPending = false,
logoSrc,
onLogin,
}: LoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [remember, setRemember] = useState(true);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void onLogin({
username: username.trim(),
password,
remember,
});
};
return (
<main className="login-page">
<section className="login-shell">
<Card className="login-card">
<Card.Content>
<div className="login-brand login-card-brand">
<img alt="数智报告系统" src={logoSrc} />
<strong>数智报告系统</strong>
</div>
<form className="login-form" onSubmit={handleSubmit}>
<TextField
className="login-field"
data-has-value={username ? 'true' : undefined}
fullWidth
isRequired
value={username}
onChange={setUsername}
>
<Label>账号</Label>
<Input
autoComplete="username"
autoFocus
className="login-input"
data-filled={username ? 'true' : undefined}
placeholder="请输入用户名、邮箱或手机号"
variant="secondary"
/>
</TextField>
<TextField
className="login-field"
data-has-value={password ? 'true' : undefined}
fullWidth
isRequired
type="password"
value={password}
onChange={setPassword}
>
<Label>密码</Label>
<Input
autoComplete="current-password"
className="login-input"
data-filled={password ? 'true' : undefined}
placeholder="请输入密码"
variant="secondary"
/>
</TextField>
<div className="login-options">
<Checkbox isSelected={remember} onChange={setRemember}>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
记住登录状态
</Checkbox.Content>
</Checkbox>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
isDisabled={!username.trim() || !password}
isPending={isPending}
type="submit"
variant="primary"
>
登录系统
</Button>
</form>
</Card.Content>
</Card>
</section>
</main>
);
}
This diff is collapsed.
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: '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, children, ...props }) {
return (
<Chip
{...props}
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, disabled, href, ...props },
ref,
) {
const content = <Badge color={color}>{children}</Badge>;
if (typeof href === 'string') {
return (
<Link {...props} className={className} href={href} ref={ref}>
{content}
</Link>
);
}
return (
<HeroButton
{...props}
className={className}
isDisabled={disabled}
ref={ref}
variant="tertiary"
>
{content}
</HeroButton>
);
});
import { Button as HeroButton } from '@heroui/react/button';
import clsx from 'clsx';
import { forwardRef } from 'react';
import { Link } from './link';
function hasLegacyClass(className, token) {
return String(className || '')
.split(/\s+/)
.includes(token);
}
function resolveButtonIntent({ className, color, outline, plain }) {
const legacyPrimary = hasLegacyClass(className, 'button--primary');
const legacyDanger = hasLegacyClass(className, 'button--danger');
const legacyOutline = hasLegacyClass(className, 'button--outline');
const legacyPlain =
hasLegacyClass(className, 'button--plain') ||
hasLegacyClass(className, 'button--ghost');
const isPrimary = color === 'blue' || legacyPrimary;
const isDanger = color === 'red' || legacyDanger;
const isPlain = plain || legacyPlain;
const isDangerEntry = isDanger && (outline || isPlain);
const isOutline =
outline || legacyOutline || (!isPrimary && !isDanger && !isPlain);
return {
isDanger,
isDangerEntry,
isOutline,
isPlain,
isPrimary,
variant: isPrimary
? 'primary'
: isDanger && !isDangerEntry
? 'danger'
: isPlain
? 'tertiary'
: 'secondary',
};
}
export const Button = forwardRef(function Button(
{
color,
outline = false,
plain = false,
className,
children,
disabled,
href,
size = 'sm',
...props
},
ref,
) {
const intent = resolveButtonIntent({ className, color, outline, plain });
const classes = clsx(
'rc-button',
(intent.isPrimary || (intent.isDanger && !intent.isDangerEntry)) &&
'rc-button--solid',
intent.isPrimary && 'rc-button--primary',
intent.isOutline && 'rc-button--secondary rc-button--outline',
intent.isPlain && 'rc-button--secondary rc-button--plain',
intent.isDanger && 'rc-button--danger',
className,
);
if (typeof href === 'string') {
return (
<Link
{...props}
aria-disabled={disabled || undefined}
className={classes}
href={href}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</Link>
);
}
return (
<HeroButton
{...props}
className={classes}
isDisabled={disabled}
ref={ref}
size={size}
variant={intent.variant}
>
<TouchTarget>{children}</TouchTarget>
</HeroButton>
);
});
export function TouchTarget({ children }) {
return <>{children}</>;
}
import { Checkbox as HeroCheckbox } from '@heroui/react/checkbox';
import clsx from 'clsx';
export function CheckboxGroup({ className, ...props }) {
return <div data-slot="control" {...props} className={clsx('space-y-3', className)} />;
}
export function CheckboxField({ className, ...props }) {
return (
<div
data-slot="field"
{...props}
className={clsx('grid grid-cols-[1rem_1fr] items-start gap-x-3 gap-y-1', className)}
/>
);
}
export function Checkbox({
checked,
className,
color = 'blue',
disabled,
indeterminate,
onChange,
...props
}) {
return (
<HeroCheckbox
{...props}
className={clsx('rc-checkbox', `rc-checkbox--${color}`, className)}
isDisabled={disabled}
isIndeterminate={indeterminate}
isSelected={checked}
onChange={onChange}
>
<HeroCheckbox.Content>
<HeroCheckbox.Control>
<HeroCheckbox.Indicator />
</HeroCheckbox.Control>
</HeroCheckbox.Content>
</HeroCheckbox>
);
}
import { Description } from '@heroui/react/description';
import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Close } from '../../components/AppIcons';
export function Dialog({ size = 'lg', className, children, open, onClose, ...props }) {
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
{...props}
className="rc-dialog-backdrop"
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size={size}>
<Modal.Dialog
className={clsx('rc-dialog-panel', `rc-dialog-panel--${size}`, className)}
>
<Modal.CloseTrigger
aria-label="关闭"
className="rc-dialog-close rc-dialog-close--automatic absolute inline-flex items-center justify-center rounded-full"
>
<Close size={16} />
</Modal.CloseTrigger>
{children}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export function DialogTitle({ className, ...props }) {
return (
<Modal.Header>
<Modal.Heading {...props} className={clsx('rc-dialog-title', className)} />
</Modal.Header>
);
}
export function DialogDescription({ className, ...props }) {
return <Description {...props} className={clsx('rc-dialog-description', className)} />;
}
export function DialogBody({ className, ...props }) {
return <Modal.Body {...props} className={clsx('rc-dialog-body', className)} />;
}
export function DialogActions({ className, ...props }) {
return <Modal.Footer {...props} className={clsx('rc-dialog-actions', className)} />;
}
import { 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 <HeroFieldset {...props} className={className} />;
}
export function Legend({ className, ...props }) {
return <HeroFieldset.Legend {...props} className={className} />;
}
export function FieldGroup({ className, ...props }) {
return <HeroFieldset.Group {...props} className={clsx('space-y-8', className)} />;
}
export function Field({ className, ...props }) {
return <div data-slot="field" {...props} className={clsx('rc-form-field', className)} />;
}
export function Label({ className, ...props }) {
return <HeroLabel data-slot="label" {...props} className={className} />;
}
export function Description({ className, ...props }) {
return <HeroDescription data-slot="description" {...props} className={className} />;
}
export function ErrorMessage({ className, ...props }) {
return <FieldError data-slot="error" {...props} className={className} />;
}
import { Input as HeroInput } from '@heroui/react/input';
import clsx from 'clsx';
import { forwardRef } from 'react';
export function InputGroup({ children, className }) {
return (
<span data-slot="control" className={clsx('rc-input-group relative block', className)}>
{children}
</span>
);
}
export const Input = forwardRef(function Input(
{ className, disabled, invalid, ...props },
ref,
) {
return (
<HeroInput
{...props}
aria-invalid={invalid || undefined}
className={clsx('rc-form-input', className)}
disabled={disabled}
fullWidth
ref={ref}
variant="secondary"
/>
);
});
import FormSelect from '@/components/FormSelect';
import { forwardRef } from 'react';
export const Select = forwardRef(function Select(props, _ref) {
return <FormSelect {...props} />;
});
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 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 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 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 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 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 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 ClockIcon = Clock;
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const FolderOpenIcon = Folder;
export const InformationCircleIcon = Info;
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 { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
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>
</Sidebar.Provider>
);
}
import {Close} from "./AppIcons";
import * as Headless from "@headlessui/react";
function ApplicationCard({application}) {
const target = application.url?.startsWith("http") ? "_blank" : undefined;
return (
<a
className="app-switcher-card group flex min-w-0 items-center gap-3 rounded-xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-3 py-3 text-left transition hover:bg-[var(--accent-soft)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)]"
data-current={application.current ? "true" : undefined}
href={application.url}
rel={target ? "noreferrer" : undefined}
target={target}
>
{application.logo ? (
<img alt={application.title} className="app-switcher-card__logo" src={application.logo} />
) : (
<span className="app-switcher-card__initial">{application.title.slice(0, 1)}</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-5 text-[var(--rc-text-primary)]">
{application.title}
</span>
<span className="mt-0.5 block truncate text-xs leading-4 text-[var(--rc-text-muted)]">
{application.description || application.name || "应用"}
</span>
</span>
{application.current ? (
<span className="shrink-0 rounded-full bg-[var(--rc-brand-primary-soft)] px-2 py-0.5 text-xs font-medium text-[var(--rc-brand-primary)]">
当前
</span>
) : null}
</a>
);
}
export default function ApplicationSwitcherDialog({open, onClose, applications = []}) {
return (
<Headless.Dialog className="relative z-[180]" onClose={onClose} open={open}>
<Headless.DialogBackdrop
className="fixed inset-0 bg-[rgba(15,23,42,0.42)] backdrop-blur-sm transition duration-200 ease-out data-closed:opacity-0"
transition
/>
<div className="fixed inset-0 overflow-y-auto p-4">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
className="app-switcher-dialog flex max-h-[min(42rem,calc(100dvh-2rem))] w-full max-w-[46rem] flex-col overflow-hidden rounded-2xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-surface)] shadow-[var(--rc-shadow-overlay)] transition duration-200 ease-out data-closed:translate-y-2 data-closed:scale-[0.98] data-closed:opacity-0"
transition
>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-[var(--rc-border-default)] px-5 py-4">
<div className="min-w-0">
<Headless.DialogTitle className="text-base font-semibold leading-6 text-[var(--rc-text-primary)]">
应用切换
</Headless.DialogTitle>
<p className="mt-1 text-sm leading-5 text-[var(--rc-text-muted)]">选择你可访问的应用</p>
</div>
<button
aria-label="关闭应用切换"
className="inline-grid size-6 shrink-0 place-items-center rounded-full bg-[var(--default)] p-1 text-[var(--rc-text-secondary)] transition hover:bg-[var(--default-hover)] hover:text-[var(--rc-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)] [&_.anticon]:grid [&_.anticon]:size-4 [&_.anticon]:place-items-center [&_.anticon]:leading-none [&_.anticon>svg]:block [&_.anticon>svg]:size-4 [&_svg]:m-0"
onClick={onClose}
type="button"
>
<Close size={16} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 [scrollbar-gutter:stable]">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{applications.map((application) => (
<ApplicationCard application={application} key={application.name} />
))}
</div>
</div>
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
);
}
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 { 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 clsx from 'clsx';
import { useMemo } from 'react';
import { Calendar as CalendarIcon } from './AppIcons';
function parseValue(value, mode) {
if (!value) {
return null;
}
try {
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 === '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 === 'datetime' || type === 'datetime-local' ? 'datetime' : 'date';
const resolvedGranularity =
granularity || (resolvedMode === 'datetime' ? 'minute' : '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 (
<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>
);
}
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>
);
}
.ReservationLeaveCalendarEvent {
transition: transform 0.18s ease, box-shadow 0.18s ease,
filter 0.18s ease;
transform-origin: center bottom;
}
.fc-daygrid-event.ReservationLeaveCalendarEvent,
.fc-daygrid-block-event.ReservationLeaveCalendarEvent {
border: 0 !important;
outline: 0 !important;
background: transparent !important;
box-shadow: none;
margin-inline: 0.25rem !important;
}
.fc-daygrid-event.ReservationLeaveCalendarEvent:hover,
.fc-daygrid-block-event.ReservationLeaveCalendarEvent:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px -12px rgba(15, 23, 42, 0.38);
filter: brightness(1.01);
z-index: 4;
}
.fc-list-event.ReservationLeaveCalendarEvent:hover {
transform: none;
box-shadow: none;
filter: brightness(0.99);
}
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.
This diff is collapsed.
...@@ -96,6 +96,9 @@ export default defineConfig({ ...@@ -96,6 +96,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.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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