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

feat: finalize Xinyuan HeroUI design standards

parent 837b6947
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
# 问题整改系统 UI/UX 全局审计 # 问题整改系统 UI/UX 全局审计
> 2026-08-19 复核:Modal 根面统一保持 HeroUI `overlay`;应用切换项、附件编辑器、流程详情摘要等需要独立边界的弹窗内对象统一使用 HeroUI `secondary`。普通详情查看态以一个 `Surface secondary` 承载整组无框标签/值,编辑态则由 `secondary` 控件直接位于 Overlay。已清理上述范围内的 L1-on-L1 默认 Card,不改变弹窗数据、回调或状态流转;深浅模式实际层级仍待浏览器验收。
审计日期:2026-08-17 审计日期:2026-08-17
目标分支:`codex/ui-ux-xinyuan-migration` 目标分支:`codex/ui-ux-xinyuan-migration`
...@@ -31,7 +33,7 @@ ...@@ -31,7 +33,7 @@
- 迁移前 `package.json` 声明 `1.0.0-beta.8`,锁文件和实际镜像为 `1.0.0-beta.7`,`npm ls` 报 invalid。 - 迁移前 `package.json` 声明 `1.0.0-beta.8`,锁文件和实际镜像为 `1.0.0-beta.7`,`npm ls` 报 invalid。
- Pro 包通过跨项目符号链接加载;相邻工程与当前工程各有一套物理 HeroUI/React Aria,存在 Context 双实例风险。 - Pro 包通过跨项目符号链接加载;相邻工程与当前工程各有一套物理 HeroUI/React Aria,存在 Context 双实例风险。
- 本地镜像包含入口、CSS、Sidebar、Navbar、DataGrid、DropZone、ActionBar 和 EmptyState。 - 本地镜像包含入口、CSS、Sidebar、Navbar、DataGrid、DropZone、ActionBar 和 EmptyState。
- 本地 `file:` 安装仅用于迁移启动,团队交付仍需授权私有 npm 或统一 `.tgz`。 - 迁移启动阶段曾使用本地 `file:` 镜像;该方式已退出当前交付链。当前团队统一通过 HeroUI 官方 `hpsetup` 授权流程取得精确固定的 `1.0.0-beta.8` 完整制品,仓库与开发机不再依赖相邻项目路径。
### 主题 ### 主题
...@@ -71,8 +73,8 @@ ...@@ -71,8 +73,8 @@
## 迁移后复核 ## 迁移后复核
- `src/design-system/theme.css` 已与 2026-08-18 skill 权威文件逐字节一致,SHA-256 为 `8f98b720884ded1163cfb355a235cf050f203fec85e7ded2faec87a8cf29673a`。 - `src/design-system/theme.css` 已同步 2026-08-19 从 HeroUI 官方主题工具下载并批准的基础值,并在同一权威主题文件内固化三项鑫元语义映射:`field-background = surface`、`default = surface-secondary`、`overlay = surface`。该映射属于共享主题,不是页面私有覆盖。`/Users/liuchenlong/Desktop/theme.css`、项目文件与 skill 基线逐字节一致,SHA-256 为 `3ba654ba04b040316eb3f6fcf8bde18bf5d5698281477b95e860bc5e2eeb6ddf`。
- `tokens.css` 已收敛为 Shell/Login 别名层,不再覆盖 HeroUI 的 Card、Tabs、DataGrid、Modal、Checkbox、Pagination、控件高度、圆角或阴影;内容组件外观由 HeroUI/HeroUI Pro CSS 与权威 `theme.css` 决定。 - `tokens.css` 已收敛为 Shell/Login 别名、页面几何与图表数据色扩展层,不再覆盖 HeroUI 的 Card、Tabs、DataGrid、Modal、Checkbox、Pagination、控件高度、圆角或阴影;内容组件外观由 HeroUI/HeroUI Pro CSS 与权威 `theme.css` 决定。图表系列色在深浅模式中固定,HeroUI Pro 负责坐标轴、网格、文字、Tooltip 和表面的主题适配。
- Tabs 复核确认当前 `@heroui/styles@3.2.2` 提供 `Tabs.ListContainer` 内置 `ScrollShadow`,但默认 Tab 的 `w-full` 会压缩或均分中文标签。活动 Tabs 已仅在官方 compound slot 的 `className` 设置 `w-fit/w-max/w-auto`、`min-w-max`、`flex-none` 和 `whitespace-nowrap`,使每项宽度跟随自身文字;未改选中逻辑,未新增外层滚动,也未覆盖官方外观。 - Tabs 复核确认当前 `@heroui/styles@3.2.2` 提供 `Tabs.ListContainer` 内置 `ScrollShadow`,但默认 Tab 的 `w-full` 会压缩或均分中文标签。活动 Tabs 已仅在官方 compound slot 的 `className` 设置 `w-fit/w-max/w-auto`、`min-w-max`、`flex-none` 和 `whitespace-nowrap`,使每项宽度跟随自身文字;未改选中逻辑,未新增外层滚动,也未覆盖官方外观。
- 行操作复核发现共享按钮的普通状态使用 `ghost`,导致静止态没有正圆背景;操作组同时存在右对齐、6px 间距和 128px 固定列宽,三个操作会被挤向右侧。现已改为 HeroUI 官方 `secondary` / `danger-soft`,Ant Design 包装层与 SVG 固定 16px 网格居中,组间距 8px并与表头左对齐,操作列按实际入口数量动态计算。两个推送弹窗内的重复删除按钮也已接入同一共享实现,业务回调与禁用条件未变。 - 行操作复核发现共享按钮的普通状态使用 `ghost`,导致静止态没有正圆背景;操作组同时存在右对齐、6px 间距和 128px 固定列宽,三个操作会被挤向右侧。现已改为 HeroUI 官方 `secondary` / `danger-soft`,Ant Design 包装层与 SVG 固定 16px 网格居中,组间距 8px并与表头左对齐,操作列按实际入口数量动态计算。两个推送弹窗内的重复删除按钮也已接入同一共享实现,业务回调与禁用条件未变。
- 短列表复核发现共享 `DataTable` 将所有业务列转换为固定像素宽度,HeroUI DataGrid 外层虽为全宽,实际单元格宽度总和不足时仍会在右侧留下大块次表面空白。共享层现已保留 HeroUI Pro 的 `%`/`fr` `ColumnSize`,非宽表第一业务主列使用 `1fr`,内容层使用 `min-w-full`;宽表路径、数据、排序、选择和行操作均未改变。 - 短列表复核发现共享 `DataTable` 将所有业务列转换为固定像素宽度,HeroUI DataGrid 外层虽为全宽,实际单元格宽度总和不足时仍会在右侧留下大块次表面空白。共享层现已保留 HeroUI Pro 的 `%`/`fr` `ColumnSize`,非宽表第一业务主列使用 `1fr`,内容层使用 `min-w-full`;宽表路径、数据、排序、选择和行操作均未改变。
......
...@@ -17,6 +17,7 @@ import EyeOutlined from '@ant-design/icons/EyeOutlined'; ...@@ -17,6 +17,7 @@ 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 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';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined'; import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
...@@ -45,6 +46,10 @@ function iconStyle(size, style) { ...@@ -45,6 +46,10 @@ function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style }; return size === undefined ? style : { fontSize: size, ...style };
} }
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) { function withSize(Icon) {
return function AppIcon({ size, style, ...props }) { return function AppIcon({ size, style, ...props }) {
return ( return (
...@@ -73,6 +78,7 @@ export const OpenExternal = withSize(ExportOutlined); ...@@ -73,6 +78,7 @@ 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 Group = withSize(GroupOutlined);
export const LogOut = withSize(LogoutOutlined); export const LogOut = withSize(LogoutOutlined);
export const Lock = withSize(LockOutlined); export const Lock = withSize(LockOutlined);
export function SidebarPanel({ size = 20, style, ...props }) { export function SidebarPanel({ size = 20, style, ...props }) {
......
...@@ -21,12 +21,9 @@ function SidebarUserMenu({ userInfo, onLogout }) { ...@@ -21,12 +21,9 @@ function SidebarUserMenu({ userInfo, onLogout }) {
return ( return (
<Dropdown> <Dropdown>
<Button <Dropdown.Trigger
fullWidth
aria-label="打开用户菜单" aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger" className="app-sidebar-user-trigger rc-sidebar-user-trigger"
size="sm"
variant="ghost"
> >
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar"> <Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? ( {userInfo?.avatar ? (
...@@ -41,7 +38,7 @@ function SidebarUserMenu({ userInfo, onLogout }) { ...@@ -41,7 +38,7 @@ function SidebarUserMenu({ userInfo, onLogout }) {
<span className="app-sidebar-user-name">{user.displayName}</span> <span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span> <span className="app-sidebar-user-description">{user.username}</span>
</span> </span>
</Button> </Dropdown.Trigger>
<Dropdown.Popover placement="top start"> <Dropdown.Popover placement="top start">
<Dropdown.Menu <Dropdown.Menu
aria-label="用户菜单" aria-label="用户菜单"
......
import { import {
Calendar as HeroCalendar, Calendar as HeroCalendar,
CloseButton,
DateField, DateField,
DatePicker, DatePicker,
I18nProvider, I18nProvider,
...@@ -7,7 +8,11 @@ import { ...@@ -7,7 +8,11 @@ import {
} from '@heroui/react'; } from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date'; import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Calendar as CalendarIcon } from './AppIcons'; import {
Calendar as CalendarIcon,
Close,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
function parseValue(value, mode) { function parseValue(value, mode) {
if (!value) { if (!value) {
...@@ -91,6 +96,7 @@ export default function FormDatePicker({ ...@@ -91,6 +96,7 @@ export default function FormDatePicker({
className, className,
disabled = false, disabled = false,
granularity, granularity,
isClearable = true,
max, max,
min, min,
mode, mode,
...@@ -123,6 +129,8 @@ export default function FormDatePicker({ ...@@ -123,6 +129,8 @@ export default function FormDatePicker({
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]); const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]); const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]); const timeValue = useMemo(() => parseTimeValue(value), [value]);
const canClear =
isClearable && Boolean(value) && !disabled && !readOnly && !required;
return ( return (
<I18nProvider locale="zh-CN"> <I18nProvider locale="zh-CN">
...@@ -141,16 +149,35 @@ export default function FormDatePicker({ ...@@ -141,16 +149,35 @@ export default function FormDatePicker({
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode)) emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
} }
> >
<DateField.Group fullWidth variant={variant}> <DateField.Group className="relative" fullWidth variant={variant}>
<DateField.Input> <DateField.Input className={canClear ? 'pe-14' : 'pe-8'}>
{(segment) => ( {(segment) => (
<DateField.Segment segment={segment} /> <DateField.Segment segment={segment} />
)} )}
</DateField.Input> </DateField.Input>
<DateField.Suffix> <DateField.Suffix className="absolute inset-y-0 end-1 mr-0 gap-0">
<DatePicker.Trigger aria-label="打开日期选择"> {canClear ? (
<CloseButton
aria-label={`清空${ariaLabel || '日期'}`}
onPress={() => emitDateChange(onChange, name, '')}
>
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</CloseButton>
) : null}
<DatePicker.Trigger
aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<DatePicker.TriggerIndicator> <DatePicker.TriggerIndicator>
<CalendarIcon /> <CalendarIcon
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</DatePicker.TriggerIndicator> </DatePicker.TriggerIndicator>
</DatePicker.Trigger> </DatePicker.Trigger>
</DateField.Suffix> </DateField.Suffix>
......
...@@ -306,7 +306,6 @@ export default function ReferenceSearchSelect({ ...@@ -306,7 +306,6 @@ export default function ReferenceSearchSelect({
pageSize = 20, pageSize = 20,
queryFilter, queryFilter,
searchFields = DEFAULT_SEARCH_FIELDS, searchFields = DEFAULT_SEARCH_FIELDS,
optionsPanelClassName = '',
optionsAnchor, optionsAnchor,
showClearAction = true, showClearAction = true,
selectedSummaryMode = 'labels', selectedSummaryMode = 'labels',
...@@ -602,7 +601,6 @@ export default function ReferenceSearchSelect({ ...@@ -602,7 +601,6 @@ export default function ReferenceSearchSelect({
options={menuOptions} options={menuOptions}
placement={optionsAnchor || 'bottom start'} placement={optionsAnchor || 'bottom start'}
placeholder={placeholder} placeholder={placeholder}
popoverClassName={optionsPanelClassName}
popoverHeader={allowClear ? ( popoverHeader={allowClear ? (
<div className="flex justify-end px-3 pb-1"> <div className="flex justify-end px-3 pb-1">
<Button size="sm" variant="tertiary" onPress={clearSelection}> <Button size="sm" variant="tertiary" onPress={clearSelection}>
......
...@@ -22,6 +22,13 @@ import { Children, Fragment, isValidElement } from 'react'; ...@@ -22,6 +22,13 @@ import { Children, Fragment, isValidElement } from 'react';
const ACTION_ICON_CLASS = const ACTION_ICON_CLASS =
'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4'; 'grid size-4 shrink-0 place-items-center [line-height:0] [&>svg]:!m-0 [&>svg]:block [&>svg]:size-4';
const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0'; const ACTION_BUTTON_LAYOUT_CLASS = 'grid place-items-center rounded-full p-0';
const IMPORTANT_ACTION_TONES = new Set(['approve', 'submit']);
function getActionVariant(tone) {
if (['delete', 'reject'].includes(tone)) return 'danger-soft';
if (IMPORTANT_ACTION_TONES.has(tone)) return 'secondary';
return 'tertiary';
}
function resolveActionTone({ icon, tone }) { function resolveActionTone({ icon, tone }) {
if (tone) { if (tone) {
...@@ -108,9 +115,10 @@ export function TableActionGroup({ className, children, ...props }) { ...@@ -108,9 +115,10 @@ export function TableActionGroup({ className, children, ...props }) {
buttonVariants({ buttonVariants({
isIconOnly: true, isIconOnly: true,
size: 'sm', size: 'sm',
variant: 'secondary', variant: 'tertiary',
}), }),
ACTION_BUTTON_LAYOUT_CLASS, ACTION_BUTTON_LAYOUT_CLASS,
'pm-table-action',
)} )}
title="更多操作" title="更多操作"
> >
...@@ -184,13 +192,15 @@ export function TableActionButton({ ...@@ -184,13 +192,15 @@ export function TableActionButton({
title={label} title={label}
isDisabled={isDisabled} isDisabled={isDisabled}
size="sm" size="sm"
variant={ variant={getActionVariant(resolvedTone)}
['delete', 'reject'].includes(resolvedTone)
? 'danger-soft'
: 'secondary'
}
onPress={onPress} onPress={onPress}
className={clsx(ACTION_BUTTON_LAYOUT_CLASS, className)} className={clsx(
ACTION_BUTTON_LAYOUT_CLASS,
'pm-table-action',
IMPORTANT_ACTION_TONES.has(resolvedTone) && 'pm-table-action--important',
['delete', 'reject'].includes(resolvedTone) && 'pm-table-action--danger',
className,
)}
> >
<Icon <Icon
className={ACTION_ICON_CLASS} className={ACTION_ICON_CLASS}
......
import { ChevronDown, Close, Search } from '@/components/AppIcons'; import {
ChevronDown,
Close,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import { import {
Autocomplete, Autocomplete,
Label, Label,
...@@ -39,7 +44,6 @@ export default function XinyuanAutocomplete({ ...@@ -39,7 +44,6 @@ export default function XinyuanAutocomplete({
options = [], options = [],
placeholder = '请选择', placeholder = '请选择',
placement = 'bottom start', placement = 'bottom start',
popoverClassName,
popoverHeader, popoverHeader,
renderOption, renderOption,
searchPlaceholder = '输入关键词搜索', searchPlaceholder = '输入关键词搜索',
...@@ -94,17 +98,21 @@ export default function XinyuanAutocomplete({ ...@@ -94,17 +98,21 @@ export default function XinyuanAutocomplete({
</span> </span>
</Autocomplete.Value> </Autocomplete.Value>
<Autocomplete.Indicator> <Autocomplete.Indicator>
<ChevronDown size={14} /> <ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator> </Autocomplete.Indicator>
</Autocomplete.Trigger> </Autocomplete.Trigger>
<Autocomplete.Popover className={popoverClassName} placement={placement}> <Autocomplete.Popover placement={placement}>
<Autocomplete.Filter <Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true} filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue} inputValue={inputValue}
onInputChange={onInputChange} onInputChange={onInputChange}
> >
{popoverHeader} {popoverHeader}
<SearchField aria-label={searchPlaceholder} fullWidth> <SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group> <SearchField.Group>
<SearchField.SearchIcon> <SearchField.SearchIcon>
<Search size={16} /> <Search size={16} />
......
...@@ -13,8 +13,10 @@ import { ...@@ -13,8 +13,10 @@ import {
Delete, Delete,
Eye, Eye,
ArrowPathIcon, ArrowPathIcon,
ChartBarSquareIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronRightIcon, ChevronRightIcon,
Group,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from '@/components/AppIcons'; } from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect'; import FilterSelect from '@/components/FilterSelect';
...@@ -23,12 +25,12 @@ import { formatFieldValue, getFieldValue } from '@/utils/subjectView'; ...@@ -23,12 +25,12 @@ import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import { import {
Alert, Alert,
Button, Button,
Card,
Disclosure, Disclosure,
Input, Input,
SearchField, SearchField,
Switch, Surface,
Tabs, Tabs,
ToggleButton,
Tooltip, Tooltip,
} from '@heroui/react'; } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
...@@ -65,35 +67,6 @@ function FilterField({ label, children }) { ...@@ -65,35 +67,6 @@ function FilterField({ label, children }) {
); );
} }
function isRootDarkTheme() {
if (typeof document === 'undefined') {
return false;
}
const root = document.documentElement;
return root.classList.contains('dark') || root.dataset.theme === 'dark';
}
function useRootDarkTheme() {
const [isDark, setIsDark] = useState(isRootDarkTheme);
useEffect(() => {
const root = document.documentElement;
const syncTheme = () => setIsDark(isRootDarkTheme());
const observer = new MutationObserver(syncTheme);
syncTheme();
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
return () => observer.disconnect();
}, []);
return isDark;
}
function getPinnedSide(column) { function getPinnedSide(column) {
const pinnedValue = column?.lockPosition || column?.pinned; const pinnedValue = column?.lockPosition || column?.pinned;
if (pinnedValue === 'left' || pinnedValue === 'right') { if (pinnedValue === 'left' || pinnedValue === 'right') {
...@@ -247,22 +220,23 @@ function orderColumnsByGroups(columns = [], fieldToGroup = {}, groupById = {}) { ...@@ -247,22 +220,23 @@ function orderColumnsByGroups(columns = [], fieldToGroup = {}, groupById = {}) {
return orderedColumns; return orderedColumns;
} }
function SummaryCard({ label, value, tone = 'neutral', icon: Icon }) { function SummaryCard({ label, value, tone, icon: Icon }) {
const iconStatus = ['danger', 'success', 'warning'].includes(tone)
? tone
: undefined;
const numericValue = Number(value); const numericValue = Number(value);
return ( return (
<KPI> <KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header> <KPI.Header>
<KPI.Title>{label}</KPI.Title> <KPI.Title>{label}</KPI.Title>
</KPI.Header> </KPI.Header>
<KPI.Content> <KPI.Content>
<KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0} /> <KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0} />
{Icon ? ( {Icon ? (
<KPI.Icon status={iconStatus} aria-label={label}> <KPI.Icon
<Icon /> aria-label={label}
className="rc-metric-icon-surface"
data-tone={tone || undefined}
>
<Icon size={20} />
</KPI.Icon> </KPI.Icon>
) : null} ) : null}
</KPI.Content> </KPI.Content>
...@@ -289,7 +263,7 @@ function getActionButtonVariant(action) { ...@@ -289,7 +263,7 @@ function getActionButtonVariant(action) {
return action?.variant === 'danger' ? 'danger' : 'danger-soft'; return action?.variant === 'danger' ? 'danger' : 'danger-soft';
} }
return action?.variant || (action?.tone === 'primary' ? 'primary' : 'outline'); return action?.variant || (action?.tone === 'primary' ? 'primary' : 'secondary');
} }
function defaultGetRecordId(record) { function defaultGetRecordId(record) {
...@@ -357,26 +331,23 @@ function getWorkbenchColumnWidth(column, stickyMeta) { ...@@ -357,26 +331,23 @@ function getWorkbenchColumnWidth(column, stickyMeta) {
return Math.min(readableWidth, 280); return Math.min(readableWidth, 280);
} }
function ColumnGroupControl({ group, collapsed, onToggle }) { function ColumnGroupControl({ group, collapsed, index, onToggle }) {
const visible = !collapsed; const Icon = index % 2 === 0 ? Group : ChartBarSquareIcon;
const actionLabel = `${collapsed ? '显示' : '隐藏'}${group.name}列`;
return ( return (
<Tooltip delay={300}> <Tooltip delay={300}>
<Switch <ToggleButton
size="sm" size="sm"
isSelected={visible} isSelected={collapsed}
onChange={onToggle} onChange={onToggle}
aria-label={`显示${group.name}列`} aria-label={actionLabel}
> >
<Switch.Content> <Icon size={16} />
<Switch.Control> <span>{group.name}</span>
<Switch.Thumb /> </ToggleButton>
</Switch.Control>
{group.name}
</Switch.Content>
</Switch>
<Tooltip.Content> <Tooltip.Content>
显示或隐藏“{group.name}”的 {group.children.length} 个列表字段,不会筛选或修改数据 {actionLabel}(共 {group.children.length} 个字段)
</Tooltip.Content> </Tooltip.Content>
</Tooltip> </Tooltip>
); );
...@@ -473,8 +444,7 @@ export default function RecordWorkbench({ ...@@ -473,8 +444,7 @@ export default function RecordWorkbench({
const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({}); const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({});
const [columnGroupAnnouncement, setColumnGroupAnnouncement] = useState(''); const [columnGroupAnnouncement, setColumnGroupAnnouncement] = useState('');
const [expandedRowIds, setExpandedRowIds] = useState({}); const [expandedRowIds, setExpandedRowIds] = useState({});
const isDarkTheme = useRootDarkTheme(); const filterFieldVariant = 'secondary';
const filterFieldVariant = isDarkTheme ? 'secondary' : 'primary';
const loading = Boolean(states?.loading); const loading = Boolean(states?.loading);
const error = states?.error || ''; const error = states?.error || '';
const totalRecords = pagination?.totalRecords ?? records.length; const totalRecords = pagination?.totalRecords ?? records.length;
...@@ -841,16 +811,6 @@ export default function RecordWorkbench({ ...@@ -841,16 +811,6 @@ export default function RecordWorkbench({
const disabledDataGridRowKeys = dataGridRows const disabledDataGridRowKeys = dataGridRows
.filter((row) => row.isExpandedRecord || row.isExpandedPanel) .filter((row) => row.isExpandedRecord || row.isExpandedPanel)
.map((row) => String(row.key)); .map((row) => String(row.key));
const summaryGridClass =
summaryItems.length === 1
? 'md:grid-cols-1'
: summaryItems.length === 2
? 'md:grid-cols-2'
: summaryItems.length === 3
? 'md:grid-cols-3'
: summaryItems.length === 5 || summaryItems.length === 6
? 'md:grid-cols-2 xl:grid-cols-3'
: 'md:grid-cols-2 xl:grid-cols-4';
const toolbarSearchField = filterSchema.find((field) => field.icon === 'search'); const toolbarSearchField = filterSchema.find((field) => field.icon === 'search');
const structuredFilterFields = filterSchema.filter( const structuredFilterFields = filterSchema.filter(
(field) => field !== toolbarSearchField, (field) => field !== toolbarSearchField,
...@@ -906,7 +866,7 @@ export default function RecordWorkbench({ ...@@ -906,7 +866,7 @@ export default function RecordWorkbench({
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
{summaryItems.length > 0 ? ( {summaryItems.length > 0 ? (
<section className={clsx('rc-kpi-grid grid', summaryGridClass)}> <section className="rc-kpi-grid" data-count={summaryItems.length}>
{summaryItems.map((item) => ( {summaryItems.map((item) => (
<SummaryCard <SummaryCard
key={item.label} key={item.label}
...@@ -945,8 +905,10 @@ export default function RecordWorkbench({ ...@@ -945,8 +905,10 @@ export default function RecordWorkbench({
) : null} ) : null}
{structuredFilterFields.length > 0 && !useInlineFilters ? ( {structuredFilterFields.length > 0 && !useInlineFilters ? (
<Card> <Surface
<Card.Content> className="rounded-3xl p-6"
variant="default"
>
<Disclosure <Disclosure
isExpanded={filtersExpanded} isExpanded={filtersExpanded}
onExpandedChange={setFiltersExpanded} onExpandedChange={setFiltersExpanded}
...@@ -1013,8 +975,7 @@ export default function RecordWorkbench({ ...@@ -1013,8 +975,7 @@ export default function RecordWorkbench({
</div> </div>
</form> </form>
</Disclosure> </Disclosure>
</Card.Content> </Surface>
</Card>
) : null} ) : null}
<section className={tableSectionClass}> <section className={tableSectionClass}>
...@@ -1053,16 +1014,19 @@ export default function RecordWorkbench({ ...@@ -1053,16 +1014,19 @@ export default function RecordWorkbench({
</form> </form>
) : null} ) : null}
<div className="rc-page-toolbar"> <div className="flex min-w-0 items-center">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<h2 className="text-base font-medium text-foreground">{title}</h2> <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 ? ( {toolbarSearchField ? (
<div className="w-full min-w-0 sm:w-80 lg:w-96"> <div className="w-full min-w-0 flex-none sm:w-80 lg:w-96">
<SearchField <SearchField
aria-label={toolbarSearchField.label || '关键词搜索'} aria-label={toolbarSearchField.label || '关键词搜索'}
fullWidth fullWidth
value={filters[toolbarSearchField.key] || ''} value={filters[toolbarSearchField.key] || ''}
variant="secondary" variant="primary"
onChange={(value) => onChange={(value) =>
onFiltersChange?.(toolbarSearchField.key, value) onFiltersChange?.(toolbarSearchField.key, value)
} }
...@@ -1080,12 +1044,13 @@ export default function RecordWorkbench({ ...@@ -1080,12 +1044,13 @@ export default function RecordWorkbench({
</div> </div>
) : null} ) : null}
{hasColumnGroups ? ( {hasColumnGroups ? (
<div className="flex flex-wrap items-center gap-3" aria-label="列分组显示控制"> <div className="flex flex-wrap items-center gap-2" aria-label="列分组显示控制">
{columnGroupMeta.groups.map((group) => ( {columnGroupMeta.groups.map((group, index) => (
<ColumnGroupControl <ColumnGroupControl
key={group.id} key={group.id}
group={group} group={group}
collapsed={Boolean(collapsedColumnGroups[group.id])} collapsed={Boolean(collapsedColumnGroups[group.id])}
index={index}
onToggle={() => toggleColumnGroup(group.id)} onToggle={() => toggleColumnGroup(group.id)}
/> />
))} ))}
......
export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
'var(--rc-chart-4)',
'var(--rc-chart-5)',
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
primary: 'var(--rc-chart-primary)',
risk: 'var(--rc-chart-risk)',
warning: 'var(--rc-chart-warning)',
positive: 'var(--rc-chart-positive)',
comparison: 'var(--rc-chart-comparison)',
neutral: 'var(--rc-chart-neutral)',
});
function normalizeChartKey(key) {
return String(key ?? '').trim();
}
function hashChartKey(key) {
return Array.from(key).reduce(
(value, character) => ((value * 31) + character.codePointAt(0)) >>> 0,
0,
);
}
export function getXinyuanChartColor(key, fallbackIndex = 0) {
const stableKey = normalizeChartKey(key);
if (!stableKey) {
return XINYUAN_CHART_PALETTE[fallbackIndex % XINYUAN_CHART_PALETTE.length];
}
return XINYUAN_CHART_PALETTE[hashChartKey(stableKey) % XINYUAN_CHART_PALETTE.length];
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))]
.sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
if (keyIndex < XINYUAN_CHART_PALETTE.length) {
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % XINYUAN_CHART_PALETTE.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
colorMap.set(key, XINYUAN_CHART_PALETTE[paletteIndex]);
});
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') {
return false;
}
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
}
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);
return {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
};
});
}
...@@ -440,11 +440,202 @@ ...@@ -440,11 +440,202 @@
gap: var(--rc-page-module-gap); gap: var(--rc-page-module-gap);
} }
.rc-chart-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.rc-chart-grid > * {
min-width: 0;
}
.rc-chart-grid > :only-child {
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
}
.rc-chart-interaction:focus-visible,
.rc-chart-interaction :focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.rc-kpi-grid { .rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0; min-width: 0;
gap: var(--rc-related-item-gap); gap: var(--rc-related-item-gap);
} }
@media (min-width: 48rem) {
.rc-kpi-grid:not([data-count='1']) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 80rem) {
.rc-kpi-grid[data-count='1'] {
grid-template-columns: minmax(0, 1fr);
}
.rc-kpi-grid[data-count='2'] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='3'],
.rc-kpi-grid[data-count='5'],
.rc-kpi-grid[data-count='6'],
.rc-kpi-grid[data-count='9'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rc-kpi-grid[data-count='4'],
.rc-kpi-grid[data-count='7'],
.rc-kpi-grid[data-count='8'] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* Shared HeroUI/HeroUI Pro business-card contract. */
.rc-card-surface {
border: 1px solid var(--rc-border-default);
background-color: var(--rc-bg-surface);
color: var(--rc-text-primary);
box-shadow: var(--rc-shadow-card);
}
.rc-dashboard-card {
border-radius: 1.5rem;
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
.rc-metric-icon-surface {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
display: grid;
width: 3rem;
min-width: 3rem;
max-width: 3rem;
height: 3rem;
min-height: 3rem;
max-height: 3rem;
flex: 0 0 3rem;
place-items: center;
border-radius: 1rem;
color: var(--rc-metric-icon-color);
background-color: var(--rc-metric-icon-background);
line-height: 1;
}
.rc-metric-icon-surface[data-tone='info'] {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-metric-icon-surface[data-tone='success'] {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-metric-icon-surface[data-tone='teal'] {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-metric-icon-surface[data-tone='danger'] {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-metric-icon-surface[data-tone='warning'] {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-metric-icon-surface[data-tone='violet'] {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-metric-icon-surface[data-tone='indigo'] {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-metric-icon-surface[data-tone='coral'] {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
/* Untoned KPI items receive a unique ordered tone for boards of up to nine. */
.rc-kpi-grid > :nth-child(1) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-info);
--rc-metric-icon-background: var(--rc-metric-tone-info-soft);
}
.rc-kpi-grid > :nth-child(2) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-success);
--rc-metric-icon-background: var(--rc-metric-tone-success-soft);
}
.rc-kpi-grid > :nth-child(3) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-warning);
--rc-metric-icon-background: var(--rc-metric-tone-warning-soft);
}
.rc-kpi-grid > :nth-child(4) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-violet);
--rc-metric-icon-background: var(--rc-metric-tone-violet-soft);
}
.rc-kpi-grid > :nth-child(5) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-danger);
--rc-metric-icon-background: var(--rc-metric-tone-danger-soft);
}
.rc-kpi-grid > :nth-child(6) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-teal);
--rc-metric-icon-background: var(--rc-metric-tone-teal-soft);
}
.rc-kpi-grid > :nth-child(7) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-indigo);
--rc-metric-icon-background: var(--rc-metric-tone-indigo-soft);
}
.rc-kpi-grid > :nth-child(8) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-coral);
--rc-metric-icon-background: var(--rc-metric-tone-coral-soft);
}
.rc-kpi-grid > :nth-child(9) .rc-metric-icon-surface:not([data-tone]) {
--rc-metric-icon-color: var(--rc-metric-tone-neutral);
--rc-metric-icon-background: var(--rc-metric-tone-neutral-soft);
}
.rc-metric-icon-surface > .anticon,
.rc-metric-icon-surface > svg {
display: block;
width: 1.25rem;
height: 1.25rem;
margin: 0;
color: inherit;
font-size: 1.25rem;
line-height: 1;
}
.rc-metric-icon-surface > .anticon > svg {
display: block;
width: 1em;
height: 1em;
margin: 0;
}
.rc-page-toolbar { .rc-page-toolbar {
display: flex; display: flex;
min-width: 0; min-width: 0;
...@@ -464,6 +655,12 @@ ...@@ -464,6 +655,12 @@
gap: var(--rc-list-stack-gap); gap: var(--rc-list-stack-gap);
} }
@media (min-width: 80rem) {
.rc-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rc-mobile-sidebar-backdrop { .rc-mobile-sidebar-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
...@@ -728,7 +925,7 @@ ...@@ -728,7 +925,7 @@
padding: var(--rc-space-2) var(--rc-space-3) var(--rc-space-4); padding: var(--rc-space-2) var(--rc-space-3) var(--rc-space-4);
} }
.rc-sidebar-user-trigger.button { .rc-sidebar-user-trigger.dropdown__trigger {
display: flex; display: flex;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
...@@ -743,9 +940,9 @@ ...@@ -743,9 +940,9 @@
text-align: left; text-align: left;
} }
.rc-sidebar-user-trigger.button:hover, .rc-sidebar-user-trigger.dropdown__trigger:hover,
.rc-sidebar-user-trigger.button[data-hovered='true'], .rc-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.rc-sidebar-user-trigger.button[aria-expanded='true'] { .rc-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default); background: var(--default);
} }
...@@ -1134,7 +1331,7 @@ ...@@ -1134,7 +1331,7 @@
padding: 0.5rem 0.75rem 1rem; padding: 0.5rem 0.75rem 1rem;
} }
.app-sidebar-user-trigger.button { .app-sidebar-user-trigger.dropdown__trigger {
display: flex; display: flex;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
...@@ -1150,13 +1347,13 @@ ...@@ -1150,13 +1347,13 @@
} }
@media (hover: hover) { @media (hover: hover) {
.app-sidebar-user-trigger.button:hover, .app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.button[data-hovered='true'] { .app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--default); background: var(--default);
} }
} }
.app-sidebar-user-trigger.button[aria-expanded='true'] { .app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
background: var(--default); background: var(--default);
} }
......
...@@ -13,34 +13,35 @@ ...@@ -13,34 +13,35 @@
/* Theme Colors (Light Mode) */ /* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03); --accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0019 27.03); --background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0019 27.03); --border: oklch(90.00% 0.0045 27.03);
--danger: oklch(65.32% 0.2337 358.52); --danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
--default: oklch(94.00% 0.0019 27.03); /* HeroUI control aliases: primary = L1, secondary = L2. */
--default-foreground: oklch(21.03% 0.0059 27.03); --default: var(--surface-secondary);
--field-background: oklch(100.00% 0.0009 27.03); --default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: oklch(21.03% 0.0019 27.03); --field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0038 27.03); --field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03); --focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0019 27.03); --foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0038 27.03); --muted: oklch(55.17% 0.0090 27.03);
--overlay: oklch(100.00% 0.0006 27.03); --overlay: var(--surface);
--overlay-foreground: oklch(21.03% 0.0019 27.03); --overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0019 27.03); --scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0019 27.03); --segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0019 27.03); --segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0019 27.03); --separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1942 123.59); --success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0009 27.03); --surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0019 27.03); --surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0015 27.03); --surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0019 27.03); --surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0015 27.03); --surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0019 27.03); --surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1591 45.11); --warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11); --warning-foreground: oklch(21.03% 0.0059 45.11);
/* Border Radius */ /* Border Radius */
...@@ -58,33 +59,34 @@ ...@@ -58,33 +59,34 @@
/* Theme Colors (Dark Mode) */ /* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03); --accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0019 27.03); --background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0019 27.03); --border: oklch(28.00% 0.0045 27.03);
--danger: oklch(59.40% 0.1974 357.41); --danger: oklch(62.00% 0.1600 27.03);
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
--default: oklch(27.40% 0.0019 27.03); /* HeroUI control aliases: primary = L1, secondary = L2. */
--default-foreground: oklch(99.11% 0 0); --default: var(--surface-secondary);
--field-background: oklch(21.03% 0.0038 27.03); --default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: oklch(99.11% 0.0019 27.03); --field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0038 27.03); --field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03); --focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0019 27.03); --foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0038 27.03); --muted: oklch(70.50% 0.0090 27.03);
--overlay: oklch(21.03% 0.0038 27.03); --overlay: var(--surface);
--overlay-foreground: oklch(99.11% 0.0019 27.03); --overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0019 27.03); --scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0019 27.03); --segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0019 27.03); --segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0019 27.03); --separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1942 123.59); --success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0038 27.03); --surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0019 27.03); --surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0029 27.03); --surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0019 27.03); --surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0029 27.03); --surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0019 27.03); --surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1393 49.12); --warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12); --warning-foreground: oklch(21.03% 0.0059 49.12);
} }
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
[data-theme='default'], [data-theme='default'],
.dark, .dark,
[data-theme='dark'] { [data-theme='dark'] {
/* Shell aliases only. HeroUI component 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);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black); --rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
...@@ -78,6 +78,82 @@ ...@@ -78,6 +78,82 @@
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%), --rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%),
0 8px 24px rgb(15 23 42 / 8%); 0 8px 24px rgb(15 23 42 / 8%);
--rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%); --rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%);
--rc-shadow-card: 0 1px 2px 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. */
--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);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
var(--surface)
);
--rc-metric-tone-success-soft: color-mix(
in oklab,
var(--rc-metric-tone-success) 9%,
var(--surface)
);
--rc-metric-tone-teal-soft: color-mix(
in oklab,
var(--rc-metric-tone-teal) 9%,
var(--surface)
);
--rc-metric-tone-danger-soft: color-mix(
in oklab,
var(--rc-metric-tone-danger) 9%,
var(--surface)
);
--rc-metric-tone-warning-soft: color-mix(
in oklab,
var(--rc-metric-tone-warning) 9%,
var(--surface)
);
--rc-metric-tone-violet-soft: color-mix(
in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
var(--surface)
);
/* 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-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--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, .dark,
...@@ -96,4 +172,6 @@ ...@@ -96,4 +172,6 @@
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%), --rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px 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-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%);
} }
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 useRootDarkTheme from '@/hooks/useRootDarkTheme'; import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField'; import TailwindEditableField from './TailwindEditableField';
import { import {
buildViewColumns, buildViewColumns,
...@@ -62,7 +62,7 @@ export default function SubjectViewContent({ ...@@ -62,7 +62,7 @@ export default function SubjectViewContent({
const contentFields = minimal const contentFields = minimal
? (displayFields || []).filter((field) => field.field !== 'title') ? (displayFields || []).filter((field) => field.field !== 'title')
: displayFields; : displayFields;
const fieldVariant = useRootDarkTheme() ? 'secondary' : 'primary'; const fieldVariant = 'secondary';
if (!contentFields || contentFields.length === 0) { if (!contentFields || contentFields.length === 0) {
if (minimal) { if (minimal) {
...@@ -76,13 +76,14 @@ export default function SubjectViewContent({ ...@@ -76,13 +76,14 @@ export default function SubjectViewContent({
); );
} }
return ( const fields = (
<section <section
className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2" className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"
> >
{contentFields.map((field) => { {contentFields.map((field) => {
const value = getFieldValue(editableRecord, field.field); const value = getFieldValue(editableRecord, field.field);
const fieldType = field.fieldDef?.type || 'text'; const fieldType = field.fieldDef?.type || 'text';
const fieldLabel = getFieldLabel(field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType); const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const fieldDisabled = const fieldDisabled =
isFieldDisabled?.(field.field, view) || isFieldDisabled?.(field.field, view) ||
...@@ -98,7 +99,7 @@ export default function SubjectViewContent({ ...@@ -98,7 +99,7 @@ export default function SubjectViewContent({
)} )}
> >
<div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}> <div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}>
{getFieldLabel(field)} {fieldLabel}
</div> </div>
<div className={minimal ? undefined : 'mt-1.5 min-w-0'}> <div className={minimal ? undefined : 'mt-1.5 min-w-0'}>
<TailwindEditableField <TailwindEditableField
...@@ -107,6 +108,7 @@ export default function SubjectViewContent({ ...@@ -107,6 +108,7 @@ export default function SubjectViewContent({
fieldPath={field.field} fieldPath={field.field}
editSessionId={editSessionId} editSessionId={editSessionId}
fieldVariant={fieldVariant} fieldVariant={fieldVariant}
placeholder={`请输入${fieldLabel}`}
mode={mode} mode={mode}
onChange={(newValue) => onChange={(newValue) =>
onFieldChange?.( onFieldChange?.(
...@@ -126,4 +128,14 @@ export default function SubjectViewContent({ ...@@ -126,4 +128,14 @@ 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;
} }
...@@ -74,9 +74,26 @@ function FieldError({ children, className }) { ...@@ -74,9 +74,26 @@ function FieldError({ children, className }) {
); );
} }
function isEmptyFieldValue(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) {
return value.length === 0 || value.every((item) => isEmptyFieldValue(item));
}
return false;
}
function getTextPlaceholder(placeholder, fieldDef) {
const explicitPlaceholder = String(placeholder || '').trim();
if (explicitPlaceholder) return explicitPlaceholder;
const fieldLabel = String(fieldDef?.label || '').trim();
return fieldLabel ? `请输入${fieldLabel}` : '请输入内容';
}
function FieldValue({ value, fieldDef }) { function FieldValue({ value, fieldDef }) {
if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) { if (isEmptyFieldValue(value)) {
return <span className="text-muted">未填写</span>; return <span className="text-sm text-muted">暂无内容</span>;
} }
if (fieldDef?.type === 'select') { if (fieldDef?.type === 'select') {
...@@ -128,7 +145,7 @@ function FieldValue({ value, fieldDef }) { ...@@ -128,7 +145,7 @@ function FieldValue({ value, fieldDef }) {
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') { if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
return ( return (
<Card variant="secondary"> <Card variant="tertiary">
<Card.Content> <Card.Content>
<pre className="max-h-48 overflow-auto text-xs leading-5"> <pre className="max-h-48 overflow-auto text-xs leading-5">
{JSON.stringify(value, null, 2)} {JSON.stringify(value, null, 2)}
...@@ -139,7 +156,11 @@ function FieldValue({ value, fieldDef }) { ...@@ -139,7 +156,11 @@ function FieldValue({ value, fieldDef }) {
} }
const text = formatFieldValue(value, fieldDef); const text = formatFieldValue(value, fieldDef);
return <span className="whitespace-pre-wrap text-foreground">{text || '-'}</span>; return (
<span className={clsx('whitespace-pre-wrap', isEmptyFieldValue(text) ? 'text-sm text-muted' : 'text-foreground')}>
{isEmptyFieldValue(text) ? '暂无内容' : text}
</span>
);
} }
function UserSelector({ value, onChange, multiple, variant }) { function UserSelector({ value, onChange, multiple, variant }) {
...@@ -280,6 +301,7 @@ function ControlledFieldEditor({ ...@@ -280,6 +301,7 @@ function ControlledFieldEditor({
fieldPath, fieldPath,
editSessionId, editSessionId,
fieldVariant, fieldVariant,
placeholder,
onChange, onChange,
onValidationChange, onValidationChange,
}) { }) {
...@@ -288,6 +310,7 @@ function ControlledFieldEditor({ ...@@ -288,6 +310,7 @@ function ControlledFieldEditor({
const fieldType = fieldDef?.type || 'text'; const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType); const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType); const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
const handleFileUpload = async (files) => { const handleFileUpload = async (files) => {
const file = files?.[0]; const file = files?.[0];
...@@ -366,7 +389,7 @@ function ControlledFieldEditor({ ...@@ -366,7 +389,7 @@ function ControlledFieldEditor({
if (fieldType === 'file') { if (fieldType === 'file') {
return ( return (
<Card> <Card variant="secondary">
<Card.Content className="space-y-3"> <Card.Content className="space-y-3">
{toArray(value).length === 0 ? ( {toArray(value).length === 0 ? (
<span className="text-sm text-muted">未上传</span> <span className="text-sm text-muted">未上传</span>
...@@ -450,6 +473,7 @@ function ControlledFieldEditor({ ...@@ -450,6 +473,7 @@ function ControlledFieldEditor({
return ( return (
<TextArea <TextArea
fullWidth fullWidth
placeholder={textPlaceholder}
rows={5} rows={5}
value={value || ''} value={value || ''}
variant={fieldVariant} variant={fieldVariant}
...@@ -471,6 +495,7 @@ function ControlledFieldEditor({ ...@@ -471,6 +495,7 @@ function ControlledFieldEditor({
return ( return (
<Input <Input
fullWidth fullWidth
placeholder={textPlaceholder}
type={fieldType === 'number' ? 'number' : 'text'} type={fieldType === 'number' ? 'number' : 'text'}
value={value ?? ''} value={value ?? ''}
variant={fieldVariant} variant={fieldVariant}
...@@ -489,7 +514,8 @@ export default function TailwindEditableField({ ...@@ -489,7 +514,8 @@ export default function TailwindEditableField({
fieldDef, fieldDef,
fieldPath, fieldPath,
editSessionId, editSessionId,
fieldVariant = 'primary', fieldVariant = 'secondary',
placeholder,
onSave, onSave,
onChange, onChange,
onValidationChange, onValidationChange,
...@@ -517,6 +543,7 @@ export default function TailwindEditableField({ ...@@ -517,6 +543,7 @@ export default function TailwindEditableField({
const isDateField = isDateFieldType(fieldType); const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType); const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType); const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
if (mode === 'view' || (mode === 'edit' && (readonly || disabled))) { if (mode === 'view' || (mode === 'edit' && (readonly || disabled))) {
return <ReadonlyFieldValue value={value} fieldDef={fieldDef} />; return <ReadonlyFieldValue value={value} fieldDef={fieldDef} />;
...@@ -530,6 +557,7 @@ export default function TailwindEditableField({ ...@@ -530,6 +557,7 @@ export default function TailwindEditableField({
fieldPath={fieldPath} fieldPath={fieldPath}
editSessionId={editSessionId} editSessionId={editSessionId}
fieldVariant={fieldVariant} fieldVariant={fieldVariant}
placeholder={textPlaceholder}
onChange={onChange} onChange={onChange}
onValidationChange={onValidationChange} onValidationChange={onValidationChange}
/> />
...@@ -734,7 +762,7 @@ export default function TailwindEditableField({ ...@@ -734,7 +762,7 @@ export default function TailwindEditableField({
if (fieldType === 'file') { if (fieldType === 'file') {
return ( return (
<Card> <Card variant="secondary">
<Card.Content className="space-y-3"> <Card.Content className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
{toArray(draft).length === 0 ? ( {toArray(draft).length === 0 ? (
...@@ -822,6 +850,7 @@ export default function TailwindEditableField({ ...@@ -822,6 +850,7 @@ export default function TailwindEditableField({
<TextArea <TextArea
autoFocus autoFocus
fullWidth fullWidth
placeholder={textPlaceholder}
rows={5} rows={5}
value={draft || ''} value={draft || ''}
variant="secondary" variant="secondary"
...@@ -848,6 +877,7 @@ export default function TailwindEditableField({ ...@@ -848,6 +877,7 @@ export default function TailwindEditableField({
<Input <Input
autoFocus autoFocus
fullWidth fullWidth
placeholder={textPlaceholder}
type={fieldType === 'number' ? 'number' : 'text'} type={fieldType === 'number' ? 'number' : 'text'}
value={draft ?? ''} value={draft ?? ''}
variant="secondary" variant="secondary"
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
Description, Description,
Modal, Modal,
Skeleton, Skeleton,
Surface,
} from '@heroui/react'; } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
...@@ -124,7 +125,7 @@ function ApplicationCard({ application, current }) { ...@@ -124,7 +125,7 @@ function ApplicationCard({ application, current }) {
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A'; const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return ( return (
<Card className="h-full" variant={current ? 'secondary' : 'default'}> <Card className="h-full" variant="secondary">
<Card.Content> <Card.Content>
<a <a
href={application.url} href={application.url}
...@@ -133,10 +134,22 @@ function ApplicationCard({ application, current }) { ...@@ -133,10 +134,22 @@ function ApplicationCard({ application, current }) {
aria-current={current ? 'page' : undefined} aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline" className="flex min-w-0 items-center gap-3 no-underline"
> >
<Avatar size="md"> <Surface
{application.logo ? <Avatar.Image src={application.logo} alt={application.title} /> : null} aria-hidden="true"
className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]"
variant="tertiary"
>
<Avatar
className="size-full rounded-[var(--rc-radius-control)] bg-transparent"
color="accent"
variant="soft"
>
{application.logo ? (
<Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" />
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback> <Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar> </Avatar>
</Surface>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block break-words font-medium leading-5 text-foreground" title={application.title}> <span className="block break-words font-medium leading-5 text-foreground" title={application.title}>
{application.title} {application.title}
...@@ -156,9 +169,9 @@ function LoadingSkeleton() { ...@@ -156,9 +169,9 @@ function LoadingSkeleton() {
return ( return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用"> <div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Card key={index}> <Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3"> <Card.Content className="flex items-center gap-3">
<Skeleton className="size-10" /> <Skeleton className="size-9" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" /> <Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" /> <Skeleton className="h-3 w-full" />
......
...@@ -131,7 +131,7 @@ export default function ProblemRectificationArchivePage() { ...@@ -131,7 +131,7 @@ export default function ProblemRectificationArchivePage() {
label: '关键词搜索', label: '关键词搜索',
type: 'text', type: 'text',
icon: 'search', icon: 'search',
placeholder: '搜索归档批次标题、编码或关联字段...', placeholder: '请输入关键词',
}, },
]} ]}
onFiltersChange={(key, value) => { onFiltersChange={(key, value) => {
...@@ -155,14 +155,14 @@ export default function ProblemRectificationArchivePage() { ...@@ -155,14 +155,14 @@ export default function ProblemRectificationArchivePage() {
{ {
key: 'archive-export', key: 'archive-export',
label: '导出批次', label: '导出批次',
variant: 'outline', variant: 'secondary',
disabled: true, disabled: true,
onClick: () => {}, onClick: () => {},
}, },
{ {
key: 'archive-compare', key: 'archive-compare',
label: '批次对比', label: '批次对比',
variant: 'outline', variant: 'secondary',
disabled: true, disabled: true,
onClick: () => {}, onClick: () => {},
}, },
......
...@@ -1288,7 +1288,7 @@ export default function ProblemRectificationFlowTablePage() { ...@@ -1288,7 +1288,7 @@ export default function ProblemRectificationFlowTablePage() {
{isDelayFlow ? ( {isDelayFlow ? (
<> <>
{editable ? ( {editable ? (
<Button isDisabled={submitting} size="sm" variant="outline" onPress={() => handleDelaySave(row)}> <Button isDisabled={submitting} size="sm" variant="secondary" onPress={() => handleDelaySave(row)}>
保存申请 保存申请
</Button> </Button>
) : null} ) : null}
...@@ -1324,7 +1324,7 @@ export default function ProblemRectificationFlowTablePage() { ...@@ -1324,7 +1324,7 @@ export default function ProblemRectificationFlowTablePage() {
) : ( ) : (
<> <>
{editable ? ( {editable ? (
<Button isDisabled={submitting} size="sm" variant="outline" onPress={() => handleSave(row)}> <Button isDisabled={submitting} size="sm" variant="secondary" onPress={() => handleSave(row)}>
保存 保存
</Button> </Button>
) : null} ) : null}
...@@ -1573,7 +1573,7 @@ export default function ProblemRectificationFlowTablePage() { ...@@ -1573,7 +1573,7 @@ export default function ProblemRectificationFlowTablePage() {
aria-label="刷新流程" aria-label="刷新流程"
isDisabled={loading || Boolean(submittingKey)} isDisabled={loading || Boolean(submittingKey)}
size="sm" size="sm"
variant="outline" variant="secondary"
onPress={loadFlow} onPress={loadFlow}
> >
<ArrowPathIcon size={16} /> <ArrowPathIcon size={16} />
......
...@@ -12,6 +12,7 @@ import { normalizeText } from '@/utils/subjectView'; ...@@ -12,6 +12,7 @@ import { normalizeText } from '@/utils/subjectView';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Alert, Button, Card, Chip, Spinner } from '@heroui/react'; import { Alert, Button, Card, Chip, Spinner } from '@heroui/react';
import { EmptyState as ProEmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState as ProEmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi'; import { useSearchParams } from 'umi';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
...@@ -73,22 +74,42 @@ function StepBadge({ title, status, active }) { ...@@ -73,22 +74,42 @@ function StepBadge({ title, status, active }) {
); );
} }
function SummaryCard({ label, value, hint, icon: Icon, tone = 'neutral' }) { function SummaryCard({ label, value, hint, icon: Icon, tone, variant = 'default' }) {
if (Icon) {
const numericValue = Number.parseFloat(String(value).replace(/,/g, ''));
return ( return (
<Card> <KPI className="rc-card-surface rc-dashboard-card">
<KPI.Header>
<KPI.Title>{label}</KPI.Title>
</KPI.Header>
<KPI.Content>
<KPI.Value value={Number.isFinite(numericValue) ? numericValue : 0}>
{() => value}
</KPI.Value>
<KPI.Icon
aria-label={label}
className="rc-metric-icon-surface"
data-tone={tone || undefined}
>
<Icon size={20} />
</KPI.Icon>
</KPI.Content>
{hint ? <KPI.Footer className="text-xs text-muted">{hint}</KPI.Footer> : null}
</KPI>
);
}
return (
<Card variant={variant}>
<Card.Content className="flex items-start justify-between gap-4"> <Card.Content className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-medium text-muted">{label}</div> <div className="text-sm font-medium text-muted">{label}</div>
<div className={Icon ? 'mt-2 text-2xl font-medium text-foreground' : 'mt-2 text-lg font-medium text-foreground'}> <div className="mt-2 text-lg font-medium text-foreground">
{value} {value}
</div> </div>
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null} {hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
</div> </div>
{Icon ? (
<Chip color={['danger', 'success', 'warning'].includes(tone) ? tone : undefined} size="lg" variant="soft" aria-label={label}>
<Icon size={20} />
</Chip>
) : null}
</Card.Content> </Card.Content>
</Card> </Card>
); );
...@@ -387,7 +408,7 @@ export default function ProblemRectificationFormPage() { ...@@ -387,7 +408,7 @@ export default function ProblemRectificationFormPage() {
/> />
</section> </section>
<section className="rc-page-grid grid md:grid-cols-3"> <section className="rc-kpi-grid" data-count="3">
<SummaryCard label="关联记录" value={summary.total} icon={Database} tone="info" /> <SummaryCard label="关联记录" value={summary.total} icon={Database} tone="info" />
<SummaryCard <SummaryCard
label="已整改" label="已整改"
...@@ -451,7 +472,7 @@ export default function ProblemRectificationFormPage() { ...@@ -451,7 +472,7 @@ export default function ProblemRectificationFormPage() {
render: (record) => ( render: (record) => (
<Button <Button
size="sm" size="sm"
variant="outline" variant="secondary"
onPress={() => setSelectedRecord(record)} onPress={() => setSelectedRecord(record)}
> >
{canEditRecords && !record.isLocked ? '查看/填写' : '查看详情'} {canEditRecords && !record.isLocked ? '查看/填写' : '查看详情'}
...@@ -491,7 +512,7 @@ export default function ProblemRectificationFormPage() { ...@@ -491,7 +512,7 @@ export default function ProblemRectificationFormPage() {
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button isDisabled={submitting} size="sm" variant="outline" onPress={reload}> <Button isDisabled={submitting} size="sm" variant="secondary" onPress={reload}>
刷新流程 刷新流程
</Button> </Button>
...@@ -553,10 +574,10 @@ export default function ProblemRectificationFormPage() { ...@@ -553,10 +574,10 @@ export default function ProblemRectificationFormPage() {
</Alert> </Alert>
) : null} ) : null}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<SummaryCard label="问题标题" value={selectedRecord.title || '-'} /> <SummaryCard label="问题标题" value={selectedRecord.title || '-'} variant="secondary" />
<SummaryCard label="反馈单位" value={selectedRecord.feedbackUnit || '-'} /> <SummaryCard label="反馈单位" value={selectedRecord.feedbackUnit || '-'} variant="secondary" />
<SummaryCard label="整改进度" value={selectedRecord.progress || '-'} /> <SummaryCard label="整改进度" value={selectedRecord.progress || '-'} variant="secondary" />
<SummaryCard label="预计完成时间" value={formatDateText(selectedRecord.raw?.metadata?.the_time_of_rectification)} /> <SummaryCard label="预计完成时间" value={formatDateText(selectedRecord.raw?.metadata?.the_time_of_rectification)} variant="secondary" />
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
......
...@@ -14,7 +14,6 @@ import { ...@@ -14,7 +14,6 @@ import {
TableActionButton, TableActionButton,
} from '@/components/TableActionButton'; } from '@/components/TableActionButton';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import useRootDarkTheme from '@/hooks/useRootDarkTheme';
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { import {
...@@ -796,8 +795,7 @@ export default function ProblemRectificationListPage() { ...@@ -796,8 +795,7 @@ export default function ProblemRectificationListPage() {
}); });
const [gridViewCounts, setGridViewCounts] = useState({}); const [gridViewCounts, setGridViewCounts] = useState({});
const [workflowUserLabels, setWorkflowUserLabels] = useState({}); const [workflowUserLabels, setWorkflowUserLabels] = useState({});
const isDarkTheme = useRootDarkTheme(); const createFieldVariant = 'secondary';
const createFieldVariant = isDarkTheme ? 'secondary' : 'primary';
const reloadResources = useCallback(async () => { const reloadResources = useCallback(async () => {
setLoading(true); setLoading(true);
...@@ -1660,7 +1658,7 @@ export default function ProblemRectificationListPage() { ...@@ -1660,7 +1658,7 @@ export default function ProblemRectificationListPage() {
label: '关键词搜索', label: '关键词搜索',
type: 'text', type: 'text',
icon: 'search', icon: 'search',
placeholder: '搜索项目名称、严重程度、分类、责任部门、问题原因...', placeholder: '请输入关键词',
}, },
{ {
key: 'inspectionReportMonth', key: 'inspectionReportMonth',
...@@ -1750,14 +1748,14 @@ export default function ProblemRectificationListPage() { ...@@ -1750,14 +1748,14 @@ export default function ProblemRectificationListPage() {
{ {
key: 'export', key: 'export',
label: '导出', label: '导出',
variant: 'outline', variant: 'secondary',
disabled: true, disabled: true,
onClick: () => {}, onClick: () => {},
}, },
{ {
key: 'archive', key: 'archive',
label: '一键归档', label: '一键归档',
variant: 'outline', variant: 'secondary',
disabled: true, disabled: true,
onClick: () => {}, onClick: () => {},
}, },
...@@ -1808,7 +1806,7 @@ export default function ProblemRectificationListPage() { ...@@ -1808,7 +1806,7 @@ export default function ProblemRectificationListPage() {
key: 'view-delay-request', key: 'view-delay-request',
label: '查看延期申请', label: '查看延期申请',
icon: CalendarDaysIcon, icon: CalendarDaysIcon,
variant: 'outline', variant: 'secondary',
hidden: ({ record }) => !resolveDelayRequestDisplay(record), hidden: ({ record }) => !resolveDelayRequestDisplay(record),
onClick: ({ record }) => setDelayDetailRecord(record), onClick: ({ record }) => setDelayDetailRecord(record),
}, },
...@@ -1816,7 +1814,7 @@ export default function ProblemRectificationListPage() { ...@@ -1816,7 +1814,7 @@ export default function ProblemRectificationListPage() {
key: 'lock', key: 'lock',
label: '锁定', label: '锁定',
icon: LockClosedIcon, icon: LockClosedIcon,
variant: 'outline', variant: 'secondary',
hidden: ({ record }) => record.isLocked || record.progress !== '已整改', hidden: ({ record }) => record.isLocked || record.progress !== '已整改',
disabled: () => actionLoading, disabled: () => actionLoading,
onClick: ({ record }) => handleLockToggle(record, true), onClick: ({ record }) => handleLockToggle(record, true),
...@@ -1825,7 +1823,7 @@ export default function ProblemRectificationListPage() { ...@@ -1825,7 +1823,7 @@ export default function ProblemRectificationListPage() {
key: 'unlock', key: 'unlock',
label: '解锁', label: '解锁',
icon: LockOpenIcon, icon: LockOpenIcon,
variant: 'outline', variant: 'secondary',
hidden: ({ record }) => !record.isLocked, hidden: ({ record }) => !record.isLocked,
disabled: () => actionLoading, disabled: () => actionLoading,
onClick: ({ record }) => handleLockToggle(record, false), onClick: ({ record }) => handleLockToggle(record, false),
...@@ -1833,14 +1831,14 @@ export default function ProblemRectificationListPage() { ...@@ -1833,14 +1831,14 @@ export default function ProblemRectificationListPage() {
{ {
key: 'clone', key: 'clone',
label: '复制', label: '复制',
variant: 'outline', variant: 'secondary',
disabled: ({ record }) => actionLoading || record.isLocked, disabled: ({ record }) => actionLoading || record.isLocked,
onClick: ({ record }) => setCloneTargetRecord(record), onClick: ({ record }) => setCloneTargetRecord(record),
}, },
{ {
key: 'delete', key: 'delete',
label: '删除', label: '删除',
variant: 'outline', variant: 'danger-soft',
tone: 'danger', tone: 'danger',
disabled: ({ record }) => actionLoading || record.isLocked, disabled: ({ record }) => actionLoading || record.isLocked,
onClick: ({ record }) => handleDelete([record]), onClick: ({ record }) => handleDelete([record]),
......
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