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 @@
该功能只适用于六个完整功能应用中允许编辑的 `file` 字段;若目标应用业务文档明确限定字段范围,以该范围为准。
- 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`,正则关键词必须转义。
- 选择已有材料只把 `{name,type,size,url}` 文件快照写入业务草稿,不保存 `am_material` ID,不重新上传、不创建材料。
- 单值字段替换原值;多值字段追加并按 URL 去重,支持逐项删除。
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -29,6 +29,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": ">=8.6.0",
"embla-carousel-react": ">=8.6.0",
"maplibre-gl": ">=6.0.0",
......@@ -39,7 +41,7 @@
"react-dom": "^19.2.7",
"react-markdown": ">=10.1.0",
"react-resizable-panels": ">=4.10.0",
"recharts": ">=2.0.0",
"recharts": "^3.10.1",
"remark-breaks": ">=4.0.0",
"remark-gfm": ">=4.0.0",
"shiki": ">=4.0.0",
......
import '@/global.less';
import '@/tailwind.css';
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') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
const theme = storedTheme === 'dark' ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
document.documentElement.dataset.theme = 'light';
document.documentElement.style.colorScheme = 'light';
}
const LEGACY_FLOW_PATH = '/self_inspection_form';
......@@ -36,7 +36,7 @@ if (typeof window !== 'undefined') {
export async function getInitialState() {
return {
name: 'project_manager_xy',
name: APPLICATION_NAME,
};
}
......
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
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';
......@@ -18,6 +18,8 @@ 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 FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import GroupOutlined from '@ant-design/icons/GroupOutlined';
import PaperClipOutlined from '@ant-design/icons/PaperClipOutlined';
import LeftOutlined from '@ant-design/icons/LeftOutlined';
......@@ -26,7 +28,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
......@@ -36,10 +37,10 @@ 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 UpOutlined from '@ant-design/icons/UpOutlined';
import UnlockOutlined from '@ant-design/icons/UnlockOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
......@@ -80,6 +81,8 @@ export const OpenExternal = withSize(ExportOutlined);
export const FileText = withSize(FileTextOutlined);
export const Info = withSize(InfoCircleOutlined);
export const Folder = withSize(FolderOpenOutlined);
export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined);
export const Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined);
......@@ -112,11 +115,9 @@ export function SidebarPanel({ size = 20, style, ...props }) {
);
}
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);
......@@ -124,6 +125,8 @@ export const Unlock = withSize(UnlockOutlined);
export const Back = withSize(LeftOutlined);
export const ArrowDownTrayIcon = withSize(DownloadOutlined);
export const ArrowDownIcon = ChevronDown;
export const ArrowUpIcon = withSize(UpOutlined);
export const ArrowPathIcon = withSize(ReloadOutlined);
export const ArrowTrendingUpIcon = withSize(RiseOutlined);
export const BanknotesIcon = withSize(DollarOutlined);
......@@ -144,4 +147,5 @@ export const LockOpenIcon = Unlock;
export const MagnifyingGlassIcon = Search;
export const PaperClipIcon = withSize(PaperClipOutlined);
export const PlusIcon = withSize(PlusOutlined);
export const TableCellsIcon = withSize(OrderedListOutlined);
export const UserGroupIcon = Team;
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({
children,
notificationCount,
......@@ -27,25 +13,9 @@ export default function AppShell({
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
......@@ -55,12 +25,8 @@ export default function AppShell({
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
......@@ -46,29 +42,13 @@ export default function DashboardNavbar({
color="danger"
size="sm"
>
{normalizedNotificationCount > 99
? '99+'
: normalizedNotificationCount}
{normalizedNotificationCount > 99 ? '99+' : normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : 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>
</Navbar.Header>
</Navbar>
......
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { Heading } from 'react-aria-components';
import { AppGrid, LogOut } from './AppIcons';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Disclosure, Dropdown, Label } from '@heroui/react';
import { AppGrid, ChevronDown, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
......@@ -64,9 +63,56 @@ function SidebarContents({
onLogout,
onOpenApplicationSwitcher,
pathname,
theme,
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 (
<>
<Sidebar.Header>
......@@ -74,12 +120,7 @@ function SidebarContents({
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
className="rc-sidebar-logo"
src={xyLogoWhite}
/>
</div>
......@@ -95,7 +136,6 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
......@@ -106,43 +146,15 @@ function SidebarContents({
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{group.showLabel !== false ? (
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
) : 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>
group.showLabel === false ? (
<Sidebar.Group key={group.title}>{renderMenu(group)}</Sidebar.Group>
) : renderGroup(group)
))}
</Sidebar.Content>
......@@ -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) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar">
<SidebarContents {...props} />
</Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<Heading slot="title" className="sr-only">
{APPLICATION_DISPLAY_NAME}导航
</Heading>
<SidebarContents {...props} />
</Sidebar.Mobile>
<AccessibleMobileSidebar {...props} />
</>
);
}
This diff is collapsed.
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { useMemo } from 'react';
const EMPTY_OPTION_KEY = '__rc_filter_empty__';
function normalizeOption(option) {
if (option && typeof option === 'object') {
return {
......@@ -32,9 +30,9 @@ export default function FilterSelect({
}) {
const normalizedOptions = useMemo(
() =>
options.map(normalizeOption).map((option) => ({
options.map(normalizeOption).filter((option) => option.value !== '').map((option) => ({
...option,
id: option.value || EMPTY_OPTION_KEY,
id: option.value,
isDisabled: option.disabled,
})),
[options],
......@@ -43,21 +41,22 @@ export default function FilterSelect({
const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue,
);
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue;
const selectedKey = selectedValue || null;
return (
<XinyuanAutocomplete
aria-label={ariaLabel || placeholder}
className={className}
isDisabled={disabled}
isClearable={Boolean(selectedValue)}
emptyText={emptyText}
options={normalizedOptions}
onClear={() => onChange?.('')}
placeholder={placeholder}
selectedKey={selectedKey}
variant={variant}
onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue);
onChange?.(String(key ?? ''));
}}
valueText={selectedOption?.label || placeholder}
/>
......
......@@ -127,9 +127,11 @@ export default function FormSelect({
aria-label={ariaLabel || resolvedPlaceholder}
className={className}
isDisabled={disabled}
isClearable={selectedOptions.length > 0}
isRequired={required}
name={name}
options={options}
onClear={() => emitChange(multiple ? [] : [''])}
placeholder={resolvedPlaceholder}
selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayText}
......
......@@ -605,9 +605,11 @@ export default function ReferenceSearchSelect({
errorText={error}
inputValue={query}
isDisabled={disabled}
isClearable={allowClear}
isLoading={loading}
isOpen={active}
options={menuOptions}
onClear={clearSelection}
placement={optionsAnchor || 'bottom start'}
placeholder={placeholder}
popoverHeader={allowClear ? (
......
......@@ -70,8 +70,10 @@ export default function ResourceFilterMultiSelect({
emptyText="没有匹配选项"
inputValue={searchKeyword}
isDisabled={disabled}
isClearable={selectedValues.length > 0}
isOpen={isOpen}
options={autocompleteOptions}
onClear={handleClearSelection}
placeholder={placeholder}
popoverHeader={(
<div className="flex items-center justify-between gap-3 px-3 pb-1">
......
......@@ -83,8 +83,10 @@ export default function UserSearchSelect({
aria-label={ariaLabel}
inputValue={keyword}
isDisabled={disabled}
isClearable={selectedIds.length > 0}
isLoading={loading}
options={options}
onClear={() => onChange?.(multiple ? [] : null)}
placeholder={placeholder}
selectionMode={multiple ? 'multiple' : 'single'}
selectedKey={multiple ? undefined : selectedIds[0] || null}
......
......@@ -52,11 +52,13 @@ export default function XinyuanAutocomplete({
fullWidth = true,
inputValue,
isDisabled = false,
isClearable = false,
isLoading = false,
isOpen,
isRequired = false,
name,
onInputChange,
onClear,
onOpenChange,
onSelectionChange,
options = [],
......@@ -65,6 +67,7 @@ export default function XinyuanAutocomplete({
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey,
selectedKeys,
selectionMode = 'single',
......@@ -81,7 +84,6 @@ export default function XinyuanAutocomplete({
Array.from(selectedKeySet).map(toSelectionKey),
);
const selectedOptions = options.filter((option) =>
selectedKeySet.has(option.id) ||
selectedKeyStringSet.has(toSelectionKey(option.id)),
);
const resolvedValueText =
......@@ -90,8 +92,10 @@ export default function XinyuanAutocomplete({
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const isMultiple = selectionMode === 'multiple';
const hasSelection = selectedKeySet.size > 0;
const normalizedSelectedKeys = selectedKeySet;
const rootSelectionProps = isMultiple
const rootSelectionProps =
isMultiple
? { selectedKey: null }
: { selectedKey: normalizedSelectedKeys.values().next().value || null };
const handleMultipleOptionAction = (optionKey) => {
......@@ -99,12 +103,12 @@ export default function XinyuanAutocomplete({
return;
}
const nextKeys = new Set(normalizedSelectedKeys);
const normalizedOptionKey = toSelectionKey(optionKey);
if (!normalizedOptionKey) {
return;
}
const nextKeys = new Set(normalizedSelectedKeys);
if (nextKeys.has(normalizedOptionKey)) {
nextKeys.delete(normalizedOptionKey);
} else {
......@@ -114,6 +118,8 @@ export default function XinyuanAutocomplete({
};
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',
onAction: handleMultipleOptionAction,
}
......@@ -140,6 +146,7 @@ export default function XinyuanAutocomplete({
isOpen={isOpen}
isRequired={isRequired}
name={name}
onClear={onClear}
selectionMode={isMultiple ? 'single' : selectionMode}
variant={variant}
onOpenChange={onOpenChange}
......@@ -148,12 +155,15 @@ export default function XinyuanAutocomplete({
<Autocomplete.Trigger>
<Autocomplete.Value>
<span
className="block min-w-0 truncate"
className={`block min-w-0 truncate ${hasSelection ? '' : 'text-field-placeholder'}`.trim()}
title={resolvedValueText}
>
{resolvedValueText}
</span>
</Autocomplete.Value>
{isClearable ? (
<Autocomplete.ClearButton aria-label={`清除${ariaLabel || placeholder}`} />
) : null}
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
......@@ -165,6 +175,7 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange}
>
{popoverHeader}
{showSearch ? (
<SearchField
aria-label={searchPlaceholder}
fullWidth
......@@ -180,6 +191,7 @@ export default function XinyuanAutocomplete({
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
) : null}
<ListBox
aria-label={ariaLabel || placeholder}
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';
import clsx from 'clsx';
const DIALOG_WIDTH_CLASS = {
md: 'sm:max-w-[30rem]',
xl: 'sm:max-w-[46rem]',
'2xl': 'sm:max-w-[52.5rem]',
'3xl': 'sm:max-w-[60rem]',
'4xl': 'sm:max-w-[68rem]',
'5xl': 'sm:max-w-[76rem]',
};
const STANDARD_PANEL_PADDING_SIZES = new Set(['xl', '2xl', '3xl', '4xl', '5xl']);
export default function XinyuanModal({
isOpen,
......@@ -30,7 +32,8 @@ export default function XinyuanModal({
<Modal.Container placement="center" scroll="inside" size={modalSize}>
<Modal.Dialog
className={clsx(
DIALOG_WIDTH_CLASS[size] && 'p-7',
STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
'bg-background',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
......@@ -40,11 +43,11 @@ export default function XinyuanModal({
</Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{title}</Modal.Heading>
</Modal.Header>
<Modal.Body className={bodyClassName}>
{description ? (
<div className="text-sm text-muted">{description}</div>
) : null}
</Modal.Header>
<Modal.Body className={bodyClassName}>
{children}
</Modal.Body>
{footer ? (
......
......@@ -509,8 +509,10 @@ function WorkbenchPagination({ pagination }) {
isDisabled={Boolean(pagination.disabled)}
page={Math.max(0, Number(pagination.page || 0))}
pageSize={pageSize}
pageSizeOptions={pagination.pageSizeOptions || []}
total={totalRecords}
onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange}
/>
);
}
......@@ -992,7 +994,6 @@ export default function RecordWorkbench({
value={filters[field.key] || ''}
onChange={(value) => onFiltersChange?.(field.key, value)}
options={[
{ value: '', label: field.placeholder || `全部${field.label}` },
...(field.options || []),
]}
placeholder={field.placeholder || `全部${field.label}`}
......@@ -1036,7 +1037,10 @@ export default function RecordWorkbench({
</FilterField>
);
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 (
<div className="rc-page-stack">
......@@ -1204,10 +1208,6 @@ export default function RecordWorkbench({
</form>
) : 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="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{toolbarSearchField ? (
......
......@@ -2,7 +2,6 @@ export const APPLICATION_DISPLAY_NAME = '问题整改';
export const APPLICATION_NAME = 'problem_rectification_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/integrity_risk_table/dashboard': '驾驶舱',
......
......@@ -7,6 +7,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'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({
......@@ -65,46 +73,121 @@ export function createXinyuanChartColorMap(keys = []) {
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') {
return false;
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
return window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim() || fallback;
}
export function colorizeXinyuanChartTooltipPayload(
payload = [],
resolveColor,
) {
return payload.map((entry, index) => {
const rechartsPayload = entry?.payload || {};
const sourceDatum = rechartsPayload?.payload || rechartsPayload;
const key = sourceDatum?.name
?? rechartsPayload?.name
?? entry?.name
?? entry?.dataKey
?? '';
const candidateColors = [
resolveColor?.({ entry, index, key, sourceDatum }),
rechartsPayload?.fill,
sourceDatum?.fill,
entry?.fill,
entry?.color,
entry?.stroke,
getXinyuanChartColor(key, index),
];
const color = candidateColors.find(isVisibleChartColor);
const concreteColorCache = new Map();
function convertOklchToRgb(value) {
const match = String(value).trim().match(
/^oklch\(\s*([\d.]+)%?\s+([\d.]+)%?\s+([\d.-]+)(?:deg)?\s*\)$/i,
);
if (!match) return value;
const lightnessValue = Number(match[1]);
const chromaValue = Number(match[2]);
const lightness = lightnessValue > 1 ? lightnessValue / 100 : lightnessValue;
const chroma = chromaValue > 1 ? chromaValue / 100 : chromaValue;
const hue = (Number(match[3]) * Math.PI) / 180;
const a = chroma * Math.cos(hue);
const b = chroma * Math.sin(hue);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const channels = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
].map((channel) => {
const clamped = Math.max(0, Math.min(1, channel));
const encoded = clamped <= 0.0031308
? 12.92 * clamped
: 1.055 * (clamped ** (1 / 2.4)) - 0.055;
return Math.round(encoded * 255);
});
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 {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
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 @@
[data-theme="light"],
[data-theme="default"] {
/* 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);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--accent-hover: #C91F17;
--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-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(94.00% 0.0000 29.20);
--default-foreground: oklch(21.03% 0.0059 29.20);
--field-background: oklch(100.00% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
--field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0000 29.20);
--overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
......@@ -51,42 +52,99 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--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,
[data-theme="dark"] {
color-scheme: dark;
/* 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);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
--accent-hover: #C91F17;
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(27.40% 0.0000 29.20);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
--field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: #DA241A;
--foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0000 29.20);
--overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
......@@ -2,9 +2,7 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
[data-theme='default'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
......@@ -27,6 +25,17 @@
var(--background)
);
--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-medium: 500;
......@@ -45,6 +54,7 @@
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-sidebar-width: 15rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
......@@ -82,14 +92,15 @@
0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
/* KPI icon accents reuse the restrained chart family; no green/fluorescent tones. */
--rc-metric-tone-info: var(--rc-chart-13);
--rc-metric-tone-success: var(--rc-chart-14);
--rc-metric-tone-teal: var(--rc-chart-15);
--rc-metric-tone-danger: var(--rc-chart-2);
--rc-metric-tone-warning: var(--rc-chart-6);
--rc-metric-tone-violet: var(--rc-chart-10);
--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-info-soft: color-mix(
in oklab,
......@@ -138,40 +149,30 @@
);
/* 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-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-1: var(--xinyuan-chart-1);
--rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: var(--xinyuan-chart-7);
--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-risk: oklch(47% 0.145 27.03);
--rc-chart-risk: var(--rc-chart-2);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.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%);
--rc-chart-positive: var(--rc-chart-13);
--rc-chart-comparison: var(--rc-chart-14);
--rc-chart-neutral: var(--rc-chart-10);
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--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);
}
:root {
--pm-brand: var(--rc-brand-primary, #9e1b1b);
--pm-brand: var(--rc-brand-primary, #DA241A);
--pm-brand-strong: var(--rc-brand-dark, #7e1515);
--pm-surface: var(--rc-bg-surface, #ffffff);
--pm-bg: var(--rc-bg-canvas, #f5f5f5);
......
import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField';
import {
buildViewColumns,
......@@ -133,13 +132,5 @@ export default function SubjectViewContent({
</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;
}
......@@ -234,6 +234,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
aria-label="用户"
emptyText="暂无用户"
inputValue={keyword}
isClearable={selectedIds.length > 0}
options={autocompleteOptions}
placeholder="请选择用户"
searchPlaceholder="搜索用户名或显示名"
......@@ -242,6 +243,7 @@ function UserSelector({ value, onChange, multiple, variant }) {
valueText={valueText || '请选择用户'}
variant={variant}
onInputChange={setKeyword}
onClear={() => onChange?.(multiple ? [] : null)}
onSelectionChange={handleSelectionChange}
renderOption={(option) => (
<span className="min-w-0">
......@@ -276,6 +278,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
<XinyuanAutocomplete
{...selectProps}
aria-label="选择字段值"
isClearable={selectedOptions.length > 0}
options={options.map((option) => ({
id: String(option.value),
label: option.label,
......@@ -284,6 +287,7 @@ function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
selectionMode={multiple ? 'multiple' : 'single'}
valueText={displayValue}
variant={variant}
onClear={() => onChange?.(multiple ? [] : '')}
/>
);
}
......
......@@ -10,7 +10,8 @@ import {
loadArchiveWorkbenchResources,
} from './archiveWorkbenchService';
const DEFAULT_PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
const DEFAULT_PAGE_SIZE = 20;
export default function ProblemRectificationArchivePage() {
const [records, setRecords] = useState([]);
......@@ -24,7 +25,7 @@ export default function ProblemRectificationArchivePage() {
const [activeViewName, setActiveViewName] = useState('');
const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0);
const pageSize = DEFAULT_PAGE_SIZE;
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const reloadList = useCallback(async () => {
setLoading(true);
......@@ -147,26 +148,16 @@ export default function ProblemRectificationArchivePage() {
pagination={{
page,
pageSize,
pageSizeOptions: PAGE_SIZE_OPTIONS,
totalRecords: filteredRecords.length,
disabled: loading,
onPageChange: setPage,
}}
toolbarActions={[
{
key: 'archive-export',
label: '导出批次',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive-compare',
label: '批次对比',
variant: 'secondary',
disabled: true,
onClick: () => {},
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
]}
}}
rowActions={[
{
key: 'view-detail',
......@@ -197,7 +188,7 @@ export default function ProblemRectificationArchivePage() {
loadingTitle: '正在加载归档批次',
loadingDescription: '正在读取归档对象、视图配置和批次记录,请稍候。',
emptyTitle: '暂无归档批次',
emptyDescription: '当前还没有归档批次记录,后续完成“一键归档”后会在这里展示。',
emptyDescription: '当前还没有归档批次记录。',
}}
renderCell={({ record, column }) => {
if (column.field === 'metadata.reference_records') {
......
......@@ -59,7 +59,6 @@ export default function ProblemRectificationMaterialField({
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [retryKey, setRetryKey] = useState(0);
......@@ -75,7 +74,6 @@ export default function ProblemRectificationMaterialField({
setActiveTab(MATERIAL_CENTER_TAB);
setKeyword('');
setDebouncedKeyword('');
setSelectedMaterialId(null);
setMaterialsError('');
setUploadError('');
setLocalFileKeys(new Set());
......@@ -124,15 +122,11 @@ export default function ProblemRectificationMaterialField({
})),
[materials],
);
const selectedMaterial = useMemo(
() => materials.find((material) => material.id === selectedMaterialId),
[materials, selectedMaterialId],
);
const selectedValueText = multiple
? fileValues.length > 0
? `已选 ${fileValues.length} 个附件,可继续添加`
: '请选择审计材料'
: selectedMaterial?.materialTitle || fileValues[0]?.name || '请选择审计材料';
: '请选择审计材料';
const handleMaterialSelection = (key) => {
const materialId = key ? String(key) : '';
......@@ -149,11 +143,9 @@ export default function ProblemRectificationMaterialField({
setUploadError('');
if (multiple) {
onChange?.(appendUniqueFiles(fileValues, [fileAsset]));
setSelectedMaterialId(null);
return;
}
setSelectedMaterialId(material.id);
setLocalFileKeys(new Set());
onPendingLocalMaterialChange?.(
fieldPath,
......@@ -192,7 +184,6 @@ export default function ProblemRectificationMaterialField({
setLocalFileKeys((currentKeys) => new Set(
multiple ? [...currentKeys, ...uploadedKeys] : uploadedKeys,
));
setSelectedMaterialId(null);
onPendingLocalMaterialChange?.(
fieldPath,
{
......@@ -238,14 +229,13 @@ export default function ProblemRectificationMaterialField({
return;
}
setSelectedMaterialId(null);
onChange?.(null);
};
return (
<div className="space-y-4">
<div className="w-full min-w-0 space-y-3">
{fileValues.length > 0 ? (
<div className="space-y-2">
<div className="space-y-1">
{fileValues.map((file, index) => {
const fileKey = getFileKey(file);
const fileLabel = file?.name || file?.url || `附件 ${index + 1}`;
......@@ -255,9 +245,9 @@ export default function ProblemRectificationMaterialField({
<div className="truncate text-sm font-medium text-foreground" title={fileLabel}>
{fileLabel}
</div>
<div className="mt-1 text-xs text-muted">
{localFileKeys.has(fileKey) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}
</div>
{localFileKeys.has(fileKey) ? (
<div className="mt-1 text-xs text-muted">本地文件,保存后同步至审计材料中心</div>
) : null}
</div>
<Tooltip>
<Button
......@@ -282,9 +272,9 @@ export default function ProblemRectificationMaterialField({
<Tabs
aria-label="附件来源"
className="w-fit max-w-full min-w-0"
className="w-full min-w-0"
selectedKey={activeTab}
variant="secondary"
variant="primary"
onSelectionChange={(key) => setActiveTab(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
......@@ -300,10 +290,11 @@ export default function ProblemRectificationMaterialField({
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3">
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id={MATERIAL_CENTER_TAB}>
<div className="w-full min-w-0 space-y-3">
<XinyuanAutocomplete
aria-label="选择审计材料"
className="w-full min-w-0"
emptyText="没有找到可选择的审计材料"
errorText={materialsError}
inputValue={keyword}
......@@ -312,7 +303,7 @@ export default function ProblemRectificationMaterialField({
options={materialOptions}
placeholder="请选择审计材料"
searchPlaceholder="搜索材料标题或文件名"
selectedKey={selectedMaterialId}
selectedKey={null}
shouldFilter={false}
valueText={selectedValueText}
variant="secondary"
......@@ -344,8 +335,8 @@ export default function ProblemRectificationMaterialField({
</div>
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3">
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id={LOCAL_UPLOAD_TAB}>
<div className="w-full min-w-0 space-y-3">
<FileUploadField
description={multiple
? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。'
......
......@@ -58,6 +58,7 @@ import {
} from '@/utils/subjectView';
import {
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
PROBLEM_RECTIFICATION_SUBJECT,
AUDIT_STATUS_APPROVED,
AUDIT_STATUS_PENDING,
......@@ -419,6 +420,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择'
return (
<XinyuanAutocomplete
aria-label={placeholder}
isClearable={Boolean(value)}
options={options.map((option) => ({
id: String(option.value),
label: option.label,
......@@ -427,6 +429,7 @@ function CreateFieldSelect({ value, options, onChange, placeholder = '请选择'
selectedKey={value || null}
variant={variant}
isDisabled={disabled}
onClear={() => onChange('')}
onSelectionChange={(key) => onChange(key ? String(key) : '')}
/>
);
......@@ -805,7 +808,7 @@ export default function ProblemRectificationListPage() {
const [delayDepartmentAssignments, setDelayDepartmentAssignments] = useState([]);
const [successMessage, setSuccessMessage] = useState('');
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({
page: 0,
pageSize: DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
......@@ -1857,20 +1860,6 @@ export default function ProblemRectificationListPage() {
)
)}
toolbarActions={activeGridView ? [
{
key: 'export',
label: '导出',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'archive',
label: '一键归档',
variant: 'secondary',
disabled: true,
onClick: () => {},
},
{
key: 'create',
label: '新增记录',
......@@ -2015,12 +2004,18 @@ export default function ProblemRectificationListPage() {
pagination={{
page,
pageSize,
pageSizeOptions: PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS,
totalRecords: totalMatchingRecords,
totalPages: pagination.totalPages,
onPageChange: (nextPage) => {
setPage(nextPage);
setSelectedIds([]);
},
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
}}
stickyTable
renderCell={({ record, column, expandable, expanded, toggleExpanded }) => {
......
......@@ -31,8 +31,8 @@ import {
} from '@/utils/subjectView';
export const PROBLEM_RECTIFICATION_SUBJECT = 'problem_rectification';
export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [10, 20, 50];
export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 10;
export const PROBLEM_RECTIFICATION_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE = 20;
export const DEPARTMENT_RECTIFICATION_FIELD = 'department_rectification_items';
export const DELAY_REQUEST_FIELD = 'problem_rectification_delay_request';
export const DELAY_REQUEST_ITEMS_FIELD = 'problem_rectification_delay_request_items';
......@@ -854,6 +854,15 @@ export function buildProblemRectificationFilterSchema(columns = []) {
} else if (!['text', 'long_text', 'rich_text'].includes(type)) {
return fields;
}
const placeholder = controlType === 'select'
? '请选择'
: controlType === 'date'
? '请选择日期'
: controlType === 'number'
? '请输入数字'
: controlType === 'user' || controlType === 'reference'
? '输入关键词'
: '请输入';
fields.push({
key: `field:${field}`,
field,
......@@ -861,7 +870,7 @@ export function buildProblemRectificationFilterSchema(columns = []) {
label: column.label || field,
type: controlType,
options,
placeholder: `筛选${column.label || field}`,
placeholder,
});
return fields;
}, []);
......
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "tailwindcss";
@import "tailwindcss" source(none);
@import "@heroui/styles";
@import "@heroui-pro/react/css";
@import "./design-system/theme.css";
@import "./design-system/tokens.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 {
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
......@@ -17,19 +19,12 @@
--font-inter:
"Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
color-scheme: light !important;
--pm-brand: var(--rc-brand-primary);
--pm-brand-strong: var(--rc-brand-dark);
}
html {
color-scheme: light !important;
}
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
......@@ -38,6 +33,43 @@ body {
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 {
--rc-login-brand-logo-size: 2.25rem;
--rc-login-card-brand-gap: 0.875rem;
......
import authService from '@/services/authService';
import { getUserInfo, userLogout } from '@/services/DataService';
import { Spinner } from '@heroui/react';
import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi';
......@@ -84,8 +85,9 @@ export default function AuthWrapper() {
if (!loaded) {
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>
);
}
......
......@@ -6,6 +6,7 @@ import weekday from 'dayjs/plugin/weekday';
import { createContext, useEffect, useState } from 'react';
import { Outlet } from 'umi';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Spinner } from '@heroui/react';
dayjs.extend(weekday);
dayjs.extend(localeData);
......@@ -59,8 +60,9 @@ export default function SiteWrapper() {
if (loading) {
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>
);
}
......
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