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