Commit 07a1a18d authored by Andy-bubu's avatar Andy-bubu

feat: complete Xinyuan UI migration and application workflows

parent 050ffd91
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -29,6 +29,8 @@
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1",
......
......@@ -71,6 +71,12 @@ importers:
dayjs:
specifier: ^1.11.20
version: 1.11.21
echarts:
specifier: ^6.1.0
version: 6.1.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.1.0)(react@19.2.8)
embla-carousel:
specifier: ^8.6.0
version: 8.6.0
......@@ -3668,6 +3674,15 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
......@@ -7164,6 +7179,9 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
......@@ -7565,6 +7583,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
......@@ -7980,6 +8001,9 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
......@@ -12271,6 +12295,18 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.8):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 19.2.8
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {}
electron-to-chromium@1.5.399: {}
......@@ -16602,6 +16638,8 @@ snapshots:
sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {}
slash@4.0.0: {}
......@@ -17079,6 +17117,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.3.0: {}
tslib@2.8.1: {}
tsutils@3.21.0(typescript@5.9.3):
......@@ -17574,4 +17614,8 @@ snapshots:
yocto-queue@1.2.2: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zwitch@2.0.4: {}
......@@ -3,16 +3,13 @@ import { defineApp } from 'umi';
import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
THEME_STORAGE_KEY,
} from '@/config/application';
if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
const theme = storedTheme === 'dark' ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
document.documentElement.dataset.theme = 'light';
document.documentElement.style.colorScheme = 'light';
}
const LEGACY_LOGIN_PATH = '/login';
......
......@@ -28,7 +28,6 @@ import LockOutlined from '@ant-design/icons/LockOutlined';
import InboxOutlined from '@ant-design/icons/InboxOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import OrderedListOutlined from '@ant-design/icons/OrderedListOutlined';
import PieChartOutlined from '@ant-design/icons/PieChartOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
......@@ -38,7 +37,6 @@ import RightOutlined from '@ant-design/icons/RightOutlined';
import RiseOutlined from '@ant-design/icons/RiseOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SendOutlined from '@ant-design/icons/SendOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import TeamOutlined from '@ant-design/icons/TeamOutlined';
import UserOutlined from '@ant-design/icons/UserOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
......@@ -117,11 +115,9 @@ export function SidebarPanel({ size = 20, style, ...props }) {
);
}
export const More = withSize(MoreOutlined);
export const Moon = withSize(MoonOutlined);
export const Project = withSize(ProjectOutlined);
export const Search = withSize(SearchOutlined);
export const Send = withSize(SendOutlined);
export const Sun = withSize(SunOutlined);
export const Team = withSize(TeamOutlined);
export const User = withSize(UserOutlined);
export const Upload = withSize(UploadOutlined);
......
import { useLayoutEffect, useState } from 'react';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { THEME_STORAGE_KEY } from '@/config/application';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark'
? 'dark'
: 'light';
} catch {
return 'light';
}
}
export default function AppShell({
children,
notificationCount,
......@@ -27,25 +13,9 @@ export default function AppShell({
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The selected theme still applies for this session when storage is unavailable.
}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
className="template-dashboard-shell rc-app-shell light"
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
......@@ -56,9 +26,7 @@ export default function AppShell({
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
theme={theme}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
import { Bell, SidebarPanel } from './AppIcons';
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
......@@ -53,20 +49,6 @@ export default function DashboardNavbar({
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
......
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sheet } from '@heroui-pro/react';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { Avatar, Button, Dropdown, Label } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
......@@ -64,7 +63,6 @@ function SidebarContents({
onLogout,
onOpenApplicationSwitcher,
pathname,
theme,
userInfo,
}) {
return (
......@@ -74,12 +72,7 @@ function SidebarContents({
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
className="rc-sidebar-logo"
src={xyLogoWhite}
/>
</div>
......@@ -95,19 +88,16 @@ function SidebarContents({
className="app-sidebar-app-switcher rc-sidebar-app-switcher"
data-sidebar="label"
>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
<Button
isIconOnly
aria-label="切换应用"
className="app-sidebar-app-trigger rc-sidebar-app-trigger"
size="sm"
variant="ghost"
onPress={onOpenApplicationSwitcher}
>
<AppGrid size={16} />
</Button>
</div>
</div>
</Sidebar.Header>
......
import { Close } from '@/components/AppIcons';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { ActionBar } from '@heroui-pro/react/action-bar';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
......@@ -8,6 +9,7 @@ import {
Pagination,
Separator,
Spinner,
Surface,
} from '@heroui/react';
import { Tooltip } from '@heroui/react';
import { useLayoutEffect, useRef, useState } from 'react';
......@@ -43,15 +45,30 @@ function normalizeDataTableEmptyTitle(title) {
return title;
}
export function DataTableEmptyState({ title = '暂无数据' }) {
export function EmptyStateSurface({ children, className = '' }) {
return (
<Surface
className={['w-full min-w-0 rounded-2xl p-4', className].filter(Boolean).join(' ')}
variant="secondary"
>
{children}
</Surface>
);
}
export function DataTableEmptyState({ title = '暂无数据', size = 'md' }) {
const resolvedTitle = normalizeDataTableEmptyTitle(title);
return (
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>{resolvedTitle}</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
<EmptyStateSurface>
<EmptyState size={size}>
<EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
{resolvedTitle}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
);
}
......@@ -106,6 +123,7 @@ export function DataTable({
selectedRowKeys,
onSelectedRowKeysChange,
emptyTitle = '暂无数据',
emptyStateSize = 'md',
className = '',
isWide,
disabledRowKeys,
......@@ -197,7 +215,7 @@ export function DataTable({
return (
<DataTableText
text={value}
showTooltip
showTooltip={false}
className={
isPrimary ? 'font-medium' : ''
}
......@@ -264,7 +282,11 @@ export function DataTable({
disabledKeys={disabledRowKeys}
getRowId={(row) => resolveRowKey(row, rows.indexOf(row), getRowKey)}
renderEmptyState={() =>
isLoading ? <DataTableLoadingState /> : <DataTableEmptyState title={emptyTitle} />
isLoading ? (
<DataTableLoadingState />
) : (
<DataTableEmptyState title={emptyTitle} size={emptyStateSize} />
)
}
scrollContainerClassName="data-table-scroll"
selectedKeys={selectedKeys}
......@@ -363,11 +385,7 @@ export function DataTableText({
</span>
);
if (
!fullText ||
fullText === '-' ||
(!showTooltip && fullText.length <= 18)
) {
if (!showTooltip || !fullText || fullText === '-') {
return trigger;
}
......@@ -439,21 +457,18 @@ export function DataTablePrimaryAction({
const handlePress = onPress || onClick;
return (
<Tooltip>
<Button
aria-label={ariaLabel || fullText}
className={className}
isDisabled={isDisabled || disabled}
size="sm"
variant="ghost"
onPress={handlePress}
>
<span className="min-w-0 truncate">
{children ?? fullText}
</span>
</Button>
<Tooltip.Content>{fullText}</Tooltip.Content>
</Tooltip>
<Button
aria-label={ariaLabel || fullText}
className={className}
isDisabled={isDisabled || disabled}
size="sm"
variant="ghost"
onPress={handlePress}
>
<span className="min-w-0 truncate">
{children ?? fullText}
</span>
</Button>
);
}
......@@ -552,8 +567,10 @@ export function DataTableChip({ children, text, className = '' }) {
export function DataTablePagination({
page,
pageSize,
pageSizeOptions = [],
total,
onPageChange,
onPageSizeChange,
isDisabled = false,
itemLabel = '条',
}) {
......@@ -562,13 +579,43 @@ export function DataTablePagination({
const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart;
const normalizedPageSizeOptions = pageSizeOptions
.map((value) => Number(value))
.filter((value, index, values) => Number.isFinite(value) && value > 0 && values.indexOf(value) === index);
const pageSizeOptionsForPicker = normalizedPageSizeOptions.map((value) => ({
id: String(value),
label: `${value} 条/页`,
}));
const hasPageSizePicker =
pageSizeOptionsForPicker.length > 0 && typeof onPageSizeChange === 'function';
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<Pagination size="sm">
<Pagination.Summary>
共 {total} {itemLabel},第{' '}
{total ? page + 1 : 0} / {total ? totalPages : 0} 页
</Pagination.Summary>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 shrink-0 items-center gap-3">
<span className="min-w-0 text-sm text-muted">
共 {total} {itemLabel},第 {total ? page + 1 : 0} / {total ? totalPages : 0} 页
</span>
{hasPageSizePicker ? (
<XinyuanAutocomplete
aria-label="每页条数"
className="w-24 shrink-0"
isDisabled={isDisabled}
options={pageSizeOptionsForPicker}
placeholder="每页条数"
selectedKey={String(pageSize)}
showSearch={false}
valueText={`${pageSize} 条/页`}
variant="secondary"
onSelectionChange={(key) => {
const nextPageSize = Number(key);
if (normalizedPageSizeOptions.includes(nextPageSize)) {
onPageSizeChange(nextPageSize);
}
}}
/>
) : null}
</div>
<Pagination className="ml-auto w-auto shrink-0" size={hasPageSizePicker ? 'lg' : 'sm'}>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
......
......@@ -51,7 +51,7 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop isOpen={isOpen} variant="blur">
<AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog>
<AlertDialog.Dialog className="bg-background">
<AlertDialog.Header>
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading>{title}</AlertDialog.Heading>
......@@ -220,7 +220,7 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
<Label>字段别名</Label>
<Input variant="secondary" disabled={Boolean(field)} value={values.name} onChange={(event) => setValues((current) => ({ ...current, name: event.target.value }))} />
</TextField>
<TextField fullWidth>
<TextField className="sm:col-span-2" fullWidth>
<Label>字段类型</Label>
<FormSelect
value={values.type}
......@@ -518,7 +518,7 @@ export default function DynamicFormManager() {
fieldDef: { type: 'text', label: '表单名称' },
render: (record) => (
<DataTablePrimaryText
showTooltip
showTooltip={false}
text={record.title || '未命名表单'}
/>
),
......@@ -555,7 +555,7 @@ export default function DynamicFormManager() {
page,
pageSize,
totalRecords: filteredRecords.length,
pageSizeOptions: [10, 20, 50, 100],
pageSizeOptions: [20, 50, 100],
onPageChange: setPage,
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
......
This diff is collapsed.
import clsx from 'clsx';
import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField } from '@heroui/react';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import FileUploadField from '@/components/FileUploadField';
......@@ -17,6 +18,7 @@ import {
getSelectOptions,
isDateFieldType,
isReferenceFieldType,
normalizeProjectYearDisplay,
normalizeValueForSubmit,
toArray,
} from '@/utils/subjectView';
......@@ -195,16 +197,21 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return <div className="text-sm text-muted">正在加载子对象结构...</div>;
}
const renderFields = (item, handleFieldChange) => (nestedSubject.fields || []).map((nestedField) => (
<DynamicField
key={nestedField.name}
fieldDef={nestedField}
value={item?.[nestedField.name]}
disabled={disabled}
onChange={(nextValue) => handleFieldChange(nestedField.name, nextValue)}
depth={depth + 1}
/>
));
const renderFields = (item, handleFieldChange) => (
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-5 md:grid-cols-2">
{(nestedSubject.fields || []).map((nestedField) => (
<DynamicField
key={nestedField.name}
className={shouldSpanDynamicFieldColumns(nestedField) ? 'md:col-span-2' : undefined}
fieldDef={nestedField}
value={item?.[nestedField.name]}
disabled={disabled}
onChange={(nextValue) => handleFieldChange(nestedField.name, nextValue)}
depth={depth + 1}
/>
))}
</div>
);
if (fieldDef?.multiple) {
const items = Array.isArray(value) ? value : [];
......@@ -359,6 +366,10 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
);
} else {
const dateInputType = ['datetime', 'date_time'].includes(type) ? 'datetime-local' : 'date';
const isProjectYearField = fieldDef?.name === 'project_year';
const inputValue = isProjectYearField
? normalizeProjectYearDisplay(value)
: value ?? '';
control = isDateFieldType(type) ? (
<FormDatePicker
disabled={disabled}
......@@ -371,7 +382,7 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
<Input
type={type === 'number' ? 'number' : 'text'}
placeholder={inputPlaceholder}
value={value ?? ''}
value={inputValue}
disabled={disabled}
variant="secondary"
onChange={(event) => onChange(type === 'number' && event.target.value !== ''
......@@ -381,16 +392,25 @@ export function DynamicField({ fieldDef, value, onChange, disabled = false, dept
);
}
const isSelectionField = type === 'select' || type === 'user' || isReferenceFieldType(type);
return (
<TextField className={className} fullWidth isRequired={required}>
<TextField className={clsx(className, isSelectionField && 'md:col-span-2')} fullWidth isRequired={required}>
<Label>
{fieldLabel}
</Label>
{control}
{type === 'file' ? (
<div className="w-full min-w-0">{control}</div>
) : control}
</TextField>
);
}
export function shouldSpanDynamicFieldColumns(fieldDef) {
const type = String(fieldDef?.type || 'text').toLowerCase();
return ['long_text', 'rich_text', 'file', 'subject', 'object', 'form'].includes(type);
}
export async function prepareValuesForSubmit(subject, values, path = '') {
const errors = [];
const metadata = {};
......@@ -547,12 +567,10 @@ export default function DynamicRecordDialog({
/>
</TextField>
{(subject?.fields || []).map((field) => {
const type = String(field?.type || 'text').toLowerCase();
const spansColumns = ['long_text', 'rich_text', 'file', 'subject', 'object', 'form'].includes(type);
return (
<DynamicField
key={field.name}
className={spansColumns ? 'md:col-span-2' : undefined}
className={shouldSpanDynamicFieldColumns(field) ? 'md:col-span-2' : undefined}
fieldDef={field}
value={values[field.name]}
disabled={saving || (editing && isPrimaryField(field))}
......
......@@ -19,7 +19,8 @@ import {
loadSubjectWorkbenchResources,
} from '@/pages/project_manager_xy/SubjectWorkbenchService';
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
function normalizeActionContext(context, helpers) {
return {
......@@ -57,6 +58,7 @@ export default function SubjectRecordWorkbench({
const [filters, setFilters] = useState({ keyword: '', status: '', year: '' });
const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const [deleteTargets, setDeleteTargets] = useState([]);
......@@ -114,11 +116,11 @@ export default function SubjectRecordWorkbench({
const hiddenFields = new Set(hiddenColumnFields);
return (configuredView.columns || []).filter((column) => !hiddenFields.has(column.field));
}, [configuredView.columns, hiddenColumnFields]);
const totalPages = Math.max(1, Math.ceil(filteredRecords.length / DEFAULT_PAGE_SIZE));
const totalPages = Math.max(1, Math.ceil(filteredRecords.length / pageSize));
const safePage = Math.min(page, totalPages - 1);
const pageRecords = filteredRecords.slice(
safePage * DEFAULT_PAGE_SIZE,
(safePage + 1) * DEFAULT_PAGE_SIZE,
safePage * pageSize,
(safePage + 1) * pageSize,
);
useEffect(() => {
......@@ -352,13 +354,20 @@ export default function SubjectRecordWorkbench({
}}
pagination={{
page: safePage,
pageSize: DEFAULT_PAGE_SIZE,
pageSize,
pageSizeOptions: PAGE_SIZE_OPTIONS,
totalRecords: filteredRecords.length,
totalPages,
onPageChange: (nextPage) => {
setPage(nextPage);
setSelectedIds([]);
},
onPageSizeChange: (nextPageSize) => {
if (!PAGE_SIZE_OPTIONS.includes(nextPageSize)) return;
setPageSize(nextPageSize);
setPage(0);
setSelectedIds([]);
},
}}
stickyTable
/>
......@@ -382,7 +391,7 @@ export default function SubjectRecordWorkbench({
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop isOpen={deleteTargets.length > 0} variant="blur">
<AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog>
<AlertDialog.Dialog className="bg-background">
<AlertDialog.Header>
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading>确认删除记录</AlertDialog.Heading>
......
......@@ -54,6 +54,7 @@ export default function XinyuanAutocomplete({
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
showSearch = true,
selectedKey,
selectedKeys,
selectionMode = 'single',
......@@ -153,21 +154,23 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
{showSearch ? (
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
) : null}
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
......
import {
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: {
color: theme.axis,
fontFamily: theme.fontFamily,
fontSize: 12,
...item?.axisLabel,
},
axisLine: {
...item?.axisLine,
lineStyle: {
color: theme.grid,
...item?.axisLine?.lineStyle,
},
},
axisTick: {
...item?.axisTick,
lineStyle: {
color: theme.grid,
...item?.axisTick?.lineStyle,
},
},
splitLine: {
...item?.splitLine,
lineStyle: {
color: theme.grid,
...item?.splitLine?.lineStyle,
},
},
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
export default function XinyuanEChart({
ariaLabel,
className = '',
height = 320,
option,
}) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => {
chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' });
}, []);
useEffect(() => {
window.addEventListener('resize', hideTooltip);
return () => window.removeEventListener('resize', hideTooltip);
}, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: {
enabled: true,
decal: { show: false },
...option?.aria,
},
color: option?.color || theme.palette,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
...option?.textStyle,
},
...option,
legend: option?.legend ? {
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...option.legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...option.legend?.textStyle,
},
} : undefined,
tooltip: {
appendToBody: true,
backgroundColor: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
borderWidth: 1,
className: 'rc-echarts-tooltip',
confine: true,
enterable: false,
extraCssText: `border-radius: 12px; box-shadow: ${theme.tooltipShadow};`,
padding: [10, 12],
renderMode: 'html',
...option?.tooltip,
textStyle: {
color: theme.foreground,
fontFamily: theme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
},
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
return (
<div
aria-label={ariaLabel}
className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()}
role="img"
tabIndex={0}
>
<ReactECharts
lazyUpdate
notMerge
onEvents={{ globalout: hideTooltip }}
option={themedOption}
opts={{ renderer: 'canvas' }}
ref={chartRef}
style={{ height, width: '100%' }}
/>
</div>
);
}
......@@ -33,6 +33,7 @@ export default function XinyuanModal({
<Modal.Dialog
className={clsx(
STANDARD_PANEL_PADDING_SIZES.has(size) && 'p-7',
'bg-background',
DIALOG_WIDTH_CLASS[size],
dialogClassName,
)}
......
......@@ -24,7 +24,7 @@ import {
} from '@/components/AppIcons';
import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker';
import { formatFieldValue, getFieldValue } from '@/utils/subjectView';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import {
Alert,
Avatar,
......@@ -163,8 +163,16 @@ function buildColumnGroupMeta(columnGroups = [], columns = []) {
return {
...group,
id,
name: group?.name || id,
name:
normalizeText(group?.label) ||
normalizeText(group?.title) ||
normalizeText(group?.name) ||
id,
children,
columnLabels: children.map((field) => {
const column = columns.find((item) => item.field === field);
return String(column?.label || field).trim();
}),
};
})
.filter((group) => group.children.length > 0);
......@@ -353,7 +361,7 @@ function defaultRenderCell(record, column) {
<DataTableText
className="text-sm text-foreground"
maxLines={isLongText ? 2 : 1}
showTooltip
showTooltip={false}
text={text}
/>
);
......@@ -402,28 +410,56 @@ function getWorkbenchColumnWidth(column, stickyMeta) {
return Math.min(readableWidth, 280);
}
function ColumnGroupControl({ group, collapsed, index, onToggle }) {
function ColumnGroupControl({ group, collapsed, index, onPreviewChange, onToggle }) {
const Icon = index % 2 === 0 ? Group : ChartBarSquareIcon;
const actionLabel = `${collapsed ? '显示' : '隐藏'}${group.name}列`;
const columnSummary = group.columnLabels.join('、');
return (
<Tooltip delay={300}>
<Tooltip closeDelay={80} delay={150}>
<ToggleButton
size="sm"
isSelected={collapsed}
onChange={onToggle}
onBlur={() => onPreviewChange?.(null)}
onFocus={() => onPreviewChange?.(group.id)}
onHoverEnd={() => onPreviewChange?.(null)}
onHoverStart={() => onPreviewChange?.(group.id)}
aria-label={actionLabel}
>
<Icon size={16} />
<span>{group.name}</span>
<span aria-hidden="true" className="text-xs opacity-70">
{group.children.length} 列
</span>
</ToggleButton>
<Tooltip.Content>
{actionLabel}
<Tooltip.Content className="max-w-80" placement="bottom">
<div className="space-y-1">
<div className="font-medium">{actionLabel}</div>
<div className="text-xs opacity-80">包含:{columnSummary}</div>
</div>
</Tooltip.Content>
</Tooltip>
);
}
function ColumnHeaderLabel({ label, groupName }) {
if (!groupName) {
return label;
}
return (
<span className="flex min-w-0 flex-col items-start gap-0.5 py-0.5">
<span aria-hidden="true" className="text-[11px] font-normal leading-4 text-muted">
{groupName}
</span>
<span className="text-xs font-medium leading-4 text-foreground">
{label}
</span>
</span>
);
}
function renderRowActionButton({ action, isDisabled, onPress }) {
const Icon = action.icon || DEFAULT_ROW_ACTION_ICONS[action.key];
......@@ -467,8 +503,10 @@ function WorkbenchPagination({ pagination }) {
isDisabled={Boolean(pagination.disabled)}
page={Math.max(0, Number(pagination.page || 0))}
pageSize={pageSize}
pageSizeOptions={pagination.pageSizeOptions}
total={totalRecords}
onPageChange={pagination.onPageChange}
onPageSizeChange={pagination.onPageSizeChange}
/>
);
}
......@@ -512,6 +550,7 @@ export default function RecordWorkbench({
const [drawerDirty, setDrawerDirty] = useState(false);
const [filtersExpanded, setFiltersExpanded] = useState(false);
const [collapsedColumnGroups, setCollapsedColumnGroups] = useState({});
const [previewColumnGroupId, setPreviewColumnGroupId] = useState(null);
const [columnGroupAnnouncement, setColumnGroupAnnouncement] = useState('');
const [expandedRowIds, setExpandedRowIds] = useState({});
const filterFieldVariant = 'secondary';
......@@ -811,9 +850,23 @@ export default function RecordWorkbench({
: []),
...stickyColumns.orderedColumns.map((column, columnIndex) => {
const stickyMeta = stickyColumns.stickyMeta[column.field];
const columnGroupId = columnGroupMeta.fieldToGroup[column.field];
const columnGroup = columnGroupId
? columnGroupMeta.groupById[columnGroupId]
: null;
const isPreviewedGroupColumn = columnGroupId === previewColumnGroupId;
return {
key: column.field,
title: column.label,
title: (
<ColumnHeaderLabel
groupName={columnGroup?.name}
label={column.label}
/>
),
headerClassName: clsx(
column.headerClassName,
isPreviewedGroupColumn && 'bg-accent-soft text-accent-soft-foreground',
),
width: getWorkbenchColumnWidth(column, stickyMeta),
minWidth: getWorkbenchColumnWidth(column, stickyMeta),
pin:
......@@ -1084,6 +1137,7 @@ export default function RecordWorkbench({
group={group}
collapsed={Boolean(collapsedColumnGroups[group.id])}
index={index}
onPreviewChange={setPreviewColumnGroupId}
onToggle={() => toggleColumnGroup(group.id)}
/>
))}
......
......@@ -3,7 +3,6 @@ export const APPLICATION_NAME = 'audit_project_manager_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const SOURCE_APPLICATION_NAME = APPLICATION_NAME;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/audit_project_table/dashboard': '驾驶舱',
......
......@@ -7,6 +7,14 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
'var(--rc-chart-9)',
'var(--rc-chart-10)',
'var(--rc-chart-11)',
'var(--rc-chart-12)',
'var(--rc-chart-13)',
'var(--rc-chart-14)',
'var(--rc-chart-15)',
'var(--rc-chart-16)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
......@@ -29,15 +37,6 @@ function hashChartKey(key) {
);
}
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();
......@@ -65,46 +64,49 @@ export function createXinyuanChartColorMap(keys = []) {
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') {
return false;
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
return window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim() || fallback;
}
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match ? readRootToken(match[1], fallback) : String(color || fallback);
}
export function colorizeXinyuanChartTooltipPayload(
payload = [],
resolveColor,
) {
return payload.map((entry, index) => {
const rechartsPayload = entry?.payload || {};
const sourceDatum = rechartsPayload?.payload || rechartsPayload;
const key = sourceDatum?.name
?? rechartsPayload?.name
?? entry?.name
?? entry?.dataKey
?? '';
const candidateColors = [
resolveColor?.({ entry, index, key, sourceDatum }),
rechartsPayload?.fill,
sourceDatum?.fill,
entry?.fill,
entry?.color,
entry?.stroke,
getXinyuanChartColor(key, index),
];
const color = candidateColors.find(isVisibleChartColor);
export function createResolvedXinyuanChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
return {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
};
});
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
fontFamily: documentFont || 'Inter, sans-serif',
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')
)),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
}
......@@ -453,7 +453,7 @@
grid-column: 1 / -1;
}
/* Recharts keeps keyboard navigation while pointer focus stays visually quiet. */
/* Charts keep keyboard navigation while pointer focus stays visually quiet. */
.rc-chart-interaction:focus:not(:focus-visible),
.rc-chart-interaction :focus:not(:focus-visible) {
outline: none;
......@@ -465,6 +465,10 @@
outline-offset: 2px;
}
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
......@@ -513,12 +517,27 @@
border-radius: var(--rc-radius-overlay);
}
.rc-detail-view-surface {
border-radius: calc(var(--radius) * 2);
.rc-dashboard-card {
border-radius: 1.5rem;
}
.rc-dashboard-card {
.rc-workspace-module {
border-radius: 1.5rem;
border: 1px solid var(--border);
background: transparent;
box-shadow: none;
}
.rc-empty-state-message {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
.rc-empty-field-text {
color: var(--rc-text-empty);
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
}
/* HeroUI Pro KPI.Icon keeps its compound role; this class supplies Xinyuan tones. */
......@@ -1221,12 +1240,12 @@
place-items: center;
padding: 0;
border-radius: 50%;
color: var(--foreground);
color: var(--rc-sidebar-foreground);
background-color: transparent;
line-height: 1;
--button-bg-hover: var(--accent-soft);
--button-bg-pressed: var(--accent-soft);
--button-fg: var(--foreground);
--button-bg-hover: var(--rc-sidebar-item-selected);
--button-bg-pressed: var(--rc-sidebar-item-selected);
--button-fg: var(--rc-sidebar-item-selected-foreground);
}
.app-sidebar-app-trigger .anticon {
......@@ -1246,16 +1265,16 @@
@media (hover: hover) {
.app-sidebar-app-trigger.button:hover,
.app-sidebar-app-trigger.button[data-hovered='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
}
.app-sidebar-app-trigger.button:active,
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--foreground);
background-color: var(--accent-soft);
color: var(--rc-sidebar-item-selected-foreground);
background-color: var(--rc-sidebar-item-selected);
}
.app-sidebar-app-dialog {
......@@ -1439,3 +1458,89 @@
border-right: 0;
}
}
/* Fixed split-shell theme: dark navigation, permanently light content canvas. */
.template-dashboard-shell .app-sidebar-glass,
.rc-mobile-sidebar.app-sidebar-glass {
color: var(--rc-sidebar-foreground);
border-color: var(--rc-sidebar-border) !important;
background-color: var(--rc-sidebar-background) !important;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content {
background: var(--rc-sidebar-item-selected);
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content *,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content * {
color: var(--rc-sidebar-item-selected-foreground) !important;
}
.template-dashboard-shell .sidebar__menu-item:hover .sidebar__menu-item-content,
.template-dashboard-shell
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item:hover .sidebar__menu-item-content,
.rc-mobile-sidebar
.sidebar__menu-item[data-hovered='true']
.sidebar__menu-item-content,
.app-sidebar-user-trigger.dropdown__trigger:hover,
.app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'] {
background: var(--rc-sidebar-item-hover);
}
.app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'],
.app-sidebar-app-trigger.button[data-pressed='true'],
.app-sidebar-app-trigger.button[aria-expanded='true'] {
background: var(--rc-sidebar-item-selected);
}
.app-sidebar-user-avatar,
.app-sidebar-app-trigger.button,
.app-sidebar-user-trigger.dropdown__trigger {
color: var(--rc-sidebar-foreground);
background: transparent;
}
.app-sidebar-user-avatar {
background: var(--rc-sidebar-item-hover);
}
.template-dashboard-shell .sidebar__group-label,
.rc-mobile-sidebar .sidebar__group-label,
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon,
.app-sidebar-user-description {
color: var(--rc-sidebar-muted);
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.app-sidebar-title,
.app-sidebar-user-name,
.app-sidebar-app-trigger.button {
color: var(--rc-sidebar-foreground);
}
.app-sidebar-user-trigger.dropdown__trigger:focus-visible,
.app-sidebar-app-trigger.button:focus-visible,
.template-dashboard-shell .sidebar__menu-item:focus-visible,
.rc-mobile-sidebar .sidebar__menu-item:focus-visible {
outline: 2px solid var(--rc-sidebar-focus);
outline-offset: 2px;
}
......@@ -11,38 +11,37 @@
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent: oklch(57.14% 0.2156 29.20);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(94.00% 0.0000 29.20);
--default-foreground: oklch(21.03% 0.0059 29.20);
--field-background: oklch(100.00% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
--field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: oklch(57.14% 0.2156 29.20);
--foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0000 29.20);
--overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */
--radius: 0.5rem;
......@@ -51,42 +50,86 @@
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: oklch(78% 0.105 29.20);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
--xinyuan-chart-1: #d92d20;
--xinyuan-chart-2: #ef4444;
--xinyuan-chart-3: #f97316;
--xinyuan-chart-4: #f59e0b;
--xinyuan-chart-5: #22c55e;
--xinyuan-chart-6: #14b87a;
--xinyuan-chart-7: #06b6d4;
--xinyuan-chart-8: #0ea5e9;
--xinyuan-chart-9: #3b82f6;
--xinyuan-chart-10: #6366f1;
--xinyuan-chart-11: #8b5cf6;
--xinyuan-chart-12: #7c3aed;
--xinyuan-chart-13: #b42318;
--xinyuan-chart-14: #84cc16;
--xinyuan-chart-15: #a855f7;
--xinyuan-chart-16: #64748b;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
--xinyuan-chart-tooltip-background: #ffffff;
--xinyuan-chart-tooltip-border: #dedede;
--xinyuan-chart-tooltip-foreground: #222222;
--xinyuan-chart-tooltip-shadow: 0 16px 40px rgb(15 23 42 / 14%);
--xinyuan-chart-legend-font-size: 0.75rem;
--xinyuan-chart-legend-line-height: 1rem;
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent: oklch(57.14% 0.2156 29.20);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
--background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--default: oklch(27.40% 0.0000 29.20);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0000 29.20);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
--field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: oklch(57.14% 0.2156 29.20);
--foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0000 29.20);
--overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
}
......@@ -2,9 +2,7 @@
.light,
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
[data-theme='default'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
......@@ -27,6 +25,17 @@
var(--background)
);
--rc-text-muted: var(--muted);
--rc-text-empty: color-mix(in oklab, var(--muted) 72%, var(--background));
/* Fixed dark Sidebar aliases. The content canvas remains light. */
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
......@@ -139,40 +148,28 @@
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-1: var(--xinyuan-chart-1);
--rc-chart-2: var(--xinyuan-chart-2);
--rc-chart-3: var(--xinyuan-chart-3);
--rc-chart-4: var(--xinyuan-chart-4);
--rc-chart-5: var(--xinyuan-chart-5);
--rc-chart-6: var(--xinyuan-chart-6);
--rc-chart-7: var(--xinyuan-chart-7);
--rc-chart-8: var(--xinyuan-chart-8);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
}
......@@ -131,7 +131,7 @@ function CellValue({ value, field }) {
<DataTableText
className={['object', 'form', 'subject'].includes(fieldType) ? 'text-muted' : 'text-foreground'}
maxLines={isLongText ? 2 : 1}
showTooltip
showTooltip={false}
text={text}
/>
);
......@@ -151,7 +151,7 @@ function getReferenceColumnWidth(column, index) {
function ReferenceRecordsFrame({ action, count, children }) {
return (
<div className="space-y-2">
<div className="w-full min-w-0 space-y-2">
<div className="flex items-center justify-between gap-3 text-xs text-muted">
<span>共 {count} 条关联记录</span>
{action}
......
import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { EmptyStateSurface } from '@/components/DataTable';
import TailwindEditableField from './TailwindEditableField';
import {
buildViewColumns,
getFieldValue,
isFieldDisabledByView,
isReferenceFieldType,
} from '@/utils/subjectView';
const FIELD_LABEL_OVERRIDES = {
problem_discovery_situation: '问题发现情况',
};
const FULL_WIDTH_FIELD_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
'file',
'reference',
'document',
'view',
'template',
'task',
]);
function getFieldName(fieldPath = '') {
return String(fieldPath).replace(/^metadata\./, '').split('.').pop();
}
......@@ -73,25 +59,27 @@ export default function SubjectViewContent({
}
return (
<EmptyState size="md">
<EmptyState.Header><EmptyState.Title>暂无可显示的字段</EmptyState.Title></EmptyState.Header>
</EmptyState>
<Surface className="rc-workspace-module w-full min-w-0 p-4" variant="tertiary">
<EmptyStateSurface>
<EmptyState size="md">
<EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
暂无可显示的字段
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
</Surface>
);
}
const fields = (
<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-y-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 isNestedStaticField =
mode === 'view' &&
!minimal &&
(fieldType === 'form' || isReferenceFieldType(fieldType));
const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view);
......@@ -130,15 +118,9 @@ export default function SubjectViewContent({
const fieldClassName = clsx(
'min-w-0',
minimal ? 'py-1' : 'py-3',
isNestedStaticField && 'px-4 sm:px-5',
isFullWidth && 'md:col-span-2',
);
return isNestedStaticField ? (
<Surface key={field.field} className={fieldClassName} variant="tertiary">
{fieldContent}
</Surface>
) : (
return (
<div key={field.field} className={fieldClassName}>
{fieldContent}
</div>
......@@ -148,11 +130,7 @@ export default function SubjectViewContent({
);
if (mode === 'view' && !minimal) {
return (
<Surface className="rc-detail-view-surface px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
{fields}
</Surface>
);
return fields;
}
return fields;
......
......@@ -86,6 +86,10 @@ function isEmptyFieldValue(value) {
return false;
}
function EmptyFieldValue() {
return <span className="rc-empty-field-text">暂无内容</span>;
}
function getTextPlaceholder(placeholder, fieldDef) {
const explicitPlaceholder = String(placeholder || '').trim();
if (explicitPlaceholder) return explicitPlaceholder;
......@@ -94,9 +98,9 @@ function getTextPlaceholder(placeholder, fieldDef) {
return fieldLabel ? `请输入${fieldLabel}` : '请输入内容';
}
function FieldValue({ value, fieldDef }) {
function FieldValue({ value, fieldDef, plainNested = false }) {
if (isEmptyFieldValue(value)) {
return <span className="text-sm text-muted">暂无内容</span>;
return <EmptyFieldValue />;
}
if (fieldDef?.type === 'select') {
......@@ -157,23 +161,18 @@ function FieldValue({ value, fieldDef }) {
}
if (fieldDef?.type === 'object' || fieldDef?.type === 'subject') {
return (
<Card variant="tertiary">
<Card.Content>
<pre className="max-h-48 overflow-auto text-xs leading-5">
{JSON.stringify(value, null, 2)}
</pre>
</Card.Content>
</Card>
);
const text = formatFieldValue(value, fieldDef);
return isEmptyFieldValue(text)
? <EmptyFieldValue />
: <span className="whitespace-pre-wrap text-foreground">{text}</span>;
}
const text = formatFieldValue(value, fieldDef);
return (
<span className={clsx('whitespace-pre-wrap', isEmptyFieldValue(text) ? 'text-sm text-muted' : 'text-foreground')}>
{isEmptyFieldValue(text) ? '暂无内容' : text}
</span>
);
if (isEmptyFieldValue(text)) {
return <EmptyFieldValue />;
}
return <span className={clsx('whitespace-pre-wrap', 'text-foreground')}>{text}</span>;
}
function UserSelector({ value, onChange, multiple, placeholder = '请选择用户', variant }) {
......@@ -305,7 +304,7 @@ function ReadonlyFieldValue({ value, fieldDef }) {
return <ReferenceRecordsTable value={value} fieldDef={fieldDef} />;
}
return <FieldValue value={value} fieldDef={fieldDef} />;
return <FieldValue value={value} fieldDef={fieldDef} plainNested />;
}
function ControlledFieldEditor({
......
......@@ -20,21 +20,10 @@ import {
getFieldValue,
hasFieldAuthorizationConfiguration,
setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
const EMPTY_ARRAY = [];
const NON_EDITABLE_FIELD_TYPES = new Set(['subject']);
const PLACEHOLDER_TITLES = new Set([
'(无标题)',
'(无标题)',
'无标题',
'未命名记录',
]);
function isPlaceholderTitle(value) {
return PLACEHOLDER_TITLES.has(String(value || '').trim());
}
function cloneRecord(record) {
return record ? JSON.parse(JSON.stringify(record)) : null;
......@@ -255,7 +244,6 @@ const RecordDetailDrawer = (props, ref) => {
const activeView = enabledDetailViews.find((view) => view.name === activeTabKey)
|| enabledDetailViews[enabledDetailViews.length - 1]
|| detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo(
......@@ -470,14 +458,6 @@ const RecordDetailDrawer = (props, ref) => {
}
};
const recordTitle =
displayedRecord?.title ||
displayedRecord?.display_name ||
displayedRecord?.name ||
recordName ||
'记录';
const showRecordTitle = !hideRecordTitle && (loading || !isPlaceholderTitle(recordTitle));
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
......@@ -491,7 +471,7 @@ const RecordDetailDrawer = (props, ref) => {
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size={isFullscreen ? 'full' : 'lg'}>
<Modal.Dialog className={isFullscreen ? undefined : 'p-7 sm:max-w-[68rem]'}>
<Modal.Dialog className={isFullscreen ? 'bg-background' : 'bg-background p-7 sm:max-w-[68rem]'}>
<div className="absolute right-7 top-7 z-10 flex items-center gap-2">
{!minimalContent ? (
<Tooltip>
......@@ -521,11 +501,6 @@ const RecordDetailDrawer = (props, ref) => {
<Modal.Header className="shrink-0 pr-16">
<Modal.Heading>{title || '记录详情'}</Modal.Heading>
{showRecordTitle ? (
<div className="truncate text-sm text-muted" title={loading ? undefined : recordTitle}>
{loading ? '正在读取记录信息' : recordTitle}
</div>
) : null}
{detailViews.length > 1 ? (
<Tabs
......
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { EmptyStateSurface } from '@/components/DataTable';
import XinyuanModal from '@/components/XinyuanModal';
import XinyuanEChart from '@/components/XinyuanEChart';
import {
colorizeXinyuanChartTooltipPayload,
createXinyuanChartColorMap,
getXinyuanChartColor,
createResolvedXinyuanChartColorMap,
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import {
CheckCircleIcon,
......@@ -12,12 +13,9 @@ import {
ChartPieIcon,
} from '@/components/AppIcons';
import { Alert, Button, Card, Chip, Spinner } from '@heroui/react';
import { BarChart } from '@heroui-pro/react/bar-chart';
import { ChartTooltipContent } from '@heroui-pro/react/chart-tooltip';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { ListView } from '@heroui-pro/react/list-view';
import { PieChart } from '@heroui-pro/react/pie-chart';
import { PressableFeedback } from '@heroui-pro/react/pressable-feedback';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
......@@ -102,9 +100,15 @@ function ProjectPanel({ title, records, emptyText, onOpen }) {
aria-label={title}
items={records}
renderEmptyState={() => (
<EmptyState className="min-h-48" size="sm">
<EmptyState.Header><EmptyState.Title>{emptyText}</EmptyState.Title></EmptyState.Header>
</EmptyState>
<EmptyStateSurface>
<EmptyState className="min-h-48" size="sm">
<EmptyState.Header>
<EmptyState.Description className="rc-empty-state-message">
{emptyText}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
)}
>
{(record) => <ProjectRow record={record} onOpen={onOpen} />}
......@@ -115,50 +119,129 @@ function ProjectPanel({ title, records, emptyText, onOpen }) {
}
function StatusChart({ data }) {
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name));
return (
<PieChart className="rc-chart-interaction" height={320}>
<PieChart.Pie data={data} dataKey="value" innerRadius="52%" nameKey="name" outerRadius="72%" paddingAngle={2}>
{data.map((item, index) => (
<PieChart.Cell key={item.name} fill={colorMap.get(item.name) || getXinyuanChartColor(item.name, index)} />
))}
</PieChart.Pie>
<PieChart.Tooltip content={({ active, label, payload }) => (
<ChartTooltipContent
active={active}
label={label}
payload={colorizeXinyuanChartTooltipPayload(payload, ({ key, index }) => colorMap.get(key) || getXinyuanChartColor(key, index))}
valueFormatter={(value) => `${value} 个`}
/>
)} />
</PieChart>
);
const option = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const colorMap = createResolvedXinyuanChartColorMap(
data.map((item) => item.name),
);
return {
legend: {
bottom: 0,
left: 'center',
},
series: [{
avoidLabelOverlap: true,
data: data.map((item) => ({
...item,
itemStyle: { color: colorMap.get(item.name) },
})),
emphasis: {
scaleSize: 4,
},
label: {
alignTo: 'labelLine',
distanceToLabelLine: 6,
formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 条}`,
lineHeight: 18,
minMargin: 10,
rich: {
name: {
color: theme.label,
fontSize: 13,
fontWeight: 500,
lineHeight: 18,
},
value: {
color: theme.label,
fontSize: 14,
fontWeight: 600,
lineHeight: 20,
},
},
show: true,
},
labelLine: {
length: 20,
length2: 22,
lineStyle: {
color: theme.grid,
width: 1.5,
},
show: true,
},
labelLayout: (params) => {
const points = params?.labelLinePoints;
if (!Array.isArray(points) || points.length < 3) return null;
const endpoint = points[2];
const isRight = endpoint[0] >= points[0][0];
return {
align: isRight ? 'left' : 'right',
hideOverlap: true,
moveOverlap: 'none',
verticalAlign: 'middle',
x: endpoint[0] + (isRight ? 8 : -8),
y: endpoint[1],
};
},
name: '项目状态',
radius: ['52%', '72%'],
center: ['50%', '44%'],
type: 'pie',
}],
tooltip: {
trigger: 'item',
valueFormatter: (value) => `${value} 个`,
},
};
}, [data]);
return <XinyuanEChart ariaLabel="项目状态分布图" option={option} />;
}
function StageChart({ data }) {
const colorMap = createXinyuanChartColorMap(data.map((item) => item.name));
return (
<BarChart className="rc-chart-interaction" data={data} height={320}>
<BarChart.Grid vertical={false} />
<BarChart.XAxis dataKey="name" />
<BarChart.YAxis allowDecimals={false} />
<BarChart.Tooltip content={({ active, label, payload }) => (
<ChartTooltipContent
active={active}
label={label}
payload={colorizeXinyuanChartTooltipPayload(payload, ({ key, index }) => colorMap.get(key) || getXinyuanChartColor(key, index))}
valueFormatter={(value) => `${value} 个`}
/>
)} />
<BarChart.Bar
dataKey="value"
fill="var(--rc-chart-primary)"
maxBarSize={28}
name="项目数量"
radius={[8, 8, 0, 0]}
/>
</BarChart>
);
const option = useMemo(() => {
const theme = getXinyuanEChartsTheme();
return {
grid: {
bottom: 20,
containLabel: true,
left: 12,
right: 12,
top: 12,
},
series: [{
barMaxWidth: 28,
data: data.map((item) => item.value),
itemStyle: {
borderRadius: [8, 8, 0, 0],
color: theme.palette[0],
},
name: '项目数量',
type: 'bar',
}],
tooltip: {
axisPointer: { type: 'shadow' },
trigger: 'axis',
valueFormatter: (value) => `${value} 个`,
},
xAxis: {
axisTick: { show: false },
data: data.map((item) => item.name),
type: 'category',
},
yAxis: {
minInterval: 1,
splitLine: { show: true },
type: 'value',
},
};
}, [data]);
return <XinyuanEChart ariaLabel="开展中项目进度柱状图" option={option} />;
}
function ProjectListDialog({ group, onClose, onOpenRecord }) {
......
......@@ -11,6 +11,7 @@ import {
getFieldValue,
isViewAccessible,
normalizeText,
normalizeProjectYearDisplay,
recordMatchesRestrictions,
sortRecordsByView,
} from '@/utils/subjectView';
......@@ -156,15 +157,18 @@ export function findSubjectField(subject, fieldName) {
}
export function collectProjectYears(records = []) {
return Array.from(
new Set(
records
.map((record) => normalizeText((record.raw || record)?.metadata?.project_year))
.filter(Boolean),
),
)
.sort((left, right) => right.localeCompare(left, 'zh-CN'))
.map((value) => ({ label: value, value }));
const yearValues = new Map();
records.forEach((record) => {
const value = normalizeText((record.raw || record)?.metadata?.project_year);
const label = normalizeProjectYearDisplay(value);
if (value && label && !yearValues.has(label)) {
yearValues.set(label, value);
}
});
return Array.from(yearValues.entries())
.sort(([left], [right]) => right.localeCompare(left, 'zh-CN'))
.map(([label, value]) => ({ label, value }));
}
export function collectProjectStatuses(records = []) {
......
......@@ -178,7 +178,7 @@ export default function AuditProjectMaterialField({
};
return (
<div className="space-y-4">
<div className="w-full min-w-0 space-y-4">
{fileValues.length > 0 ? (
<div className="space-y-2">
{fileValues.map((file, index) => {
......@@ -206,9 +206,9 @@ export default function AuditProjectMaterialField({
<Tabs
aria-label="附件来源"
className="w-fit max-w-full min-w-0"
className="w-full min-w-0"
selectedKey={activeTab}
variant="secondary"
variant="primary"
onSelectionChange={(key) => setActiveTab(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
......@@ -223,8 +223,8 @@ export default function AuditProjectMaterialField({
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3">
<Tabs.Panel className="w-full min-w-0 pt-3" id={MATERIAL_CENTER_TAB}>
<div className="w-full min-w-0 space-y-3">
<XinyuanAutocomplete
aria-label="选择审计材料"
emptyText="没有找到可选择的审计材料"
......@@ -255,8 +255,8 @@ export default function AuditProjectMaterialField({
) : null}
</div>
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3">
<Tabs.Panel className="w-full min-w-0 pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="w-full min-w-0 space-y-3">
<FileUploadField
description={multiple
? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。'
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { DataTablePagination, EmptyStateSurface } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
......@@ -262,15 +262,17 @@ export default function NotificationListPage() {
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"><Spinner size="sm" /></div>
) : null}
{!loading && !error && notifications.length === 0 ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Media><Bell size={22} /></EmptyState.Media>
<EmptyState.Header>
<EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title>
<EmptyState.Description>
{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
<EmptyStateSurface>
<EmptyState className="min-h-64" size="md">
<EmptyState.Media><Bell size={22} /></EmptyState.Media>
<EmptyState.Header>
<EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title>
<EmptyState.Description>
{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
</EmptyStateSurface>
) : null}
{notifications.length > 0 ? (
<div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>
......
......@@ -22,7 +22,7 @@
按后端分组及成员顺序过滤无权限和空分组,未被配置引用的可访问应用归入“其他应用”,
无分组配置时显示“全部应用”。应用点击仍使用既有名称到根路径的跳转规则。
侧栏将“驾驶舱”作为独立入口;“审计项目管理”和“往期项目归档”归入可展开的“开展审计工作”,“项目表单”归入可展开的“表单配置及维护”。两个分组默认展开,并支持手动收起。模块主题色与 `problem_rectification` 保持一致,使用品牌红 `#b42318` 统一导航选中态、主按钮、复选框、当前视图和焦点反馈。
侧栏将“驾驶舱”作为独立入口;“审计项目管理”和“往期项目归档”归入可展开的“开展审计工作”,“项目表单”归入可展开的“表单配置及维护”。两个分组默认展开,并支持手动收起。应用不提供深浅模式切换:根内容固定使用新 HeroUI 浅色主题,侧栏固定使用 `--xinyuan-sidebar-*` 深色主题,主按钮、复选框、当前视图和焦点反馈继续使用 HeroUI `--accent`。
## 2. 项目列表与详情
......@@ -83,6 +83,10 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- 逾期未完成:未进入 `整改`/`归档`,且 `project_completion_date` 早于当天。
- 待开展:状态为 `待启动`,且 `planned_start_up_time` 在未来 1 至 30 天内。
驾驶舱的状态分布和开展中进度统一通过共享 `XinyuanEChart` 使用 ECharts 渲染;图表迁移不
改变 `POST /record/query`、统计口径、数据字段、项目详情入口或刷新时机。图标继续统一使用
Ant Design Icons。
统计卡片和项目行可打开项目列表或详情;加载失败时保留重试入口。
## 7. 外部填写与复核链接
......@@ -109,8 +113,11 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- 字段管理表格仅展示字段标签和类型,不展示内部字段别名或字段属性;这些数据仍作为字段接口和编辑弹窗的内部配置使用。
- 字段类型、必填、多值、主键和 `settings` 由表单字段接口保存。
- `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。
- 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:左侧按 `settings.group` 展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。
- 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。
- 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:查看态与编辑态均保持顶部统计、左侧
`settings.group` 纵向分组导航和右侧当前分组表单的主分支布局。查看态只允许切换分组、展开表单
和查看数据表格;零行表单显示单一空数据占位,不出现无意义的横向滚动;新增、删除、模板/分组
选择和行维护操作仅在编辑态出现。
- 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;默认分组固定显示在分组导航最上方,其余自定义分组保持数据中的顺序;删除自定义分组时,其中已配置的表单移入默认分组。
- 表单数据行使用 HeroUI Pro DataGrid 展示,通过 HeroUI Modal 新增或编辑;详情中的修改先作为
草稿保留,点击详情“保存”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。
- 关联的表单模板不存在或加载失败时,在当前表单内容内显示“表单模板不可用”,不展示内部
......@@ -139,7 +146,7 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
## 10. 消息通知
- Navbar 的通知按钮位于主题按钮左侧,未读数来自 `GET /notice/customer/count`;0 不显示,
- Navbar 不再提供主题按钮;通知按钮作为最右侧全局动作,未读数来自 `GET /notice/customer/count`;0 不显示,
1–99 显示真实数量,100 及以上显示 `99+`,并通过通知状态事件和 60 秒轮询刷新。
- `/notifications` 使用原平台通知接口,支持消息/待办、全部/未读、300ms 搜索、固定每页 10 条、
单条已读、全部已读和服务端配置的动态动作;未读筛选向服务端提交 `status = "unread"`。
......
......@@ -25,11 +25,6 @@ html {
color-scheme: light !important;
}
html.dark,
html[data-theme="dark"] {
color-scheme: dark !important;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
......
......@@ -5,6 +5,11 @@ export function normalizeText(value) {
return String(value ?? '').trim();
}
// 项目年度在界面统一展示为纯数字,业务字段仍保留原始值。
export function normalizeProjectYearDisplay(value) {
return normalizeText(value).replace(/\s*年\s*$/, '');
}
export function toArray(value) {
if (value === null || value === undefined || value === '') {
return [];
......@@ -525,6 +530,60 @@ export function normalizeUserLabel(value) {
return value.display_name || value.displayName || value.username || value.name || value.title || value._id || '';
}
function parseStructuredText(value) {
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text || !/^[\[{]/.test(text)) return value;
try {
return JSON.parse(text);
} catch (error) {
return value;
}
}
function countNestedBlocks(sections) {
return toArray(sections).reduce((count, section) => count + toArray(section?.blocks).length, 0);
}
function formatStructuredObject(value) {
if (!value || typeof value !== 'object') return '';
if (Array.isArray(value)) {
return value.map((item) => formatStructuredValue(item)).filter(Boolean).join('、');
}
if (Array.isArray(value.nodes) || Array.isArray(value.edges)) {
const nodes = toArray(value.nodes).length;
const edges = toArray(value.edges).length;
if (nodes || edges) return `包含 ${nodes} 个节点、${edges} 条连线`;
}
if (Array.isArray(value.sections)) {
const sections = value.sections.length;
const blocks = countNestedBlocks(value.sections);
if (sections || blocks) return `包含 ${sections} 个章节、${blocks} 个内容块`;
}
const identity = normalizeRecordLabel(value);
if (identity && identity !== normalizeText(value._id)) return identity;
const readableValues = Object.entries(value)
.filter(([key, item]) => !['_id', 'id', 'type', 'style', 'position', 'positionAbsolute', 'selected', 'dragging'].includes(key))
.map(([, item]) => formatStructuredValue(item))
.filter(Boolean);
return readableValues.length > 0 ? readableValues.slice(0, 3).join('、') : '已配置';
}
function formatStructuredValue(value) {
const parsed = parseStructuredText(value);
if (parsed === null || parsed === undefined || parsed === '') return '';
if (Array.isArray(parsed)) {
const scalarValues = parsed.map((item) => normalizeText(item)).filter(Boolean);
if (scalarValues.length > 1 && scalarValues.every((item) => /^[a-f0-9]{24}$/i.test(item))) {
return `已关联 ${scalarValues.length} 项`;
}
return parsed.map((item) => formatStructuredValue(item)).filter(Boolean).join('、');
}
if (typeof parsed === 'object') return formatStructuredObject(parsed);
const text = normalizeText(parsed);
return /^[a-f0-9]{24}$/i.test(text) ? '已关联记录' : text;
}
export function formatDateText(value) {
if (!value) {
return '';
......@@ -568,58 +627,75 @@ export function formatFieldValue(value, fieldDef) {
return '';
}
if (Array.isArray(value) && value.length === 0) {
const parsedValue = parseStructuredText(value);
if (Array.isArray(parsedValue) && parsedValue.length === 0) {
return '';
}
if (fieldDef?.name === 'project_year') {
return toArray(parsedValue)
.map(normalizeProjectYearDisplay)
.filter(Boolean)
.join('、');
}
if (isDateFieldType(fieldDef?.type)) {
return formatDateText(value);
return formatDateText(parsedValue);
}
if (fieldDef?.type === 'boolean') {
return value ? '是' : '否';
return parsedValue ? '是' : '否';
}
if (fieldDef?.type === 'number') {
return typeof value === 'number' ? value.toLocaleString() : normalizeText(value);
return typeof parsedValue === 'number' ? parsedValue.toLocaleString() : normalizeText(parsedValue);
}
if (fieldDef?.type === 'select') {
const options = getSelectOptions(fieldDef);
const labelForValue = (item) => {
const rawValue = typeof item === 'object' && item !== null ? item.value : item;
const option = options.find((candidate) => candidate.value === rawValue);
return option?.label || normalizeText(rawValue);
const option = options.find((candidate) => String(candidate.value) === String(rawValue));
const fallback = normalizeText(rawValue);
return option?.label || (/^[a-f0-9]{24}$/i.test(fallback) ? '已关联记录' : fallback);
};
return toArray(value).map(labelForValue).filter(Boolean).join('、');
return toArray(parsedValue).map(labelForValue).filter(Boolean).join('、');
}
if (fieldDef?.type === 'user') {
return toArray(value).map(normalizeUserLabel).filter(Boolean).join('、');
return toArray(parsedValue).map(normalizeUserLabel).filter(Boolean).join('、');
}
if (isReferenceFieldType(fieldDef?.type)) {
return toArray(value).map(normalizeRecordLabel).filter(Boolean).join('、');
const labels = toArray(parsedValue).map(normalizeRecordLabel).filter(Boolean);
const unresolvedIds = labels.filter((label) => /^[a-f0-9]{24}$/i.test(label));
if (unresolvedIds.length === labels.length && unresolvedIds.length > 1) {
return `已关联 ${unresolvedIds.length} 项`;
}
return labels
.map((label) => /^[a-f0-9]{24}$/i.test(label) ? '已关联记录' : label)
.join('、');
}
if (fieldDef?.type === 'file') {
return toArray(value)
return toArray(parsedValue)
.map((file) => file?.name || file?.title || file?.url || normalizeText(file))
.filter(Boolean)
.join('、');
}
if (fieldDef?.type === 'form') {
return getFormValueSummaries(value, fieldDef?.label || '表单')
return getFormValueSummaries(parsedValue, fieldDef?.label || '表单')
.map((item) => `${item.label}(${item.rowCount})`)
.join('、');
}
if (typeof value === 'object') {
return JSON.stringify(value);
if (typeof parsedValue === 'object') {
return formatStructuredValue(parsedValue);
}
return normalizeText(value);
return normalizeText(parsedValue);
}
export function dateInputValue(value) {
......
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