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

feat: complete xinyuan material center ui

parent 30d1d111
...@@ -21,10 +21,10 @@ upload field, confirmation dialog and application switcher. They expose current ...@@ -21,10 +21,10 @@ upload field, confirmation dialog and application switcher. They expose current
HeroUI selection/value/event contracts; page controllers adapt those values to HeroUI selection/value/event contracts; page controllers adapt those values to
existing material services. existing material services.
`Select`-shaped compatibility wrappers and page-local ECharts theme helpers are `Select`-shaped compatibility wrappers and page-local chart theme helpers are
not part of the target architecture. Enum/reference fields use the current not part of the target architecture. Enum/reference fields use the current
HeroUI Autocomplete compound model, while dashboards use HeroUI Pro KPI and HeroUI Autocomplete compound model, while dashboards use HeroUI Pro KPI and the
chart compounds with the approved chart Tokens. shared `XinyuanEChart` ECharts adapter with approved chart Tokens.
## Dependency Source ## Dependency Source
......
...@@ -12,7 +12,7 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`. ...@@ -12,7 +12,7 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`.
| Baseline login QA | superseded | Earlier viewport evidence predates the corrected problem-rectification login parity pass | | Baseline login QA | superseded | Earlier viewport evidence predates the corrected problem-rectification login parity pass |
| Authoritative theme | done | Exact baseline `theme.css`, Xinyuan assets, Inter and app-only extension Tokens installed | | Authoritative theme | done | Exact baseline `theme.css`, Xinyuan assets, Inter and app-only extension Tokens installed |
| Login | done | Baseline structure, assets and approved 12px label / 24px field rhythm synchronized | | Login | done | Baseline structure, assets and approved 12px label / 24px field rhythm synchronized |
| Shell | done | Pro AppLayout/Sidebar/Navbar; desktop offcanvas plus an accessible Pro Sheet mobile sidebar | | Shell | done | Pro Sidebar.Provider/Sidebar/Navbar; fixed dark Sidebar, light content canvas, desktop offcanvas plus accessible mobile Sheet |
| Application switcher | done | model_analysis parity; `/info -> site.group`, counts, empty-group filtering, 其他应用/全部应用 fallbacks, two-column grid | | Application switcher | done | model_analysis parity; `/info -> site.group`, counts, empty-group filtering, 其他应用/全部应用 fallbacks, two-column grid |
| Dashboard | done | Pro KPI uses the approved 24px Card and 48px toned icon Surface; analytical modules follow the same radius contract | | Dashboard | done | Pro KPI uses the approved 24px Card and 48px toned icon Surface; analytical modules follow the same radius contract |
| Material archive filters | done | HeroUI Surface, Disclosure, InputGroup, Autocomplete and DatePicker compounds | | Material archive filters | done | HeroUI Surface, Disclosure, InputGroup, Autocomplete and DatePicker compounds |
...@@ -27,10 +27,10 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`. ...@@ -27,10 +27,10 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`.
| Real-data-only states | done | Demo material/project fallbacks removed; empty/error states reflect the API | | Real-data-only states | done | Demo material/project fallbacks removed; empty/error states reflect the API |
| Private visual CSS | done | Duplicate portable/app-token layers removed; approved shared shell/login baseline retained | | Private visual CSS | done | Duplicate portable/app-token layers removed; approved shared shell/login baseline retained |
| Production build | done | `npm run build` completed after core migration | | Production build | done | `npm run build` completed after core migration |
| Browser acceptance | in progress | Four switcher viewports, dark theme, desktop/mobile sidebar and user menu pass; Autocomplete library warnings remain | | Browser acceptance | in progress | 1440x900 and 390x844 shell/material flows pass; remaining warnings are HeroUI Autocomplete internal dialog labels |
| Cross-app scope classification | done | Real app name is 审计材料中心; notification-only rollout, full-feature items intentionally excluded | | Cross-app scope classification | done | Real app name is 审计材料中心; notification-only rollout, full-feature items intentionally excluded |
| Notification route | done | Independent `/notifications` page with message/todo, all/unread, search and fixed pagination | | Notification route | done | Independent `/notifications` page with message/todo, all/unread, search and fixed pagination |
| Notification Navbar entry | done | HeroUI Badge.Anchor/Badge/Button/Tooltip, 36px Bell left of theme action, no Sidebar entry | | Notification Navbar entry | done | HeroUI Badge.Anchor/Badge/Button/Tooltip, 36px Bell action, no theme toggle or Sidebar entry |
| Notification mutations | done | Existing query/count/read/action APIs; single/all read and `{notice_id}` dynamic actions preserved | | Notification mutations | done | Existing query/count/read/action APIs; single/all read and `{notice_id}` dynamic actions preserved |
| Notification link safety | done | Plain text content, base-aware same-app links, external new-window links and normalized login redirect | | Notification link safety | done | Plain text content, base-aware same-app links, external new-window links and normalized login redirect |
| Notification browser acceptance | done | Four required viewports in both themes; badge boundaries, actions, links, overflow and isolated console pass | | Notification browser acceptance | done | Four required viewports in both themes; badge boundaries, actions, links, overflow and isolated console pass |
......
...@@ -29,8 +29,8 @@ paths are not valid delivery mechanisms. ...@@ -29,8 +29,8 @@ paths are not valid delivery mechanisms.
- Primary brand: `#9E1B1B` through the authoritative `--accent` token. - Primary brand: `#9E1B1B` through the authoritative `--accent` token.
- Light identity: `xy-logo-red.png`. - Light identity: `xy-logo-red.png`.
- Dark identity: `xy-logo-white.png`. - Dark identity: `xy-logo-white.png`.
- Root theme: `.light`/`.dark` plus matching `data-theme`. - Root theme: the application shell is explicitly `.light`; the content canvas stays light while the Sidebar consumes the fixed dark split-shell tokens.
- Theme storage key: `xinyuan_color_theme`. - Theme switching is intentionally absent from the production Navbar.
- Application display name: `审计材料中心` everywhere. - Application display name: `审计材料中心` everywhere.
## Surface Hierarchy ## Surface Hierarchy
...@@ -92,8 +92,8 @@ IDs only. Dates are submitted as ISO 8601. `source_system` and ...@@ -92,8 +92,8 @@ IDs only. Dates are submitted as ISO 8601. `source_system` and
## Notification Center ## Notification Center
- The Bell is a 36px HeroUI icon Button immediately left of the theme action, - The Bell is a 36px HeroUI icon Button in the Navbar action area, composed with
composed with `Tooltip`, `Badge.Anchor` and `Badge`. `Tooltip`, `Badge.Anchor` and `Badge`; no theme toggle is rendered.
- Zero unread hides the Badge, 1-99 shows the real count, and values above 99 - Zero unread hides the Badge, 1-99 shows the real count, and values above 99
display `99+` while retaining the real accessible count. display `99+` while retaining the real accessible count.
- `/notifications` uses message/todo and all/unread HeroUI Tabs, HeroUI - `/notifications` uses message/todo and all/unread HeroUI Tabs, HeroUI
......
...@@ -63,7 +63,7 @@ The authoritative references are `src/services/record-api.md`, ...@@ -63,7 +63,7 @@ The authoritative references are `src/services/record-api.md`,
## Remaining UI Inventory ## Remaining UI Inventory
Production routes contain no Catalyst, Headless UI, ECharts, visible native Production routes contain no Catalyst, Headless UI or visible native
select/date/file controls, legacy UI compatibility imports or page-owned select/date/file controls, legacy UI compatibility imports or page-owned
HeroUI appearance styles. The historical kit, unreachable workspace and unused HeroUI appearance styles. The historical kit, unreachable workspace and unused
decorative empty-state page were removed after production references reached decorative empty-state page were removed after production references reached
...@@ -88,11 +88,12 @@ switcher grouping gap without changing its application source or navigation. ...@@ -88,11 +88,12 @@ switcher grouping gap without changing its application source or navigation.
## Theme Audit ## Theme Audit
- `src/design-system/theme.css` is byte-identical to the approved baseline. - `src/design-system/theme.css` retains the Xinyuan HeroUI tokens and now includes
the fixed dark Sidebar and ECharts chart tokens used by the approved baseline.
- `tokens.css`, `shell.css` and the login contract in `tailwind.css` are exact - `tokens.css`, `shell.css` and the login contract in `tailwind.css` are exact
copies of the approved `problem_rectification` shared baseline. copies of the approved `problem_rectification` shared baseline.
- Inter weights and the stable red/white Xinyuan assets are loaded globally. - Inter weights and the stable red/white Xinyuan assets are loaded globally.
- Official `@heroui-pro/react/css` loads before the exact Xinyuan theme and - Official `@heroui-pro/react/css` loads before the Xinyuan theme and
extension Tokens. The temporary Pro CSS compatibility snapshot was removed. extension Tokens. The temporary Pro CSS compatibility snapshot was removed.
## Browser Evidence ## Browser Evidence
......
...@@ -29,6 +29,8 @@ ...@@ -29,6 +29,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0", "embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
...@@ -12642,6 +12644,36 @@ ...@@ -12642,6 +12644,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
...@@ -23635,6 +23667,12 @@ ...@@ -23635,6 +23667,12 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
...@@ -26605,6 +26643,21 @@ ...@@ -26605,6 +26643,21 @@
"zod": "^3.18.0" "zod": "^3.18.0"
} }
}, },
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zwitch": { "node_modules/zwitch": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
......
...@@ -31,6 +31,8 @@ ...@@ -31,6 +31,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0", "embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
......
import { Button, Chip, Pagination, Spinner, Tooltip } from "@heroui/react"; import { Avatar, Button, Chip, Pagination, Spinner, Tooltip } from "@heroui/react";
import { ActionBar } from "@heroui-pro/react/action-bar"; import { ActionBar } from "@heroui-pro/react/action-bar";
import { DataGrid } from "@heroui-pro/react/data-grid"; import { DataGrid } from "@heroui-pro/react/data-grid";
import { EmptyState } from "@heroui-pro/react/empty-state"; import { EmptyState } from "@heroui-pro/react/empty-state";
import XinyuanAutocomplete from "@/components/XinyuanAutocomplete";
import { useMemo } from "react"; import { useMemo } from "react";
function resolveRowKey(row, index, getRowKey) { function resolveRowKey(row, index, getRowKey) {
...@@ -239,13 +240,57 @@ export function DataTableTextPair({ primary, secondary, className = "" }) { ...@@ -239,13 +240,57 @@ export function DataTableTextPair({ primary, secondary, className = "" }) {
); );
} }
export function DataTablePersonIdentity({ name, account, className = "" }) { function getPersonFallback(name) {
const normalized = String(name ?? "").trim();
if (!normalized || normalized === "-") {
return "?";
}
const compact = normalized.replace(/\s+/g, "");
if (/^[\u3400-\u9fff]/.test(compact)) {
return Array.from(compact)[0];
}
return Array.from(compact).slice(0, 2).join("").toUpperCase();
}
export function DataTablePersonIdentity({
name,
account,
secondary,
avatar,
className = "",
}) {
const primaryText = String(name ?? "").trim() || "-";
const secondaryText = String(account ?? secondary ?? "").trim();
return ( return (
<DataTableTextPair <Tooltip delay={0}>
primary={name} <div
secondary={account} className={["flex min-w-0 items-center gap-2", className]
className={className} .filter(Boolean)
/> .join(" ")}
tabIndex={0}
>
<Avatar size="sm" variant="soft" className="shrink-0">
{avatar ? <Avatar.Image alt={primaryText} src={avatar} /> : null}
<Avatar.Fallback>{getPersonFallback(primaryText)}</Avatar.Fallback>
</Avatar>
<span className="grid min-w-0 gap-1">
<span className="min-w-0 truncate text-sm font-medium">
{primaryText}
</span>
{secondaryText ? (
<span className="min-w-0 truncate text-sm text-muted">
{secondaryText}
</span>
) : null}
</span>
</div>
<Tooltip.Content>
<span className="whitespace-pre-wrap">
{[primaryText, secondaryText].filter(Boolean).join("\n")}
</span>
</Tooltip.Content>
</Tooltip>
); );
} }
...@@ -264,8 +309,10 @@ export function DataTableChip({ children, text, className = "" }) { ...@@ -264,8 +309,10 @@ export function DataTableChip({ children, text, className = "" }) {
export function DataTablePagination({ export function DataTablePagination({
page, page,
pageSize, pageSize,
pageSizeOptions = [],
total, total,
onPageChange, onPageChange,
onPageSizeChange,
isDisabled = false, isDisabled = false,
itemLabel = "条", itemLabel = "条",
}) { }) {
...@@ -274,13 +321,50 @@ export function DataTablePagination({ ...@@ -274,13 +321,50 @@ export function DataTablePagination({
const pageWindowEnd = total const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5) ? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart; : 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 ( return (
<div className="mt-2"> <div className="mt-2 flex min-w-0 flex-wrap items-center justify-between gap-3">
<Pagination size="sm" className="w-full"> <div className="flex min-w-0 shrink-0 items-center gap-3">
<Pagination.Summary> <span className="min-w-0 text-sm text-muted">
共 {total} {itemLabel},第 {total ? page + 1 : 0}{" "} 共 {total} {itemLabel},第 {total ? page + 1 : 0}{" "}
/ {total ? totalPages : 0} 页 / {total ? totalPages : 0} 页
</Pagination.Summary> </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.Content>
<Pagination.Item> <Pagination.Item>
<Pagination.Previous <Pagination.Previous
......
...@@ -54,6 +54,7 @@ export default function XinyuanAutocomplete({ ...@@ -54,6 +54,7 @@ export default function XinyuanAutocomplete({
popoverHeader, popoverHeader,
renderOption, renderOption,
searchPlaceholder = "输入关键词搜索", searchPlaceholder = "输入关键词搜索",
showSearch = true,
selectedKey, selectedKey,
selectedKeys, selectedKeys,
selectionMode = "single", selectionMode = "single",
...@@ -139,6 +140,7 @@ export default function XinyuanAutocomplete({ ...@@ -139,6 +140,7 @@ export default function XinyuanAutocomplete({
onInputChange={onInputChange} onInputChange={onInputChange}
> >
{popoverHeader} {popoverHeader}
{showSearch ? (
<SearchField <SearchField
aria-label={searchPlaceholder} aria-label={searchPlaceholder}
fullWidth fullWidth
...@@ -154,6 +156,7 @@ export default function XinyuanAutocomplete({ ...@@ -154,6 +156,7 @@ export default function XinyuanAutocomplete({
</SearchField.ClearButton> </SearchField.ClearButton>
</SearchField.Group> </SearchField.Group>
</SearchField> </SearchField>
) : null}
<ListBox <ListBox
aria-label={ariaLabel || placeholder} aria-label={ariaLabel || placeholder}
dependencies={[Array.from(selectedKeyStringSet).join("\u0000")]} dependencies={[Array.from(selectedKeyStringSet).join("\u0000")]}
......
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, onEvents, option }) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => {
chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' });
}, []);
useEffect(() => {
window.addEventListener('resize', hideTooltip);
return () => window.removeEventListener('resize', hideTooltip);
}, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applyPieTheme = (series) => series?.type === 'pie'
? {
...series,
itemStyle: { borderColor: theme.sectorGap, borderRadius: theme.sectorRadius, borderWidth: 2, ...series.itemStyle },
padAngle: series.padAngle ?? 1,
}
: series;
const series = Array.isArray(option?.series) ? option.series.map(applyPieTheme) : applyPieTheme(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: { enabled: true, decal: { show: false }, ...option?.aria },
color: option?.color || theme.palette,
textStyle: { color: theme.label, fontFamily: theme.fontFamily, ...option?.textStyle },
...option,
series,
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, ...onEvents }} option={themedOption} opts={{ renderer: 'canvas' }} ref={chartRef} style={{ height, width: '100%' }} />
</div>
);
}
...@@ -93,3 +93,36 @@ export function colorizeXinyuanChartTooltipPayload(payload = [], resolveColor) { ...@@ -93,3 +93,36 @@ export function colorizeXinyuanChartTooltipPayload(payload = [], resolveColor) {
}; };
}); });
} }
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
return window.getComputedStyle(document.documentElement).getPropertyValue(tokenName).trim() || fallback;
}
export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
return new Map(Array.from(createXinyuanChartColorMap(keys, options)).map(([key, color]) => {
const match = String(color).match(/^var\((--[^)]+)\)$/);
return [key, match ? readRootToken(match[1], '#667085') : color];
}));
}
export function getXinyuanEChartsTheme() {
const fontFamily = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
fontFamily: fontFamily || 'Inter, sans-serif',
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%)'),
sectorGap: readRootToken('--xinyuan-chart-sector-gap', '#ffffff'),
sectorRadius: Number.parseFloat(readRootToken('--xinyuan-chart-sector-radius', '10')) || 10,
};
}
...@@ -436,6 +436,24 @@ ...@@ -436,6 +436,24 @@
gap: var(--rc-page-module-gap); gap: var(--rc-page-module-gap);
} }
.rc-section-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1.125rem;
font-weight: var(--rc-font-bold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-module-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1rem;
font-weight: var(--rc-font-semibold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-page-grid { .rc-page-grid {
min-width: 0; min-width: 0;
gap: var(--rc-page-module-gap); gap: var(--rc-page-module-gap);
...@@ -466,6 +484,10 @@ ...@@ -466,6 +484,10 @@
outline-offset: 2px; outline-offset: 2px;
} }
.rc-echarts-tooltip {
position: fixed !important;
}
.rc-kpi-grid { .rc-kpi-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
...@@ -1454,3 +1476,118 @@ ...@@ -1454,3 +1476,118 @@
border-right: 0; border-right: 0;
} }
} }
/* Final fixed dark Sidebar overrides. */
.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__group-label,
.rc-mobile-sidebar .sidebar__group-label,
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon { color: var(--rc-sidebar-muted); }
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.template-dashboard-shell .sidebar__menu-item-content,
.rc-mobile-sidebar .sidebar__menu-item-content { color: var(--rc-sidebar-foreground); }
.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 {
color: var(--rc-sidebar-item-selected-foreground);
background: var(--rc-sidebar-item-selected);
}
.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 {
color: var(--rc-sidebar-foreground);
background: var(--rc-sidebar-item-hover);
}
.template-dashboard-shell .app-sidebar-user-trigger,
.rc-mobile-sidebar .app-sidebar-user-trigger { color: var(--rc-sidebar-foreground); }
/* Keep HeroUI's generated slot styles from replacing the fixed dark-sidebar tokens. */
.template-dashboard-shell .sidebar__menu-icon,
.rc-mobile-sidebar .sidebar__menu-icon,
.template-dashboard-shell .sidebar__menu-icon .anticon,
.rc-mobile-sidebar .sidebar__menu-icon .anticon {
color: var(--rc-sidebar-muted) !important;
}
.template-dashboard-shell .sidebar__menu-label,
.rc-mobile-sidebar .sidebar__menu-label,
.template-dashboard-shell .app-sidebar-title,
.rc-mobile-sidebar .app-sidebar-title,
.template-dashboard-shell .app-sidebar-user-name,
.rc-mobile-sidebar .app-sidebar-user-name,
.template-dashboard-shell .app-sidebar-app-trigger.button,
.rc-mobile-sidebar .app-sidebar-app-trigger.button {
color: var(--rc-sidebar-foreground) !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 {
color: var(--rc-sidebar-item-selected-foreground) !important;
background: var(--rc-sidebar-item-selected) !important;
}
.template-dashboard-shell
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content
:is(.sidebar__menu-icon, .sidebar__menu-label, .anticon),
.rc-mobile-sidebar
.sidebar__menu-item[data-current='true']
.sidebar__menu-item-content
:is(.sidebar__menu-icon, .sidebar__menu-label, .anticon) {
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 {
color: var(--rc-sidebar-foreground) !important;
background: var(--rc-sidebar-item-hover) !important;
}
.template-dashboard-shell .app-sidebar-user-description,
.rc-mobile-sidebar .app-sidebar-user-description {
color: var(--rc-sidebar-muted) !important;
}
.template-dashboard-shell .app-sidebar-user-trigger,
.rc-mobile-sidebar .app-sidebar-user-trigger {
color: var(--rc-sidebar-foreground) !important;
}
.template-dashboard-shell .app-sidebar-user-trigger.dropdown__trigger:hover,
.template-dashboard-shell .app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.template-dashboard-shell .app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'],
.rc-mobile-sidebar .app-sidebar-user-trigger.dropdown__trigger:hover,
.rc-mobile-sidebar .app-sidebar-user-trigger.dropdown__trigger[data-hovered='true'],
.rc-mobile-sidebar .app-sidebar-user-trigger.dropdown__trigger[aria-expanded='true'] {
color: var(--rc-sidebar-foreground) !important;
background: var(--rc-sidebar-item-hover) !important;
}
.template-dashboard-shell .app-sidebar-user-avatar,
.rc-mobile-sidebar .app-sidebar-user-avatar {
background: var(--rc-sidebar-item-hover) !important;
}
.template-dashboard-shell .app-sidebar-app-trigger.button:hover,
.template-dashboard-shell .app-sidebar-app-trigger.button[data-hovered='true'],
.template-dashboard-shell .app-sidebar-app-trigger.button[data-pressed='true'],
.template-dashboard-shell .app-sidebar-app-trigger.button[aria-expanded='true'],
.rc-mobile-sidebar .app-sidebar-app-trigger.button:hover,
.rc-mobile-sidebar .app-sidebar-app-trigger.button[data-hovered='true'],
.rc-mobile-sidebar .app-sidebar-app-trigger.button[data-pressed='true'],
.rc-mobile-sidebar .app-sidebar-app-trigger.button[aria-expanded='true'] {
color: var(--rc-sidebar-item-selected-foreground) !important;
background: var(--rc-sidebar-item-selected) !important;
}
...@@ -11,38 +11,37 @@ ...@@ -11,38 +11,37 @@
[data-theme="light"], [data-theme="light"],
[data-theme="default"] { [data-theme="default"] {
/* Theme Colors (Light Mode) */ /* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03); --accent: oklch(57.14% 0.2156 29.20);
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03); --background: oklch(97.02% 0.0000 29.20);
--border: oklch(90.00% 0.0045 27.03); --border: oklch(90.00% 0.0000 29.20);
--danger: oklch(45.32% 0.1661 27.03); --danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(94.00% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(21.03% 0.0059 29.20);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(100.00% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(21.03% 0.0000 29.20);
--field-placeholder: oklch(55.17% 0.0090 27.03); --field-placeholder: oklch(55.17% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: oklch(57.14% 0.2156 29.20);
--foreground: oklch(21.03% 0.0045 27.03); --foreground: oklch(21.03% 0.0000 29.20);
--muted: oklch(55.17% 0.0090 27.03); --muted: oklch(55.17% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(100.00% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(21.03% 0.0000 29.20);
--scrollbar: oklch(87.10% 0.0045 27.03); --scrollbar: oklch(87.10% 0.0000 29.20);
--segment: oklch(100.00% 0.0045 27.03); --segment: oklch(100.00% 0.0000 29.20);
--segment-foreground: oklch(21.03% 0.0045 27.03); --segment-foreground: oklch(21.03% 0.0000 29.20);
--separator: oklch(92.00% 0.0045 27.03); --separator: oklch(92.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); --success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(100.00% 0.0022 27.03); --surface: oklch(100.00% 0.0000 29.20);
--surface-foreground: oklch(21.03% 0.0045 27.03); --surface-foreground: oklch(21.03% 0.0000 29.20);
--surface-secondary: oklch(95.24% 0.0036 27.03); --surface-secondary: oklch(95.24% 0.0000 29.20);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03); --surface-secondary-foreground: oklch(21.03% 0.0000 29.20);
--surface-tertiary: oklch(93.73% 0.0036 27.03); --surface-tertiary: oklch(93.73% 0.0000 29.20);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03); --surface-tertiary-foreground: oklch(21.03% 0.0000 29.20);
--warning: oklch(78.19% 0.1599 45.11); --warning: oklch(78.19% 0.1585 45.37);
--warning-foreground: oklch(21.03% 0.0059 45.11); --warning-foreground: oklch(21.03% 0.0059 45.37);
/* Border Radius */ /* Border Radius */
--radius: 0.5rem; --radius: 0.5rem;
...@@ -51,42 +50,86 @@ ...@@ -51,42 +50,86 @@
/* Font Family */ /* Font Family */
/* Make sure to load Inter font in your app */ /* Make sure to load Inter font in your app */
--font-sans: var(--font-inter); --font-sans: var(--font-inter);
/*
* Xinyuan fixed split-shell contract.
* The application canvas always uses this light theme. The Sidebar is the
* only persistent dark plane and must consume these semantic tokens.
*/
--xinyuan-sidebar-background: oklch(16% 0.020 255);
--xinyuan-sidebar-border: oklch(26% 0.022 255);
--xinyuan-sidebar-foreground: oklch(97% 0.004 255);
--xinyuan-sidebar-muted: oklch(76% 0.018 255);
--xinyuan-sidebar-item-hover: oklch(22% 0.026 255);
--xinyuan-sidebar-item-selected: oklch(28% 0.016 255);
--xinyuan-sidebar-item-selected-foreground: oklch(99% 0.002 255);
--xinyuan-sidebar-focus: oklch(78% 0.105 29.20);
/*
* ECharts is the required chart renderer for Xinyuan applications.
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
--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, .dark,
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* Theme Colors (Dark Mode) */ /* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03); --accent: oklch(57.14% 0.2156 29.20);
--accent-foreground: oklch(99.11% 0 0); --accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03); --background: oklch(12.00% 0.0000 29.20);
--border: oklch(28.00% 0.0045 27.03); --border: oklch(28.00% 0.0000 29.20);
--danger: oklch(62.00% 0.1600 27.03); --danger: oklch(62% 0.16 27.03);
--danger-foreground: oklch(99.11% 0 0); --danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */ --default: oklch(27.40% 0.0000 29.20);
--default: var(--surface-secondary); --default-foreground: oklch(99.11% 0 0);
--default-foreground: var(--surface-secondary-foreground); --field-background: oklch(21.03% 0.0000 29.20);
--field-background: var(--surface);
--field-border: transparent; --field-border: transparent;
--field-foreground: var(--surface-foreground); --field-foreground: oklch(99.11% 0.0000 29.20);
--field-placeholder: oklch(70.50% 0.0090 27.03); --field-placeholder: oklch(70.50% 0.0000 29.20);
--focus: oklch(45.32% 0.1661 27.03); --focus: oklch(57.14% 0.2156 29.20);
--foreground: oklch(99.11% 0.0045 27.03); --foreground: oklch(99.11% 0.0000 29.20);
--muted: oklch(70.50% 0.0090 27.03); --muted: oklch(70.50% 0.0000 29.20);
--overlay: var(--surface); --overlay: oklch(21.03% 0.0000 29.20);
--overlay-foreground: var(--surface-foreground); --overlay-foreground: oklch(99.11% 0.0000 29.20);
--scrollbar: oklch(70.50% 0.0045 27.03); --scrollbar: oklch(70.50% 0.0000 29.20);
--segment: oklch(39.64% 0.0045 27.03); --segment: oklch(39.64% 0.0000 29.20);
--segment-foreground: oklch(99.11% 0.0045 27.03); --segment-foreground: oklch(99.11% 0.0000 29.20);
--separator: oklch(25.00% 0.0045 27.03); --separator: oklch(25.00% 0.0000 29.20);
--success: oklch(73.29% 0.1952 123.59); --success: oklch(73.29% 0.1935 123.85);
--success-foreground: oklch(21.03% 0.0059 123.59); --success-foreground: oklch(21.03% 0.0059 123.85);
--surface: oklch(21.03% 0.0090 27.03); --surface: oklch(21.03% 0.0000 29.20);
--surface-foreground: oklch(99.11% 0.0045 27.03); --surface-foreground: oklch(99.11% 0.0000 29.20);
--surface-secondary: oklch(25.70% 0.0067 27.03); --surface-secondary: oklch(25.70% 0.0000 29.20);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03); --surface-secondary-foreground: oklch(99.11% 0.0000 29.20);
--surface-tertiary: oklch(27.21% 0.0067 27.03); --surface-tertiary: oklch(27.21% 0.0000 29.20);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03); --surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1400 49.12); --warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.12); --warning-foreground: oklch(21.03% 0.0059 49.38);
} }
...@@ -27,10 +27,19 @@ ...@@ -27,10 +27,19 @@
var(--background) var(--background)
); );
--rc-text-muted: var(--muted); --rc-text-muted: var(--muted);
--rc-sidebar-background: var(--xinyuan-sidebar-background);
--rc-sidebar-border: var(--xinyuan-sidebar-border);
--rc-sidebar-foreground: var(--xinyuan-sidebar-foreground);
--rc-sidebar-muted: var(--xinyuan-sidebar-muted);
--rc-sidebar-item-hover: var(--xinyuan-sidebar-item-hover);
--rc-sidebar-item-selected: var(--xinyuan-sidebar-item-selected);
--rc-sidebar-item-selected-foreground: var(--xinyuan-sidebar-item-selected-foreground);
--rc-sidebar-focus: var(--xinyuan-sidebar-focus);
--rc-font-sans: var(--font-sans); --rc-font-sans: var(--font-sans);
--rc-font-medium: 500; --rc-font-medium: 500;
--rc-font-semibold: 600; --rc-font-semibold: 600;
--rc-font-bold: 700;
--rc-line-normal: 1.5; --rc-line-normal: 1.5;
--rc-text-xs: 0.75rem; --rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem; --rc-text-sm: 0.875rem;
...@@ -146,6 +155,14 @@ ...@@ -146,6 +155,14 @@
--rc-chart-6: oklch(70% 0.12 80); --rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295); --rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250); --rc-chart-8: oklch(57% 0.035 250);
--rc-chart-9: var(--xinyuan-chart-9);
--rc-chart-10: var(--xinyuan-chart-10);
--rc-chart-11: var(--xinyuan-chart-11);
--rc-chart-12: var(--xinyuan-chart-12);
--rc-chart-13: var(--xinyuan-chart-13);
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1); --rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03); --rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6); --rc-chart-warning: var(--rc-chart-6);
......
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { ExclamationCircleIcon, XMarkIcon } from '@/components/AppIcons'; import { ExclamationCircleIcon, MagnifyingGlassIcon, XMarkIcon } from '@/components/AppIcons';
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME, APPLICATION_DISPLAY_NAME,
} from '@/config/application'; } from '@/config/application';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import { import {
Alert, Alert,
Avatar, Avatar,
Card,
Chip, Chip,
Description, Description,
Modal, Modal,
Skeleton, Skeleton,
SearchField,
ScrollShadow,
Surface, Surface,
} from '@heroui/react'; } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
...@@ -226,68 +229,38 @@ function markCurrentApplication(applications) { ...@@ -226,68 +229,38 @@ function markCurrentApplication(applications) {
function ApplicationCard({ application }) { function ApplicationCard({ application }) {
const current = application.current === true; const current = application.current === true;
const target = application?.url?.startsWith('http') ? '_blank' : undefined; const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A'; const description = application.description?.trim();
const hasDistinctDescription = description && description !== application.title.trim();
return ( return (
<Card className="h-full" variant="secondary"> <ItemCard aria-current={current ? 'page' : undefined} className="h-full min-w-0 no-underline" href={application.url} rel={target ? 'noreferrer' : undefined} render={(props) => <a {...props} />} target={target} variant={current ? 'outline' : 'default'}>
<Card.Content> <ItemCard.Icon aria-hidden="true">
<a <Surface className="rc-application-identity-well" variant="default">
href={application.url} <Avatar className="rc-application-identity-avatar" color="accent" variant="soft">
target={target} <Avatar.Image alt="" className="object-contain p-1.5" src={application.logo || xinyuanLogo} />
rel={target ? 'noreferrer' : undefined} <Avatar.Fallback><img alt="" className="object-contain p-1.5" src={xinyuanLogo} /></Avatar.Fallback>
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
>
<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
alt=""
className="object-contain p-1.5"
src={application.logo}
/>
) : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar> </Avatar>
</Surface> </Surface>
<span className="min-w-0 flex-1"> </ItemCard.Icon>
<span className="block truncate text-sm font-medium leading-5 text-foreground" title={application.title}> <ItemCard.Content>
{application.title} <ItemCard.Title title={application.title}>{application.title}</ItemCard.Title>
</span> {hasDistinctDescription ? <ItemCard.Description title={description}>{description}</ItemCard.Description> : null}
<Description className="mt-0.5 block truncate leading-4" title={application.description}> </ItemCard.Content>
{application.description || application.name || '应用'} {current ? <ItemCard.Action><Chip color="accent" size="sm">当前</Chip></ItemCard.Action> : null}
</Description> </ItemCard>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
); );
} }
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用"> <ItemCardGroup aria-label="正在加载应用" className="grid-cols-1 sm:grid-cols-2" columns={2} layout="grid" variant="transparent">
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary"> <ItemCard key={index} variant="default">
<Card.Content className="flex items-center gap-3"> <ItemCard.Icon><Skeleton className="size-9" /></ItemCard.Icon>
<Skeleton className="size-9" /> <ItemCard.Content className="gap-2"><Skeleton className="h-4 w-2/3" /><Skeleton className="h-3 w-full" /></ItemCard.Content>
<div className="min-w-0 flex-1 space-y-2"> </ItemCard>
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</Card.Content>
</Card>
))} ))}
</div> </ItemCardGroup>
); );
} }
...@@ -319,6 +292,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -319,6 +292,7 @@ export default function ApplicationSwitcherDialog({
fallbackApplications.length > 0 ? 'success' : 'idle', fallbackApplications.length > 0 ? 'success' : 'idle',
); );
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || ''; return applications.find(isCurrentApplication)?.name || '';
}, [applications]); }, [applications]);
...@@ -326,6 +300,20 @@ export default function ApplicationSwitcherDialog({ ...@@ -326,6 +300,20 @@ export default function ApplicationSwitcherDialog({
() => buildApplicationSections(applications, siteInfo?.group), () => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group], [applications, siteInfo?.group],
); );
const filteredApplicationSections = useMemo(() => {
const keyword = applicationSearch.trim().toLowerCase();
if (!keyword) return applicationSections;
return applicationSections
.map((section) => ({
...section,
applications: section.applications.filter((application) =>
[application.title, application.description, application.name]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword)),
),
}))
.filter((section) => section.applications.length > 0);
}, [applicationSearch, applicationSections]);
useEffect(() => { useEffect(() => {
if (fallbackApplications.length === 0) { if (fallbackApplications.length === 0) {
...@@ -343,6 +331,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -343,6 +331,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setApplicationSearch('');
return; return;
} }
...@@ -399,7 +388,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -399,7 +388,7 @@ export default function ApplicationSwitcherDialog({
> >
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg"> <Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Dialog <Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))] p-7" className="max-h-[min(42rem,calc(100dvh-2rem))] bg-background p-7"
style={{ style={{
maxWidth: 'min(46rem, calc(100vw - 2rem))', maxWidth: 'min(46rem, calc(100vw - 2rem))',
width: 'min(46rem, calc(100vw - 2rem))', width: 'min(46rem, calc(100vw - 2rem))',
...@@ -412,11 +401,23 @@ export default function ApplicationSwitcherDialog({ ...@@ -412,11 +401,23 @@ export default function ApplicationSwitcherDialog({
<XMarkIcon size={16} /> <XMarkIcon size={16} />
</Modal.CloseTrigger> </Modal.CloseTrigger>
<Modal.Header> <Modal.Header>
<Modal.Heading>应用切换</Modal.Heading> <SearchField
<div className="text-sm text-muted">选择你可访问的应用</div> aria-label="搜索应用"
className="w-full min-w-0"
value={applicationSearch}
variant="secondary"
onChange={setApplicationSearch}
>
<SearchField.Group>
<SearchField.SearchIcon><MagnifyingGlassIcon size={16} /></SearchField.SearchIcon>
<SearchField.Input placeholder="搜索应用" />
<SearchField.ClearButton aria-label="清空应用搜索" />
</SearchField.Group>
</SearchField>
</Modal.Header> </Modal.Header>
<Modal.Body style={{ marginTop: '0.75rem' }}> <Modal.Body className="flex min-h-0 overflow-hidden" style={{ marginTop: '0.75rem' }}>
<ScrollShadow className="h-full min-h-0 flex-1 overflow-y-auto pe-1 pb-1" hideScrollBar={false} offset={12} size={24}>
{status === 'error' && errorMessage ? ( {status === 'error' && errorMessage ? (
<Alert status="warning"> <Alert status="warning">
<Alert.Indicator><ExclamationCircleIcon size={20} /></Alert.Indicator> <Alert.Indicator><ExclamationCircleIcon size={20} /></Alert.Indicator>
...@@ -428,9 +429,9 @@ export default function ApplicationSwitcherDialog({ ...@@ -428,9 +429,9 @@ export default function ApplicationSwitcherDialog({
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : applications.length > 0 ? ( ) : filteredApplicationSections.length > 0 ? (
<div className="space-y-6"> <div className="space-y-6">
{applicationSections.map((section) => ( {filteredApplicationSections.map((section) => (
<section key={section.key}> <section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium leading-5 text-foreground"> <h3 className="text-sm font-medium leading-5 text-foreground">
...@@ -440,7 +441,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -440,7 +441,7 @@ export default function ApplicationSwitcherDialog({
{section.applications.length} 个应用 {section.applications.length} 个应用
</Description> </Description>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <ItemCardGroup className="grid-cols-1 sm:grid-cols-2" columns={2} layout="grid" variant="transparent">
{section.applications.map((application) => ( {section.applications.map((application) => (
<ApplicationCard <ApplicationCard
key={application.name} key={application.name}
...@@ -450,18 +451,19 @@ export default function ApplicationSwitcherDialog({ ...@@ -450,18 +451,19 @@ export default function ApplicationSwitcherDialog({
}} }}
/> />
))} ))}
</div> </ItemCardGroup>
</section> </section>
))} ))}
</div> </div>
) : ( ) : (
<EmptyState size="md"> <EmptyState size="md">
<EmptyState.Header> <EmptyState.Header>
<EmptyState.Title>暂无可切换应用</EmptyState.Title> <EmptyState.Title>{applicationSearch.trim() ? '没有找到匹配的应用' : '暂无可切换应用'}</EmptyState.Title>
<EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description> <EmptyState.Description>{applicationSearch.trim() ? '请尝试其他关键词。' : '当前账号还没有分配其它应用权限。'}</EmptyState.Description>
</EmptyState.Header> </EmptyState.Header>
</EmptyState> </EmptyState>
)} )}
</ScrollShadow>
</Modal.Body> </Modal.Body>
</Modal.Dialog> </Modal.Dialog>
</Modal.Container> </Modal.Container>
......
import xinyuanLogoRed from "@/assets/xy-logo-red.png";
import xinyuanLogoWhite from "@/assets/xy-logo-white.png"; import xinyuanLogoWhite from "@/assets/xy-logo-white.png";
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
...@@ -6,68 +5,46 @@ import { ...@@ -6,68 +5,46 @@ import {
ROUTE_TITLES, ROUTE_TITLES,
} from "@/config/application"; } from "@/config/application";
import ApplicationSwitcherDialog from "@/pages/project_manager_xy/ApplicationSwitcherDialog"; import ApplicationSwitcherDialog from "@/pages/project_manager_xy/ApplicationSwitcherDialog";
import { Bell, SidebarPanel } from "@/components/AppIcons"; import {
AppGrid,
Bell,
Database,
Folder,
LogOut,
SidebarPanel,
} from "@/components/AppIcons";
import { import {
loadUnreadNotificationCount, loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT, NOTIFICATION_STATE_EVENT,
} from "@/pages/project_manager_xy/notifications/notificationService"; } from "@/pages/project_manager_xy/notifications/notificationService";
import { UserContext } from "@/wrapper/Auth"; import { UserContext } from "@/wrapper/Auth";
import { SiteContext } from "@/wrapper/Site"; import { SiteContext } from "@/wrapper/Site";
import {
AppstoreOutlined,
DatabaseOutlined,
FolderOpenOutlined,
LogoutOutlined,
MoonOutlined,
SunOutlined,
} from "@ant-design/icons";
import { Avatar, Badge, Button, Dropdown, Label, Tooltip } from "@heroui/react"; import { Avatar, Badge, Button, Dropdown, Label, Tooltip } from "@heroui/react";
import { AppLayout } from "@heroui-pro/react/app-layout";
import { Navbar } from "@heroui-pro/react/navbar"; import { Navbar } from "@heroui-pro/react/navbar";
import { Sheet } from "@heroui-pro/react/sheet"; import { Sheet } from "@heroui-pro/react/sheet";
import { Sidebar, useSidebar } from "@heroui-pro/react/sidebar"; import { Sidebar, useSidebar } from "@heroui-pro/react/sidebar";
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { history, Outlet, useLocation } from "umi"; import { history, Outlet, useLocation } from "umi";
const THEME_STORAGE_KEY = "xinyuan_color_theme";
const menuGroups = [ const menuGroups = [
{ {
title: "统一材料工作台", title: APPLICATION_DISPLAY_NAME,
showLabel: false,
items: [ items: [
{ {
name: "工作台", name: "工作台",
path: "/resource/stat", path: "/resource/stat",
icon: DatabaseOutlined, icon: Database,
}, },
{ {
name: "材料归档", name: "材料归档",
path: "/resource/booking", path: "/resource/booking",
icon: FolderOpenOutlined, icon: Folder,
}, },
], ],
}, },
]; ];
function getPreferredTheme() {
if (typeof window === "undefined") {
return "light";
}
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
return storedTheme === "dark" ? "dark" : "light";
}
function applyTheme(theme) {
if (typeof document === "undefined") {
return;
}
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.classList.toggle("light", theme !== "dark");
}
function NavItem({ name, path, active, icon: Icon }) { function NavItem({ name, path, active, icon: Icon }) {
return ( return (
<Sidebar.MenuItem <Sidebar.MenuItem
...@@ -78,7 +55,7 @@ function NavItem({ name, path, active, icon: Icon }) { ...@@ -78,7 +55,7 @@ function NavItem({ name, path, active, icon: Icon }) {
tooltip={name} tooltip={name}
> >
<Sidebar.MenuIcon> <Sidebar.MenuIcon>
<Icon /> <Icon size={16} />
</Sidebar.MenuIcon> </Sidebar.MenuIcon>
<Sidebar.MenuLabel>{name}</Sidebar.MenuLabel> <Sidebar.MenuLabel>{name}</Sidebar.MenuLabel>
</Sidebar.MenuItem> </Sidebar.MenuItem>
...@@ -108,16 +85,7 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) { ...@@ -108,16 +85,7 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) {
<Sidebar.Header> <Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand"> <div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark"> <div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img <img alt="鑫元" className="rc-sidebar-logo" src={xinyuanLogoWhite} />
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--light"
src={xinyuanLogoRed}
/>
<img
alt="鑫元"
className="rc-sidebar-logo rc-sidebar-logo--dark"
src={xinyuanLogoWhite}
/>
</div> </div>
<div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label"> <div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label">
<span className="app-sidebar-title rc-sidebar-title"> <span className="app-sidebar-title rc-sidebar-title">
...@@ -125,7 +93,6 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) { ...@@ -125,7 +93,6 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) {
</span> </span>
</div> </div>
<div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label"> <div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label">
<Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
aria-label="切换应用" aria-label="切换应用"
...@@ -134,10 +101,8 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) { ...@@ -134,10 +101,8 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) {
variant="ghost" variant="ghost"
onPress={handleOpenApplicationDialog} onPress={handleOpenApplicationDialog}
> >
<AppstoreOutlined /> <AppGrid size={16} />
</Button> </Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div> </div>
</div> </div>
</Sidebar.Header> </Sidebar.Header>
...@@ -145,7 +110,9 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) { ...@@ -145,7 +110,9 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) {
<Sidebar.Content> <Sidebar.Content>
{menuGroups.map((group) => ( {menuGroups.map((group) => (
<Sidebar.Group key={group.title}> <Sidebar.Group key={group.title}>
{group.showLabel !== false ? (
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
) : null}
<Sidebar.Menu aria-label={group.title}> <Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => ( {group.items.map((item) => (
<NavItem <NavItem
...@@ -195,7 +162,7 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) { ...@@ -195,7 +162,7 @@ function SidebarContent({ currentPath, onOpenApplicationDialog, userInfo }) {
> >
<div className="rc-sidebar-user-menu-item"> <div className="rc-sidebar-user-menu-item">
<Label>退出登录</Label> <Label>退出登录</Label>
<LogoutOutlined aria-hidden="true" /> <LogOut size={14} />
</div> </div>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
...@@ -214,19 +181,16 @@ function MobileSidebar({ children }) { ...@@ -214,19 +181,16 @@ function MobileSidebar({ children }) {
} }
return ( return (
<Sheet <Sheet.Root
isOpen={isMobileOpen} isOpen={isMobileOpen}
placement={side} placement={side}
onOpenChange={setMobileOpen} onOpenChange={setMobileOpen}
> >
<Sheet.Backdrop variant="blur"> <Sheet.Backdrop variant="blur">
<Sheet.Content className="h-full w-4/5"> <Sheet.Content className="sidebar__mobile-sheet">
<Sheet.Dialog <Sheet.Dialog aria-label={`${APPLICATION_DISPLAY_NAME}导航`} className="sidebar__mobile-dialog">
aria-label="主导航"
className="h-full"
>
<div <div
className="app-sidebar-glass rc-mobile-sidebar" className="sidebar__mobile app-sidebar-glass rc-mobile-sidebar"
data-slot="sidebar-mobile" data-slot="sidebar-mobile"
> >
{children} {children}
...@@ -234,7 +198,7 @@ function MobileSidebar({ children }) { ...@@ -234,7 +198,7 @@ function MobileSidebar({ children }) {
</Sheet.Dialog> </Sheet.Dialog>
</Sheet.Content> </Sheet.Content>
</Sheet.Backdrop> </Sheet.Backdrop>
</Sheet> </Sheet.Root>
); );
} }
...@@ -242,16 +206,10 @@ export default function ProjectManagerLayout() { ...@@ -242,16 +206,10 @@ export default function ProjectManagerLayout() {
const location = useLocation(); const location = useLocation();
const siteInfo = useContext(SiteContext); const siteInfo = useContext(SiteContext);
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const [theme, setTheme] = useState(() => getPreferredTheme());
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false); const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0); const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
useEffect(() => {
applyTheme(theme);
window.localStorage?.setItem?.(THEME_STORAGE_KEY, theme);
}, [theme]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -313,7 +271,6 @@ export default function ProjectManagerLayout() { ...@@ -313,7 +271,6 @@ export default function ProjectManagerLayout() {
document.title = `${currentRoute.name} - ${APPLICATION_DISPLAY_NAME}`; document.title = `${currentRoute.name} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentRoute.name]); }, [currentRoute.name]);
const nextTheme = theme === "dark" ? "light" : "dark";
const sidebarNavigation = ( const sidebarNavigation = (
<SidebarContent <SidebarContent
currentPath={appPathname} currentPath={appPathname}
...@@ -323,15 +280,19 @@ export default function ProjectManagerLayout() { ...@@ -323,15 +280,19 @@ export default function ProjectManagerLayout() {
); );
return ( return (
<AppLayout <Sidebar.Provider
className="template-dashboard-shell rc-app-shell" className="template-dashboard-shell rc-app-shell light"
data-sidebar-collapsible="offcanvas" collapsible="offcanvas"
navigate={(href) => history.push(href)} navigate={(href) => history.push(href)}
navbar={ open={isSidebarOpen}
<Navbar onOpenChange={setIsSidebarOpen}
className="app-navbar rc-navbar"
maxWidth="full"
> >
<Sidebar.Root className="app-sidebar-glass rc-sidebar">
{sidebarNavigation}
</Sidebar.Root>
<MobileSidebar>{sidebarNavigation}</MobileSidebar>
<Sidebar.Main className="app-main-glass rc-app-main">
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header> <Navbar.Header>
<Sidebar.Trigger <Sidebar.Trigger
className="app-navbar-trigger rc-navbar-trigger" className="app-navbar-trigger rc-navbar-trigger"
...@@ -339,12 +300,6 @@ export default function ProjectManagerLayout() { ...@@ -339,12 +300,6 @@ export default function ProjectManagerLayout() {
> >
<SidebarPanel size={16} /> <SidebarPanel size={16} />
</Sidebar.Trigger> </Sidebar.Trigger>
<AppLayout.MenuToggle
aria-label="打开导航"
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel size={16} />
</AppLayout.MenuToggle>
<h1 className="app-navbar-page-title rc-navbar-title">{currentRoute.name}</h1> <h1 className="app-navbar-page-title rc-navbar-title">{currentRoute.name}</h1>
<Navbar.Spacer /> <Navbar.Spacer />
<div className="rc-navbar-actions"> <div className="rc-navbar-actions">
...@@ -374,52 +329,19 @@ export default function ProjectManagerLayout() { ...@@ -374,52 +329,19 @@ export default function ProjectManagerLayout() {
</Badge.Anchor> </Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content> <Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip> </Tooltip>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={theme === "dark" ? "切换至浅色模式" : "切换至深色模式"}
aria-pressed={theme === "dark"}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => setTheme(nextTheme)}
>
{theme === "dark" ? <SunOutlined /> : <MoonOutlined />}
</Button>
<Tooltip.Content>
{theme === "dark" ? "切换至浅色模式" : "切换至深色模式"}
</Tooltip.Content>
</Tooltip>
</div> </div>
</Navbar.Header> </Navbar.Header>
</Navbar> </Navbar>
} <div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
scrollMode="content" <div className="rc-app-page min-h-full"><Outlet /></div>
sidebar={
<>
<Sidebar.Root className="app-sidebar-glass rc-sidebar">
{sidebarNavigation}
</Sidebar.Root>
<MobileSidebar>
{sidebarNavigation}
</MobileSidebar>
</>
}
sidebarCollapsible="offcanvas"
sidebarOpen={isSidebarOpen}
onSidebarOpenChange={setIsSidebarOpen}
toggleShortcut="mod+b"
>
<div className="rc-app-page min-h-full">
<Outlet />
</div> </div>
</Sidebar.Main>
<ApplicationSwitcherDialog <ApplicationSwitcherDialog
open={isApplicationDialogOpen} open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)} onClose={() => setIsApplicationDialogOpen(false)}
siteInfo={siteInfo} siteInfo={siteInfo}
userInfo={userInfo} userInfo={userInfo}
/> />
</AppLayout> </Sidebar.Provider>
); );
} }
...@@ -2,6 +2,7 @@ import { ...@@ -2,6 +2,7 @@ import {
DataTable, DataTable,
DataTableChip, DataTableChip,
DataTablePagination, DataTablePagination,
DataTablePersonIdentity,
DataTableText, DataTableText,
DataTableTextPair, DataTableTextPair,
} from "@/components/DataTable"; } from "@/components/DataTable";
...@@ -75,7 +76,22 @@ import { ...@@ -75,7 +76,22 @@ import {
} from "../materialService"; } from "../materialService";
const ALL_CATEGORY_KEY = "__all__"; const ALL_CATEGORY_KEY = "__all__";
const DEFAULT_PAGE_SIZE = 10; const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
const READABLE_FILE_TYPE_LABELS = {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
"Excel(XLSX)",
"application/vnd.ms-excel": "Excel(XLS)",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"Word(DOCX)",
"application/msword": "Word(DOC)",
"application/pdf": "PDF",
"application/json": "JSON",
"application/zip": "ZIP",
"application/octet-stream": "文件",
"text/csv": "CSV",
"text/plain": "TXT",
};
const BOOLEAN_FILTER_OPTIONS = [ const BOOLEAN_FILTER_OPTIONS = [
{ value: "true", label: "是" }, { value: "true", label: "是" },
{ value: "false", label: "否" }, { value: "false", label: "否" },
...@@ -144,6 +160,33 @@ function buildEmptyBatchTagForm() { ...@@ -144,6 +160,33 @@ function buildEmptyBatchTagForm() {
}; };
} }
function getReadableFileType(fileName = "", mimeType = "") {
const normalizedMimeType = String(mimeType || "")
.trim()
.toLowerCase();
if (
normalizedMimeType &&
normalizedMimeType !== "application/octet-stream" &&
READABLE_FILE_TYPE_LABELS[normalizedMimeType]
) {
return READABLE_FILE_TYPE_LABELS[normalizedMimeType];
}
const fileNameParts = String(fileName || "").split(".");
const extension =
fileNameParts.length > 1 ? fileNameParts.pop().trim().toUpperCase() : "";
if (extension) {
return extension;
}
if (READABLE_FILE_TYPE_LABELS[normalizedMimeType]) {
return READABLE_FILE_TYPE_LABELS[normalizedMimeType];
}
const mimeSubtype = normalizedMimeType.split("/")[1] || "";
return mimeSubtype.replace(/^x-/, "").toUpperCase() || "文件";
}
function formatDateTimeLocalInput(value) { function formatDateTimeLocalInput(value) {
if (!value) { if (!value) {
return ""; return "";
...@@ -228,20 +271,11 @@ function FormSection({ title, description, children, className }) { ...@@ -228,20 +271,11 @@ function FormSection({ title, description, children, className }) {
); );
} }
function DetailContent({ isEditing, children }) { function DetailContent({ children }) {
const className = 'grid min-h-0 min-w-0 gap-6';
if (isEditing) {
return <div className={className}>{children}</div>;
}
return ( return (
<Surface <div className="grid min-h-0 min-w-0 gap-6">
className={`${className} px-5 py-2 sm:px-6 sm:py-3`}
variant="secondary"
>
{children} {children}
</Surface> </div>
); );
} }
...@@ -263,10 +297,10 @@ function FieldLabel({ children, required = false, infoText, infoTone }) { ...@@ -263,10 +297,10 @@ function FieldLabel({ children, required = false, infoText, infoTone }) {
function DetailValue({ label, children }) { function DetailValue({ label, children }) {
return ( return (
<div className="min-w-0 py-2"> <div className="min-w-0 py-3">
<div className="text-sm text-muted">{label}</div> <div className="text-sm font-medium text-muted">{label}</div>
<div className="mt-1 break-words text-sm font-medium"> <div className="mt-1.5 min-w-0 break-words text-sm text-foreground">
{children || "-"} {children || <span className="text-muted">-</span>}
</div> </div>
</div> </div>
); );
...@@ -385,7 +419,7 @@ export default function ResourceBookingPage() { ...@@ -385,7 +419,7 @@ export default function ResourceBookingPage() {
useState(false); useState(false);
const deferredMaterialSearchForm = useDeferredValue(materialSearchForm); const deferredMaterialSearchForm = useDeferredValue(materialSearchForm);
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY); const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const pageSize = DEFAULT_PAGE_SIZE; const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [categoryOptions, setCategoryOptions] = useState([]); const [categoryOptions, setCategoryOptions] = useState([]);
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
...@@ -620,7 +654,10 @@ export default function ResourceBookingPage() { ...@@ -620,7 +654,10 @@ export default function ResourceBookingPage() {
render: (material) => ( render: (material) => (
<DataTableTextPair <DataTableTextPair
primary={material.fileName} primary={material.fileName}
secondary={`${material.fileType} · ${material.fileSizeLabel}`} secondary={`${getReadableFileType(
material.fileName,
material.fileType,
)} · ${material.fileSizeLabel}`}
/> />
), ),
}, },
...@@ -656,7 +693,7 @@ export default function ResourceBookingPage() { ...@@ -656,7 +693,7 @@ export default function ResourceBookingPage() {
title: "上传人", title: "上传人",
minWidth: 160, minWidth: 160,
render: (material) => ( render: (material) => (
<DataTableText text={material.uploaderLabel} showTooltip /> <DataTablePersonIdentity name={material.uploaderLabel} />
), ),
}, },
{ {
...@@ -711,6 +748,16 @@ export default function ResourceBookingPage() { ...@@ -711,6 +748,16 @@ export default function ResourceBookingPage() {
setPage(Math.max(0, Math.min(nextPage, totalPages - 1))); setPage(Math.max(0, Math.min(nextPage, totalPages - 1)));
} }
function handlePageSizeChange(nextPageSize) {
const normalizedPageSize = Number(nextPageSize);
if (!PAGE_SIZE_OPTIONS.includes(normalizedPageSize)) {
return;
}
setPageSize(normalizedPageSize);
setPage(0);
}
function updateMaterialSearchField(fieldName, value) { function updateMaterialSearchField(fieldName, value) {
setMaterialSearchForm((prev) => ({ setMaterialSearchForm((prev) => ({
...prev, ...prev,
...@@ -1527,8 +1574,10 @@ export default function ResourceBookingPage() { ...@@ -1527,8 +1574,10 @@ export default function ResourceBookingPage() {
<DataTablePagination <DataTablePagination
page={page} page={page}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={total} total={total}
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/> />
</div> </div>
</div> </div>
...@@ -1622,24 +1671,48 @@ export default function ResourceBookingPage() { ...@@ -1622,24 +1671,48 @@ export default function ResourceBookingPage() {
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{activeMaterial ? ( {activeMaterial ? (
<DetailContent isEditing={isDetailEditing}> <DetailContent>
<aside className="grid min-w-0 items-center gap-4 lg:grid-cols-[minmax(0,1fr)_auto]"> <aside className="grid min-w-0 gap-4">
<div className="grid min-w-0 items-center gap-3 lg:grid-cols-[minmax(0,1fr)_auto]"> <div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div <div
className="truncate text-base font-medium" className="break-words text-lg font-semibold leading-6 text-foreground"
title={activeMaterial.fileName} title={activeMaterial.fileName}
> >
{activeMaterial.fileName} {activeMaterial.fileName}
</div> </div>
<div className="mt-1.5 flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-sm text-muted"> <div className="mt-2 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-muted">
<span>{activeMaterial.fileType}</span> <span>
{getReadableFileType(
activeMaterial.fileName,
activeMaterial.fileType,
)}
</span>
<span>{activeMaterial.fileSizeLabel}</span> <span>{activeMaterial.fileSizeLabel}</span>
<span>{activeMaterial.uploaderLabel}</span> <span>上传人:{activeMaterial.uploaderLabel || "-"}</span>
<span>上传于 {activeMaterial.uploadedAtLabel}</span> <span>上传于 {activeMaterial.uploadedAtLabel}</span>
</div> </div>
</div> </div>
<div className="flex max-w-96 flex-wrap justify-end gap-1.5 max-lg:justify-start"> <div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="secondary"
className="cursor-pointer"
onPress={handleCopyFileUrl}
>
<ClipboardDocumentIcon size={16} />
复制链接
</Button>
<Button
variant="secondary"
className="cursor-pointer"
onPress={handleOpenFile}
>
<ArrowTopRightOnSquareIcon size={16} />
打开文件
</Button>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Chip <Chip
color={activeMaterial.archiveYear ? "warning" : "default"} color={activeMaterial.archiveYear ? "warning" : "default"}
size="sm" size="sm"
...@@ -1647,7 +1720,11 @@ export default function ResourceBookingPage() { ...@@ -1647,7 +1720,11 @@ export default function ResourceBookingPage() {
> >
{activeMaterial.archiveYearLabel} {activeMaterial.archiveYearLabel}
</Chip> </Chip>
<Chip color="accent" size="sm" variant="soft"> <Chip
color={activeMaterial.archiveCategory === UNCATEGORIZED_CATEGORY_LABEL ? "default" : "accent"}
size="sm"
variant="soft"
>
{activeMaterial.archiveCategory} {activeMaterial.archiveCategory}
</Chip> </Chip>
{activeMaterial.tag ? ( {activeMaterial.tag ? (
...@@ -1656,29 +1733,16 @@ export default function ResourceBookingPage() { ...@@ -1656,29 +1733,16 @@ export default function ResourceBookingPage() {
</Chip> </Chip>
) : null} ) : null}
{activeMaterial.isConfidential ? ( {activeMaterial.isConfidential ? (
<Chip color="warning" size="sm" variant="soft">涉密</Chip> <Chip color="warning" size="sm" variant="soft">
涉密
</Chip>
) : null} ) : null}
{activeMaterial.isAuditDeptMaterial ? ( {activeMaterial.isAuditDeptMaterial ? (
<Chip color="accent" size="sm" variant="soft">审计部材料</Chip> <Chip color="accent" size="sm" variant="soft">
审计部材料
</Chip>
) : null} ) : null}
</div> </div>
</div>
<div className="flex flex-wrap justify-end gap-2 max-lg:justify-start">
<Button
variant="secondary"
className="cursor-pointer"
onPress={handleCopyFileUrl}
>
复制链接
</Button>
<Button
variant="secondary"
className="cursor-pointer"
onPress={handleOpenFile}
>
打开文件
</Button>
</div>
</aside> </aside>
<div className="grid min-w-0 content-start gap-6"> <div className="grid min-w-0 content-start gap-6">
...@@ -1876,7 +1940,7 @@ export default function ResourceBookingPage() { ...@@ -1876,7 +1940,7 @@ export default function ResourceBookingPage() {
</FormSection> </FormSection>
</> </>
) : ( ) : (
<div className="grid gap-x-8 sm:grid-cols-2"> <div className="grid min-w-0 gap-x-8 gap-y-2 sm:grid-cols-2">
<DetailValue label="材料标题">{activeMaterial.materialTitle}</DetailValue> <DetailValue label="材料标题">{activeMaterial.materialTitle}</DetailValue>
<DetailValue label="所属周期/年份">{activeMaterial.archiveYearLabel}</DetailValue> <DetailValue label="所属周期/年份">{activeMaterial.archiveYearLabel}</DetailValue>
<DetailValue label="来源应用">{activeMaterial.sourceAppLabel}</DetailValue> <DetailValue label="来源应用">{activeMaterial.sourceAppLabel}</DetailValue>
......
...@@ -2,6 +2,7 @@ import { UserContext } from "@/wrapper/Auth"; ...@@ -2,6 +2,7 @@ import { UserContext } from "@/wrapper/Auth";
import { import {
DataTable, DataTable,
DataTableChip, DataTableChip,
DataTablePersonIdentity,
DataTableText, DataTableText,
DataTableTextPair, DataTableTextPair,
} from "@/components/DataTable"; } from "@/components/DataTable";
...@@ -17,38 +18,41 @@ import { ...@@ -17,38 +18,41 @@ import {
ShieldExclamationIcon, ShieldExclamationIcon,
} from "@/components/AppIcons"; } from "@/components/AppIcons";
import { import {
colorizeXinyuanChartTooltipPayload, createResolvedXinyuanChartColorMap,
createXinyuanChartColorMap, getXinyuanEChartsTheme,
getXinyuanChartColor,
} from "@/design-system/chartPalette"; } from "@/design-system/chartPalette";
import XinyuanEChart from "@/components/XinyuanEChart";
import { import {
Alert, Alert,
Button, Button,
Card, Card,
Chip, Chip,
ProgressBar as HeroProgressBar, ProgressBar as HeroProgressBar,
Separator,
Spinner, Spinner,
Surface,
Tooltip, Tooltip,
} from "@heroui/react"; } 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 { EmptyState } from "@heroui-pro/react/empty-state";
import { KPI } from "@heroui-pro/react/kpi"; import { KPI } from "@heroui-pro/react/kpi";
import { PieChart } from "@heroui-pro/react/pie-chart";
import { Cell, Legend } from "recharts";
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { import {
buildMaterialDashboard, buildMaterialDashboard,
formatDateTime, formatDateTime,
formatFileSize,
loadMaterialRecords, loadMaterialRecords,
} from "../materialService"; } from "../materialService";
function Panel({ className, children }) { function Panel({ title, titleInfo, className, children }) {
return ( return (
<Card className="rc-card-surface rc-dashboard-card"> <Card className="rc-card-surface rc-dashboard-card">
{title ? (
<Card.Header className="flex-row items-center gap-3">
<Card.Title className="rc-module-title min-w-0 flex-1">
{title}
</Card.Title>
{titleInfo ? (
<TitleInfoTooltip label={title} text={titleInfo} />
) : null}
</Card.Header>
) : null}
<Card.Content className={className}>{children}</Card.Content> <Card.Content className={className}>{children}</Card.Content>
</Card> </Card>
); );
...@@ -56,10 +60,10 @@ function Panel({ className, children }) { ...@@ -56,10 +60,10 @@ function Panel({ className, children }) {
function GovernanceCard({ title, titleInfo, icon: Icon, tone, children }) { function GovernanceCard({ title, titleInfo, icon: Icon, tone, children }) {
return ( return (
<Card className="rc-card-surface rc-dashboard-card h-full"> <Card className="rc-card-surface rc-dashboard-card">
<Card.Header className="p-5 pb-0"> <Card.Header className="flex-row items-center gap-3">
<div className="flex w-full min-w-0 items-center justify-between gap-4"> <div className="flex w-full min-w-0 items-center justify-between gap-4">
<Card.Title className="inline-flex min-w-0 items-center gap-1.5 text-sm font-medium"> <Card.Title className="rc-module-title inline-flex min-w-0 items-center gap-1.5">
<span>{title}</span> <span>{title}</span>
{titleInfo ? ( {titleInfo ? (
<TitleInfoTooltip label={title} text={titleInfo} /> <TitleInfoTooltip label={title} text={titleInfo} />
...@@ -74,7 +78,7 @@ function GovernanceCard({ title, titleInfo, icon: Icon, tone, children }) { ...@@ -74,7 +78,7 @@ function GovernanceCard({ title, titleInfo, icon: Icon, tone, children }) {
</span> </span>
</div> </div>
</Card.Header> </Card.Header>
<Card.Content className="flex min-h-0 flex-1 flex-col p-5 pt-4"> <Card.Content className="flex min-h-0 flex-1 flex-col">
{children} {children}
</Card.Content> </Card.Content>
</Card> </Card>
...@@ -129,14 +133,12 @@ function MetricCard({ icon: Icon, label, value, hint, tone }) { ...@@ -129,14 +133,12 @@ function MetricCard({ icon: Icon, label, value, hint, tone }) {
function SectionHeader({ title, description }) { function SectionHeader({ title, description }) {
return ( return (
<div className="mb-5"> <h2 className="rc-section-title inline-flex items-center gap-1.5">
<div className="inline-flex items-center gap-1.5 text-lg font-medium">
<span>{title}</span> <span>{title}</span>
{description ? ( {description ? (
<TitleInfoTooltip label={title} text={description} /> <TitleInfoTooltip label={title} text={description} />
) : null} ) : null}
</div> </h2>
</div>
); );
} }
...@@ -150,62 +152,92 @@ function ChartEmptyState({ title }) { ...@@ -150,62 +152,92 @@ function ChartEmptyState({ title }) {
); );
} }
function XinyuanChartTooltipContent({ active, label, payload, colorForEntry }) {
return (
<ChartTooltipContent
active={active}
label={label}
payload={colorizeXinyuanChartTooltipPayload(payload, colorForEntry)}
valueFormatter={(value) => `${Number(value || 0)} 份`}
/>
);
}
function SourceDistributionChart({ data }) { function SourceDistributionChart({ data }) {
const chartData = data.slice(0, 6).map((item) => ({ const chartData = data.slice(0, 6).map((item) => ({
name: item.label, name: item.label,
value: item.count, value: item.count,
})); }));
const colorMap = createXinyuanChartColorMap(chartData.map((item) => item.name)); const colorMap = createResolvedXinyuanChartColorMap(chartData.map((item) => item.name));
if (chartData.length === 0) { if (chartData.length === 0) {
return <ChartEmptyState title="暂无来源分布数据" />; return <ChartEmptyState title="暂无来源分布数据" />;
} }
return ( const theme = getXinyuanEChartsTheme();
<BarChart const chartHeight = 220;
className="rc-chart-interaction" const option = {
data={chartData} grid: { left: 112, right: 40, top: 10, bottom: 10, containLabel: true },
height={320} xAxis: {
layout="vertical" type: "value",
margin={{ left: 16, right: 24 }} minInterval: 1,
> axisLabel: { show: false },
<BarChart.Grid horizontal={false} /> axisLine: { show: false },
<BarChart.XAxis allowDecimals={false} type="number" /> axisTick: { show: false },
<BarChart.YAxis dataKey="name" type="category" width={112} /> splitLine: { show: false },
<BarChart.Tooltip },
content={( yAxis: {
<XinyuanChartTooltipContent type: "category",
colorForEntry={({ sourceDatum }) => inverse: true,
colorMap.get(String(sourceDatum?.name || "")) data: chartData.map((item) => item.name),
axisLine: { show: false },
axisTick: { show: false },
},
series: [{
type: "bar",
name: "数量",
barMaxWidth: 18,
emphasis: { disabled: true },
itemStyle: { borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: "right",
color: theme.label,
fontSize: 12,
formatter: "{c}",
},
data: chartData.map((item) => ({
value: item.value,
itemStyle: { color: colorMap.get(item.name) },
emphasis: {
itemStyle: { color: colorMap.get(item.name), opacity: 1 },
},
blur: {
itemStyle: { color: colorMap.get(item.name), opacity: 1 },
},
})),
}],
tooltip: {
trigger: "axis",
axisPointer: { type: "none" },
valueFormatter: (value) => `${value} 份`,
},
};
return <XinyuanEChart ariaLabel="来源应用分布图" height={chartHeight} option={option} />;
}
function DepartmentRanking({ data }) {
const maxValue = Math.max(...data.slice(0, 5).map((item) => item.count), 1);
if (data.length === 0) {
return <ChartEmptyState title="当前还没有已登记的提供部门" />;
} }
return (
<div className="space-y-4">
{data.slice(0, 1).map((item) => (
<div key={item.key} className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate text-sm">{item.label}</span>
<span className="shrink-0 text-sm font-medium tabular-nums">{item.count}</span>
</div>
<ProgressBar
ariaLabel={`${item.label}材料数占比`}
value={(item.count / maxValue) * 100}
tone="accent"
/> />
)} </div>
/>
<BarChart.Bar
dataKey="value"
maxBarSize={28}
name="数量"
radius={[0, 4, 4, 0]}
>
{chartData.map((item, index) => (
<Cell
key={item.name}
fill={colorMap.get(item.name) || getXinyuanChartColor(item.name, index)}
/>
))} ))}
</BarChart.Bar> </div>
</BarChart>
); );
} }
...@@ -214,63 +246,114 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) { ...@@ -214,63 +246,114 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) {
name: item.label, name: item.label,
value: item.count, value: item.count,
})); }));
const colorMap = createXinyuanChartColorMap(chartData.map((item) => item.name)); const colorMap = createResolvedXinyuanChartColorMap(chartData.map((item) => item.name));
if (chartData.length === 0) { if (chartData.length === 0) {
return <ChartEmptyState title="暂无分类数据" />; return <ChartEmptyState title="暂无分类数据" />;
} }
return ( const theme = getXinyuanEChartsTheme();
<PieChart className="rc-chart-interaction" height={320}> const option = {
<PieChart.Pie legend: { bottom: 0, left: "center" },
data={chartData} series: [{
dataKey="value" type: "pie", name: "分类热度", radius: ["52%", "72%"], center: ["50%", "44%"],
innerRadius="52%" emphasis: { disabled: true },
nameKey="name" data: chartData.map((item) => ({
outerRadius="72%" name: item.name,
paddingAngle={2} value: item.value,
> itemStyle: { color: colorMap.get(item.name) },
{chartData.map((item, index) => ( emphasis: {
<PieChart.Cell itemStyle: { color: colorMap.get(item.name), opacity: 1 },
key={item.name} },
fill={colorMap.get(item.name) || getXinyuanChartColor(item.name, index)} blur: {
/> itemStyle: { color: colorMap.get(item.name), opacity: 1 },
))} },
<PieChart.Label })),
content={({ viewBox }) => { label: { show: true, formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 份}`, rich: { name: { color: theme.label, fontSize: 12, lineHeight: 16 }, value: { color: theme.label, fontSize: 13, fontWeight: 600, lineHeight: 18 } } },
const { cx = 0, cy = 0 } = viewBox || {}; labelLine: { show: true, length: 16, length2: 18, lineStyle: { color: theme.grid } },
return ( }],
<text textAnchor="middle" x={cx} y={cy}> tooltip: {
<tspan fill="var(--foreground)" fontSize="24" fontWeight="600" x={cx} dy="-0.1em"> trigger: "item",
{uniqueCategoryCount} valueFormatter: (value) => `${value} 份`,
</tspan> position: (point, params, dom, rect, size) => {
<tspan fill="var(--muted)" fontSize="12" x={cx} dy="1.6em"> const [viewWidth, viewHeight] = size.viewSize;
分类热度 const [tooltipWidth, tooltipHeight] = size.contentSize;
</tspan> const padding = 12;
</text> const isRightSide = point[0] >= viewWidth / 2;
const x = isRightSide
? padding
: viewWidth - tooltipWidth - padding;
const y = Math.max(
padding,
Math.min(viewHeight - tooltipHeight - padding, point[1] - tooltipHeight / 2),
); );
}} return [x, y];
position="center" },
/> },
</PieChart.Pie> };
<PieChart.Tooltip return <XinyuanEChart ariaLabel="分类热度图" height={220} option={option} />;
content={( }
<XinyuanChartTooltipContent
colorForEntry={({ key, index }) => function ComplianceDistributionChart({ items, total }) {
colorMap.get(String(key || "")) || getXinyuanChartColor(key, index) if (total <= 0) {
return <ChartEmptyState title="暂无合规标记数据" />;
} }
/>
)} const theme = getXinyuanEChartsTheme();
/> const chartItems = items.map((item, index) => ({
<Legend ...item,
iconSize={8} percent: Math.round((item.value / total) * 100),
iconType="circle" color: theme.palette[[0, 3, 15][index] || 0],
wrapperStyle={{ }));
fontSize: "var(--rc-chart-legend-font-size)", const option = {
lineHeight: "var(--rc-chart-legend-line-height)", grid: { left: 0, right: 0, top: 8, bottom: 8 },
}} xAxis: { type: "value", max: 100, show: false },
/> yAxis: { type: "category", data: ["合规构成"], show: false },
</PieChart> series: chartItems.map((item, index) => ({
type: "bar",
name: item.label,
stack: "total",
barWidth: 22,
data: [item.percent],
itemStyle: {
color: item.color,
borderRadius: index === 0
? [11, 0, 0, 11]
: index === chartItems.length - 1
? [0, 11, 11, 0]
: 0,
},
label: {
show: item.percent >= 10,
position: "inside",
color: "#ffffff",
fontSize: 12,
fontWeight: 600,
formatter: `${item.percent}%`,
},
})),
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
valueFormatter: (value) => `${value}%`,
},
};
return (
<div className="space-y-4">
<XinyuanEChart ariaLabel="合规标记构成图" height={74} option={option} />
<div className="grid grid-cols-3 gap-3">
{chartItems.map((item) => (
<div key={item.label} className="min-w-0">
<div className="truncate text-sm text-muted">{item.label}</div>
<div className="mt-1 flex items-baseline gap-1.5 tabular-nums">
<span className="text-lg font-semibold">{item.value}</span>
<span className="text-xs text-muted">{item.percent}%</span>
</div>
</div>
))}
</div>
</div>
); );
} }
...@@ -286,7 +369,7 @@ function ChartPanel({ children, className = "", emptyText = "暂无图表数据" ...@@ -286,7 +369,7 @@ function ChartPanel({ children, className = "", emptyText = "暂无图表数据"
return <div className={className}>{children}</div>; return <div className={className}>{children}</div>;
} }
function ProgressBar({ value, tone = "emerald" }) { function ProgressBar({ value, tone = "emerald", ariaLabel = "完成比例" }) {
const normalizedValue = Math.max(0, Math.min(100, value)); const normalizedValue = Math.max(0, Math.min(100, value));
const color = const color =
tone === "amber" tone === "amber"
...@@ -298,7 +381,7 @@ function ProgressBar({ value, tone = "emerald" }) { ...@@ -298,7 +381,7 @@ function ProgressBar({ value, tone = "emerald" }) {
: "accent"; : "accent";
return ( return (
<HeroProgressBar aria-label="完成比例" color={color} value={normalizedValue}> <HeroProgressBar aria-label={ariaLabel} color={color} value={normalizedValue}>
<HeroProgressBar.Track> <HeroProgressBar.Track>
<HeroProgressBar.Fill /> <HeroProgressBar.Fill />
</HeroProgressBar.Track> </HeroProgressBar.Track>
...@@ -306,33 +389,6 @@ function ProgressBar({ value, tone = "emerald" }) { ...@@ -306,33 +389,6 @@ function ProgressBar({ value, tone = "emerald" }) {
); );
} }
function RatioRow({ label, value, total, tone = "emerald", icon: Icon }) {
const percent = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
{Icon ? (
<span
aria-hidden="true"
className="grid size-5 shrink-0 place-items-center text-muted"
>
<Icon size={16} />
</span>
) : null}
<div className="truncate text-sm">{label}</div>
</div>
<div className="flex shrink-0 items-center gap-2 tabular-nums">
<span className="font-medium">{value}</span>
<span className="text-sm text-muted">{percent}%</span>
</div>
</div>
<ProgressBar value={percent} tone={tone} />
</div>
);
}
const RECENT_UPLOAD_COLUMNS = [ const RECENT_UPLOAD_COLUMNS = [
{ {
key: "materialTitle", key: "materialTitle",
...@@ -367,8 +423,8 @@ const RECENT_UPLOAD_COLUMNS = [ ...@@ -367,8 +423,8 @@ const RECENT_UPLOAD_COLUMNS = [
title: "上传人 / 文件大小", title: "上传人 / 文件大小",
minWidth: 180, minWidth: 180,
render: (item) => ( render: (item) => (
<DataTableTextPair <DataTablePersonIdentity
primary={item.uploaderLabel} name={item.uploaderLabel}
secondary={item.fileSizeLabel} secondary={item.fileSizeLabel}
/> />
), ),
...@@ -446,10 +502,6 @@ export default function ResourceStatPage() { ...@@ -446,10 +502,6 @@ export default function ResourceStatPage() {
"审计同事" "审计同事"
); );
}, [userInfo]); }, [userInfo]);
const topCategoryCards = useMemo(
() => dashboard.categoryGroups.slice(0, 4),
[dashboard.categoryGroups],
);
const providedInfoPercent = useMemo(() => { const providedInfoPercent = useMemo(() => {
if (dashboard.totalCount <= 0) { if (dashboard.totalCount <= 0) {
return 0; return 0;
...@@ -459,11 +511,6 @@ export default function ResourceStatPage() { ...@@ -459,11 +511,6 @@ export default function ResourceStatPage() {
(dashboard.providedInfoCompleteCount / dashboard.totalCount) * 100, (dashboard.providedInfoCompleteCount / dashboard.totalCount) * 100,
); );
}, [dashboard.providedInfoCompleteCount, dashboard.totalCount]); }, [dashboard.providedInfoCompleteCount, dashboard.totalCount]);
const missingProvideInfoCount = useMemo(
() =>
Math.max(dashboard.totalCount - dashboard.providedInfoCompleteCount, 0),
[dashboard.providedInfoCompleteCount, dashboard.totalCount],
);
const plainGovernanceCount = useMemo( const plainGovernanceCount = useMemo(
() => () =>
Math.max( Math.max(
...@@ -478,19 +525,8 @@ export default function ResourceStatPage() { ...@@ -478,19 +525,8 @@ export default function ResourceStatPage() {
dashboard.totalCount, dashboard.totalCount,
], ],
); );
const maxProvideDepartmentCount = useMemo(
() =>
Math.max(
...dashboard.provideDepartmentGroups
.slice(0, 5)
.map((item) => item.count),
1,
),
[dashboard.provideDepartmentGroups],
);
return ( return (
<div className="space-y-6"> <div className="rc-page-stack">
<Panel className="p-5 sm:p-6"> <Panel className="p-5 sm:p-6">
<div> <div>
<h1 className="sr-only">工作台</h1> <h1 className="sr-only">工作台</h1>
...@@ -597,103 +633,31 @@ export default function ResourceStatPage() { ...@@ -597,103 +633,31 @@ export default function ResourceStatPage() {
/> />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <div className="rc-page-grid rc-chart-grid">
<Panel className="p-5 sm:p-6"> <Panel
<SectionHeader
title="来源应用分布" title="来源应用分布"
description="帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。" titleInfo="帮助判断材料是否已经从项目管理、问题整改等应用顺利汇入统一材料池。"
/> >
<ChartPanel emptyText="暂无来源分布数据"> <ChartPanel emptyText="暂无来源分布数据">
<SourceDistributionChart data={dashboard.sourceGroups} /> <SourceDistributionChart data={dashboard.sourceGroups} />
</ChartPanel> </ChartPanel>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{dashboard.sourceGroups.slice(0, 3).map((item, index) => (
<div key={item.key} className="flex min-w-0 items-start gap-3 py-2">
<Chip size="sm" variant="soft">{index + 1}</Chip>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.label}</div>
<div className="mt-1 text-sm text-muted">
{item.count} 份,占比{" "}
{dashboard.totalCount > 0
? `${Math.round((item.count / dashboard.totalCount) * 100)}%`
: "0%"}
</div>
</div>
</div>
))}
</div>
<Separator className="my-5" />
<div>
<div className="text-sm font-medium">来源系统分布</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{dashboard.sourceSystemGroups.slice(0, 4).length > 0 ? (
dashboard.sourceSystemGroups.slice(0, 4).map((item, index) => (
<div key={item.key} className="min-w-0 space-y-3 py-2">
<div className="flex min-w-0 items-start gap-3">
<Chip size="sm" variant="soft">{index + 1}</Chip>
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{item.label}
</div>
<div className="mt-1 text-sm text-muted">
{item.count} 份,占比{" "}
{dashboard.totalCount > 0
? `${Math.round((item.count / dashboard.totalCount) * 100)}%`
: "0%"}
</div>
</div>
</div>
<ProgressBar
value={
dashboard.totalCount > 0
? (item.count / dashboard.totalCount) * 100
: 0
}
tone={index === 0 ? "rose" : "sky"}
/>
</div>
))
) : (
<div className="text-sm text-muted">暂无来源系统数据</div>
)}
</div>
</div>
</Panel> </Panel>
<Panel className="p-5 sm:p-6"> <Panel
<SectionHeader
title="分类热度" title="分类热度"
description="按当前材料数量快速观察哪些分类最常用,也方便检查是否出现大量未分类材料。" titleInfo="按当前材料数量快速观察哪些分类最常用,也方便检查是否出现大量未分类材料。"
/> >
<ChartPanel emptyText="暂无分类数据"> <ChartPanel emptyText="暂无分类数据">
<CategoryDistributionChart <CategoryDistributionChart
data={dashboard.categoryGroups} data={dashboard.categoryGroups}
uniqueCategoryCount={dashboard.uniqueCategoryCount} uniqueCategoryCount={dashboard.uniqueCategoryCount}
/> />
</ChartPanel> </ChartPanel>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{topCategoryCards.length > 0 ? (
topCategoryCards.map((item) => (
<div key={item.key} className="min-w-0 py-2">
<div className="truncate text-sm text-muted">
{item.label}
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">
{item.count}
</div>
<div className="mt-1 text-sm text-muted">
占用 {formatFileSize(item.bytes)}
</div>
</div>
))
) : (
<div className="text-sm text-muted">暂无分类数据</div>
)}
</div>
</Panel> </Panel>
</div> </div>
<section className="min-w-0"> <section className="rc-page-section min-w-0">
<SectionHeader <SectionHeader
title="提供与合规" title="提供与合规"
description="根据最新字段快速看清提供信息补录进度,以及涉密、审计部材料的标记情况。" description="根据最新字段快速看清提供信息补录进度,以及涉密、审计部材料的标记情况。"
...@@ -735,20 +699,6 @@ export default function ResourceStatPage() { ...@@ -735,20 +699,6 @@ export default function ResourceStatPage() {
<div className="mt-1 text-sm text-muted">缺时间</div> <div className="mt-1 text-sm text-muted">缺时间</div>
</div> </div>
</div> </div>
<Surface
className="mt-auto flex items-start gap-3 rounded-2xl p-4"
variant="secondary"
>
<span
aria-hidden="true"
className="grid size-5 shrink-0 place-items-center text-muted"
>
<InformationCircleIcon size={16} />
</span>
<span className="text-sm leading-5 text-muted">
待补录 {missingProvideInfoCount} 份,优先补齐提供部门和提供时间。
</span>
</Surface>
</GovernanceCard> </GovernanceCard>
<GovernanceCard <GovernanceCard
...@@ -757,29 +707,14 @@ export default function ResourceStatPage() { ...@@ -757,29 +707,14 @@ export default function ResourceStatPage() {
titleInfo="分别关注敏感材料、审计部材料与常规材料占比。" titleInfo="分别关注敏感材料、审计部材料与常规材料占比。"
tone="danger" tone="danger"
> >
<div className="space-y-3"> <ComplianceDistributionChart
<RatioRow items={[
icon={ShieldExclamationIcon} { label: "涉密材料", value: dashboard.confidentialCount },
label="涉密材料" { label: "审计部材料", value: dashboard.auditDeptCount },
value={dashboard.confidentialCount} { label: "其它材料", value: plainGovernanceCount },
total={dashboard.totalCount} ]}
tone="rose"
/>
<RatioRow
icon={ShieldCheckIcon}
label="审计部材料"
value={dashboard.auditDeptCount}
total={dashboard.totalCount}
tone="emerald"
/>
<RatioRow
icon={DocumentTextIcon}
label="其它材料"
value={plainGovernanceCount}
total={dashboard.totalCount} total={dashboard.totalCount}
tone="sky"
/> />
</div>
</GovernanceCard> </GovernanceCard>
<GovernanceCard <GovernanceCard
...@@ -791,35 +726,14 @@ export default function ResourceStatPage() { ...@@ -791,35 +726,14 @@ export default function ResourceStatPage() {
{dashboard.provideDepartmentGroups.length} {dashboard.provideDepartmentGroups.length}
</div> </div>
<div className="mt-1 text-sm text-muted">已登记部门数</div> <div className="mt-1 text-sm text-muted">已登记部门数</div>
<div className="mt-5 space-y-4"> <div className="mt-5">
{dashboard.provideDepartmentGroups.length > 0 ? ( <DepartmentRanking data={dashboard.provideDepartmentGroups} />
dashboard.provideDepartmentGroups.slice(0, 5).map((item) => (
<div key={item.key}>
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0 truncate font-medium">
{item.label}
</div>
<div className="shrink-0 tabular-nums text-muted">
{item.count} 份
</div>
</div>
<ProgressBar
value={(item.count / maxProvideDepartmentCount) * 100}
tone="sky"
/>
</div>
))
) : (
<div className="text-sm text-muted">
当前还没有已登记的提供部门
</div>
)}
</div> </div>
</GovernanceCard> </GovernanceCard>
</div> </div>
</section> </section>
<section className="min-w-0"> <section className="rc-page-section min-w-0">
<SectionHeader <SectionHeader
title="最近上传" title="最近上传"
description="快速浏览最近进入材料池的文件,便于发现异常命名、来源遗漏或归档不完整的情况。" description="快速浏览最近进入材料池的文件,便于发现异常命名、来源遗漏或归档不完整的情况。"
......
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