Commit 14cd2363 authored by Andy-bubu's avatar Andy-bubu

feat: align integrity management with Xinyuan UI

parent cd90a7d4
......@@ -13,3 +13,5 @@
/.mfsu
.swc
.pnpm-store
.playwright-cli/
docs/ui-acceptance/
......@@ -22,6 +22,12 @@ export default defineConfig({
fastRefresh: true,
base: APP_BASE_PATH,
publicPath: APP_BASE_PATH,
links: [
{
rel: 'stylesheet',
href: `${APP_BASE_PATH}fonts/inter.css`,
},
],
alias: {
react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'),
......@@ -72,7 +78,7 @@ export default defineConfig({
],
},
],
npmClient: 'npm',
npmClient: 'pnpm',
proxy: {
'/api': {
target: 'https://xy.streams7.com/api',
......
# 廉洁从业管理 UI 架构
## 运行结构
```text
app.js (主题初始化)
-> Site/Auth wrappers (现有鉴权与登出)
-> integrity_practice_management/Layout
-> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
-> RecordDetailDrawer (Modal/Tabs/Surface/动态字段)
-> UserAssignmentDialog / AlertDialog
```
## 责任边界
- `src/config/application.js`:应用名、base、主题存储键。
- `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
- `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。
- `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
- `record-workbench`:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
- `RecordDetailDrawer`:详情草稿、Modal 状态和字段编排;保存仍由 `useSubjectRecord` 处理。
- `TailwindEditableField`:按动态字段类型选择官方控件;不硬编码选项或 API 字段。
- service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。
## 状态与数据流
```text
路由 -> Subject 配置 -> GET Subject/View -> POST record/query
-> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调
-> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name
```
主题状态写入 `xinyuan_color_theme`,同时更新根 `data-theme` 与 `dark` class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离
`.umirc.ts` 只挂载 Login、Layout 和七个 SubjectWorkbench 路由。`src/pages/problem_rectification`、`src/catalyst-ui-kit` 与 demo services 是仓库历史源码,不属于当前运行图。审计必须分别报告“活动路由扫描”和“全仓扫描”,不能用历史命中否定活动迁移,也不能宣称全仓已清零。
## 字体与开发时热更新
- Inter 400/500/600 的拉丁字符字体文件固定放在 `public/fonts/`,由 `.umirc.ts` 生成的常驻 `inter.css` link 按需加载。
- `src/tailwind.css` 不得重新导入 `@fontsource/inter/*.css`。否则字体注册会进入 `umi.css`,开发时替换样式表会造成英文、数字和符号在 Inter 与系统回退字体之间跳动。
- Tailwind v4 使用 `source(none)` 和显式 `@source`,只扫描活动源码入口、`components`、`pages` 和 `wrapper`。禁止自动扫描 `src/.umi`,避免 Umi 生成文件与 Tailwind 编译互相触发无限 HMR。
- 同一工作区只允许一个 Umi dev 实例。多个实例会共享写入 `src/.umi` 并形成交叉编译循环。
# 鑫元 UI 迁移检查清单
## 安装与依赖
- [x] 在目标根目录执行指定 `pnpm dlx hpsetup@latest ...` 命令并成功
- [x] `@heroui-pro/react@1.0.0-beta.8` 来自正式 pnpm 安装
- [x] 根入口、CSS 和 11 个 Pro 子路径通过导入检查
- [x] `package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml` 已更新
- [x] 删除含跨项目 `file:` 路径的过期 `package-lock.json`
- [x] 授权值未写入仓库
## 活动路由
- [x] 登录、鉴权、真实退出
- [x] Pro Sidebar/Navbar/offcanvas/mobile
- [x] HeroUI 应用切换 Modal:46rem/42rem、桌面两列/移动单列、12px 列间距、API Logo/首字 identity、唯一当前态;用户 Dropdown
- [x] 七条 SubjectWorkbench 路由
- [x] Tabs、SearchField/Surface 筛选
- [x] Pro DataGrid 选择、排序、固定列、横向滚动
- [x] HeroUI Pagination(固定每页 10 条、无页容量选择器)、ActionBar、EmptyState
- [x] 4+ 行动作 More 菜单与危险动作末位
- [x] 详情/编辑 Modal、指派 Modal、删除 AlertDialog
- [x] Select、Autocomplete、DatePicker、DropZone 直接 compound API
- [x] 活动路由 KPI/图表判定为不适用
## 保护与扫描
- [x] API、参数、路由、权限、校验、保存回调和业务状态未改
- [x] 活动源码 Catalyst/Headless/Heroicons/ECharts/旧包装引用为零
- [x] 活动源码原生 select/date/datetime-local/file/button 为零
- [x] 活动页面私有背景、色板、描边、圆角和阴影扫描为零
- [x] 包和锁文件本机绝对路径、相邻项目、`file:` 依赖为零
- [x] `npm run build` 通过
- [x] `git diff --check` 通过
- [x] 鑫元 `audit_project.sh` 已运行并分类历史目录告警
## 浏览器
- [x] 目标与问题整改同时运行并同视口比较
- [x] 1440x900、1280x720、768x1024、390x844
- [x] Shell 浅/深色真实按钮切换
- [x] 登录空/填充/聚焦及浅/深色主题继承
- [x] 移动 Sidebar、应用切换、用户菜单和 Portal
- [x] DataGrid populated/empty、选择、排序、ActionBar、Pagination
- [x] 所有活动 DataGrid 的操作列预览入口、首列只读文本、32px 图标按钮与 More 危险项顺序
- [x] 操作列固定内容宽度;非溢出表格由首个业务列 `1fr` 吸收余量,表头与首个动作按标准内边距对齐,右侧无弹性空白
- [x] Tabs、Select、Autocomplete、DatePicker 打开态
- [x] 编辑弹窗远程引用多选:长标题省略、数量汇总、搜索/清空、受控选择、移动端视口边界与单选关闭行为
- [x] 详情查看/编辑、指派、删除确认
- [x] 详情 Modal 4xl/24px、secondary 查看区、tertiary DataGrid 表头、default 表体及固定列层级(四视口浅/深色)
- [x] 新页面重载控制台 0 error
- [x] 真实退出请求 200、base-aware 登录跳转、`current_user`/`auth_token` 清理
## 明确保留
- [ ] 全仓旧组件扫描为零:未完成。未挂载历史 `src/pages/problem_rectification` 与 `src/catalyst-ui-kit` 仍保留,且不进入当前应用路由。
- [ ] KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -8,28 +8,52 @@
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "file:../project_manager_new/node_modules/@ant-design/icons",
"@ant-design/icons": "^6.3.2",
"@fontsource/inter": "^5.3.0",
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@heroui-pro/react": "file:../project_manager_new/node_modules/@heroui-pro/react",
"@heroui/react": "file:../project_manager_new/node_modules/@heroui/react",
"@heroui/styles": "file:../project_manager_new/node_modules/@heroui/styles",
"@number-flow/react": "file:../project_manager_new/node_modules/@number-flow/react",
"@heroui-pro/react": "1.0.0-beta.8",
"@heroui/react": "^3.2.4",
"@heroui/styles": "^3.2.4",
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@number-flow/react": "^0.6.2",
"@react-aria/utils": "^3.34.1",
"@react-stately/utils": "^3.12.1",
"@tailwindcss/postcss": "^4.2.0",
"@tiptap/core": "^3.30.2",
"@tiptap/extension-link": "^3.30.2",
"@tiptap/extension-underline": "^3.30.2",
"@tiptap/extensions": "^3.30.2",
"@tiptap/pm": "^3.30.2",
"@tiptap/react": "^3.30.2",
"@tiptap/starter-kit": "^3.30.2",
"@tiptap/suggestion": "^3.30.2",
"@umijs/max": "^4.5.3",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"motion": "file:../project_manager_new/node_modules/motion",
"react": "file:../project_manager_new/node_modules/react",
"react-aria-components": "file:../project_manager_new/node_modules/react-aria-components",
"react-dom": "file:../project_manager_new/node_modules/react-dom",
"tailwind-merge": "file:../project_manager_new/node_modules/tailwind-merge",
"tailwind-variants": "file:../project_manager_new/node_modules/tailwind-variants",
"maplibre-gl": "^6.4.1",
"marked": "^18.0.10",
"motion": "^12.43.0",
"react": "^19.2.8",
"react-aria-components": "^1.20.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.2.0",
"umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0",
......
This diff is collapsed.
......@@ -3,3 +3,5 @@ allowBuilds:
core-js-pure: false
es5-ext: false
esbuild: false
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url("./inter-latin-400-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 500;
src: url("./inter-latin-500-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 600;
src: url("./inter-latin-600-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
import '@/global.less';
import '@/tailwind.css';
import { defineApp } from 'umi';
import { APPLICATION_BASE_PATH, THEME_STORAGE_KEY } from '@/config/application';
if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
const theme = storedTheme === 'dark' ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}
const appBasePath = String(
INTEGRITY_PRACTICE_APP_BASE_PATH || APPLICATION_BASE_PATH || '/',
).replace(/\/+$/, '');
if (typeof window !== 'undefined' && appBasePath && window.location.pathname === '/login') {
window.location.replace(
`${appBasePath}/login${window.location.search}${window.location.hash}`,
);
}
export async function getInitialState() {
return {
......
......@@ -23,6 +23,8 @@ import SunOutlined from '@ant-design/icons/SunOutlined';
import ToolOutlined from '@ant-design/icons/ToolOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
......@@ -58,6 +60,7 @@ export const Fullscreen = withSize(FullscreenOutlined);
export const FullscreenExit = withSize(FullscreenExitOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined);
export const Reload = withSize(ReloadOutlined);
export const Send = withSize(SendOutlined);
export const Search = withSize(SearchOutlined);
......@@ -66,6 +69,7 @@ export const Sun = withSize(SunOutlined);
export const Tool = withSize(ToolOutlined);
export const Upload = withSize(UploadOutlined);
export const Plus = withSize(PlusOutlined);
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export function SidebarPanel({ size = 16, style, ...props }) {
return (
......
import { THEME_STORAGE_KEY } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { useLayoutEffect, useState } from 'react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try {
return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light';
} catch {
return 'light';
}
}
export default function AppShell({ children, open, onOpenChange, title, ...sidebarProps }) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.classList.toggle('light', theme === 'light');
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {}
}, [theme]);
return (
<Sidebar.Provider
className={`template-dashboard-shell rc-app-shell ${theme}`}
collapsible="offcanvas"
navigate={(href) => history.push(href)}
open={open}
onOpenChange={onOpenChange}
>
<DashboardSidebar theme={theme} {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar theme={theme} title={title} onThemeChange={setTheme} />
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import xyLogo from '@/assets/xy-logo_small.png';
import { getApplications } from '@/services/DataService';
import * as Headless from '@headlessui/react';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { Close } from './AppIcons';
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
......@@ -37,48 +37,59 @@ function isCurrentApplication(application) {
function ApplicationCard({ application }) {
const current = isCurrentApplication(application);
const external = application.url?.startsWith('http');
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return (
<a
className="app-switcher-card"
data-current={current ? 'true' : undefined}
href={application.url}
rel={external ? 'noreferrer' : undefined}
target={external ? '_blank' : undefined}
>
{application.logo ? (
<img alt={application.title} className="app-switcher-card__logo" src={application.logo} />
) : application.name === 'integrity_practice_management' ? (
<img alt="鑫元" className="app-switcher-card__logo" src={xyLogo} />
) : (
<span className="app-switcher-card__initial">{application.title.slice(0, 1)}</span>
)}
<span className="app-switcher-card__copy">
<strong>{application.title}</strong>
<small>{application.description || application.name}</small>
</span>
{current ? <span className="app-switcher-card__current">当前</span> : null}
</a>
<Card className="h-full" variant="secondary">
<Card.Content>
<a
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
href={application.url}
rel={external ? 'noreferrer' : undefined}
target={external ? '_blank' : undefined}
>
<Surface aria-hidden="true" className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]" variant="tertiary">
<Avatar className="size-full rounded-[var(--rc-radius-control)] bg-transparent" color="accent" variant="soft">
{application.logo ? <Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" /> : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block break-words font-medium leading-5 text-foreground" title={application.title}>{application.title}</span>
<span className="block truncate text-sm text-muted" title={application.description}>{application.description || application.name}</span>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
</Card.Content>
</Card>
);
}
export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
const fallback = useMemo(
() => (userInfo?.applications || []).map((item) => normalizeApplication(item, item)).filter((item) => item.name),
() => (userInfo?.applications || [])
.map((item) => normalizeApplication(item, { ...item, description: item?.title }))
.filter((item) => item.name),
[userInfo],
);
const [applications, setApplications] = useState(fallback);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!open) return undefined;
let cancelled = false;
setLoading(true);
setError('');
getApplications()
.then((response) => {
if (!cancelled) setApplications(mergeApplications(Array.isArray(response) ? response : [], fallback));
})
.catch(() => {
if (!cancelled) setApplications(fallback);
if (!cancelled) {
setApplications(fallback);
setError('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
......@@ -89,28 +100,35 @@ export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
}, [fallback, open]);
return (
<Headless.Dialog className="app-switcher-root" onClose={onClose} open={open}>
<Headless.DialogBackdrop className="app-switcher-backdrop" transition />
<div className="app-switcher-viewport">
<Headless.DialogPanel className="app-switcher-dialog" transition>
<header className="app-switcher-header">
<div>
<Headless.DialogTitle>应用切换</Headless.DialogTitle>
<p>选择你可访问的应用</p>
</div>
<button aria-label="关闭应用切换" className="app-switcher-close" onClick={onClose} type="button">
<Close size={16} />
</button>
</header>
<div className="app-switcher-body">
{loading && applications.length === 0 ? <p className="app-switcher-state">应用加载中...</p> : null}
{!loading && applications.length === 0 ? <p className="app-switcher-state">暂无可切换应用</p> : null}
<div className="app-switcher-grid">
{applications.map((application) => <ApplicationCard application={application} key={application.name} />)}
</div>
</div>
</Headless.DialogPanel>
</div>
</Headless.Dialog>
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && onClose?.()}>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
>
<Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0"><Modal.Heading>应用切换</Modal.Heading><Description>选择你可访问的应用</Description></div>
</Modal.Header>
<Modal.Body>
{error ? <Alert status="warning"><Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
{loading && applications.length === 0 ? (
<div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => <Card className="h-full" key={index} variant="secondary"><Card.Content className="flex items-center gap-3"><Skeleton className="size-9" /><div className="flex-1 space-y-2"><Skeleton className="h-4 w-2/3" /><Skeleton className="h-3 w-full" /></div></Card.Content></Card>)}
</div>
) : applications.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{applications.map((application) => <ApplicationCard application={application} key={application.name} />)}
</div>
) : (
<EmptyState size="md"><EmptyState.Header><EmptyState.Title>暂无可切换应用</EmptyState.Title><EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description></EmptyState.Header></EmptyState>
)}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger
aria-label={isMobile ? '打开导航' : isOpen ? '收起侧栏' : '展开侧栏'}
className="app-navbar-trigger rc-navbar-trigger"
>
<SidebarPanel />
</Sidebar.Trigger>
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={themeLabel}
aria-pressed={isDark}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => onThemeChange(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip.Content>{themeLabel}</Tooltip.Content>
</Tooltip>
</div>
</Navbar.Header>
</Navbar>
);
}
import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
const username = userInfo?.username || userInfo?.email || '已登录';
return {
displayName,
username,
initials: String(displayName).trim().slice(0, 1).toUpperCase() || 'U',
};
}
function SidebarUserMenu({ userInfo, onLogout }) {
const user = getUserDisplay(userInfo);
return (
<Dropdown>
<Dropdown.Trigger aria-label="打开用户菜单" className="app-sidebar-user-trigger rc-sidebar-user-trigger">
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? <Avatar.Image alt={user.displayName} src={userInfo.avatar} /> : null}
<Avatar.Fallback>{user.initials}</Avatar.Fallback>
</Avatar>
<span className="app-sidebar-user-copy rc-sidebar-user-copy" data-sidebar="label">
<span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span>
</span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => key === 'logout' && onLogout?.()}>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger">
<div className="rc-sidebar-user-menu-item"><Label>退出登录</Label><LogOut size={14} /></div>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ menuGroups, onLogout, onOpenApplicationSwitcher, pathname, userInfo }) {
return (
<>
<Sidebar.Header>
<div className="app-sidebar-brand rc-sidebar-brand">
<div className="app-sidebar-brand-mark rc-sidebar-brand-mark">
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--light" src={xyLogoRed} />
<img alt="鑫元" className="rc-sidebar-logo rc-sidebar-logo--dark" src={xyLogoWhite} />
</div>
<div className="app-sidebar-brand-main rc-sidebar-brand-main" data-sidebar="label">
<span className="app-sidebar-title rc-sidebar-title">{APPLICATION_DISPLAY_NAME}</span>
</div>
<div className="app-sidebar-app-switcher rc-sidebar-app-switcher" data-sidebar="label">
<Tooltip delay={0}>
<Button isIconOnly aria-label="切换应用" className="app-sidebar-app-trigger rc-sidebar-app-trigger" size="sm" variant="ghost" onPress={onOpenApplicationSwitcher}>
<AppGrid size={16} />
</Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</div>
</div>
</Sidebar.Header>
<Sidebar.Content>
{menuGroups.map((group) => (
<Sidebar.Group key={group.title}>
{group.showLabel !== false ? <Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel> : null}
<Sidebar.Menu aria-label={group.title}>
{group.items.map((item) => {
const Icon = item.icon;
return (
<Sidebar.MenuItem href={item.path} isCurrent={pathname === item.path} key={item.path} textValue={item.name} tooltip={item.name}>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
);
})}
</Sidebar.Menu>
</Sidebar.Group>
))}
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} onLogout={onLogout} /></Sidebar.Footer>
</>
);
}
export default function DashboardSidebar(props) {
return (
<>
<Sidebar className="app-sidebar-glass rc-sidebar"><SidebarContents {...props} /></Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 className="sr-only" slot="title">{APPLICATION_DISPLAY_NAME}导航</h2>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
);
}
import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Close } from './AppIcons';
export function Dialog({ size = 'lg', className, children, open, onClose, ...props }) {
return (
<Modal.Root
isOpen={Boolean(open)}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop {...props} className="rc-dialog-backdrop" variant="blur">
<Modal.Container placement="center" scroll="inside" size={size}>
<Modal.Dialog className={clsx('rc-dialog-panel', `rc-dialog-panel--${size}`, className)}>
<Modal.CloseTrigger
aria-label="关闭"
className="rc-dialog-close rc-dialog-close--automatic absolute inline-flex items-center justify-center rounded-full"
>
<Close size={16} />
</Modal.CloseTrigger>
{children}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
export function DialogTitle({ className, ...props }) {
return (
<Modal.Header>
<Modal.Heading {...props} className={clsx('rc-dialog-title', className)} />
</Modal.Header>
);
}
export function DialogBody({ className, ...props }) {
return <Modal.Body {...props} className={clsx('rc-dialog-body', className)} />;
}
export function DialogActions({ className, ...props }) {
return <Modal.Footer {...props} className={clsx('rc-dialog-actions', className)} />;
}
import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react';
function createFileChangeEvent(files) {
return { target: { files }, currentTarget: { files } };
}
export default function FileUploadField({
accept,
ariaLabel = '选择文件',
className = '',
description = '将文件拖到此处,或从本地选择。',
disabled = false,
fileName = '',
multiple = false,
onChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback((files) => {
if (!disabled) onChange?.(createFileChangeEvent(files));
}, [disabled, onChange]);
const handleDrop = useCallback(async (event) => {
const fileItems = Array.from(event?.items || []).filter(
(item) => item?.kind === 'file' && typeof item.getFile === 'function',
);
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(multiple ? files : files.slice(0, 1));
}, [emitFiles, multiple]);
return (
<DropZone className={`rc-file-upload ${className}`.trim()}>
<DropZone.Area aria-label={ariaLabel} isDisabled={disabled} onDrop={handleDrop}>
<DropZone.Input accept={accept} disabled={disabled} multiple={multiple} onSelect={emitFiles} />
<DropZone.Icon><Upload /></DropZone.Icon>
<DropZone.Label title={fileName || undefined}>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>{fileName ? '已选择文件,可重新选择进行替换。' : description}</DropZone.Description>
<DropZone.Trigger isDisabled={disabled}>{triggerLabel}</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
}
import { Calendar as CalendarIcon } from '@/components/AppIcons';
import { Calendar } from '@heroui/react/calendar';
import { DateField } from '@heroui/react/date-field';
import { DatePicker } from '@heroui/react/date-picker';
import { TimeField } from '@heroui/react/time-field';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import clsx from 'clsx';
import { useMemo } from 'react';
function parseValue(value, mode) {
if (!value) return null;
try {
return mode === 'datetime' ? parseDateTime(String(value).slice(0, 16)) : parseDate(String(value).slice(0, 10));
} catch {
return null;
}
}
function formatValue(value, mode) {
if (!value) return '';
const text = value.toString();
if (mode === 'date') return text.slice(0, 10);
return text.includes('T') ? text.slice(0, 16) : `${text.slice(0, 10)}T00:00`;
}
function parseTimeValue(value) {
if (!value || !String(value).includes('T')) return null;
try { return parseTime(String(value).slice(11, 16)); } catch { return null; }
}
export default function FormDatePicker({ className, disabled = false, mode, onChange, value }) {
const resolvedMode = mode === 'datetime' ? 'datetime' : 'date';
const parsedValue = useMemo(() => parseValue(value, resolvedMode), [resolvedMode, value]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
const emit = (nextValue) => onChange?.(formatValue(nextValue, resolvedMode));
return (
<DatePicker aria-label={resolvedMode === 'datetime' ? '选择日期和时间' : '选择日期'} className={clsx('rc-form-date-picker', className)} granularity={resolvedMode === 'datetime' ? 'minute' : 'day'} isDisabled={disabled} value={parsedValue} onChange={emit}>
<DateField.Group className="rc-form-date-picker__group" fullWidth variant="secondary">
<DateField.Input className="rc-form-date-picker__input">{(segment) => <DateField.Segment className="rc-form-date-picker__segment" segment={segment} />}</DateField.Input>
<DateField.Suffix className="rc-form-date-picker__suffix"><DatePicker.Trigger aria-label="打开日期选择" className="rc-form-date-picker__trigger"><DatePicker.TriggerIndicator><CalendarIcon size={16} /></DatePicker.TriggerIndicator></DatePicker.Trigger></DateField.Suffix>
</DateField.Group>
<DatePicker.Popover className="rc-form-date-picker__popover" placement="bottom start">
<Calendar className="rc-form-date-picker__calendar">
<Calendar.Header><Calendar.NavButton slot="previous" /><Calendar.Heading /><Calendar.NavButton slot="next" /></Calendar.Header>
<Calendar.Grid><Calendar.GridHeader>{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}</Calendar.GridHeader><Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody></Calendar.Grid>
</Calendar>
{resolvedMode === 'datetime' ? (
<div className="rc-form-date-picker__time-panel"><span>时间</span><TimeField aria-label="选择时间" granularity="minute" hideTimeZone hourCycle={24} value={timeValue} onChange={(nextTime) => {
const date = String(value || parsedValue || '').slice(0, 10);
if (date) onChange?.(`${date}T${nextTime ? nextTime.toString().slice(0, 5) : '00:00'}`);
}}><TimeField.Group className="rc-form-date-picker__time-group" variant="secondary"><TimeField.Input>{(segment) => <TimeField.Segment className="rc-form-date-picker__segment" segment={segment} />}</TimeField.Input></TimeField.Group></TimeField></div>
) : null}
</DatePicker.Popover>
</DatePicker>
);
}
import { ChevronDown } from '@/components/AppIcons';
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
const EMPTY_ARRAY = [];
function normalizeValue(value, multiple) {
if (multiple) {
return (Array.isArray(value) ? value : value === null || value === undefined || value === '' ? [] : [value])
.map(String);
}
return value === null || value === undefined || value === '' ? null : String(value);
}
function getDisplayText(options, value, multiple, placeholder) {
const selectedValues = multiple ? value : value === null ? EMPTY_ARRAY : [value];
const labels = options
.filter((option) => selectedValues.includes(String(option.value)))
.map((option) => String(option.label ?? option.value));
if (labels.length === 0) return placeholder;
if (!multiple || labels.length <= 2) return labels.join('、');
return `${labels.slice(0, 2).join('、')} 等 ${labels.length} 项`;
}
export default function FormSelect({
ariaLabel,
className,
disabled = false,
multiple = false,
onChange,
options = EMPTY_ARRAY,
placeholder = '请选择',
value,
}) {
const normalizedValue = normalizeValue(value, multiple);
const displayText = getDisplayText(options, normalizedValue, multiple, placeholder);
const hasSelection = multiple ? normalizedValue.length > 0 : normalizedValue !== null;
return (
<Select
aria-label={ariaLabel || placeholder}
className={clsx('rc-form-select', className)}
fullWidth
isDisabled={disabled}
selectionMode={multiple ? 'multiple' : 'single'}
shouldCloseOnSelect={!multiple}
value={normalizedValue}
variant="secondary"
onChange={(nextValue) => {
if (multiple) {
onChange?.((Array.isArray(nextValue) ? nextValue : []).map(String));
return;
}
onChange?.(nextValue === null || nextValue === undefined || nextValue === '' ? null : String(nextValue));
}}
>
<Select.Trigger className="rc-form-select__trigger">
<Select.Value className="rc-form-select__value">
<span className={clsx('rc-form-select__text', !hasSelection && 'is-placeholder')}>{displayText}</span>
</Select.Value>
<Select.Indicator className="rc-form-select__indicator"><ChevronDown size={16} /></Select.Indicator>
</Select.Trigger>
<Select.Popover className="rc-form-select__popover" placement="bottom start">
<ListBox aria-label={ariaLabel || placeholder} className="rc-form-select__listbox">
{options.map((option) => (
<ListBox.Item
className="rc-form-select__option"
id={String(option.value)}
isDisabled={Boolean(option.disabled)}
key={String(option.value)}
textValue={String(option.label ?? option.value)}
>
<Label className="rc-form-select__option-label">{option.label ?? option.value}</Label>
<ListBox.ItemIndicator className="rc-form-select__option-indicator" />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
);
}
This diff is collapsed.
export const APPLICATION_DISPLAY_NAME = '廉洁从业管理';
export const APPLICATION_BASE_PATH = '/integrity_practice_management/';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/self_check_the_results_of_the_working_draft': '自查底稿填写表',
'/self_inspection_submission_form': '附件1:廉洁从业自查底稿',
'/statistical_table': '附件2:廉洁从业规定落实情况统计表',
'/integrity_practice_report': '廉洁从业报告生成',
'/self_check_the_draft_template': '自查底稿模版',
'/self_check_the_working_draft': '自查底稿项目',
'/self_inspection_annual_submission_materials': '历年报送材料',
});
This diff is collapsed.
/*
* HeroUI Theme Customization
* Add this to your global.css after importing @heroui/styles
* Only includes variables users need to customize
* @see https://heroui.com/docs/react/getting-started/theming
*/
:root,
.light,
.default,
[data-theme="light"],
[data-theme="default"] {
/* Theme Colors (Light Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0045 27.03);
--border: oklch(90.00% 0.0045 27.03);
--danger: oklch(45.32% 0.1661 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(55.17% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(21.03% 0.0045 27.03);
--muted: oklch(55.17% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(87.10% 0.0045 27.03);
--segment: oklch(100.00% 0.0045 27.03);
--segment-foreground: oklch(21.03% 0.0045 27.03);
--separator: oklch(92.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(100.00% 0.0022 27.03);
--surface-foreground: oklch(21.03% 0.0045 27.03);
--surface-secondary: oklch(95.24% 0.0036 27.03);
--surface-secondary-foreground: oklch(21.03% 0.0045 27.03);
--surface-tertiary: oklch(93.73% 0.0036 27.03);
--surface-tertiary-foreground: oklch(21.03% 0.0045 27.03);
--warning: oklch(78.19% 0.1599 45.11);
--warning-foreground: oklch(21.03% 0.0059 45.11);
/* Border Radius */
--radius: 0.5rem;
--field-radius: 0.75rem;
/* Font Family */
/* Make sure to load Inter font in your app */
--font-sans: var(--font-inter);
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
/* Theme Colors (Dark Mode) */
--accent: oklch(45.32% 0.1661 27.03);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0045 27.03);
--border: oklch(28.00% 0.0045 27.03);
--danger: oklch(62.00% 0.1600 27.03);
--danger-foreground: oklch(99.11% 0 0);
/* HeroUI control aliases: primary = L1, secondary = L2. */
--default: var(--surface-secondary);
--default-foreground: var(--surface-secondary-foreground);
--field-background: var(--surface);
--field-border: transparent;
--field-foreground: var(--surface-foreground);
--field-placeholder: oklch(70.50% 0.0090 27.03);
--focus: oklch(45.32% 0.1661 27.03);
--foreground: oklch(99.11% 0.0045 27.03);
--muted: oklch(70.50% 0.0090 27.03);
--overlay: var(--surface);
--overlay-foreground: var(--surface-foreground);
--scrollbar: oklch(70.50% 0.0045 27.03);
--segment: oklch(39.64% 0.0045 27.03);
--segment-foreground: oklch(99.11% 0.0045 27.03);
--separator: oklch(25.00% 0.0045 27.03);
--success: oklch(73.29% 0.1952 123.59);
--success-foreground: oklch(21.03% 0.0059 123.59);
--surface: oklch(21.03% 0.0090 27.03);
--surface-foreground: oklch(99.11% 0.0045 27.03);
--surface-secondary: oklch(25.70% 0.0067 27.03);
--surface-secondary-foreground: oklch(99.11% 0.0045 27.03);
--surface-tertiary: oklch(27.21% 0.0067 27.03);
--surface-tertiary-foreground: oklch(99.11% 0.0045 27.03);
--warning: oklch(82.03% 0.1400 49.12);
--warning-foreground: oklch(21.03% 0.0059 49.12);
}
:root,
.light,
[data-theme='light'] {
--rc-brand-primary: #9e1b1b;
--rc-brand-light: #b83a3a;
--rc-brand-dark: #7e1515;
--rc-brand-primary-soft: rgb(158 27 27 / 8%);
--rc-brand-primary-soft-hover: rgb(158 27 27 / 12%);
--rc-brand-on-primary: #fff;
--rc-color-success: #16845b;
--rc-color-success-soft: rgb(22 132 91 / 9%);
--rc-color-success-border: rgb(22 132 91 / 24%);
--rc-color-warning: #b46a00;
--rc-color-warning-soft: rgb(180 106 0 / 10%);
--rc-color-warning-border: rgb(180 106 0 / 25%);
--rc-color-danger: #c93636;
--rc-color-danger-soft: rgb(201 54 54 / 9%);
--rc-color-danger-border: rgb(201 54 54 / 22%);
--rc-bg-canvas: #f5f5f5;
--rc-bg-surface: #fff;
--rc-bg-subtle: #f8fafc;
--rc-border-default: #e3e8ef;
--rc-border-strong: #cbd3de;
--rc-field-background: #fff;
--rc-field-border: #d9e1eb;
--rc-field-border-hover: #b8c4d2;
--rc-field-border-focus: var(--rc-brand-primary);
--rc-button-secondary-border: rgb(9 9 11 / 12%);
--rc-button-secondary-border-hover: rgb(9 9 11 / 20%);
--rc-button-secondary-background-hover: rgb(9 9 11 / 4%);
--rc-text-primary: #172033;
--rc-text-secondary: #4e5d73;
--rc-text-muted: #718096;
--rc-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%), 0 4px 12px rgb(15 23 42 / 2%);
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%), 0 8px 24px rgb(15 23 42 / 8%);
.default,
[data-theme='light'],
[data-theme='default'],
.dark,
[data-theme='dark'] {
/* Xinyuan aliases and extension tokens. HeroUI core tokens are owned by theme.css. */
--rc-brand-primary: var(--accent);
--rc-brand-light: color-mix(in oklab, var(--accent) 84%, white);
--rc-brand-dark: color-mix(in oklab, var(--accent) 82%, black);
--rc-bg-canvas: var(--background);
--rc-bg-surface: var(--surface);
--rc-border-default: var(--border);
--rc-field-background: var(--field-background);
--rc-field-border-focus: var(--focus);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 12%,
transparent
);
--rc-text-primary: var(--foreground);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 72%,
var(--background)
);
--rc-text-muted: var(--muted);
--rc-font-sans: var(--font-sans);
--rc-font-medium: 500;
--rc-font-semibold: 600;
--rc-line-normal: 1.5;
--rc-text-xs: 0.75rem;
--rc-text-sm: 0.875rem;
--rc-space-1: 0.25rem;
--rc-space-2: 0.5rem;
--rc-space-3: 0.75rem;
--rc-space-4: 1rem;
--rc-space-5: 1.25rem;
--rc-space-6: 1.5rem;
--rc-space-8: 2rem;
/* Page geometry only. Component appearance remains owned by HeroUI. */
--rc-shell-inline-gutter: 3rem;
--rc-navbar-inline-padding: var(--rc-shell-inline-gutter);
--rc-page-inline-start: var(--rc-shell-inline-gutter);
--rc-page-inline-end: var(--rc-shell-inline-gutter);
--rc-page-padding-block-start: 1.5rem;
--rc-page-padding-block-end: 2rem;
--rc-page-section-gap: 2rem;
--rc-page-module-gap: 1.5rem;
--rc-related-item-gap: 1rem;
--rc-page-toolbar-gap: 1rem;
--rc-list-stack-gap: 1rem;
--rc-radius-control: 0.375rem;
--rc-radius-container: 0.5rem;
--rc-radius-overlay: 0.75rem;
--rc-radius-detail-surface: 1rem;
--rc-radius-data-grid: 1.25rem;
--rc-radius-modal: 1.5rem;
--rc-dialog-width-4xl: 68rem;
--rc-motion-fast: 120ms;
--rc-motion-normal: 180ms;
--rc-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--rc-button-radius: 1.5rem;
--rc-navbar-trigger-size: 2rem;
--rc-navbar-icon-button-size: 2.25rem;
--rc-navbar-title-size: 1.25rem;
--rc-navbar-title-weight: 600;
--rc-navbar-title-line-height: 1.75rem;
--rc-sidebar-app-trigger-size: 2.25rem;
--rc-sidebar-app-trigger-icon-size: 1rem;
--rc-login-panel-width: 28rem;
--rc-login-brand-size: 1.5rem;
--rc-login-brand-weight: var(--rc-font-semibold);
--rc-login-brand-gap: 1.5rem;
--rc-shadow-raised: 0 1px 2px rgb(15 23 42 / 6%),
0 8px 24px rgb(15 23 42 / 8%);
--rc-shadow-overlay: 0 16px 40px rgb(15 23 42 / 14%);
--rc-page-title-inset: 68px;
--rc-login-brand-weight: 600;
--rc-modal-radius: 24px;
--rc-modal-padding: 28px;
--rc-modal-title-size: 16px;
--rc-modal-title-weight: 500;
--rc-modal-title-line-height: 24px;
--rc-modal-section-gap: 12px;
--rc-modal-footer-gap: 8px;
--rc-modal-footer-margin: 24px;
--rc-modal-backdrop-blur: 12px;
--rc-modal-close-size: 24px;
--rc-modal-close-inset: var(--rc-modal-padding);
--rc-modal-close-icon-size: 16px;
--rc-modal-width-xs: 320px;
--rc-modal-width-sm: 384px;
--rc-modal-width-md: 480px;
--rc-modal-width-lg: 640px;
--rc-modal-width-xl: 736px;
--rc-modal-width-2xl: 840px;
--rc-modal-width-3xl: 960px;
--rc-modal-width-4xl: 1088px;
--rc-modal-width-5xl: 1216px;
--background: var(--rc-bg-canvas);
--surface: var(--rc-bg-surface);
--surface-shadow: var(--rc-shadow-card);
--surface-secondary: var(--rc-bg-subtle);
--overlay: var(--rc-bg-surface);
--foreground: var(--rc-text-primary);
--muted: var(--rc-text-muted);
--muted-foreground: var(--rc-text-secondary);
--accent: var(--rc-brand-primary);
--accent-foreground: var(--rc-brand-on-primary);
--accent-hover: var(--rc-brand-light);
--accent-soft: var(--rc-brand-primary-soft);
--accent-soft-hover: var(--rc-brand-primary-soft-hover);
--border: var(--rc-border-default);
--separator: var(--rc-border-default);
--focus: var(--rc-brand-primary);
--backdrop: rgb(0 0 0 / 50%);
--rc-shadow-card: 0 1px 2px rgb(15 23 42 / 2%),
0 4px 12px rgb(15 23 42 / 2%);
/* KPI icon surfaces. Card bodies remain neutral; only the icon well carries tone. */
--rc-metric-tone-info: oklch(55% 0.14 255);
--rc-metric-tone-success: oklch(54% 0.13 145);
--rc-metric-tone-teal: oklch(55% 0.1 205);
--rc-metric-tone-danger: oklch(52% 0.17 25);
--rc-metric-tone-warning: oklch(62% 0.12 75);
--rc-metric-tone-violet: oklch(56% 0.12 300);
--rc-metric-tone-indigo: oklch(53% 0.13 275);
--rc-metric-tone-coral: oklch(60% 0.12 48);
--rc-metric-tone-neutral: var(--muted);
--rc-metric-tone-info-soft: color-mix(
in oklab,
var(--rc-metric-tone-info) 9%,
var(--surface)
);
--rc-metric-tone-success-soft: color-mix(
in oklab,
var(--rc-metric-tone-success) 9%,
var(--surface)
);
--rc-metric-tone-teal-soft: color-mix(
in oklab,
var(--rc-metric-tone-teal) 9%,
var(--surface)
);
--rc-metric-tone-danger-soft: color-mix(
in oklab,
var(--rc-metric-tone-danger) 9%,
var(--surface)
);
--rc-metric-tone-warning-soft: color-mix(
in oklab,
var(--rc-metric-tone-warning) 9%,
var(--surface)
);
--rc-metric-tone-violet-soft: color-mix(
in oklab,
var(--rc-metric-tone-violet) 9%,
var(--surface)
);
--rc-metric-tone-indigo-soft: color-mix(
in oklab,
var(--rc-metric-tone-indigo) 9%,
var(--surface)
);
--rc-metric-tone-coral-soft: color-mix(
in oklab,
var(--rc-metric-tone-coral) 9%,
var(--surface)
);
--rc-metric-tone-neutral-soft: color-mix(
in oklab,
var(--rc-metric-tone-neutral) 8%,
var(--surface)
);
/* Data visualization only. Do not reuse UI status colors as a series palette. */
--rc-chart-1: oklch(45.32% 0.1661 27.03);
--rc-chart-2: oklch(56% 0.135 255);
--rc-chart-3: oklch(64% 0.11 220);
--rc-chart-4: oklch(56% 0.115 280);
--rc-chart-5: oklch(60% 0.095 200);
--rc-chart-6: oklch(70% 0.12 80);
--rc-chart-7: oklch(60% 0.1 295);
--rc-chart-8: oklch(57% 0.035 250);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: 0.75rem;
--rc-chart-legend-line-height: 1rem;
}
.dark,
[data-theme='dark'] {
color-scheme: dark;
--rc-brand-primary-soft: rgb(158 27 27 / 16%);
--rc-brand-primary-soft-hover: rgb(158 27 27 / 22%);
--rc-color-success: #4fca92;
--rc-color-success-soft: rgb(79 202 146 / 14%);
--rc-color-success-border: rgb(79 202 146 / 30%);
--rc-color-warning: #f0ad4e;
--rc-color-warning-soft: rgb(240 173 78 / 15%);
--rc-color-warning-border: rgb(240 173 78 / 32%);
--rc-color-danger: #ff7272;
--rc-color-danger-soft: rgb(255 114 114 / 14%);
--rc-color-danger-border: rgb(255 114 114 / 30%);
--rc-bg-canvas: #0f0f11;
--rc-bg-surface: #18181b;
--rc-bg-subtle: #202024;
--rc-border-default: #303036;
--rc-border-strong: #45454d;
--rc-field-background: #202024;
--rc-field-border: #3b3b42;
--rc-field-border-hover: #56565f;
--rc-field-border-focus: var(--rc-brand-primary);
--rc-button-secondary-border: rgb(255 255 255 / 18%);
--rc-button-secondary-border-hover: rgb(255 255 255 / 28%);
--rc-button-secondary-background-hover: rgb(255 255 255 / 7%);
--rc-text-primary: #f4f4f5;
--rc-text-secondary: #d4d4d8;
--rc-text-muted: #a1a1aa;
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 18%), 0 4px 12px rgb(0 0 0 / 12%);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%), 0 8px 24px rgb(0 0 0 / 24%);
--rc-text-secondary: color-mix(
in oklab,
var(--foreground) 76%,
var(--background)
);
--rc-button-secondary-border: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
--rc-shadow-raised: 0 1px 2px rgb(0 0 0 / 24%),
0 8px 24px rgb(0 0 0 / 24%);
--rc-shadow-overlay: 0 16px 40px rgb(0 0 0 / 38%);
--surface-shadow: var(--rc-shadow-card);
--default: #27272a;
--default-hover: #3f3f46;
}
@media (max-width: 767px) {
:root { --rc-page-title-inset: 72px; }
--rc-shadow-card: 0 1px 2px rgb(0 0 0 / 20%),
0 4px 12px rgb(0 0 0 / 18%);
}
This diff is collapsed.
This diff is collapsed.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { DataGrid } from '@heroui-pro/react/data-grid';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Button, Spinner, Tooltip } from '@heroui/react';
import { Alert, Button, Spinner, Surface, Tooltip } from '@heroui/react';
import { Eye } from '@/components/AppIcons';
import { getSubjectView, loadSubject, queryRecords } from '@/services/DataService';
import {
buildViewColumns,
......@@ -115,33 +116,20 @@ function sortRecordsByReferenceOrder(records, referenceIds) {
});
}
function CellValue({ value, fieldDef, primary = false, onPress }) {
function CellValue({ value, fieldDef, primary = false }) {
const text = formatFieldValue(value, fieldDef);
if (!text) {
return <span className="rc-data-grid-text rc-reference-table__muted">-</span>;
return <span className="text-muted">-</span>;
}
return (
<Tooltip delay={300}>
{onPress ? (
<Button
aria-label={`查看${text}详情`}
className="rc-reference-table__primary-action"
size="sm"
variant="ghost"
onPress={onPress}
>
<span className={`rc-data-grid-text rc-reference-table__value${primary ? ' rc-data-grid-primary' : ''}${LONG_TEXT_TYPES.has(fieldDef?.type) ? ' is-long-text' : ''}`}>{text}</span>
</Button>
) : (
<span
className={`rc-data-grid-text rc-reference-table__value${primary ? ' rc-data-grid-primary' : ''}${LONG_TEXT_TYPES.has(fieldDef?.type) ? ' is-long-text' : ''}`}
tabIndex={0}
>
<Tooltip.Trigger className="block min-w-0" role="presentation">
<span className={primary ? 'block max-w-80 truncate font-medium' : 'block max-w-80 truncate'}>
{text}
</span>
)}
<Tooltip.Content className="rc-reference-table__tooltip">{text}</Tooltip.Content>
</Tooltip.Trigger>
<Tooltip.Content>{text}</Tooltip.Content>
</Tooltip>
);
}
......@@ -238,6 +226,7 @@ export default function ReferenceRecordsTable({
const columns = useMemo(() => buildColumns(subject, records, views), [records, subject, views]);
const isSingleColumn = columns.length === 1;
const hasPreviewAction = Boolean(subjectName && onOpenReference);
const columnWidths = useMemo(
() => columns.map((column, index) => {
const type = column.fieldDef?.type;
......@@ -250,7 +239,7 @@ export default function ReferenceRecordsTable({
}),
[columns],
);
const minimumGridWidth = columnWidths.reduce((total, width) => total + width, 0);
const minimumGridWidth = columnWidths.reduce((total, width) => total + width, 0) + (hasPreviewAction ? 80 : 0);
useLayoutEffect(() => {
const wrapper = gridWrapRef.current;
......@@ -263,32 +252,59 @@ export default function ReferenceRecordsTable({
}, [isSingleColumn, minimumGridWidth]);
const gridColumns = useMemo(
() => columns.map((column, index) => ({
id: column.field,
header: column.label,
isRowHeader: index === 0,
minWidth: columnWidths[index],
maxWidth: hasHorizontalOverflow ? columnWidths[index] : undefined,
width: columnWidths[index],
pinned: hasHorizontalOverflow && index === 0 ? 'start' : undefined,
cell: (record) => (
<CellValue
fieldDef={column.fieldDef}
primary={index === 0 && !isSingleColumn}
value={getFieldValue(record, column.field)}
onPress={index === 0 && subjectName && onOpenReference ? () => onOpenReference({ recordName: normalizeRecordId(record), subjectName }) : undefined}
/>
),
})),
[columnWidths, columns, hasHorizontalOverflow, isSingleColumn, onOpenReference, subjectName],
() => [
...columns.map((column, index) => ({
id: column.field,
header: column.label,
isRowHeader: index === 0,
minWidth: columnWidths[index],
maxWidth: hasHorizontalOverflow ? columnWidths[index] : undefined,
width: !hasHorizontalOverflow && index === 0 ? '1fr' : columnWidths[index],
pinned: hasHorizontalOverflow && index === 0 ? 'start' : undefined,
cell: (record) => (
<CellValue
fieldDef={column.fieldDef}
primary={index === 0 && !isSingleColumn}
value={getFieldValue(record, column.field)}
/>
),
})),
...(hasPreviewAction
? [{
id: 'actions',
header: '操作',
align: 'start',
minWidth: 80,
maxWidth: 80,
width: 80,
pinned: hasHorizontalOverflow ? 'end' : undefined,
cell: (record) => (
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="预览"
className="grid !size-8 shrink-0 place-items-center p-0"
size="sm"
variant="tertiary"
onPress={() => onOpenReference({ recordName: normalizeRecordId(record), subjectName })}
>
<Eye className="!grid size-4 shrink-0 place-items-center !leading-none [&>svg]:!m-0 [&>svg]:block [&>svg]:!size-4" size={16} aria-hidden="true" />
</Button>
<Tooltip.Content>预览</Tooltip.Content>
</Tooltip>
),
}]
: []),
],
[columnWidths, columns, hasHorizontalOverflow, hasPreviewAction, isSingleColumn, onOpenReference, subjectName],
);
const configurationMissing = referenceIds.length > 0 && !subjectName && populatedRecords.length === 0;
return (
<div className="rc-reference-table">
<div className="rc-reference-table__toolbar">
<span className="rc-reference-table__summary">共 {loading ? referenceIds.length : records.length} 条关联记录</span>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 text-xs text-muted">
<span>共 {loading ? referenceIds.length : records.length} 条关联记录</span>
{canEdit ? (
<Button size="sm" variant="outline" onPress={onEdit}>
编辑关联记录
......@@ -296,42 +312,37 @@ export default function ReferenceRecordsTable({
) : null}
</div>
{error ? (
<div className="rc-reference-table__warning">
{error}
</div>
<Alert status="warning"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
) : null}
{configurationMissing ? (
<div className="rc-reference-table__warning">
引用字段缺少目标 Subject 配置,暂不能加载关联明细。
</div>
<Alert status="warning"><Alert.Content><Alert.Description>引用字段缺少目标 Subject 配置,暂不能加载关联明细。</Alert.Description></Alert.Content></Alert>
) : null}
<div
className={`rc-data-grid-wrap rc-reference-data-grid-wrap ${records.length === 0 ? 'is-empty' : 'is-populated'}${hasHorizontalOverflow ? ' is-wide' : ''}${isSingleColumn ? ' is-single-column' : ''}`}
className="relative min-w-0"
ref={gridWrapRef}
style={{ '--rc-data-grid-min-width': `${minimumGridWidth}px` }}
>
<DataGrid
aria-label="关联记录"
className="rc-data-grid rc-reference-data-grid"
className="rc-detail-data-grid w-full"
columns={gridColumns}
contentClassName={hasHorizontalOverflow ? 'rc-data-grid-content is-wide' : 'rc-data-grid-content'}
contentClassName="min-w-full"
data={records}
getRowId={(record) => normalizeRecordId(record)}
renderEmptyState={() => loading ? (
<div className="rc-data-grid-loading"><Spinner size="sm" /></div>
<div className="flex min-h-32 items-center justify-center"><Spinner size="sm" /></div>
) : (
<EmptyState className="rc-data-grid-empty" size="sm">
<EmptyState size="sm">
<EmptyState.Header>
<EmptyState.Title>{referenceIds.length === 0 ? '暂无关联记录' : '暂无可展示的关联明细'}</EmptyState.Title>
</EmptyState.Header>
</EmptyState>
)}
scrollContainerClassName="rc-data-grid-scroll"
scrollContainerClassName="overflow-x-auto"
selectionMode="none"
variant="primary"
verticalAlign="middle"
/>
{loading && records.length > 0 ? <div className="rc-data-grid-busy"><Spinner size="sm" /></div> : null}
{loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div>
</div>
);
......
import { useEffect, useMemo, useState } from 'react';
import { Checkbox, Input, Radio, RadioGroup } from '@heroui/react';
import { ChevronDown, Close, Search } from '@/components/AppIcons';
import { queryRecords } from '@/services/DataService';
import {
getReferenceSubjectName,
......@@ -7,20 +6,40 @@ import {
normalizeRecordLabel,
toArray,
} from '@/utils/subjectView';
import {
Alert,
Button,
Label,
ListBox,
Popover,
SearchField,
Spinner,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
export default function ReferenceSearchSelect({
fieldDef,
value,
onChange,
placeholder,
}) {
function uniqueRecords(records) {
const seenIds = new Set();
return records.filter((record) => {
const recordId = normalizeRecordId(record);
if (!recordId || seenIds.has(recordId)) return false;
seenIds.add(recordId);
return true;
});
}
export default function ReferenceSearchSelect({ fieldDef, value, onChange, placeholder }) {
const [isOpen, setIsOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const subjectName = getReferenceSubjectName(fieldDef);
const multiple = Boolean(fieldDef?.multiple);
const selectedValues = useMemo(() => toArray(value).filter(Boolean), [value]);
const selectedIds = useMemo(
() => toArray(value).map(normalizeRecordId).filter(Boolean),
[value],
() => selectedValues.map(normalizeRecordId).filter(Boolean),
[selectedValues],
);
useEffect(() => {
......@@ -32,6 +51,9 @@ export default function ReferenceSearchSelect({
return;
}
setLoading(true);
setError('');
try {
const result = await queryRecords({
subject: subjectName,
......@@ -41,83 +63,156 @@ export default function ReferenceSearchSelect({
mode: 'basic',
});
if (!cancelled) {
setOptions(result?.records || []);
}
if (!cancelled) setOptions(result?.records || []);
} catch (requestError) {
if (!cancelled) {
setOptions([]);
setError(requestError?.message || '引用记录加载失败');
}
} finally {
if (!cancelled) setLoading(false);
}
};
loadOptions();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [keyword, subjectName]);
const toggleRecord = (record, checked) => {
const recordId = normalizeRecordId(record);
if (!recordId) {
return;
}
const menuOptions = useMemo(
() => uniqueRecords([
...selectedValues.filter((item) => typeof item === 'object'),
...options,
]),
[options, selectedValues],
);
const selectedLabels = useMemo(
() => selectedIds.map((id) => {
const record = menuOptions.find((item) => normalizeRecordId(item) === id);
return record ? normalizeRecordLabel(record) || id : id;
}),
[menuOptions, selectedIds],
);
const triggerText = selectedIds.length === 0
? placeholder || '请选择引用记录'
: multiple
? `已选择 ${selectedIds.length} 项`
: selectedLabels[0];
const triggerTitle = selectedLabels.length > 0 ? selectedLabels.join('\n') : triggerText;
if (!multiple) {
onChange(checked ? recordId : null);
return;
}
const handleSelectionChange = (keys) => {
const nextIds = keys === 'all'
? menuOptions.map(normalizeRecordId).filter(Boolean)
: Array.from(keys || []).map(String);
if (checked) {
onChange(Array.from(new Set([...selectedIds, recordId])));
if (multiple) {
onChange(nextIds);
return;
}
onChange(selectedIds.filter((item) => item !== recordId));
onChange(nextIds[0] || null);
setIsOpen(false);
setKeyword('');
};
const clearSelection = () => {
onChange(multiple ? [] : null);
setKeyword('');
};
if (!subjectName) {
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
引用字段缺少目标 Subject 配置,暂不能搜索选择。
</div>
);
return <Alert status="warning"><Alert.Content><Alert.Description>引用字段缺少目标 Subject 配置,暂不能搜索选择。</Alert.Description></Alert.Content></Alert>;
}
return (
<div className="space-y-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={placeholder || '搜索引用记录标题'}
<Popover.Root
isOpen={isOpen}
onOpenChange={(nextOpen) => {
setIsOpen(nextOpen);
if (!nextOpen) setKeyword('');
}}
>
<Button
aria-label={fieldDef?.label || '选择引用记录'}
className="rc-reference-select-trigger"
fullWidth
variant="secondary"
/>
<div className="rc-choice-list">
{options.length === 0 ? (
<div className="rc-choice-list__empty">暂无可选记录</div>
) : multiple ? (
options.map((record) => {
const recordId = normalizeRecordId(record);
const checked = selectedIds.includes(recordId);
return (
<Checkbox className="rc-choice-list__item" isSelected={checked} key={recordId} onChange={(nextChecked) => toggleRecord(record, nextChecked)}>
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control><span>
<span className="block font-medium text-zinc-950">{normalizeRecordLabel(record) || recordId}</span>
<span className="block text-xs text-zinc-500">{record.name || recordId}</span>
</span></Checkbox.Content>
</Checkbox>
);
})
) : (
<RadioGroup value={selectedIds[0] || ''} onChange={(nextId) => onChange(nextId || null)}>
{options.map((record) => {
const recordId = normalizeRecordId(record);
return <Radio className="rc-choice-list__item" key={recordId} value={recordId}><Radio.Content><Radio.Control><Radio.Indicator /></Radio.Control><span><span className="block font-medium text-zinc-950">{normalizeRecordLabel(record) || recordId}</span><span className="block text-xs text-zinc-500">{record.name || recordId}</span></span></Radio.Content></Radio>;
})}
</RadioGroup>
)}
</div>
</div>
>
<span className="rc-reference-select-trigger__value" title={triggerTitle}>
{triggerText}
</span>
<span className="rc-reference-select-trigger__indicator">
<ChevronDown size={16} />
</span>
</Button>
<Popover.Content
className="rc-reference-select-popover"
placement="bottom start"
>
<Popover.Dialog
aria-label={fieldDef?.label || '选择引用记录'}
className="min-w-0 p-0"
>
<div className="flex items-center justify-between gap-3 px-3 pb-2 pt-3">
<span className="min-w-0 truncate text-sm text-muted">
{selectedIds.length > 0 ? `已选择 ${selectedIds.length} 项` : '选择引用记录'}
</span>
{selectedIds.length > 0 ? (
<Button size="sm" variant="tertiary" onPress={clearSelection}>清空</Button>
) : null}
</div>
<div className="px-2 pb-2">
<SearchField
aria-label="搜索引用记录"
fullWidth
value={keyword}
variant="secondary"
onChange={setKeyword}
>
<SearchField.Group>
<SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={placeholder || '搜索引用记录标题'} />
<SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton>
</SearchField.Group>
</SearchField>
</div>
{loading ? (
<div className="grid min-h-24 place-items-center" role="status"><Spinner size="sm" /></div>
) : error ? (
<div className="grid min-h-24 place-items-center px-3 text-sm text-danger">{error}</div>
) : menuOptions.length === 0 ? (
<div className="grid min-h-24 place-items-center px-3 text-sm text-muted">暂无可选记录</div>
) : (
<ListBox
aria-label="引用记录候选项"
className="rc-reference-select-options"
items={menuOptions}
selectedKeys={new Set(selectedIds)}
selectionMode={multiple ? 'multiple' : 'single'}
onSelectionChange={handleSelectionChange}
>
{(record) => {
const recordId = normalizeRecordId(record);
const label = normalizeRecordLabel(record) || recordId;
return (
<ListBox.Item
className="rc-reference-select-option"
id={recordId}
textValue={label}
>
<span className="rc-reference-select-option__content min-w-0 flex-1">
<Label className="rc-reference-select-option__label" title={label}>{label}</Label>
<span className="block truncate text-xs text-muted" title={record.name || recordId}>
{record.name || recordId}
</span>
</span>
<ListBox.ItemIndicator />
</ListBox.Item>
);
}}
</ListBox>
)}
</Popover.Dialog>
</Popover.Content>
</Popover.Root>
);
}
import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import TailwindEditableField from './TailwindEditableField';
import {
buildViewColumns,
......@@ -34,15 +35,15 @@ export default function SubjectViewContent({
if (!displayFields || displayFields.length === 0) {
return (
<div className="rc-subject-view-empty">
暂无可显示的字段
</div>
<EmptyState size="md">
<EmptyState.Header><EmptyState.Title>暂无可显示的字段</EmptyState.Title></EmptyState.Header>
</EmptyState>
);
}
return (
<section className={clsx('rc-subject-view', mode === 'edit' && 'is-editing')}>
<div className="rc-subject-view__fields">
<section className="min-w-0">
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
{displayFields.map((field) => {
const value = getFieldValue(editableRecord, field.field);
const fieldDisabled =
......@@ -57,12 +58,12 @@ export default function SubjectViewContent({
<section
key={field.field}
className={clsx(
'rc-subject-view__field',
isFullWidth && 'is-full-width',
'min-w-0 py-3',
isFullWidth && 'md:col-span-2',
)}
>
<div className="rc-subject-view__label">{field.label}</div>
<div className="rc-subject-view__value">
<div className="text-sm font-medium text-muted">{field.label}</div>
<div className="mt-1.5 min-w-0">
<TailwindEditableField
value={value}
fieldDef={field.fieldDef}
......
import { Button, Toast, toast } from '@heroui/react';
import { Alert, Button, Modal, Surface, Tabs, Toast, toast } from '@heroui/react';
import React, {
forwardRef,
useEffect,
......@@ -6,7 +6,7 @@ import React, {
useMemo,
useState,
} from 'react';
import { Dialog, DialogActions, DialogBody, DialogTitle } from '@/components/Dialog';
import { Close } from '@/components/AppIcons';
import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent';
import {
......@@ -179,59 +179,58 @@ const RecordDetailDrawer = (props, ref) => {
};
return (
<Dialog className="rc-record-detail-dialog" open={open} onClose={closeDrawer} size="4xl">
<Toast.Provider placement="top" />
<DialogTitle>{mode === 'edit' ? '编辑记录' : referenceStack.length > 0 ? '关联记录详情' : '记录详情'}</DialogTitle>
{detailViews.length > 1 ? (
<div className="rc-record-detail-dialog__views rc-segmented-control">
{detailViews.map((view) => {
const selected = view.name === activeView?.name;
return <button className={`rc-segmented-control__item${selected ? ' is-selected' : ''}`} key={view.name} onClick={() => setActiveTabKey(view.name)} type="button">{view.title || view.name}</button>;
})}
</div>
) : null}
<DialogBody className="rc-record-detail-dialog__body">
{loading ? (
<div className="rc-record-detail-dialog__state">
正在加载详情...
</div>
) : error ? (
<div className="rc-record-detail-dialog__error">
{error}
</div>
) : (
<div className="space-y-4">
{isRecordLocked ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
当前问题整改记录已锁定,仅可查看。如需修改,请先在问题清单中解锁。
</div>
) : null}
<SubjectViewContent
view={activeView}
subject={subject}
editableRecord={editableRecord}
onFieldUpdate={handleFieldUpdate}
mode={mode}
readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled}
onOpenReference={openReference}
/>
</div>
)}
</DialogBody>
<DialogActions>
{mode === 'edit' ? (
<>
<Button isDisabled={saving} size="sm" variant="outline" onPress={cancelEdit}>取消</Button>
<Button isPending={saving} size="sm" variant="primary" onPress={saveChanges}>{saving ? '保存中...' : '保存'}</Button>
</>
) : effectiveReadonly || referenceStack.length > 0 ? (
<Button size="sm" variant="outline" onPress={closeDrawer}>{referenceStack.length > 0 ? '返回上一级' : '关闭'}</Button>
) : (
<Button size="sm" variant="primary" onPress={() => setMode('edit')}>编辑</Button>
)}
</DialogActions>
</Dialog>
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && closeDrawer()}>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-detail-dialog rc-dialog-panel--4xl">
<Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{mode === 'edit' ? '编辑记录' : referenceStack.length > 0 ? '关联记录详情' : '记录详情'}</Modal.Heading>
</Modal.Header>
<Modal.Body>
<Toast.Provider placement="top" />
{detailViews.length > 1 ? (
<div className="max-w-full overflow-x-auto">
<Tabs className="w-fit max-w-full" selectedKey={activeView?.name} onSelectionChange={(key) => setActiveTabKey(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full">
<Tabs.List aria-label="详情视图" className="min-w-max">
{detailViews.map((view) => <Tabs.Tab className="flex-none whitespace-nowrap" id={view.name} key={view.name}>{view.title || view.name}<Tabs.Indicator /></Tabs.Tab>)}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
</div>
) : null}
{loading ? (
<div className="grid min-h-48 place-items-center text-sm text-muted">正在加载详情...</div>
) : error ? (
<Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
) : (
<div className="space-y-4">
{isRecordLocked ? <Alert status="warning"><Alert.Content><Alert.Description>当前问题整改记录已锁定,仅可查看。如需修改,请先在问题清单中解锁。</Alert.Description></Alert.Content></Alert> : null}
{mode === 'view' ? (
<Surface className="rc-detail-surface p-5" variant="secondary">
<SubjectViewContent view={activeView} subject={subject} editableRecord={editableRecord} onFieldUpdate={handleFieldUpdate} mode={mode} readonly={effectiveReadonly} isFieldDisabled={isDrawerFieldDisabled} onOpenReference={openReference} />
</Surface>
) : (
<SubjectViewContent view={activeView} subject={subject} editableRecord={editableRecord} onFieldUpdate={handleFieldUpdate} mode={mode} readonly={effectiveReadonly} isFieldDisabled={isDrawerFieldDisabled} onOpenReference={openReference} />
)}
</div>
)}
</Modal.Body>
<Modal.Footer>
{mode === 'edit' ? (
<><Button isDisabled={saving} size="sm" variant="secondary" onPress={cancelEdit}>取消</Button><Button isPending={saving} size="sm" variant="primary" onPress={saveChanges}>{saving ? '保存中...' : '保存'}</Button></>
) : effectiveReadonly || referenceStack.length > 0 ? (
<Button size="sm" variant="secondary" onPress={closeDrawer}>{referenceStack.length > 0 ? '返回上一级' : '关闭'}</Button>
) : (
<Button size="sm" variant="primary" onPress={() => setMode('edit')}>编辑</Button>
)}
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
};
......
import xyLogoRed from '@/assets/xy-logo_small.png';
import xyLogoWhite from '@/assets/xy-logo-white.png';
import { AppGrid, Archive, CheckSquare, FileDone, FileText, LogOut, Moon, Settings, SidebarPanel, Sun } from '@/components/AppIcons';
import AppShell from '@/components/AppShell';
import { Archive, CheckSquare, FileDone, FileText, Settings } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import { UserContext } from '@/wrapper/Auth';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { useContext, useLayoutEffect, useMemo, useState } from 'react';
import { Heading } from 'react-aria-components/Dialog';
import { history, Outlet, useLocation } from 'umi';
import { findMenuEntryByPath, INTEGRITY_PRACTICE_APP_TITLE, INTEGRITY_PRACTICE_MENU } from './integrityPracticeConfig';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'umi';
import { INTEGRITY_PRACTICE_MENU } from './integrityPracticeConfig';
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
const GROUP_ICONS = [CheckSquare, FileDone, Settings, Archive];
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
try { return window.localStorage.getItem(THEME_STORAGE_KEY) === 'dark' ? 'dark' : 'light'; } catch { return 'light'; }
}
function SidebarUserMenu({ userInfo }) {
const displayName = userInfo?.display_name || userInfo?.username || '当前用户';
return (
<Dropdown>
<Dropdown.Trigger className="rc-sidebar-user-trigger" variant="ghost">
<Avatar className="rc-sidebar-avatar"><Avatar.Fallback>{displayName.slice(0, 1).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="rc-sidebar-user-copy" data-sidebar="label"><strong>{displayName}</strong><small>{userInfo?.username || '已登录'}</small></span>
</Dropdown.Trigger>
<Dropdown.Popover placement="top start">
<Dropdown.Menu aria-label="用户菜单" onAction={(key) => { if (key === 'logout') userInfo?.logout?.(); }}>
<Dropdown.Item id="logout" textValue="退出登录" variant="danger"><Label>退出登录</Label><LogOut size={14} /></Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
);
}
function SidebarContents({ pathname, theme, userInfo, onSwitchApplication }) {
return (
<>
<Sidebar.Header>
<div className="rc-sidebar-brand">
<img alt="鑫元" className="rc-sidebar-logo" src={theme === 'dark' ? xyLogoWhite : xyLogoRed} />
<span className="rc-sidebar-title" data-sidebar="label">{INTEGRITY_PRACTICE_APP_TITLE}</span>
<span data-sidebar="label">
<Tooltip delay={0}>
<Button isIconOnly aria-label="切换应用" className="rc-sidebar-app-trigger" size="sm" variant="ghost" onPress={onSwitchApplication}><AppGrid size={16} /></Button>
<Tooltip.Content>切换应用</Tooltip.Content>
</Tooltip>
</span>
</div>
</Sidebar.Header>
<Sidebar.Content>
{INTEGRITY_PRACTICE_MENU.map((group, index) => {
const Icon = GROUP_ICONS[index] || FileText;
return (
<Sidebar.Group key={group.path}>
<Sidebar.GroupLabel>{group.label}</Sidebar.GroupLabel>
<Sidebar.Menu aria-label={group.label} showGuideLines={false}>
{group.items.map((item) => (
<Sidebar.MenuItem href={item.path} isCurrent={pathname.endsWith(item.path)} key={item.path} textValue={item.label} tooltip={item.label}>
<Sidebar.MenuIcon><Icon size={16} /></Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.label}</Sidebar.MenuLabel>
</Sidebar.MenuItem>
))}
</Sidebar.Menu>
</Sidebar.Group>
);
})}
</Sidebar.Content>
<Sidebar.Footer><SidebarUserMenu userInfo={userInfo} /></Sidebar.Footer>
</>
);
}
const MENU_GROUPS = INTEGRITY_PRACTICE_MENU.map((group, index) => ({
title: group.label,
items: group.items.map((item) => ({
name: item.label,
path: item.path,
icon: GROUP_ICONS[index] || FileText,
})),
}));
export default function IntegrityPracticeLayout() {
const location = useLocation();
const userInfo = useContext(UserContext);
const [theme, setTheme] = useState(getInitialTheme);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [switcherOpen, setSwitcherOpen] = useState(false);
const currentMenu = useMemo(() => findMenuEntryByPath(location.pathname), [location.pathname]);
const pageTitle = currentMenu?.item?.label || INTEGRITY_PRACTICE_APP_TITLE;
const pageTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
[location.pathname],
);
useLayoutEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.title = `${pageTitle} - ${INTEGRITY_PRACTICE_APP_TITLE}`;
try { window.localStorage.setItem(THEME_STORAGE_KEY, theme); } catch {}
}, [pageTitle, theme]);
useEffect(() => {
document.title = `${pageTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [pageTitle]);
const sidebarProps = { pathname: location.pathname, theme, userInfo, onSwitchApplication: () => setSwitcherOpen(true) };
return (
<Sidebar.Provider className={`rc-app-shell ${theme}`} collapsible="offcanvas" navigate={(href) => history.push(href)}>
<Sidebar className="rc-sidebar"><SidebarContents {...sidebarProps} /></Sidebar>
<Sidebar.Mobile className="rc-mobile-sidebar"><Heading className="sr-only" slot="title">{INTEGRITY_PRACTICE_APP_TITLE}导航</Heading><SidebarContents {...sidebarProps} /></Sidebar.Mobile>
<Sidebar.Main className="rc-app-main">
<Navbar className="rc-navbar" maxWidth="full">
<Navbar.Header>
<Sidebar.Trigger aria-label="打开或收起导航" className="rc-navbar-trigger"><SidebarPanel size={16} /></Sidebar.Trigger>
<h1 className="rc-navbar-title">{pageTitle}</h1>
<Navbar.Spacer />
<Tooltip delay={0}>
<Button isIconOnly aria-label={theme === 'dark' ? '切换至浅色模式' : '切换至深色模式'} className="rc-navbar-icon-button" size="sm" variant="ghost" onPress={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}</Button>
<Tooltip.Content>{theme === 'dark' ? '切换至浅色模式' : '切换至深色模式'}</Tooltip.Content>
</Tooltip>
</Navbar.Header>
</Navbar>
<div className="min-h-0 flex-1 overflow-y-auto"><div className="rc-app-page"><Outlet /></div></div>
</Sidebar.Main>
<>
<AppShell
menuGroups={MENU_GROUPS}
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setSwitcherOpen(true)}
open={sidebarOpen}
pathname={location.pathname}
title={pageTitle}
userInfo={userInfo}
onOpenChange={setSidebarOpen}
>
<Outlet />
</AppShell>
<ApplicationSwitcherDialog open={switcherOpen} onClose={() => setSwitcherOpen(false)} userInfo={userInfo} />
</Sidebar.Provider>
</>
);
}
......@@ -18,7 +18,6 @@ import {
isConfiguredActionVisible,
loadSubjectRecords,
loadSubjectResources,
PAGE_SIZE_OPTIONS,
} from './integrityPracticeWorkbenchService';
const EMPTY_ARRAY = [];
......@@ -53,11 +52,11 @@ function ActionConfirmationDialog({ state, onClose, onConfirm, submitting }) {
<AlertDialog isOpen={Boolean(state)} onOpenChange={(open) => { if (!open && !submitting) onClose(); }}>
<AlertDialog.Backdrop isKeyboardDismissDisabled={submitting}>
<AlertDialog.Container size="md">
<AlertDialog.Dialog className="rc-dialog-panel">
<AlertDialog.Dialog>
<AlertDialog.Header><AlertDialog.Heading>确认{state?.label || '执行操作'}</AlertDialog.Heading></AlertDialog.Header>
<AlertDialog.Body>{state?.description}</AlertDialog.Body>
<AlertDialog.Footer>
<Button isDisabled={submitting} variant="outline" onPress={onClose}>取消</Button>
<Button isDisabled={submitting} variant="secondary" onPress={onClose}>取消</Button>
<Button isDisabled={submitting} isPending={submitting} variant={state?.actionName?.includes('deleteData') ? 'danger' : 'primary'} onPress={onConfirm}>{submitting ? '正在处理...' : state?.actionName?.includes('deleteData') ? '确认删除' : '确认执行'}</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
......@@ -82,7 +81,7 @@ export default function SubjectWorkbench() {
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const pageSize = DEFAULT_PAGE_SIZE;
const [pagination, setPagination] = useState(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
......@@ -229,6 +228,7 @@ export default function SubjectWorkbench() {
const Icon = resolveActionIcon(action.action);
return {
key: action.action || `action-${index}`,
action: action.action,
label: action.name,
icon: Icon,
tone: action.action?.includes('deleteData') ? 'danger' : undefined,
......@@ -311,7 +311,6 @@ export default function SubjectWorkbench() {
toolbarActions={toolbarActions}
selectionActions={selectionActions}
rowActions={rowActions}
actionColumnWidth={Math.min(440, Math.max(176, rowActions.length * 88))}
states={{
loading,
error,
......@@ -337,12 +336,7 @@ export default function SubjectWorkbench() {
pagination
? {
...pagination,
pageSizeOptions: PAGE_SIZE_OPTIONS,
onPageChange: setPage,
onPageSizeChange: (nextPageSize) => {
setPageSize(nextPageSize);
setPage(0);
},
}
: null
}
......
This diff is collapsed.
This diff is collapsed.
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