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

feat: unify xinyuan heroui ui and chart theme

parent 768c1456
This diff is collapsed.
This diff is collapsed.
...@@ -69,6 +69,7 @@ ...@@ -69,6 +69,7 @@
该功能只适用于六个完整功能应用中允许编辑的 `file` 字段;若目标应用业务文档明确限定字段范围,以该范围为准。 该功能只适用于六个完整功能应用中允许编辑的 `file` 字段;若目标应用业务文档明确限定字段范围,以该范围为准。
- UI 使用 HeroUI Tabs 在“材料中心 / 本地上传”之间切换;材料选择使用 HeroUI Autocomplete,本地文件使用 HeroUI Pro `DropZone`。必须运行时读取 Subject 字段 `multiple`。 - UI 使用 HeroUI Tabs 在“材料中心 / 本地上传”之间切换;材料选择使用 HeroUI Autocomplete,本地文件使用 HeroUI Pro `DropZone`。必须运行时读取 Subject 字段 `multiple`。
- 当前附件与来源入口分层展示:附件列表负责呈现已选文件,单值 Autocomplete 保持“请选择审计材料”作为替换入口,不重复回显当前文件名;删除图标与文件行垂直居中并使用 HeroUI `danger-soft`。
- 材料中心通过 `POST /record/query` 查询 `am_material` 最近 20 条,按当前用户 Record 权限返回,远程搜索 `metadata.material_title` 和 `metadata.file_asset.name`,正则关键词必须转义。 - 材料中心通过 `POST /record/query` 查询 `am_material` 最近 20 条,按当前用户 Record 权限返回,远程搜索 `metadata.material_title` 和 `metadata.file_asset.name`,正则关键词必须转义。
- 选择已有材料只把 `{name,type,size,url}` 文件快照写入业务草稿,不保存 `am_material` ID,不重新上传、不创建材料。 - 选择已有材料只把 `{name,type,size,url}` 文件快照写入业务草稿,不保存 `am_material` ID,不重新上传、不创建材料。
- 单值字段替换原值;多值字段追加并按 URL 去重,支持逐项删除。 - 单值字段替换原值;多值字段追加并按 URL 去重,支持逐项删除。
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -29,6 +29,8 @@ ...@@ -29,6 +29,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": ">=8.6.0", "embla-carousel": ">=8.6.0",
"embla-carousel-react": ">=8.6.0", "embla-carousel-react": ">=8.6.0",
"maplibre-gl": ">=6.0.0", "maplibre-gl": ">=6.0.0",
...@@ -39,7 +41,7 @@ ...@@ -39,7 +41,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-markdown": ">=10.1.0", "react-markdown": ">=10.1.0",
"react-resizable-panels": ">=4.10.0", "react-resizable-panels": ">=4.10.0",
"recharts": ">=2.0.0", "recharts": "^3.10.1",
"remark-breaks": ">=4.0.0", "remark-breaks": ">=4.0.0",
"remark-gfm": ">=4.0.0", "remark-gfm": ">=4.0.0",
"shiki": ">=4.0.0", "shiki": ">=4.0.0",
......
import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { APPLICATION_BASE_PATH, THEME_STORAGE_KEY } from '@/config/application'; import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
} from '@/config/application';
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY); document.documentElement.classList.remove('dark');
const theme = storedTheme === 'dark' ? 'dark' : 'light'; document.documentElement.classList.add('light');
document.documentElement.classList.toggle('dark', theme === 'dark'); document.documentElement.dataset.theme = 'light';
document.documentElement.classList.toggle('light', theme === 'light'); document.documentElement.style.colorScheme = 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
} }
const LEGACY_FLOW_PATH = '/self_inspection_form'; const LEGACY_FLOW_PATH = '/self_inspection_form';
...@@ -36,7 +36,7 @@ if (typeof window !== 'undefined') { ...@@ -36,7 +36,7 @@ if (typeof window !== 'undefined') {
export async function getInitialState() { export async function getInitialState() {
return { return {
name: 'project_manager_xy', name: APPLICATION_NAME,
}; };
} }
......
import BankOutlined from '@ant-design/icons/BankOutlined'; import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined'; import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined'; import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined'; import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined'; import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
...@@ -18,6 +18,8 @@ import EyeOutlined from '@ant-design/icons/EyeOutlined'; ...@@ -18,6 +18,8 @@ import EyeOutlined from '@ant-design/icons/EyeOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined'; import ExportOutlined from '@ant-design/icons/ExportOutlined';
import FileTextOutlined from '@ant-design/icons/FileTextOutlined'; import FileTextOutlined from '@ant-design/icons/FileTextOutlined';
import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined'; import FolderOpenOutlined from '@ant-design/icons/FolderOpenOutlined';
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined'; import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined'; import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined'; import LeftOutlined from '@ant-design/icons/LeftOutlined';
...@@ -26,7 +28,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined'; ...@@ -26,7 +28,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined'; import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined'; import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined'; import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined'; import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined'; import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined'; import PlusOutlined from '@ant-design/icons/PlusOutlined';
...@@ -36,10 +37,10 @@ import RightOutlined from '@ant-design/icons/RightOutlined'; ...@@ -36,10 +37,10 @@ import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined'; import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined'; import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined'; import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined'; import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined'; import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined'; import UploadOutlined from '@ant-design/icons/UploadOutlined';
import UpOutlined from '@ant-design/icons/UpOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined'; import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined'; import WarningOutlined from '@ant-design/icons/WarningOutlined';
...@@ -80,6 +81,8 @@ export const OpenExternal = withSize(ExportOutlined); ...@@ -80,6 +81,8 @@ export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined); export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined); export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined); export const Folder = withSize(FolderOpenOutlined);
export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined);
export const Group = withSize(GroupOutlined); export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined); export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined); export const Lock = withSize(LockOutlined);
...@@ -112,11 +115,9 @@ export function SidebarPanel({ size = 20, style, ...props }) { ...@@ -112,11 +115,9 @@ export function SidebarPanel({ size = 20, style, ...props }) {
); );
} }
export const More = withSize(MoreOutlined); export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined); export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined); export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined); export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined); export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined); export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined); export const Upload = withSize(UploadOutlined);
...@@ -124,6 +125,8 @@ export const Unlock = withSize(UnlockOutlined); ...@@ -124,6 +125,8 @@ export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined); export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined); export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowDownIcon = ChevronDown;
export const ArrowUpIcon = withSize(UpOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined); export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined); export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined); export const BanknotesIcon = withSize(DollarOutlined);
...@@ -144,4 +147,5 @@ export const LockOpenIcon = Unlock; ...@@ -144,4 +147,5 @@ export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search; export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined); export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined); export const PlusIcon = withSize(PlusOutlined);
export const TableCellsIcon = withSize(OrderedListOutlined);
export const UserGroupIcon = Team; export const UserGroupIcon = Team;
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react'; import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar'; 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({ export default function AppShell({
children, children,
notificationCount, notificationCount,
...@@ -27,25 +13,9 @@ export default function AppShell({ ...@@ -27,25 +13,9 @@ export default function AppShell({
title, title,
...sidebarProps ...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 ( return (
<Sidebar.Provider <Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`} className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas" collapsible="offcanvas"
navigate={(href) => history.push(href)} navigate={(href) => history.push(href)}
open={open} open={open}
...@@ -55,12 +25,8 @@ export default function AppShell({ ...@@ -55,12 +25,8 @@ export default function AppShell({
<Sidebar.Main className="app-main-glass rc-app-main"> <Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar <DashboardNavbar
notificationCount={notificationCount} notificationCount={notificationCount}
onOpenNotifications={ onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
title={title} title={title}
onThemeChange={setTheme}
/> />
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto"> <div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div> <div className="rc-app-page">{children}</div>
......
import { Navbar } from '@heroui-pro/react/navbar'; import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react'; import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons'; import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({ export default function DashboardNavbar({
notificationCount = 0, notificationCount = 0,
onOpenNotifications, onOpenNotifications,
onThemeChange,
theme,
title, title,
}) { }) {
const { isMobile, isOpen } = useSidebar(); const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0); const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return ( return (
...@@ -46,29 +42,13 @@ export default function DashboardNavbar({ ...@@ -46,29 +42,13 @@ export default function DashboardNavbar({
color="danger" color="danger"
size="sm" size="sm"
> >
{normalizedNotificationCount > 99 {normalizedNotificationCount > 99 ? '99+' : normalizedNotificationCount}
? '99+'
: normalizedNotificationCount}
</Badge> </Badge>
) : null} ) : null}
</Badge.Anchor> </Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content> <Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip> </Tooltip>
) : null} ) : null}
<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> </div>
</Navbar.Header> </Navbar.Header>
</Navbar> </Navbar>
......
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png'; import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application'; import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sheet } from '@heroui-pro/react';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Heading } from 'react-aria-components'; import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons'; import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) { function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户'; const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
...@@ -64,9 +63,56 @@ function SidebarContents({ ...@@ -64,9 +63,56 @@ function SidebarContents({
onLogout, onLogout,
onOpenApplicationSwitcher, onOpenApplicationSwitcher,
pathname, pathname,
theme,
userInfo, userInfo,
}) { }) {
const renderMenu = (group) => (
<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>
);
const renderGroup = (group) => {
const hasCurrentItem = group.items.some((item) => pathname === item.path);
const GroupIcon = group.icon;
return (
<Sidebar.Group key={group.title}>
<Disclosure defaultExpanded={hasCurrentItem}>
<Disclosure.Heading>
<Sidebar.GroupLabel>
<Disclosure.Trigger className="flex w-full min-w-0 items-center gap-2 text-left">
{GroupIcon ? <GroupIcon size={16} /> : null}
<span className="min-w-0 flex-1 truncate">{group.title}</span>
<Disclosure.Indicator>
<ChevronDown size={14} />
</Disclosure.Indicator>
</Disclosure.Trigger>
</Sidebar.GroupLabel>
</Disclosure.Heading>
<Disclosure.Content>
<Disclosure.Body>{renderMenu(group)}</Disclosure.Body>
</Disclosure.Content>
</Disclosure>
</Sidebar.Group>
);
};
return ( return (
<> <>
<Sidebar.Header> <Sidebar.Header>
...@@ -74,12 +120,7 @@ function SidebarContents({ ...@@ -74,12 +120,7 @@ function SidebarContents({
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark"> <div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img <img
alt="鑫元" alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light" className="rc-sidebar-logo"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
src={xyLogoWhite} src={xyLogoWhite}
/> />
</div> </div>
...@@ -95,54 +136,25 @@ function SidebarContents({ ...@@ -95,54 +136,25 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher" className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label" data-sidebar="label"
> >
<Tooltip delay={0}> <Button
<Button isIconOnly
isIconOnly aria-label="切换应用"
aria-label="切换应用" className="app-sidebar-app-trigger rc-sidebar-app-trigger"
className="app-sidebar-app-trigger rc-sidebar-app-trigger" size="sm"
size="sm" variant="ghost"
variant="ghost" onPress={onOpenApplicationSwitcher}
onPress={onOpenApplicationSwitcher} >
> <AppGrid size={16} />
<AppGrid size={16} /> </Button>
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div> </div>
</div> </div>
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Content> <Sidebar.Content>
{menuGroups.map((group) => ( {menuGroups.map((group) => (
<Sidebar.Group key={group.title}> group.showLabel === false ? (
{group.showLabel !== false ? ( <Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> ) : renderGroup(group)
) : null}
<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>
{Number(item.count) > 0 ? (
<Sidebar.MenuChip>
{Number(item.count) > 99 ? '99+' : Number(item.count)}
</Sidebar.MenuChip>
) : null}
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
))} ))}
</Sidebar.Content> </Sidebar.Content>
...@@ -153,18 +165,45 @@ function SidebarContents({ ...@@ -153,18 +165,45 @@ function SidebarContents({
); );
} }
function AccessibleMobileSidebar(props) {
const { isMobile, isMobileOpen, setMobileOpen, side } = useSidebar();
if (!isMobile) {
return null;
}
return (
<Sheet.Root
isOpen={isMobileOpen}
placement={side}
onOpenChange={setMobileOpen}
>
<Sheet.Backdrop variant="blur">
<Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog
aria-label={`${APPLICATION_DISPLAY_NAME}导航`}
className="sidebar__mobile-dialog"
>
<div
className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile"
>
<SidebarContents {...props} />
</div>
</Sheet.Dialog>
</Sheet.Content>
</Sheet.Backdrop>
</Sheet.Root>
);
}
export default function DashboardSidebar(props) { export default function DashboardSidebar(props) {
return ( return (
<> <>
<Sidebar className="app-sidebar-glass rc-sidebar"> <Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} /> <SidebarContents {...props} />
</Sidebar> </Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar"> <AccessibleMobileSidebar {...props} />
<Heading slot="title" className="sr-only">
{APPLICATION_DISPLAY_NAME}导航
</Heading>
<SidebarContents {...props} />
</Sidebar.Mobile>
</> </>
); );
} }
This diff is collapsed.
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useMemo } from 'react'; import { useMemo } from 'react';
const EMPTY_OPTION_KEY = '__rc_filter_empty__';
function normalizeOption(option) { function normalizeOption(option) {
if (option && typeof option === 'object') { if (option && typeof option === 'object') {
return { return {
...@@ -32,9 +30,9 @@ export default function FilterSelect({ ...@@ -32,9 +30,9 @@ export default function FilterSelect({
}) { }) {
const normalizedOptions = useMemo( const normalizedOptions = useMemo(
() => () =>
options.map(normalizeOption).map((option) => ({ options.map(normalizeOption).filter((option) => option.value !== '').map((option) => ({
...option, ...option,
id: option.value || EMPTY_OPTION_KEY, id: option.value,
isDisabled: option.disabled, isDisabled: option.disabled,
})), })),
[options], [options],
...@@ -43,21 +41,22 @@ export default function FilterSelect({ ...@@ -43,21 +41,22 @@ export default function FilterSelect({
const selectedOption = normalizedOptions.find( const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue, (option) => option.value === selectedValue,
); );
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue; const selectedKey = selectedValue || null;
return ( return (
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
className={className} className={className}
isDisabled={disabled} isDisabled={disabled}
isClearable={Boolean(selectedValue)}
emptyText={emptyText} emptyText={emptyText}
options={normalizedOptions} options={normalizedOptions}
onClear={() => onChange?.('')}
placeholder={placeholder} placeholder={placeholder}
selectedKey={selectedKey} selectedKey={selectedKey}
variant={variant} variant={variant}
onSelectionChange={(key) => { onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? ''); onChange?.(String(key ?? ''));
onChange?.(nextValue);
}} }}
valueText={selectedOption?.label || placeholder} valueText={selectedOption?.label || placeholder}
/> />
......
...@@ -127,9 +127,11 @@ export default function FormSelect({ ...@@ -127,9 +127,11 @@ export default function FormSelect({
aria-label={ariaLabel || resolvedPlaceholder} aria-label={ariaLabel || resolvedPlaceholder}
className={className} className={className}
isDisabled={disabled} isDisabled={disabled}
isClearable={selectedOptions.length > 0}
isRequired={required} isRequired={required}
name={name} name={name}
options={options} options={options}
onClear={() => emitChange(multiple ? [] : [''])}
placeholder={resolvedPlaceholder} placeholder={resolvedPlaceholder}
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayText} valueText={displayText}
......
...@@ -605,9 +605,11 @@ export default function ReferenceSearchSelect({ ...@@ -605,9 +605,11 @@ export default function ReferenceSearchSelect({
errorText={error} errorText={error}
inputValue={query} inputValue={query}
isDisabled={disabled} isDisabled={disabled}
isClearable={allowClear}
isLoading={loading} isLoading={loading}
isOpen={active} isOpen={active}
options={menuOptions} options={menuOptions}
onClear={clearSelection}
placement={optionsAnchor || 'bottom start'} placement={optionsAnchor || 'bottom start'}
placeholder={placeholder} placeholder={placeholder}
popoverHeader={allowClear ? ( popoverHeader={allowClear ? (
......
...@@ -70,8 +70,10 @@ export default function ResourceFilterMultiSelect({ ...@@ -70,8 +70,10 @@ export default function ResourceFilterMultiSelect({
emptyText="没有匹配选项" emptyText="没有匹配选项"
inputValue={searchKeyword} inputValue={searchKeyword}
isDisabled={disabled} isDisabled={disabled}
isClearable={selectedValues.length > 0}
isOpen={isOpen} isOpen={isOpen}
options={autocompleteOptions} options={autocompleteOptions}
onClear={handleClearSelection}
placeholder={placeholder} placeholder={placeholder}
popoverHeader={( popoverHeader={(
<div className="flex items-center justify-between gap-3 px-3 pb-1"> <div className="flex items-center justify-between gap-3 px-3 pb-1">
......
...@@ -83,8 +83,10 @@ export default function UserSearchSelect({ ...@@ -83,8 +83,10 @@ export default function UserSearchSelect({
aria-label={ariaLabel} aria-label={ariaLabel}
inputValue={keyword} inputValue={keyword}
isDisabled={disabled} isDisabled={disabled}
isClearable={selectedIds.length > 0}
isLoading={loading} isLoading={loading}
options={options} options={options}
onClear={() => onChange?.(multiple ? [] : null)}
placeholder={placeholder} placeholder={placeholder}
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
selectedKey={multiple ? undefined : selectedIds[0] || null} selectedKey={multiple ? undefined : selectedIds[0] || null}
......
...@@ -52,11 +52,13 @@ export default function XinyuanAutocomplete({ ...@@ -52,11 +52,13 @@ export default function XinyuanAutocomplete({
fullWidth = true, fullWidth = true,
inputValue, inputValue,
isDisabled = false, isDisabled = false,
isClearable = false,
isLoading = false, isLoading = false,
isOpen, isOpen,
isRequired = false, isRequired = false,
name, name,
onInputChange, onInputChange,
onClear,
onOpenChange, onOpenChange,
onSelectionChange, onSelectionChange,
options = [], options = [],
...@@ -65,6 +67,7 @@ export default function XinyuanAutocomplete({ ...@@ -65,6 +67,7 @@ export default function XinyuanAutocomplete({
popoverHeader, popoverHeader,
renderOption, renderOption,
searchPlaceholder = '输入关键词搜索', searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey, selectedKey,
selectedKeys, selectedKeys,
selectionMode = 'single', selectionMode = 'single',
...@@ -81,7 +84,6 @@ export default function XinyuanAutocomplete({ ...@@ -81,7 +84,6 @@ export default function XinyuanAutocomplete({
Array.from(selectedKeySet).map(toSelectionKey), Array.from(selectedKeySet).map(toSelectionKey),
); );
const selectedOptions = options.filter((option) => const selectedOptions = options.filter((option) =>
selectedKeySet.has(option.id) ||
selectedKeyStringSet.has(toSelectionKey(option.id)), selectedKeyStringSet.has(toSelectionKey(option.id)),
); );
const resolvedValueText = const resolvedValueText =
...@@ -90,21 +92,23 @@ export default function XinyuanAutocomplete({ ...@@ -90,21 +92,23 @@ export default function XinyuanAutocomplete({
? selectedOptions.map((option) => option.label).join('、') ? selectedOptions.map((option) => option.label).join('、')
: placeholder); : placeholder);
const isMultiple = selectionMode === 'multiple'; const isMultiple = selectionMode === 'multiple';
const hasSelection = selectedKeySet.size > 0;
const normalizedSelectedKeys = selectedKeySet; const normalizedSelectedKeys = selectedKeySet;
const rootSelectionProps = isMultiple const rootSelectionProps =
? { selectedKey: null } isMultiple
: { selectedKey: normalizedSelectedKeys.values().next().value || null }; ? { selectedKey: null }
: { selectedKey: normalizedSelectedKeys.values().next().value || null };
const handleMultipleOptionAction = (optionKey) => { const handleMultipleOptionAction = (optionKey) => {
if (!onSelectionChange) { if (!onSelectionChange) {
return; return;
} }
const nextKeys = new Set(normalizedSelectedKeys);
const normalizedOptionKey = toSelectionKey(optionKey); const normalizedOptionKey = toSelectionKey(optionKey);
if (!normalizedOptionKey) { if (!normalizedOptionKey) {
return; return;
} }
const nextKeys = new Set(normalizedSelectedKeys);
if (nextKeys.has(normalizedOptionKey)) { if (nextKeys.has(normalizedOptionKey)) {
nextKeys.delete(normalizedOptionKey); nextKeys.delete(normalizedOptionKey);
} else { } else {
...@@ -114,6 +118,8 @@ export default function XinyuanAutocomplete({ ...@@ -114,6 +118,8 @@ export default function XinyuanAutocomplete({
}; };
const listBoxSelectionProps = isMultiple const listBoxSelectionProps = isMultiple
? { ? {
// Autocomplete is single-selection under the hood. Use item actions
// for multi-select so the list cannot replace the controlled values.
selectionMode: 'single', selectionMode: 'single',
onAction: handleMultipleOptionAction, onAction: handleMultipleOptionAction,
} }
...@@ -140,6 +146,7 @@ export default function XinyuanAutocomplete({ ...@@ -140,6 +146,7 @@ export default function XinyuanAutocomplete({
isOpen={isOpen} isOpen={isOpen}
isRequired={isRequired} isRequired={isRequired}
name={name} name={name}
onClear={onClear}
selectionMode={isMultiple ? 'single' : selectionMode} selectionMode={isMultiple ? 'single' : selectionMode}
variant={variant} variant={variant}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
...@@ -148,12 +155,15 @@ export default function XinyuanAutocomplete({ ...@@ -148,12 +155,15 @@ export default function XinyuanAutocomplete({
<Autocomplete.Trigger> <Autocomplete.Trigger>
<Autocomplete.Value> <Autocomplete.Value>
<span <span
className="block min-w-0 truncate" className={`block min-w-0 truncate ${hasSelection ? '' : 'text-field-placeholder'}`.trim()}
title={resolvedValueText} title={resolvedValueText}
> >
{resolvedValueText} {resolvedValueText}
</span> </span>
</Autocomplete.Value> </Autocomplete.Value>
{isClearable ? (
<Autocomplete.ClearButton aria-label={`清除${ariaLabel || placeholder}`} />
) : null}
<Autocomplete.Indicator> <Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} /> <ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator> </Autocomplete.Indicator>
...@@ -165,21 +175,23 @@ export default function XinyuanAutocomplete({ ...@@ -165,21 +175,23 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange} onInputChange={onInputChange}
> >
{popoverHeader} {popoverHeader}
<SearchField {showSearch ? (
aria-label={searchPlaceholder} <SearchField
fullWidth aria-label={searchPlaceholder}
variant={variant} fullWidth
> variant={variant}
<SearchField.Group> >
<SearchField.SearchIcon> <SearchField.Group>
<Search size={16} /> <SearchField.SearchIcon>
</SearchField.SearchIcon> <Search size={16} />
<SearchField.Input autoFocus placeholder={searchPlaceholder} /> </SearchField.SearchIcon>
<SearchField.ClearButton aria-label="清空搜索"> <SearchField.Input autoFocus placeholder={searchPlaceholder} />
<Close size={14} /> <SearchField.ClearButton aria-label="清空搜索">
</SearchField.ClearButton> <Close size={14} />
</SearchField.Group> </SearchField.ClearButton>
</SearchField> </SearchField.Group>
</SearchField>
) : null}
<ListBox <ListBox
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
items={visibleOptions} items={visibleOptions}
......
import {
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: {
color: theme.axis,
fontFamily: theme.fontFamily,
fontSize: 12,
...item?.axisLabel,
},
axisLine: {
...item?.axisLine,
lineStyle: {
color: theme.grid,
...item?.axisLine?.lineStyle,
},
},
axisTick: {
...item?.axisTick,
lineStyle: {
color: theme.grid,
...item?.axisTick?.lineStyle,
},
},
splitLine: {
...item?.splitLine,
lineStyle: {
color: theme.grid,
...item?.splitLine?.lineStyle,
},
},
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
function readAxisType(axis) {
if (Array.isArray(axis)) {
return axis[0]?.type;
}
return axis?.type;
}
function getBarRadius(option, theme) {
const isHorizontal = readAxisType(option?.xAxis) === 'value'
&& readAxisType(option?.yAxis) === 'category';
return isHorizontal
? [0, theme.barRadius, theme.barRadius, 0]
: [theme.barRadius, theme.barRadius, 0, 0];
}
function forceItemStyle(data, itemStyle) {
if (!Array.isArray(data)) return data;
return data.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
return {
...item,
itemStyle: {
...item.itemStyle,
...itemStyle,
},
};
});
}
export default function XinyuanEChart({
ariaLabel,
className = '',
height = 320,
onEvents,
option,
}) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => {
chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' });
}, []);
useEffect(() => {
window.addEventListener('resize', hideTooltip);
return () => window.removeEventListener('resize', hideTooltip);
}, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applyPieTheme = (series) => {
if (series?.type !== 'pie') return series;
return {
...series,
itemStyle: {
...series.itemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
},
data: forceItemStyle(series.data, {
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
}),
padAngle: 1,
};
};
const applySeriesTheme = (series) => {
const themedSeries = applyPieTheme(series);
if (themedSeries?.type !== 'bar') return themedSeries;
return {
...themedSeries,
itemStyle: {
...themedSeries.itemStyle,
borderRadius: getBarRadius(option, theme),
},
data: forceItemStyle(themedSeries.data, {
borderRadius: getBarRadius(option, theme),
}),
};
};
const themedSeries = Array.isArray(option?.series)
? option.series.map(applySeriesTheme)
: applySeriesTheme(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: {
enabled: true,
decal: { show: false },
...option?.aria,
},
color: option?.color || theme.palette,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
...option?.textStyle,
},
...option,
series: themedSeries,
legend: Array.isArray(option?.legend)
? option.legend.map((legend) => ({
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...legend?.textStyle,
},
}))
: option?.legend ? {
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...option.legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...option.legend?.textStyle,
},
} : undefined,
tooltip: {
appendToBody: true,
backgroundColor: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
borderWidth: 1,
className: 'rc-echarts-tooltip',
confine: true,
enterable: false,
extraCssText: `border-radius: ${theme.tooltipRadius}; box-shadow: ${theme.tooltipShadow};`,
padding: [10, 12],
renderMode: 'html',
...option?.tooltip,
textStyle: {
color: theme.foreground,
fontFamily: theme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
},
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
return (
<div
aria-label={ariaLabel}
className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()}
role="img"
tabIndex={0}
>
<ReactECharts
lazyUpdate
notMerge
onEvents={{ globalout: hideTooltip, ...onEvents }}
option={themedOption}
opts={{ renderer: 'canvas' }}
ref={chartRef}
style={{ height, width: '100%' }}
/>
</div>
);
}
...@@ -3,12 +3,14 @@ import { Modal } from '@heroui/react'; ...@@ -3,12 +3,14 @@ import { Modal } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = { const DIALOG_WIDTH_CLASS = {
md: 'sm:max-w-[30rem]',
xl: 'sm:max-w-[46rem]', xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]', '2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]', '3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]', '4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]', '5xl': 'sm:max-w-[76rem]',
}; };
const STANDARD_PANEL_PADDING_SIZES = new Set(['xl', '2xl', '3xl', '4xl', '5xl']);
export default function XinyuanModal({ export default function XinyuanModal({
isOpen, isOpen,
...@@ -30,7 +32,8 @@ export default function XinyuanModal({ ...@@ -30,7 +32,8 @@ export default function XinyuanModal({
<Modal.Container placement="center" scroll="inside" size={modalSize}> <Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog <Modal.Dialog
className={clsx( className={clsx(
DIALOG_WIDTH_CLASS[size] && 'p-7', STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
'bg-background',
DIALOG_WIDTH_CLASS[size], DIALOG_WIDTH_CLASS[size],
dialogClassName, dialogClassName,
)} )}
...@@ -40,11 +43,11 @@ export default function XinyuanModal({ ...@@ -40,11 +43,11 @@ export default function XinyuanModal({
</Modal.CloseTrigger> </Modal.CloseTrigger>
<Modal.Header> <Modal.Header>
<Modal.Heading>{title}</Modal.Heading> <Modal.Heading>{title}</Modal.Heading>
</Modal.Header>
<Modal.Body className={bodyClassName}>
{description ? ( {description ? (
<div className="text-sm text-muted">{description}</div> <div className="text-sm text-muted">{description}</div>
) : null} ) : null}
</Modal.Header>
<Modal.Body className={bodyClassName}>
{children} {children}
</Modal.Body> </Modal.Body>
{footer ? ( {footer ? (
......
...@@ -509,8 +509,10 @@ function WorkbenchPagination({ pagination }) { ...@@ -509,8 +509,10 @@ function WorkbenchPagination({ pagination }) {
isDisabled={Boolean(pagination.disabled)} isDisabled={Boolean(pagination.disabled)}
page={Math.max(0, Number(pagination.page || 0))} page={Math.max(0, Number(pagination.page || 0))}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={pagination.pageSizeOptions || []}
total={totalRecords} total={totalRecords}
onPageChange={pagination.onPageChange} onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange}
/> />
); );
} }
...@@ -992,7 +994,6 @@ export default function RecordWorkbench({ ...@@ -992,7 +994,6 @@ export default function RecordWorkbench({
value={filters[field.key] || ''} value={filters[field.key] || ''}
onChange={(value) => onFiltersChange?.(field.key, value)} onChange={(value) => onFiltersChange?.(field.key, value)}
options={[ options={[
{ value: '', label: field.placeholder || `全部${field.label}` },
...(field.options || []), ...(field.options || []),
]} ]}
placeholder={field.placeholder || `全部${field.label}`} placeholder={field.placeholder || `全部${field.label}`}
...@@ -1036,7 +1037,10 @@ export default function RecordWorkbench({ ...@@ -1036,7 +1037,10 @@ export default function RecordWorkbench({
</FilterField> </FilterField>
); );
const tableSectionClass = 'rc-list-stack'; const tableSectionClass = 'rc-list-stack';
const tableContentClass = 'rc-list-data-stack'; const tableContentClass = [
'rc-list-data-stack',
stickyTable ? 'rc-data-table-sticky' : '',
].filter(Boolean).join(' ');
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
...@@ -1204,10 +1208,6 @@ export default function RecordWorkbench({ ...@@ -1204,10 +1208,6 @@ export default function RecordWorkbench({
</form> </form>
) : null} ) : null}
<div className="flex min-w-0 items-center">
<h2 className="text-base font-medium text-foreground">{title}</h2>
</div>
<div className="rc-page-toolbar"> <div className="rc-page-toolbar">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{toolbarSearchField ? ( {toolbarSearchField ? (
......
...@@ -2,7 +2,6 @@ export const APPLICATION_DISPLAY_NAME = '问题整改'; ...@@ -2,7 +2,6 @@ export const APPLICATION_DISPLAY_NAME = '问题整改';
export const APPLICATION_NAME = 'problem_rectification_new'; export const APPLICATION_NAME = 'problem_rectification_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`; export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME; export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
'/integrity_risk_table/dashboard': '驾驶舱', '/integrity_risk_table/dashboard': '驾驶舱',
......
...@@ -7,6 +7,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([ ...@@ -7,6 +7,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-6)', 'var(--rc-chart-6)',
'var(--rc-chart-7)', 'var(--rc-chart-7)',
'var(--rc-chart-8)', 'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]); ]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({ export const XINYUAN_CHART_SEMANTIC = Object.freeze({
...@@ -65,46 +73,121 @@ export function createXinyuanChartColorMap(keys = []) { ...@@ -65,46 +73,121 @@ export function createXinyuanChartColorMap(keys = []) {
return colorMap; return colorMap;
} }
function isVisibleChartColor(color) { function readRootToken(tokenName, fallback = '') {
if (typeof color !== 'string') { if (typeof window === 'undefined' || typeof document === 'undefined') {
return false; return fallback;
} }
const normalizedColor = color.trim().toLowerCase(); return window.getComputedStyle(document.documentElement)
return Boolean(normalizedColor) .getPropertyValue(tokenName)
&& normalizedColor !== 'none' .trim() || fallback;
&& normalizedColor !== 'transparent';
} }
export function colorizeXinyuanChartTooltipPayload( const concreteColorCache = new Map();
payload = [],
resolveColor, function convertOklchToRgb(value) {
) { const match = String(value).trim().match(
return payload.map((entry, index) => { /^oklch\(\s*([\d.]+)%?\s+([\d.]+)%?\s+([\d.-]+)(?:deg)?\s*\)$/i,
const rechartsPayload = entry?.payload || {}; );
const sourceDatum = rechartsPayload?.payload || rechartsPayload; if (!match) return value;
const key = sourceDatum?.name
?? rechartsPayload?.name const lightnessValue = Number(match[1]);
?? entry?.name const chromaValue = Number(match[2]);
?? entry?.dataKey const lightness = lightnessValue > 1 ? lightnessValue / 100 : lightnessValue;
?? ''; const chroma = chromaValue > 1 ? chromaValue / 100 : chromaValue;
const candidateColors = [ const hue = (Number(match[3]) * Math.PI) / 180;
resolveColor?.({ entry, index, key, sourceDatum }), const a = chroma * Math.cos(hue);
rechartsPayload?.fill, const b = chroma * Math.sin(hue);
sourceDatum?.fill, const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
entry?.fill, const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
entry?.color, const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
entry?.stroke, const channels = [
getXinyuanChartColor(key, index), 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
]; -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
const color = candidateColors.find(isVisibleChartColor); -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
].map((channel) => {
return { const clamped = Math.max(0, Math.min(1, channel));
...entry, const encoded = clamped <= 0.0031308
color, ? 12.92 * clamped
fill: color, : 1.055 * (clamped ** (1 / 2.4)) - 0.055;
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey, return Math.round(encoded * 255);
stroke: color,
};
}); });
return `rgb(${channels.join(', ')})`;
}
function readConcreteColorToken(tokenName, fallback = '') {
const raw = readRootToken(tokenName, fallback);
const cached = concreteColorCache.get(tokenName);
if (cached?.raw === raw) return cached.value;
if (typeof document === 'undefined' || !raw) {
return raw || fallback;
}
const probe = document.createElement('span');
probe.style.color = raw;
if (!probe.style.color) {
return fallback;
}
probe.style.position = 'absolute';
probe.style.pointerEvents = 'none';
document.body?.appendChild(probe);
const resolved = convertOklchToRgb(window.getComputedStyle(probe).color || fallback);
probe.remove();
concreteColorCache.set(tokenName, { raw, value: resolved });
return resolved;
}
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match
? readConcreteColorToken(match[1], fallback)
: String(color || fallback);
}
export function createResolvedXinyuanChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
accentForeground: readConcreteColorToken('--accent-foreground', '#ffffff'),
fontFamily: documentFont || 'Inter, sans-serif',
foreground: readConcreteColorToken('--foreground', readRootToken('--xinyuan-chart-tooltip-foreground', '#222222')),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')
)),
barRadius: Number.parseFloat(
readRootToken('--xinyuan-chart-bar-radius', '8'),
) || 8,
sectorGap: readConcreteColorToken('--xinyuan-chart-sector-gap', '#ffffff'),
surfaceSecondary: readConcreteColorToken('--surface-secondary', '#f5f5f5'),
surfaceTertiary: readConcreteColorToken('--surface-tertiary', '#eeeeee'),
sectorGapWidth: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-gap-width', '2'),
) || 2,
sectorRadius: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-radius', '10'),
) || 10,
cardRadius: readRootToken('--xinyuan-chart-card-radius', '1.5rem'),
tooltipRadius: readRootToken('--xinyuan-chart-tooltip-radius', '0.75rem'),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
} }
This diff is collapsed.
...@@ -11,38 +11,39 @@ ...@@ -11,38 +11,39 @@
[data-theme="light"], [data-theme="light"],
[data-theme="default"] { [data-theme="default"] {
/* Theme Colors (Light Mode) */ /* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03); /* Xinyuan brand primary. Keep the same value in every theme mode. */
--accent: #DA241A;
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03); --accent-hover: #C91F17;
--border: oklch(90.00% 0.0045 27.03); --background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03); --danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(94.00% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(21.03% 0.0059 29.20);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(100.00% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0090 27.03); --field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: #DA241A;
--foreground: oklch(21.03% 0.0045 27.03); --foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0090 27.03); --muted: oklch(55.17% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0045 27.03); --scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0045 27.03); --segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0045 27.03); --segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0045 27.03); --separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); --success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0022 27.03); --surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0045 27.03); --surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0036 27.03); --surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03); --surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0036 27.03); --surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03); --surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1599 45.11); --warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.11); --warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */ /* Border Radius */
--radius: 0.5rem; --radius: 0.5rem;
...@@ -51,42 +52,99 @@ ...@@ -51,42 +52,99 @@
/* Font Family */ /* Font Family */
/* Make sure to load Inter font in your app */ /* Make sure to load Inter font in your app */
--font-sans: var(--font-inter); --font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: oklch(78% 0.105 29.20);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
/*
* Calm Xinyuan data palette: brand reds, muted warm neutrals, then
* restrained cool blue-grays. These tokens are data-only and do not
* change between light and dark mode.
*/
--xinyuan-chart-1: #DA241A;
--xinyuan-chart-2: #B83A32;
--xinyuan-chart-3: #C95A52;
--xinyuan-chart-4: #D47A70;
--xinyuan-chart-5: #A65A45;
--xinyuan-chart-6: #B87958;
--xinyuan-chart-7: #C99772;
--xinyuan-chart-8: #8A6F63;
--xinyuan-chart-9: #9A8174;
--xinyuan-chart-10: #6F6674;
--xinyuan-chart-11: #807783;
--xinyuan-chart-12: #938B96;
--xinyuan-chart-13: #4F6380;
--xinyuan-chart-14: #61758F;
--xinyuan-chart-15: #74879A;
--xinyuan-chart-16: #B0A4A0;
--xinyuan-chart-bar-radius: 8px;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-gap-width: 2px;
--xinyuan-chart-sector-radius: 8px;
--xinyuan-chart-card-radius: 1.5rem;
--xinyuan-chart-tooltip-radius: 0.75rem;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
} }
.dark, .dark,
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* Theme Colors (Dark Mode) */ /* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03); /* Keep brand and focus stable when the Sidebar uses its dark plane. */
--accent: #DA241A;
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03); --accent-hover: #C91F17;
--border: oklch(28.00% 0.0045 27.03); --background: oklch(12.00% 0.0000 29.20);
--danger: oklch(62.00% 0.1600 27.03); --border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(27.40% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(99.11% 0 0);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(21.03% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0090 27.03); --field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: #DA241A;
--foreground: oklch(99.11% 0.0045 27.03); --foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0090 27.03); --muted: oklch(70.50% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0045 27.03); --scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0045 27.03); --segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0045 27.03); --segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0045 27.03); --separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); --success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0090 27.03); --surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0045 27.03); --surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0067 27.03); --surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03); --surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0067 27.03); --surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03); --surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1400 49.12); --warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.12); --warning-foreground: oklch(21.03% 0.0059 49.38);
} }
...@@ -2,9 +2,7 @@ ...@@ -2,9 +2,7 @@
.light, .light,
.default, .default,
[data-theme='light'], [data-theme='light'],
[data-theme='default'], [data-theme='default'] {
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */ /* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent); --rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white); --rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
...@@ -27,6 +25,17 @@ ...@@ -27,6 +25,17 @@
var(--background) var(--background)
); );
--rc-text-muted: var(--muted); --rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans); --rc-font-sans: var(--font-sans);
--rc-font-medium: 500; --rc-font-medium: 500;
...@@ -45,6 +54,7 @@ ...@@ -45,6 +54,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */ /* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem; --rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter); --rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter); --rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter); --rc-page-inline-end: var(--rc-shell-inline-gutter);
...@@ -82,14 +92,15 @@ ...@@ -82,14 +92,15 @@
0 4px 12px rgb(15 23 42 / 2%); 0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */ /* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255); /* KPI icon accents reuse the restrained chart family; no green/fluorescent tones. */
--rc-metric-tone-success: oklch(54% 0.13 145); --rc-metric-tone-info: var(--rc-chart-13);
--rc-metric-tone-teal: oklch(55% 0.1 205); --rc-metric-tone-success: var(--rc-chart-14);
--rc-metric-tone-danger: oklch(52% 0.17 25); --rc-metric-tone-teal: var(--rc-chart-15);
--rc-metric-tone-warning: oklch(62% 0.12 75); --rc-metric-tone-danger: var(--rc-chart-2);
--rc-metric-tone-violet: oklch(56% 0.12 300); --rc-metric-tone-warning: var(--rc-chart-6);
--rc-metric-tone-indigo: oklch(53% 0.13 275); --rc-metric-tone-violet: var(--rc-chart-10);
--rc-metric-tone-coral: oklch(60% 0.12 48); --rc-metric-tone-indigo: var(--rc-chart-11);
--rc-metric-tone-coral: var(--rc-chart-3);
--rc-metric-tone-neutral: var(--muted); --rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix( --rc-metric-tone-info-soft: color-mix(
in oklab, in oklab,
...@@ -138,40 +149,30 @@ ...@@ -138,40 +149,30 @@
); );
/* Data visualization only. Do not reuse UI status colors as a series palette. */ /* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03); --rc-chart-1: var(--xinyuan-chart-1);
--rc-chart-2: oklch(56% 0.135 255); --rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: oklch(64% 0.11 220); --rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: oklch(56% 0.115 280); --rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: oklch(60% 0.095 200); --rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: oklch(70% 0.12 80); --rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: oklch(60% 0.1 295); --rc-chart-7: var(--xinyuan-chart-7);
--rc-chart-8: oklch(57% 0.035 250); --rc-chart-8: var(--xinyuan-chart-8);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1); --rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03); --rc-chart-risk: var(--rc-chart-2);
--rc-chart-warning: var(--rc-chart-6); --rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2); --rc-chart-positive: var(--rc-chart-13);
--rc-chart-comparison: var(--rc-chart-4); --rc-chart-comparison: var(--rc-chart-14);
--rc-chart-neutral: var(--rc-chart-8); --rc-chart-neutral: var(--rc-chart-10);
--rc-chart-legend-font-size: 0.75rem; --rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: 1rem; --rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
} --rc-chart-card-radius: var(--xinyuan-chart-card-radius);
--rc-chart-tooltip-radius: var(--xinyuan-chart-tooltip-radius);
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
} }
:root { :root {
--pm-brand: var(--rc-brand-primary, #9e1b1b); --pm-brand: var(--rc-brand-primary, #DA241A);
--pm-brand-strong: var(--rc-brand-dark, #7e1515); --pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: var(--rc-bg-surface, #ffffff); --pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5); --pm-bg: var(--rc-bg-canvas, #f5f5f5);
......
import clsx from 'clsx'; import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField'; import TailwindEditableField from './TailwindEditableField';
import { import {
buildViewColumns, buildViewColumns,
...@@ -133,13 +132,5 @@ export default function SubjectViewContent({ ...@@ -133,13 +132,5 @@ export default function SubjectViewContent({
</section> </section>
); );
if (mode === 'view' && !minimal) {
return (
<Surface className="rounded-2xl px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
{fields}
</Surface>
);
}
return fields; return fields;
} }
...@@ -234,6 +234,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -234,6 +234,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
aria-label="用户" aria-label="用户"
emptyText="暂无用户" emptyText="暂无用户"
inputValue={keyword} inputValue={keyword}
isClearable={selectedIds.length > 0}
options={autocompleteOptions} options={autocompleteOptions}
placeholder="请选择用户" placeholder="请选择用户"
searchPlaceholder="搜索用户名或显示名" searchPlaceholder="搜索用户名或显示名"
...@@ -242,6 +243,7 @@ function UserSelector({ value, onChange, multiple, variant }) { ...@@ -242,6 +243,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
valueText={valueText || '请选择用户'} valueText={valueText || '请选择用户'}
variant={variant} variant={variant}
onInputChange={setKeyword} onInputChange={setKeyword}
onClear={() => onChange?.(multiple ? [] : null)}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
renderOption={(option) => ( renderOption={(option) => (
<span className="min-w-0"> <span className="min-w-0">
...@@ -276,6 +278,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) { ...@@ -276,6 +278,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
<XinyuanAutocomplete <XinyuanAutocomplete
{...selectProps} {...selectProps}
aria-label="选择字段值" aria-label="选择字段值"
isClearable={selectedOptions.length > 0}
options={options.map((option) => ({ options={options.map((option) => ({
id: String(option.value), id: String(option.value),
label: option.label, label: option.label,
...@@ -284,6 +287,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) { ...@@ -284,6 +287,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
selectionMode={multiple ? 'multiple' : 'single'} selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayValue} valueText={displayValue}
variant={variant} variant={variant}
onClear={() => onChange?.(multiple ? [] : '')}
/> />
); );
} }
......
...@@ -10,7 +10,8 @@ import { ...@@ -10,7 +10,8 @@ import {
loadArchiveWorkbenchResources, loadArchiveWorkbenchResources,
} from './archiveWorkbenchService'; } from './archiveWorkbenchService';
const DEFAULT_PAGE_SIZE = 10; const PAGE_SIZE_OPTIONS = [20, 50, 100];
const DEFAULT_PAGE_SIZE = 20;
export default function ProblemRectificationArchivePage() { export default function ProblemRectificationArchivePage() {
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
...@@ -24,7 +25,7 @@ export default function ProblemRectificationArchivePage() { ...@@ -24,7 +25,7 @@ export default function ProblemRectificationArchivePage() {
const [activeViewName, setActiveViewName] = useState(''); const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = DEFAULT_PAGE_SIZE; const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const reloadList = useCallback(async () => { const reloadList = useCallback(async () => {
setLoading(true); setLoading(true);
...@@ -147,26 +148,16 @@ export default function ProblemRectificationArchivePage() { ...@@ -147,26 +148,16 @@ export default function ProblemRectificationArchivePage() {
pagination={{ pagination={{
page, page,
pageSize, pageSize,
pageSizeOptions: PAGE_SIZE_OPTIONS,
totalRecords: filteredRecords.length, totalRecords: filteredRecords.length,
disabled: loading, disabled: loading,
onPageChange: setPage, onPageChange: setPage,
}} onPageSizeChange: (nextPageSize) => {
toolbarActions={[ setPageSize(nextPageSize);
{ setPage(0);
key: 'archive-export', setSelectedIds([]);
label: '导出批次',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive-compare',
label: '批次对比',
variant: 'secondary',
disabled: true,
onClick: () => {},
}, },
]} }}
rowActions={[ rowActions={[
{ {
key: 'view-detail', key: 'view-detail',
...@@ -197,7 +188,7 @@ export default function ProblemRectificationArchivePage() { ...@@ -197,7 +188,7 @@ export default function ProblemRectificationArchivePage() {
loadingTitle: '正在加载归档批次', loadingTitle: '正在加载归档批次',
loadingDescription: '正在读取归档对象、视图配置和批次记录,请稍候。', loadingDescription: '正在读取归档对象、视图配置和批次记录,请稍候。',
emptyTitle: '暂无归档批次', emptyTitle: '暂无归档批次',
emptyDescription: '当前还没有归档批次记录,后续完成“一键归档”后会在这里展示。', emptyDescription: '当前还没有归档批次记录。',
}} }}
renderCell={({ record, column }) => { renderCell={({ record, column }) => {
if (column.field === 'metadata.reference_records') { if (column.field === 'metadata.reference_records') {
......
...@@ -59,7 +59,6 @@ export default function ProblemRectificationMaterialField({ ...@@ -59,7 +59,6 @@ export default function ProblemRectificationMaterialField({
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState(''); const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false); const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState(''); const [materialsError, setMaterialsError] = useState('');
const [retryKey, setRetryKey] = useState(0); const [retryKey, setRetryKey] = useState(0);
...@@ -75,7 +74,6 @@ export default function ProblemRectificationMaterialField({ ...@@ -75,7 +74,6 @@ export default function ProblemRectificationMaterialField({
setActiveTab(MATERIAL_CENTER_TAB); setActiveTab(MATERIAL_CENTER_TAB);
setKeyword(''); setKeyword('');
setDebouncedKeyword(''); setDebouncedKeyword('');
setSelectedMaterialId(null);
setMaterialsError(''); setMaterialsError('');
setUploadError(''); setUploadError('');
setLocalFileKeys(new Set()); setLocalFileKeys(new Set());
...@@ -124,15 +122,11 @@ export default function ProblemRectificationMaterialField({ ...@@ -124,15 +122,11 @@ export default function ProblemRectificationMaterialField({
})), })),
[materials], [materials],
); );
const selectedMaterial = useMemo(
() => materials.find((material) => material.id === selectedMaterialId),
[materials, selectedMaterialId],
);
const selectedValueText = multiple const selectedValueText = multiple
? fileValues.length > 0 ? fileValues.length > 0
? `已选 ${fileValues.length} 个附件,可继续添加` ? `已选 ${fileValues.length} 个附件,可继续添加`
: '请选择审计材料' : '请选择审计材料'
: selectedMaterial?.materialTitle || fileValues[0]?.name || '请选择审计材料'; : '请选择审计材料';
const handleMaterialSelection = (key) => { const handleMaterialSelection = (key) => {
const materialId = key ? String(key) : ''; const materialId = key ? String(key) : '';
...@@ -149,11 +143,9 @@ export default function ProblemRectificationMaterialField({ ...@@ -149,11 +143,9 @@ export default function ProblemRectificationMaterialField({
setUploadError(''); setUploadError('');
if (multiple) { if (multiple) {
onChange?.(appendUniqueFiles(fileValues, [fileAsset])); onChange?.(appendUniqueFiles(fileValues, [fileAsset]));
setSelectedMaterialId(null);
return; return;
} }
setSelectedMaterialId(material.id);
setLocalFileKeys(new Set()); setLocalFileKeys(new Set());
onPendingLocalMaterialChange?.( onPendingLocalMaterialChange?.(
fieldPath, fieldPath,
...@@ -192,7 +184,6 @@ export default function ProblemRectificationMaterialField({ ...@@ -192,7 +184,6 @@ export default function ProblemRectificationMaterialField({
setLocalFileKeys((currentKeys) => new Set( setLocalFileKeys((currentKeys) => new Set(
multiple ? [...currentKeys, ...uploadedKeys] : uploadedKeys, multiple ? [...currentKeys, ...uploadedKeys] : uploadedKeys,
)); ));
setSelectedMaterialId(null);
onPendingLocalMaterialChange?.( onPendingLocalMaterialChange?.(
fieldPath, fieldPath,
{ {
...@@ -238,14 +229,13 @@ export default function ProblemRectificationMaterialField({ ...@@ -238,14 +229,13 @@ export default function ProblemRectificationMaterialField({
return; return;
} }
setSelectedMaterialId(null);
onChange?.(null); onChange?.(null);
}; };
return ( return (
<div className="space-y-4"> <div className="w-full min-w-0 space-y-3">
{fileValues.length > 0 ? ( {fileValues.length > 0 ? (
<div className="space-y-2"> <div className="space-y-1">
{fileValues.map((file, index) => { {fileValues.map((file, index) => {
const fileKey = getFileKey(file); const fileKey = getFileKey(file);
const fileLabel = file?.name || file?.url || `附件 ${index + 1}`; const fileLabel = file?.name || file?.url || `附件 ${index + 1}`;
...@@ -255,9 +245,9 @@ export default function ProblemRectificationMaterialField({ ...@@ -255,9 +245,9 @@ export default function ProblemRectificationMaterialField({
<div className="truncate text-sm font-medium text-foreground" title={fileLabel}> <div className="truncate text-sm font-medium text-foreground" title={fileLabel}>
{fileLabel} {fileLabel}
</div> </div>
<div className="mt-1 text-xs text-muted"> {localFileKeys.has(fileKey) ? (
{localFileKeys.has(fileKey) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'} <div className="mt-1 text-xs text-muted">本地文件,保存后同步至审计材料中心</div>
</div> ) : null}
</div> </div>
<Tooltip> <Tooltip>
<Button <Button
...@@ -282,9 +272,9 @@ export default function ProblemRectificationMaterialField({ ...@@ -282,9 +272,9 @@ export default function ProblemRectificationMaterialField({
<Tabs <Tabs
aria-label="附件来源" aria-label="附件来源"
className="w-fit max-w-full min-w-0" className="w-full min-w-0"
selectedKey={activeTab} selectedKey={activeTab}
variant="secondary" variant="primary"
onSelectionChange={(key) => setActiveTab(String(key))} onSelectionChange={(key) => setActiveTab(String(key))}
> >
<Tabs.ListContainer className="w-fit max-w-full min-w-0"> <Tabs.ListContainer className="w-fit max-w-full min-w-0">
...@@ -300,10 +290,11 @@ export default function ProblemRectificationMaterialField({ ...@@ -300,10 +290,11 @@ export default function ProblemRectificationMaterialField({
</Tabs.List> </Tabs.List>
</Tabs.ListContainer> </Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}> <Tabs.Panel className="mt-3 w-full min-w-0 p-0" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3"> <div className="w-full min-w-0 space-y-3">
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label="选择审计材料" aria-label="选择审计材料"
className="w-full min-w-0"
emptyText="没有找到可选择的审计材料" emptyText="没有找到可选择的审计材料"
errorText={materialsError} errorText={materialsError}
inputValue={keyword} inputValue={keyword}
...@@ -312,7 +303,7 @@ export default function ProblemRectificationMaterialField({ ...@@ -312,7 +303,7 @@ export default function ProblemRectificationMaterialField({
options={materialOptions} options={materialOptions}
placeholder="请选择审计材料" placeholder="请选择审计材料"
searchPlaceholder="搜索材料标题或文件名" searchPlaceholder="搜索材料标题或文件名"
selectedKey={selectedMaterialId} selectedKey={null}
shouldFilter={false} shouldFilter={false}
valueText={selectedValueText} valueText={selectedValueText}
variant="secondary" variant="secondary"
...@@ -344,8 +335,8 @@ export default function ProblemRectificationMaterialField({ ...@@ -344,8 +335,8 @@ export default function ProblemRectificationMaterialField({
</div> </div>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}> <Tabs.Panel className="mt-3 w-full min-w-0 p-0" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3"> <div className="w-full min-w-0 space-y-3">
<FileUploadField <FileUploadField
description={multiple description={multiple
? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。' ? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。'
......
...@@ -58,6 +58,7 @@ import { ...@@ -58,6 +58,7 @@ import {
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { import {
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE, DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
PROBLEM_RECTIFICATION_SUBJECT, PROBLEM_RECTIFICATION_SUBJECT,
AUDIT_STATUS_APPROVED, AUDIT_STATUS_APPROVED,
AUDIT_STATUS_PENDING, AUDIT_STATUS_PENDING,
...@@ -419,6 +420,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择' ...@@ -419,6 +420,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择'
return ( return (
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label={placeholder} aria-label={placeholder}
isClearable={Boolean(value)}
options={options.map((option) => ({ options={options.map((option) => ({
id: String(option.value), id: String(option.value),
label: option.label, label: option.label,
...@@ -427,6 +429,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择' ...@@ -427,6 +429,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择'
selectedKey={value || null} selectedKey={value || null}
variant={variant} variant={variant}
isDisabled={disabled} isDisabled={disabled}
onClear={() => onChange('')}
onSelectionChange={(key) => onChange(key ? String(key) : '')} onSelectionChange={(key) => onChange(key ? String(key) : '')}
/> />
); );
...@@ -805,7 +808,7 @@ export default function ProblemRectificationListPage() { ...@@ -805,7 +808,7 @@ export default function ProblemRectificationListPage() {
const [delayDepartmentAssignments, setDelayDepartmentAssignments] = useState([]); const [delayDepartmentAssignments, setDelayDepartmentAssignments] = useState([]);
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE; const [pageSize, setPageSize] = useState(DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE);
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
page: 0, page: 0,
pageSize: DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE, pageSize: DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
...@@ -1857,20 +1860,6 @@ export default function ProblemRectificationListPage() { ...@@ -1857,20 +1860,6 @@ export default function ProblemRectificationListPage() {
) )
)} )}
toolbarActions={activeGridView ? [ toolbarActions={activeGridView ? [
{
key: 'export',
label: '导出',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive',
label: '一键归档',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{ {
key: 'create', key: 'create',
label: '新增记录', label: '新增记录',
...@@ -2015,12 +2004,18 @@ export default function ProblemRectificationListPage() { ...@@ -2015,12 +2004,18 @@ export default function ProblemRectificationListPage() {
pagination={{ pagination={{
page, page,
pageSize, pageSize,
pageSizeOptions: PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
totalRecords: totalMatchingRecords, totalRecords: totalMatchingRecords,
totalPages: pagination.totalPages, totalPages: pagination.totalPages,
onPageChange: (nextPage) => { onPageChange: (nextPage) => {
setPage(nextPage); setPage(nextPage);
setSelectedIds([]); setSelectedIds([]);
}, },
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
}} }}
stickyTable stickyTable
renderCell={({ record, column, expandable, expanded, toggleExpanded }) => { renderCell={({ record, column, expandable, expanded, toggleExpanded }) => {
......
...@@ -31,8 +31,8 @@ import { ...@@ -31,8 +31,8 @@ import {
} from '@/utils/subjectView'; } from '@/utils/subjectView';
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification'; export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [10, 20, 50]; export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 10; export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 20;
export const DEPARTMENT_RECTIFICATION_FIELD = 'department_rectification_items'; export const DEPARTMENT_RECTIFICATION_FIELD = 'department_rectification_items';
export const DELAY_REQUEST_FIELD = 'problem_rectification_delay_request'; export const DELAY_REQUEST_FIELD = 'problem_rectification_delay_request';
export const DELAY_REQUEST_ITEMS_FIELD = 'problem_rectification_delay_request_items'; export const DELAY_REQUEST_ITEMS_FIELD = 'problem_rectification_delay_request_items';
...@@ -854,6 +854,15 @@ export function buildProblemRectificationFilterSchema(columns = []) { ...@@ -854,6 +854,15 @@ export function buildProblemRectificationFilterSchema(columns = []) {
} else if (!['text', 'long_text', 'rich_text'].includes(type)) { } else if (!['text', 'long_text', 'rich_text'].includes(type)) {
return fields; return fields;
} }
const placeholder = controlType === 'select'
? '请选择'
: controlType === 'date'
? '请选择日期'
: controlType === 'number'
? '请输入数字'
: controlType === 'user' || controlType === 'reference'
? '输入关键词'
: '请输入';
fields.push({ fields.push({
key: `field:${field}`, key: `field:${field}`,
field, field,
...@@ -861,7 +870,7 @@ export function buildProblemRectificationFilterSchema(columns = []) { ...@@ -861,7 +870,7 @@ export function buildProblemRectificationFilterSchema(columns = []) {
label: column.label || field, label: column.label || field,
type: controlType, type: controlType,
options, options,
placeholder: `筛选${column.label || field}`, placeholder,
}); });
return fields; return fields;
}, []); }, []);
......
@import "@fontsource/inter/400.css"; @import "tailwindcss" source(none);
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "tailwindcss";
@import "@heroui/styles"; @import "@heroui/styles";
@import "@heroui-pro/react/css"; @import "@heroui-pro/react/css";
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@source "./*.{js,jsx,ts,tsx}";
@source "./components/**/*.{js,jsx,ts,tsx}";
@source "./pages/**/*.{js,jsx,ts,tsx}";
@source "./wrapper/**/*.{js,jsx,ts,tsx}";
@theme { @theme {
--font-inter: --font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; "Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
...@@ -17,19 +19,12 @@ ...@@ -17,19 +19,12 @@
--font-inter: --font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; "Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color-scheme: light !important; color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
} }
html { html {
color-scheme: light !important; color-scheme: light !important;
} }
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body { body {
margin: 0; margin: 0;
font-family: var(--rc-font-sans); font-family: var(--rc-font-sans);
...@@ -38,6 +33,43 @@ body { ...@@ -38,6 +33,43 @@ body {
color-scheme: inherit; color-scheme: inherit;
} }
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__control,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__control {
border-color: var(--rc-brand-primary) !important;
background: var(--rc-brand-primary) !important;
box-shadow: none !important;
filter: none !important;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__control::before,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__control::before {
border-color: var(--rc-brand-primary) !important;
background: var(--rc-brand-primary) !important;
box-shadow: none !important;
filter: none !important;
}
:is(.checkbox[data-selected="true"], .checkbox[data-indeterminate="true"])
.checkbox__indicator,
:is(
.checkbox__content[data-selected="true"],
.checkbox__content[data-indeterminate="true"]
)
.checkbox__indicator {
color: var(--accent-foreground) !important;
filter: none !important;
}
body .login-page { body .login-page {
--rc-login-brand-logo-size: 2.25rem; --rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem; --rc-login-card-brand-gap: 0.875rem;
......
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getUserInfo, userLogout } from '@/services/DataService'; import { getUserInfo, userLogout } from '@/services/DataService';
import { Spinner } from '@heroui/react';
import { createContext, useEffect, useMemo, useState } from 'react'; import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi'; import { history, Navigate, Outlet, useLocation } from 'umi';
...@@ -84,8 +85,9 @@ export default function AuthWrapper() { ...@@ -84,8 +85,9 @@ export default function AuthWrapper() {
if (!loaded) { if (!loaded) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted" role="status">
正在验证登录状态... <Spinner size="sm" />
<span>正在验证登录状态...</span>
</div> </div>
); );
} }
......
...@@ -6,6 +6,7 @@ import weekday from 'dayjs/plugin/weekday'; ...@@ -6,6 +6,7 @@ import weekday from 'dayjs/plugin/weekday';
import { createContext, useEffect, useState } from 'react'; import { createContext, useEffect, useState } from 'react';
import { Outlet } from 'umi'; import { Outlet } from 'umi';
import { APPLICATION_DISPLAY_NAME } from '@/config/application'; import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Spinner } from '@heroui/react';
dayjs.extend(weekday); dayjs.extend(weekday);
dayjs.extend(localeData); dayjs.extend(localeData);
...@@ -59,8 +60,9 @@ export default function SiteWrapper() { ...@@ -59,8 +60,9 @@ export default function SiteWrapper() {
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted" role="status">
加载中... <Spinner size="sm" />
<span>加载中...</span>
</div> </div>
); );
} }
......
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