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

UI统一

parent c1615014
......@@ -12,3 +12,4 @@
/.mfsu
.swc
.pnpm-store
.codex-audit
......@@ -74,6 +74,9 @@ export default defineConfig({
}
},
esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
define: {
STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser',
......
This diff is collapsed.
This diff is collapsed.
......@@ -8,8 +8,12 @@
"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": "file:../project_manager_new/node_modules/@heroui/react",
"@heroui/styles": "file:../project_manager_new/node_modules/@heroui/styles",
"@tailwindcss/postcss": "^4.2.0",
"@umijs/max": "^4.5.3",
"clsx": "^2.1.1",
......@@ -17,16 +21,21 @@
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"motion": "^12.43.0",
"react": "^19.2.8",
"react-aria-components": "^1.20.0",
"react-dom": "^19.2.8",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"typescript": "^5.0.3"
}
}
import '@/global.less';
import '@/tailwind.css';
import '@/global.less';
import { I18nProvider } from '@heroui/react';
import { createElement } from 'react';
import { defineApp } from 'umi';
export async function getInitialState() {
......@@ -8,4 +10,8 @@ export async function getInitialState() {
};
}
export default defineApp({});
export default defineApp({
rootContainer(container) {
return createElement(I18nProvider, { locale: 'zh-CN' }, container);
},
});
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 { Sidebar } from '@heroui-pro/react/sidebar';
export default function AppShell({
theme,
open,
onOpenChange,
navigate,
sidebar,
navbar,
children,
overlays,
}) {
return (
<Sidebar.Provider
className={`rc-app-shell ${theme}`}
collapsible="offcanvas"
navigate={navigate}
open={open}
onOpenChange={onOpenChange}
>
{sidebar}
<Sidebar.Main className="rc-app-main">
{navbar}
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<main className="rc-app-page">{children}</main>
</div>
</Sidebar.Main>
{overlays}
</Sidebar.Provider>
);
}
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 { Moon, SidebarPanel, Sun } from '@/components/AppIcons';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
export default function DashboardNavbar({ theme, title, onThemeChange }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
return (
<Navbar className="rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}>
<SidebarPanel size={16} />
</Sidebar.Trigger>
<h1 className="rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="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>
</Navbar.Header>
</Navbar>
);
}
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { AppGrid, LogOut } from '@/components/AppIcons';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
function getUserDisplay(userInfo) {
const displayName =
userInfo?.display_name || userInfo?.name || userInfo?.username || '当前用户';
return {
displayName,
username: userInfo?.username || userInfo?.email || '已登录',
initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U',
};
}
function SidebarUserMenu({ userInfo }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Button fullWidth className="rc-sidebar-user-trigger" size="sm" variant="ghost">
<Avatar className="rc-sidebar-avatar">
{userInfo?.avatar ? (
<Avatar.Image alt={user.displayName} src={userInfo.avatar} />
) : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span className="rc-sidebar-user-copy" data-sidebar="label">
<strong>{user.displayName}</strong>
<small>{user.username}</small>
</span>
</Button>
<Dropdown.Popover placement="top start">
<Dropdown.Menu
aria-label="用户菜单"
onAction={(key) => {
if (key === 'logout') userInfo?.logout?.();
}}
>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="flex w-full items-center justify-between gap-3">
<Label>退出登录</Label>
<LogOut size={14} />
</div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ appName, menuGroups, pathname, userInfo, onOpenApplications }) {
return (
<>
<Sidebar.Header>
<div className="rc-sidebar-brand">
<div className="rc-sidebar-brand-mark">
<img src={xyLogoRed} alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo-light" />
<img src={xyLogoWhite} alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo-dark" />
</div>
<div className="rc-sidebar-brand-main" data-sidebar="label">
<span className="rc-sidebar-title">{appName}</span>
</div>
<div className="rc-sidebar-app-switcher" data-sidebar="label">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplications}
>
<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} />
</Sidebar.Footer>
</>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="rc-sidebar"><SidebarContents {...props} /></Sidebar>
<Sidebar.Mobile className="rc-mobile-sidebar" aria-label={`${props.appName}导航`}>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
);
}
This diff is collapsed.
import { Description } from '@heroui/react/description';
import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Close } from './AppIcons';
export function Dialog({
size = 'lg',
className,
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}
</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 { 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 title={fileName || undefined}>
{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>
);
}
import { Card } from '@heroui/react/card';
import clsx from 'clsx';
export default function MetricCard({
label,
value,
helper,
icon: Icon,
tone = 'neutral',
className,
}) {
return (
<Card className={clsx('rc-metric-card rc-card-surface', className)}>
<Card.Content className="rc-metric-card-content">
<div className="rc-metric-card-copy">
<div className="rc-metric-card-label">{label}</div>
<div className="rc-metric-card-value">{value}</div>
{helper ? <div className="rc-metric-card-helper">{helper}</div> : null}
</div>
<div className={clsx('rc-metric-icon-surface', `rc-tone-surface--${tone}`)}>
{Icon ? <Icon size={20} /> : null}
</div>
</Card.Content>
</Card>
);
}
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"
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>
);
}
import { ChevronDown } from '@/components/AppIcons';
import {
fetchSystemUsersByIds,
mergeUserRecords,
searchSystemUsers,
} from '@/pages/project_manager_xy/projectTaskService';
import { Button, Input, Label, ListBox, Popover } from '@heroui/react';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
function formatUserLabel(user) {
return user?.displayName || user?.username || user?.id || '-';
}
function normalizeIds(value, multiple) {
const values = multiple
? Array.isArray(value)
? value
: []
: value
? [value]
: [];
return Array.from(new Set(values.filter(Boolean).map(String)));
}
export default function SystemUserSearchSelect({
value,
onChange,
multiple = false,
disabled = false,
placeholder = '远程搜索用户',
}) {
const selectedIds = useMemo(
() => normalizeIds(value, multiple),
[multiple, value],
);
const [query, setQuery] = useState('');
const [options, setOptions] = useState([]);
const [selectedUsers, setSelectedUsers] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const requestIdRef = useRef(0);
useEffect(() => {
let cancelled = false;
if (selectedIds.length === 0) {
setSelectedUsers([]);
return undefined;
}
fetchSystemUsersByIds(selectedIds).then((records) => {
if (cancelled) return;
setSelectedUsers(records || []);
setOptions((current) =>
mergeUserRecords([...(current || []), ...(records || [])]),
);
});
return () => {
cancelled = true;
};
}, [selectedIds.join('|')]);
useEffect(() => {
if (!isOpen) return undefined;
const requestId = ++requestIdRef.current;
const timer = window.setTimeout(() => {
setLoading(true);
searchSystemUsers(query)
.then((records) => {
if (requestId !== requestIdRef.current) return;
setOptions((current) =>
mergeUserRecords([
...(selectedUsers || []),
...(records || []),
...(current || []),
]),
);
})
.finally(() => {
if (requestId === requestIdRef.current) setLoading(false);
});
}, 200);
return () => window.clearTimeout(timer);
}, [isOpen, query, selectedUsers]);
const selectedById = useMemo(
() => new Map(selectedUsers.map((user) => [String(user.id), user])),
[selectedUsers],
);
const selectedLabels = selectedIds.map((id) =>
formatUserLabel(selectedById.get(id) || { id }),
);
const displayText =
selectedLabels.length === 0
? placeholder
: selectedLabels.length <= 2
? selectedLabels.join('、')
: `${selectedLabels.slice(0, 2).join('、')} 等 ${selectedLabels.length} 人`;
const menuOptions = useMemo(
() => mergeUserRecords([...(selectedUsers || []), ...(options || [])]),
[options, selectedUsers],
);
const commitSelection = (keys) => {
const nextIds =
keys === 'all'
? menuOptions.map((user) => String(user.id))
: [...keys].map(String);
if (multiple) {
onChange?.(nextIds);
return;
}
onChange?.(nextIds[0] || '');
setIsOpen(false);
};
return (
<span className="system-user-search-select block min-w-0 w-full">
<Popover
isOpen={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) setQuery('');
}}
>
<Button
aria-expanded={isOpen}
aria-label={placeholder}
className="reference-search-select__shell rc-filter-select__trigger"
data-disabled={disabled ? 'true' : undefined}
isDisabled={disabled}
type="button"
variant="tertiary"
>
<span className="rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
selectedIds.length === 0 && 'rc-filter-select__placeholder',
)}
title={selectedLabels.join('、') || displayText}
>
{displayText}
</span>
</span>
<ChevronDown
className="rc-filter-select__indicator"
data-open={isOpen ? 'true' : undefined}
/>
</Button>
<Popover.Content
className="rc-filter-options-popover reference-search-select__options system-user-search-select__options"
placement="bottom start"
>
<Popover.Dialog>
<div className="system-user-search-select__search">
<Input
aria-label="搜索用户"
autoFocus
placeholder="输入姓名或账号"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{selectedIds.length > 0 ? (
<div className="reference-search-select__popover-actions flex justify-end px-2 py-1">
<Button
size="sm"
variant="tertiary"
onPress={() => commitSelection(new Set())}
>
清空
</Button>
</div>
) : null}
{loading ? (
<div className="rc-filter-options-popover__status">搜索中...</div>
) : menuOptions.length === 0 ? (
<div className="rc-filter-options-popover__status">未搜索到用户</div>
) : (
<ListBox
aria-label={placeholder}
className="rc-filter-select__listbox"
selectedKeys={new Set(selectedIds)}
selectionMode={multiple ? 'multiple' : 'single'}
onSelectionChange={commitSelection}
>
{menuOptions.map((user) => (
<ListBox.Item
id={String(user.id)}
key={user.id}
className="rc-filter-option rc-filter-select__option"
textValue={formatUserLabel(user)}
>
<div className="min-w-0 flex-1">
<Label className="block truncate">
{formatUserLabel(user)}
</Label>
<div className="rc-filter-option__description truncate text-xs">
{user.username || user.id}
</div>
</div>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
)}
</Popover.Dialog>
</Popover.Content>
</Popover>
</span>
);
}
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.
......@@ -40,7 +40,8 @@ function createRequestWithPrefix(prefix = baseUrl) {
console.warn('[request] authentication required (401)');
// 跳转到登录页(可选,根据业务需求调整)
location.href = '/login?redirect=' + encodeURIComponent(location.href);
const loginPath = '/project_manager_xy/login';
location.href = `${loginPath}?redirect=${encodeURIComponent(location.href)}`;
} else if (response?.status === 403) {
authService.clearUser();
console.warn('[request] forbidden (403)');
......
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