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

feat: migrate application to Xinyuan design system

parent 5d84edd7
...@@ -10,5 +10,7 @@ ...@@ -10,5 +10,7 @@
/.umi-test /.umi-test
/dist /dist
/.mfsu /.mfsu
.playwright-cli/
/test-results/
.swc .swc
.pnpm-store .pnpm-store
# UI 架构
## 运行结构
应用为 Umi 4 / React 19,`base` 与 `publicPath` 均为 `/risk_case_library/`。`Layout.jsx` 只负责根主题、HeroUI Pro Shell、路由标题、应用切换与真实退出;页面业务请求继续由现有 service/controller 管理。
## 直接导入边界
- UI 原语与 compound component 在页面或业务中立共享组合中直接导入 HeroUI/HeroUI Pro。
- 共享组合只可封装重复布局、可访问性与当前 HeroUI value/selection/event 模型,不得复刻 Catalyst props、DOM event 或旧 CSS hook。
- DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;分页容量继续保持 20/50/100,并使用 HeroUI Autocomplete compound API。
- 文件上传采用 HeroUI Pro DropZone 的 `File[]` 协议;页面边界继续只取既有单文件值。
## 依赖来源
`@heroui-pro/react@1.0.0-beta.8` 由官方 `hpsetup` 安装到本项目,`package.json` 使用正式版本范围,锁文件解析到 registry 包。本项目不得再使用跨项目 `file:`、绝对路径、相邻 `node_modules`、符号链接或 Webpack symlink 兼容配置。
## 样式所有权
`src/design-system/theme.css` 是不可变主题权威;`tokens.css`、`shell.css`、`tailwind.css` 与 `global.less` 使用“问题整改”实际副本。项目不再加载 `portable.css`,页面没有私有 CSS 外观层。
本地浏览器验收产物目录 `.playwright-cli/` 与 `test-results/` 必须保持在 `.gitignore` 中。Tailwind CSS v4 会自动探测未忽略的项目文件;若验收日志进入扫描范围,CSS HMR 日志写入会反向触发持续编译,造成样式表和字体反复重载。
## 业务边界
服务层继续负责 `risk_case_library` 的 `/record` 协议,所有业务字段仍写入 `metadata`。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。
# UI 迁移清单
## 基线与依赖
- [x] 读取 API、Subject 与业务流程文档
- [x] 执行官方 `hpsetup` 并验证本项目 Pro 解析路径
- [x] 启动目标、问题整改和独立 `HEAD` 原始基线
- [x] 建立全路由、依赖、组件与样式审计
- [x] 清除旧镜像专用 Webpack 配置并校验唯一依赖树
## 页面迁移
- [x] Login:问题整改几何、字段节奏、验证码、filled/autofill、浅/深色
- [x] Shell:Sidebar/Navbar/退出/应用切换/主题/offcanvas(代码结构)
- [x] 驾驶舱:Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip(代码结构)
- [x] 案例管理:Tabs、筛选工具栏、DataGrid、Pagination、ActionBar、EmptyState(代码结构)
- [x] 弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
- [x] 清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS
## 浏览器验收
- [x] Login 1440x900 light/dark
- [x] Login 1280x720 light/dark
- [x] Login 768x1024 light/dark
- [x] Login 390x844 light/dark
- [ ] 打开 Sidebar、用户退出菜单、应用切换、Dropdown/ListBox/DatePicker
- [ ] 验证 Tabs、筛选、Checkbox、DataGrid、Pagination、ActionBar、Modal、KPI、图表 Tooltip
- [x] Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
## 最终门槛
- [x] `npm run build`
- [x] `git diff --check`
- [x] 鑫元 `audit_project.sh`(脚本完成;误报单独记录)
- [x] 旧组件引用扫描为零
- [x] 页面私有视觉 CSS 扫描为零
- [x] `file:` 与本机路径扫描为零
- [x] HeroUI/React Aria 依赖树唯一
- [x] HeroUI Pro 必需子路径通过 ESM 实际导入
未完成项只剩登录后真实浏览器状态。不得使用伪造 token、网络数据或测试数字替代真实验收。
# 鑫元设计系统
本项目直接采用 HeroUI/HeroUI Pro 与鑫元权威主题。“问题整改”应用是结构、尺寸、间距、组件层级与交互的唯一实现基准。
## 依赖与样式
- 基础控件直接从 `@heroui/react` 导入。
- Sidebar、Navbar、DataGrid、ActionBar、EmptyState、DropZone、KPI 与图表从 `@heroui-pro/react` 对应子路径直接导入。
- 样式顺序固定为 Tailwind、HeroUI、HeroUI Pro、`theme.css`、`tokens.css`、批准的共享布局层。
- `theme.css` 必须与 Skill 权威副本逐字节一致;页面不得覆盖 HeroUI 外观、slot、BEM 类或交互状态。
- 页面 `className` 只承担 flex、grid、宽度、间距、对齐和响应式布局。
## 页面规则
- Shell 使用 HeroUI Pro offcanvas Sidebar 与 Navbar;页面和 Navbar 使用 48/32/16px 对称 gutter。
- 应用切换使用 HeroUI Modal,应用卡片在小屏单列、`sm` 及以上固定两列,与“问题整改”保持一致。
- 结构化筛选使用 HeroUI Surface,内部控件使用 `secondary`;关键词搜索位于列表工具栏左侧。
- 列表使用 HeroUI Pro DataGrid 原生选择、排序、固定列、ActionBar、EmptyState 与 compound Pagination。
- 详情和表单使用 4xl HeroUI Modal;查看态只有一个 `Surface secondary` 字段组,编辑态直接放置 `secondary` 控件。
- 指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。
- 图表业务序列只使用 `--rc-chart-*` Token,不使用 UI 状态色或页面硬编码颜色。
## 禁止项
- Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。
- ECharts、自制 Tooltip、自制表格/分页/弹窗/KPI 或 HeroUI 仿制组件。
- 页面私有背景、颜色、边框、圆角、阴影、Hover、Focus、Selected、Disabled 样式。
- `file:`、工作站绝对路径、相邻项目 `node_modules` 或符号链接依赖。
# 鑫元 UI 审计
审计日期:2026-08-19。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。唯一实现基准为本机已确认的“问题整改”应用 `/Users/liuchenlong/Desktop/job/problem_rectification`。
## 安装与运行基线
- 官方安装命令已在项目根目录执行成功,`@heroui-pro/react` 解析为本项目 `node_modules` 内的 `1.0.0-beta.8`,不是符号链接或跨项目 `file:` 依赖。
- 项目使用 npm,保留 `package-lock.json`;旧 `pnpm-lock.yaml` 已删除。
- 当前迁移实例运行于 `http://localhost:8001/risk_case_library/`。
- “问题整改”基准运行于 `http://localhost:8000/problem_rectification_new/`。
- `HEAD` 原始业务基线通过独立临时工作树运行于 `http://localhost:8004/risk_case_library/`。原始路由、鉴权、验证码、查询、保存、导入导出和分页协议以该实例及 `git show HEAD` 为准。
## 业务契约
- 业务对象:`risk_case_library`。
- 查询:`POST /record/query`;新增:`POST /record`;更新:`PUT /record/:name`;删除:`DELETE /record/:name`。
- 业务字段继续封装在 `metadata`,引用值只传 ID,日期保持既有 ISO 8601 转换。
- 路由保持 `/login`、`/resource/stat`、`/resource/booking`;分页保持默认 20 条及 20/50/100 可选口径。
- 登录仍提交账号、加密密码、验证码和验证码 token;退出仍调用既有 `authService.logout()` 并跳转到 base-aware 登录页。
## 全局审计结果
| 范围 | 当前结果 | 迁移要求 |
| --- | --- | --- |
| Login | HeroUI;结构、尺寸、字段节奏与问题整改一致;可访问性告警已消除 | 四视口浅/深色对照通过 |
| Shell | HeroUI Pro Sidebar/Navbar,offcanvas、主题、用户退出与应用切换保留既有业务回调 | 代码与共享基准一致;登录后浏览器状态待真实凭证 |
| 应用切换 | HeroUI Modal compound API,桌面两列应用卡、Surface + Avatar identity well、当前态 | 登录后打开态待真实凭证 |
| 驾驶舱 | HeroUI Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltipContent | 构建通过;真实图表 Tooltip 待登录验收 |
| 案例管理 | HeroUI Surface/Tabs/TextField/Autocomplete、Pro DataGrid/ActionBar/EmptyState | 默认 20、20/50/100 分页口径已保护;交互待登录验收 |
| 弹窗 | HeroUI Modal/Alert/DropZone,4xl 表单与单一 secondary 查看 Surface | 结构完成;打开态待登录验收 |
| 旧组件 | Catalyst、Headless、ECharts、旧 Select/Date/Table 兼容层已移除 | 活动源码扫描为零 |
| 样式 | `theme.css` 匹配 Skill;`tokens.css`、`shell.css`、`tailwind.css`、`global.less` 匹配问题整改 | `portable.css` 与页面私有视觉 CSS 已移除 |
## 风险与保护
- 不改变 API URL、请求参数、响应映射、路由、权限、校验、请求时机、保存回调、登录退出或业务状态流。
- 不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
- 静态、构建与登录页浏览器门槛已完成;缺少真实登录凭证,因此 Sidebar、退出菜单、应用切换、业务列表、弹窗、KPI 和图表的登录后验收仍处于阻塞状态,不能标记全量迁移完成。
This diff is collapsed.
...@@ -9,11 +9,10 @@ ...@@ -9,11 +9,10 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@headlessui/react": "^2.2.10", "@fontsource/inter": "^5.3.0",
"@heroicons/react": "^2.2.0", "@heroui-pro/react": "^1.0.0-beta.8",
"@heroui-pro/react": "^1.0.0-beta.7", "@heroui/react": "^3.2.4",
"@heroui/react": "^3.2.3", "@heroui/styles": "^3.2.4",
"@heroui/styles": "^3.2.3",
"@internationalized/date": "^3.12.3", "@internationalized/date": "^3.12.3",
"@number-flow/react": ">=0.5.0", "@number-flow/react": ">=0.5.0",
"@react-aria/i18n": "^3.13.1", "@react-aria/i18n": "^3.13.1",
...@@ -32,8 +31,6 @@ ...@@ -32,8 +31,6 @@
"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",
"maplibre-gl": ">=6.0.0", "maplibre-gl": ">=6.0.0",
......
This diff is collapsed.
import { Button as HeroButton } from '@heroui/react/button';
import { Chip } from '@heroui/react/chip';
import clsx from 'clsx';
import { forwardRef } from 'react';
import { Link } from './link';
const colors = {
red: 'rc-status-chip--danger',
orange: 'rc-status-chip--warning',
amber: 'rc-status-chip--warning',
yellow: 'rc-status-chip--warning',
lime: 'rc-status-chip--success',
green: 'rc-status-chip--success',
emerald: 'rc-status-chip--success',
teal: 'rc-status-chip--teal',
cyan: 'rc-status-chip--info',
sky: 'rc-status-chip--info',
blue: 'rc-status-chip--info',
indigo: 'rc-status-chip--info',
violet: 'rc-status-chip--info',
purple: 'rc-status-chip--info',
fuchsia: 'rc-status-chip--danger',
pink: 'rc-status-chip--danger',
rose: 'rc-status-chip--danger',
zinc: 'rc-status-chip--neutral',
};
export function Badge({ color = 'zinc', className, children, ...props }) {
return (
<Chip
{...props}
className={clsx('rc-status-chip', colors[color], className)}
size="sm"
variant="soft"
>
<Chip.Label>{children}</Chip.Label>
</Chip>
);
}
export const BadgeButton = forwardRef(function BadgeButton(
{ color = 'zinc', className, children, disabled, href, ...props },
ref,
) {
const content = <Badge color={color}>{children}</Badge>;
if (typeof href === 'string') {
return (
<Link {...props} className={className} href={href} ref={ref}>
{content}
</Link>
);
}
return (
<HeroButton
{...props}
className={className}
isDisabled={disabled}
ref={ref}
variant="tertiary"
>
{content}
</HeroButton>
);
});
import { Button as HeroButton } from '@heroui/react/button';
import clsx from 'clsx';
import { forwardRef } from 'react';
import { Link } from './link';
function hasLegacyClass(className, token) {
return String(className || '')
.split(/\s+/)
.includes(token);
}
function resolveButtonIntent({ className, color, outline, plain }) {
const legacyPrimary = hasLegacyClass(className, 'button--primary');
const legacyDanger = hasLegacyClass(className, 'button--danger');
const legacyOutline = hasLegacyClass(className, 'button--outline');
const legacyPlain =
hasLegacyClass(className, 'button--plain') ||
hasLegacyClass(className, 'button--ghost');
const isPrimary = color === 'blue' || legacyPrimary;
const isDanger = color === 'red' || legacyDanger;
const isPlain = plain || legacyPlain;
const isDangerEntry = isDanger && (outline || isPlain);
const isOutline =
outline || legacyOutline || (!isPrimary && !isDanger && !isPlain);
return {
isDanger,
isDangerEntry,
isOutline,
isPlain,
isPrimary,
variant: isPrimary
? 'primary'
: isDanger && !isDangerEntry
? 'danger'
: isPlain
? 'tertiary'
: 'secondary',
};
}
export const Button = forwardRef(function Button(
{
color,
outline = false,
plain = false,
className,
children,
disabled,
href,
onClick,
onPress,
size = 'sm',
...props
},
ref,
) {
const intent = resolveButtonIntent({ className, color, outline, plain });
const classes = clsx(
'rc-button',
(intent.isPrimary || (intent.isDanger && !intent.isDangerEntry)) &&
'rc-button--solid',
intent.isPrimary && 'rc-button--primary',
intent.isOutline && 'rc-button--secondary rc-button--outline',
intent.isPlain && 'rc-button--secondary rc-button--plain',
intent.isDanger && 'rc-button--danger',
className,
);
if (typeof href === 'string') {
return (
<Link
{...props}
aria-disabled={disabled || undefined}
className={classes}
href={href}
ref={ref}
>
<TouchTarget>{children}</TouchTarget>
</Link>
);
}
return (
<HeroButton
{...props}
className={classes}
isDisabled={disabled}
ref={ref}
size={size}
onPress={onPress || onClick}
variant={intent.variant}
>
<TouchTarget>{children}</TouchTarget>
</HeroButton>
);
});
export function TouchTarget({ children }) {
return <>{children}</>;
}
import { Checkbox as HeroCheckbox } from '@heroui/react/checkbox';
import clsx from 'clsx';
export function CheckboxGroup({ className, ...props }) {
return <div data-slot="control" {...props} className={clsx('space-y-3', className)} />;
}
export function CheckboxField({ className, ...props }) {
return (
<div
data-slot="field"
{...props}
className={clsx('grid grid-cols-[1rem_1fr] items-start gap-x-3 gap-y-1', className)}
/>
);
}
export function Checkbox({
checked,
className,
color = 'blue',
disabled,
indeterminate,
onChange,
...props
}) {
return (
<HeroCheckbox
{...props}
className={clsx('rc-checkbox', `rc-checkbox--${color}`, className)}
isDisabled={disabled}
isIndeterminate={indeterminate}
isSelected={checked}
onChange={onChange}
>
<HeroCheckbox.Content>
<HeroCheckbox.Control>
<HeroCheckbox.Indicator />
</HeroCheckbox.Control>
</HeroCheckbox.Content>
</HeroCheckbox>
);
}
import { Description } from '@heroui/react/description';
import { Modal } from '@heroui/react/modal';
import clsx from 'clsx';
import { Close } from '../../components/AppIcons';
export function Dialog({ size = 'lg', className, children, open, onClose, ...props }) {
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
{...props}
className="rc-dialog-backdrop"
isOpen={open}
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
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 DialogDescription({ className, ...props }) {
return <Description {...props} className={clsx('rc-dialog-description', className)} />;
}
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 { Description as HeroDescription } from '@heroui/react/description';
import { FieldError } from '@heroui/react/field-error';
import { Fieldset as HeroFieldset } from '@heroui/react/fieldset';
import { Label as HeroLabel } from '@heroui/react/label';
import clsx from 'clsx';
export function Fieldset({ className, ...props }) {
return <HeroFieldset {...props} className={className} />;
}
export function Legend({ className, ...props }) {
return <HeroFieldset.Legend {...props} className={className} />;
}
export function FieldGroup({ className, ...props }) {
return <HeroFieldset.Group {...props} className={clsx('space-y-8', className)} />;
}
export function Field({ className, ...props }) {
return <div data-slot="field" {...props} className={clsx('rc-form-field', className)} />;
}
export function Label({ className, ...props }) {
return <HeroLabel data-slot="label" {...props} className={className} />;
}
export function Description({ className, ...props }) {
return <HeroDescription data-slot="description" {...props} className={className} />;
}
export function ErrorMessage({ className, ...props }) {
return <FieldError data-slot="error" {...props} className={className} />;
}
import { Input as HeroInput } from '@heroui/react/input';
import clsx from 'clsx';
import { forwardRef } from 'react';
export function InputGroup({ children, className }) {
return (
<span data-slot="control" className={clsx('rc-input-group relative block', className)}>
{children}
</span>
);
}
export const Input = forwardRef(function Input(
{ className, disabled, invalid, ...props },
ref,
) {
return (
<HeroInput
{...props}
aria-invalid={invalid || undefined}
className={clsx('rc-form-input', className)}
disabled={disabled}
fullWidth
ref={ref}
variant="secondary"
/>
);
});
/**
* TODO: Update this component to use your client-side framework's link
* component. We've provided examples of how to do this for Next.js, Remix, and
* Inertia.js in the Catalyst documentation:
*
* https://catalyst.tailwindui.com/docs#client-side-router-integration
*/
import * as Headless from '@headlessui/react'
import React, { forwardRef } from 'react'
export const Link = forwardRef(function Link(props, ref) {
return (
<Headless.DataInteractive>
<a {...props} ref={ref} />
</Headless.DataInteractive>
)
})
import clsx from 'clsx'
import { Button } from './button'
export function Pagination({ 'aria-label': ariaLabel = 'Page navigation', className, ...props }) {
return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
}
export function PaginationPrevious({ href = null, className, children = 'Previous' }) {
return (
<span className={clsx(className, 'grow basis-0')}>
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Previous page">
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2.75 8H13.25M2.75 8L5.25 5.5M2.75 8L5.25 10.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{children}
</Button>
</span>
)
}
export function PaginationNext({ href = null, className, children = 'Next' }) {
return (
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Next page">
{children}
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M13.25 8L2.75 8M13.25 8L10.75 10.5M13.25 8L10.75 5.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
</span>
)
}
export function PaginationList({ className, ...props }) {
return <span {...props} className={clsx(className, 'hidden items-baseline gap-x-2 sm:flex')} />
}
export function PaginationPage({ href, className, current = false, children }) {
return (
<Button
href={href}
plain
aria-label={`Page ${children}`}
aria-current={current ? 'page' : undefined}
className={clsx(
className,
'min-w-9 before:absolute before:-inset-px before:rounded-lg',
current && 'before:bg-zinc-950/5'
)}
>
<span className="-mx-0.5">{children}</span>
</Button>
)
}
export function PaginationGap({ className, children = <>&hellip;</>, ...props }) {
return (
<span
aria-hidden="true"
{...props}
className={clsx(className, 'w-9 text-center text-sm/6 font-semibold text-zinc-950 select-none')}
>
{children}
</span>
)
}
'use client';
import clsx from 'clsx';
export function Segment({ className, children }) {
return (
<div
className={clsx(
'rc-segmented-control inline-flex items-center gap-1',
className,
)}
>
{children}
</div>
);
}
export function SegmentOption({
active = false,
className,
children,
...props
}) {
return (
<button
type="button"
{...props}
className={clsx(
'rc-segmented-control__item',
active && 'is-active',
className,
)}
>
{children}
</button>
);
}
import FormSelect from '@/components/FormSelect';
import { forwardRef } from 'react';
export const Select = forwardRef(function Select(props, _ref) {
return <FormSelect {...props} />;
});
'use client';
import clsx from 'clsx';
import { createContext, useContext, useState } from 'react';
import { Link } from './link';
const TableContext = createContext({
bleed: false,
dense: false,
grid: false,
striped: false,
});
export function Table({
bleed = false,
dense = false,
grid = false,
striped = false,
className,
children,
...props
}) {
return (
<TableContext.Provider value={{ bleed, dense, grid, striped }}>
<div className="flow-root">
<div
{...props}
className={clsx(
className,
'-mx-(--gutter) overflow-x-auto whitespace-nowrap',
)}
>
<div
className={clsx(
'inline-block min-w-full align-middle',
!bleed && 'sm:px-(--gutter)',
)}
>
<table className="min-w-full text-left text-sm/6 text-zinc-950">
{children}
</table>
</div>
</div>
</div>
</TableContext.Provider>
);
}
export function TableHead({ className, ...props }) {
return <thead {...props} className={clsx(className, 'text-zinc-500')} />;
}
export function TableBody(props) {
return <tbody {...props} />;
}
const TableRowContext = createContext({
href: undefined,
target: undefined,
title: undefined,
});
export function TableRow({ href, target, title, className, ...props }) {
let { striped } = useContext(TableContext);
return (
<TableRowContext.Provider value={{ href, target, title }}>
<tr
{...props}
className={clsx(
className,
href &&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-[var(--pm-brand)]',
striped && 'even:bg-zinc-950/2.5',
href && striped && 'hover:bg-zinc-950/5',
href && !striped && 'hover:bg-zinc-950/2.5',
)}
/>
</TableRowContext.Provider>
);
}
export function TableHeader({ className, ...props }) {
let { bleed, grid } = useContext(TableContext);
return (
<th
{...props}
className={clsx(
className,
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
/>
);
}
export function TableCell({ className, children, ...props }) {
let { bleed, dense, grid, striped } = useContext(TableContext);
let { href, target, title } = useContext(TableRowContext);
let [cellRef, setCellRef] = useState(null);
return (
<td
ref={href ? setCellRef : undefined}
{...props}
className={clsx(
className,
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
!striped && 'border-b border-zinc-950/5',
grid && 'border-l border-l-zinc-950/5 first:border-l-0',
dense ? 'py-2.5' : 'py-4',
!bleed && 'sm:first:pl-4 sm:last:pr-4',
)}
>
{href && (
<Link
data-row-link
href={href}
target={target}
aria-label={title}
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
className="absolute inset-0 focus:outline-hidden"
/>
)}
{children}
</td>
);
}
import clsx from 'clsx'
import { Link } from './link'
export function Text({ className, ...props }) {
return (
<p
data-slot="text"
{...props}
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6')}
/>
)
}
export function TextLink({ className, ...props }) {
return (
<Link
{...props}
className={clsx(
className,
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950'
)}
/>
)
}
export function Strong({ className, ...props }) {
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950')} />
}
export function Code({ className, ...props }) {
return (
<code
{...props}
className={clsx(
className,
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem]'
)}
/>
)
}
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
export const Textarea = forwardRef(function Textarea({ className, resizable = true, ...props }, ref) {
return (
<span
data-slot="control"
className={clsx([
className,
// Basic layout
'relative block w-full',
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
,
// Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]',
// Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])}
>
<Headless.Textarea
ref={ref}
{...props}
className={clsx([
// Basic layout
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
// Typography
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6',
// Border
'border border-zinc-950/10 data-hover:border-zinc-950/20',
// Background color
'bg-transparent',
// Hide default focus styles
'focus:outline-hidden',
// Invalid state
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500',
// Disabled state
'disabled:border-zinc-950/20',
// Resizable
resizable ? 'resize-y' : 'resize-none',
])}
/>
</span>
)
})
This diff is collapsed.
...@@ -2,30 +2,23 @@ import { Upload } from '@/components/AppIcons'; ...@@ -2,30 +2,23 @@ import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone'; import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react'; import { useCallback } from 'react';
function createFileChangeEvent(files) {
return {
target: { files },
currentTarget: { files },
};
}
export default function FileUploadField({ export default function FileUploadField({
accept, accept,
ariaLabel = '选择文件', ariaLabel = '选择文件',
className = '', className = '',
description = '将文件拖到此处,或从本地选择。', description = '将文件拖到此处,或从本地选择。',
disabled = false, isDisabled = false,
fileName = '', fileName = '',
onChange, onFilesChange,
triggerLabel = '选择文件', triggerLabel = '选择文件',
}) { }) {
const emitFiles = useCallback( const emitFiles = useCallback(
(files) => { (files) => {
if (!disabled) { if (!isDisabled) {
onChange?.(createFileChangeEvent(files)); onFilesChange?.(Array.from(files || []));
} }
}, },
[disabled, onChange], [isDisabled, onFilesChange],
); );
const handleDrop = useCallback( const handleDrop = useCallback(
...@@ -42,15 +35,15 @@ export default function FileUploadField({ ...@@ -42,15 +35,15 @@ export default function FileUploadField({
); );
return ( return (
<DropZone className={`rc-file-upload ${className}`.trim()}> <DropZone className={className}>
<DropZone.Area <DropZone.Area
aria-label={ariaLabel} aria-label={ariaLabel}
isDisabled={disabled} isDisabled={isDisabled}
onDrop={handleDrop} onDrop={handleDrop}
> >
<DropZone.Input <DropZone.Input
accept={accept} accept={accept}
disabled={disabled} disabled={isDisabled}
onSelect={emitFiles} onSelect={emitFiles}
/> />
<DropZone.Icon> <DropZone.Icon>
...@@ -60,7 +53,7 @@ export default function FileUploadField({ ...@@ -60,7 +53,7 @@ export default function FileUploadField({
<DropZone.Description> <DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description} {fileName ? '已选择文件,可重新选择进行替换。' : description}
</DropZone.Description> </DropZone.Description>
<DropZone.Trigger isDisabled={disabled}> <DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" /> <Upload aria-hidden="true" />
{triggerLabel} {triggerLabel}
</DropZone.Trigger> </DropZone.Trigger>
......
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
import { useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_filter_empty__';
function normalizeOption(option) {
if (option && typeof option === 'object') {
return {
label: String(option.label ?? option.value ?? ''),
value: String(option.value ?? ''),
disabled: Boolean(option.disabled),
};
}
return {
label: String(option ?? ''),
value: String(option ?? ''),
disabled: false,
};
}
export default function FilterSelect({
'aria-label': ariaLabel,
className,
disabled = false,
emptyText = '暂无可选项',
onChange,
options = [],
placeholder = '请选择',
value = '',
}) {
const normalizedOptions = useMemo(
() => options.map(normalizeOption),
[options],
);
const selectedValue = String(value ?? '');
const selectedOption = normalizedOptions.find(
(option) => option.value === selectedValue,
);
const selectedKey = selectedValue === '' ? EMPTY_OPTION_KEY : selectedValue;
return (
<Select
aria-label={ariaLabel || placeholder}
className={clsx('rc-filter-select', className)}
fullWidth
isDisabled={disabled}
selectedKey={selectedKey}
variant="secondary"
onSelectionChange={(key) => {
const nextValue = key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
onChange?.(nextValue);
}}
>
<Select.Trigger className="rc-filter-select__trigger">
<Select.Value className="rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
!selectedOption && 'rc-filter-select__placeholder',
)}
title={selectedOption?.label || placeholder}
>
{selectedOption?.label || placeholder}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
</Select.Trigger>
<Select.Popover
className="rc-filter-select__popover"
placement="bottom start"
>
<ListBox
aria-label={ariaLabel || placeholder}
className="rc-filter-select__listbox"
>
{normalizedOptions.length > 0 ? (
normalizedOptions.map((option) => (
<ListBox.Item
key={option.value || EMPTY_OPTION_KEY}
id={option.value || EMPTY_OPTION_KEY}
className="rc-filter-select__option"
isDisabled={option.disabled}
textValue={option.label}
>
<Label
className="rc-filter-select__option-label"
title={option.label}
>
{option.label}
</Label>
<ListBox.ItemIndicator className="rc-filter-select__option-indicator" />
</ListBox.Item>
))
) : (
<div className="rc-filter-select__empty">{emptyText}</div>
)}
</ListBox>
</Select.Popover>
</Select>
);
}
import { Calendar as HeroCalendar } 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';
import { Calendar as CalendarIcon } from './AppIcons';
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 rawValue = value.toString();
if (mode === 'date') {
return rawValue.slice(0, 10);
}
if (rawValue.includes('T')) {
return rawValue.slice(0, 16);
}
return `${rawValue.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;
}
}
function formatTimeValue(value) {
return value ? value.toString().slice(0, 5) : '00:00';
}
function combineDateAndTime(value, dateValue, timeValue) {
const dateText = dateValue
? dateValue.toString().slice(0, 10)
: String(value || '').slice(0, 10);
if (!dateText) {
return '';
}
return `${dateText}T${formatTimeValue(timeValue)}`;
}
function emitDateChange(onChange, name, nextValue) {
onChange?.({
target: {
name,
value: nextValue,
},
});
}
export default function FormDatePicker({
'aria-label': ariaLabel,
className,
disabled = false,
granularity,
max,
min,
mode,
name,
onChange,
readOnly = false,
required = false,
type = 'date',
value,
...props
}) {
const resolvedMode =
mode === 'datetime' || type === 'datetime-local' ? 'datetime' : 'date';
const resolvedGranularity =
granularity || (resolvedMode === 'datetime' ? 'minute' : 'day');
const parsedValue = useMemo(
() => parseValue(value, resolvedMode),
[resolvedMode, value],
);
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
return (
<DatePicker
{...props}
aria-label={ariaLabel || '选择日期'}
className={clsx('rc-form-date-picker', className)}
granularity={resolvedGranularity}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
maxValue={maxValue || undefined}
minValue={minValue || undefined}
value={parsedValue}
onChange={(nextValue) =>
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
}
>
<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
className="rc-form-date-picker__trigger"
aria-label="打开日期选择"
>
<DatePicker.TriggerIndicator>
<CalendarIcon />
</DatePicker.TriggerIndicator>
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover
className="rc-form-date-picker__popover"
placement="bottom start"
>
<HeroCalendar className="rc-form-date-picker__calendar">
<HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading />
<HeroCalendar.NavButton slot="next" />
</HeroCalendar.Header>
<HeroCalendar.Grid>
<HeroCalendar.GridHeader>
{(day) => <HeroCalendar.HeaderCell>{day}</HeroCalendar.HeaderCell>}
</HeroCalendar.GridHeader>
<HeroCalendar.GridBody>
{(date) => <HeroCalendar.Cell date={date} />}
</HeroCalendar.GridBody>
</HeroCalendar.Grid>
</HeroCalendar>
{resolvedMode === 'datetime' ? (
<div className="rc-form-date-picker__time-panel">
<span className="rc-form-date-picker__time-label">时间</span>
<TimeField
aria-label="选择时间"
className="rc-form-date-picker__time-field"
granularity="minute"
hideTimeZone
hourCycle={24}
value={timeValue}
onChange={(nextTime) =>
emitDateChange(
onChange,
name,
combineDateAndTime(value, parsedValue, nextTime),
)
}
>
<TimeField.Group
className="rc-form-date-picker__time-group"
variant="secondary"
>
<TimeField.Input className="rc-form-date-picker__time-input">
{(segment) => (
<TimeField.Segment
className="rc-form-date-picker__segment"
segment={segment}
/>
)}
</TimeField.Input>
</TimeField.Group>
</TimeField>
</div>
) : null}
</DatePicker.Popover>
</DatePicker>
);
}
import { Label, ListBox, Select } from '@heroui/react';
import clsx from 'clsx';
import { Children, Fragment, isValidElement, useMemo } from 'react';
import { ChevronDown } from './AppIcons';
const EMPTY_OPTION_KEY = '__rc_form_select_empty__';
function extractText(node) {
if (node === null || node === undefined || typeof node === 'boolean') {
return '';
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractText).join('');
}
if (isValidElement(node)) {
return extractText(node.props.children);
}
return '';
}
function normalizeOptions(children) {
const options = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment || child.type === 'optgroup') {
options.push(...normalizeOptions(child.props.children));
return;
}
if (child.type !== 'option') {
return;
}
options.push({
disabled: Boolean(child.props.disabled),
label: extractText(child.props.children),
value: String(child.props.value ?? ''),
});
});
return options;
}
function toOptionKey(value) {
return value === '' ? EMPTY_OPTION_KEY : String(value);
}
function fromOptionKey(key) {
return key === EMPTY_OPTION_KEY ? '' : String(key ?? '');
}
export default function FormSelect({
'aria-label': ariaLabel,
children,
className,
disabled = false,
multiple = false,
name,
onChange,
placeholder,
required = false,
value,
...props
}) {
const options = useMemo(() => normalizeOptions(children), [children]);
const emptyOption = options.find((option) => option.value === '');
const resolvedPlaceholder = placeholder || emptyOption?.label || '请选择';
const selectedValues = multiple
? (Array.isArray(value) ? value : []).map(String)
: [String(value ?? '')];
const selectedOptions = options.filter(
(option) => option.value !== '' && selectedValues.includes(option.value),
);
const displayText = multiple
? selectedOptions.length <= 2
? selectedOptions.map((option) => option.label).join('、')
: `${selectedOptions
.slice(0, 2)
.map((option) => option.label)
.join('、')} 等 ${selectedOptions.length} 项`
: options.find((option) => option.value === selectedValues[0])?.label ||
resolvedPlaceholder;
const hasSelection = multiple
? selectedOptions.length > 0
: selectedValues[0] !== '';
const emitChange = (nextValues) => {
const normalizedValues = nextValues.map(fromOptionKey);
onChange?.({
target: {
name,
selectedOptions: normalizedValues.map((nextValue) => ({
value: nextValue,
})),
value: normalizedValues[0] || '',
},
});
};
const sharedProps = {
...props,
'aria-label': ariaLabel || resolvedPlaceholder,
className: clsx('rc-form-select rc-filter-select', className),
fullWidth: true,
isDisabled: disabled,
isRequired: required,
selectionMode: multiple ? 'multiple' : 'single',
variant: 'secondary',
};
if (multiple) {
sharedProps.selectedKeys = new Set(selectedValues.map(toOptionKey));
sharedProps.onSelectionChange = (keys) => {
const nextKeys =
keys === 'all' ? options.map((option) => option.value) : [...keys];
emitChange(nextKeys);
};
} else {
sharedProps.selectedKey = toOptionKey(selectedValues[0]);
sharedProps.onSelectionChange = (key) => emitChange([key]);
}
return (
<Select {...sharedProps}>
<Select.Trigger className="rc-form-select__trigger rc-filter-select__trigger">
<Select.Value className="rc-form-select__value rc-filter-select__value">
<span
className={clsx(
'block min-w-0 truncate',
!hasSelection && 'rc-filter-select__placeholder',
)}
title={displayText}
>
{displayText}
</span>
</Select.Value>
<ChevronDown className="rc-filter-select__indicator" />
</Select.Trigger>
<Select.Popover
className="rc-form-select__popover rc-filter-select__popover"
placement="bottom start"
>
<ListBox
aria-label={ariaLabel || resolvedPlaceholder}
className="rc-filter-select__listbox"
selectionMode={multiple ? 'multiple' : 'single'}
>
{options.map((option) => (
<ListBox.Item
key={toOptionKey(option.value)}
id={toOptionKey(option.value)}
className="rc-form-select__option rc-filter-select__option"
isDisabled={option.disabled}
textValue={option.label}
>
<Label
className="rc-filter-select__option-label"
title={option.label}
>
{option.label}
</Label>
<ListBox.ItemIndicator className="rc-filter-select__option-indicator" />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
);
}
This diff is collapsed.
import { ChevronDown } from '@/components/AppIcons';
import { Button, Input, Label, ListBox, Popover } from '@heroui/react';
import { useCallback, useMemo, useState } from 'react';
export default function ResourceFilterMultiSelect({
options,
value,
onChange,
placeholder,
disabled = false,
searchable = false,
searchPlaceholder = '输入关键字搜索',
}) {
const [searchKeyword, setSearchKeyword] = useState('');
const [isOpen, setIsOpen] = useState(false);
const selectedValues = Array.isArray(value) ? value.filter(Boolean) : [];
const selectedOptions = options.filter((option) =>
selectedValues.includes(option.value),
);
const normalizedSearchKeyword = searchKeyword.trim().toLowerCase();
const visibleOptions = useMemo(() => {
if (!searchable || !normalizedSearchKeyword) return options;
return options.filter((option) =>
`${option.label || ''} ${option.value || ''}`
.trim()
.toLowerCase()
.includes(normalizedSearchKeyword),
);
}, [normalizedSearchKeyword, options, searchable]);
const summaryText =
selectedOptions.length === 0
? placeholder
: selectedOptions.length <= 2
? selectedOptions.map((option) => option.label).join('、')
: `${selectedOptions
.slice(0, 2)
.map((option) => option.label)
.join('、')} 等 ${selectedOptions.length} 项`;
const handleSelectionChange = useCallback(
(keys) => {
onChange?.(
keys === 'all' ? options.map((option) => option.value) : [...keys],
);
},
[onChange, options],
);
const handleClearSelection = useCallback(
(event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
if (disabled || selectedValues.length === 0) {
return;
}
setSearchKeyword('');
onChange?.([]);
},
[disabled, onChange, selectedValues.length],
);
const handleOpenChange = useCallback((nextOpen) => {
setIsOpen(nextOpen);
if (!nextOpen) {
setSearchKeyword('');
}
}, []);
return (
<span className="resource-filter-multi-select rc-filter-select block min-w-0 w-full">
<Popover isOpen={isOpen} onOpenChange={handleOpenChange}>
<Button
aria-expanded={isOpen}
aria-label={placeholder}
className="resource-filter-multi-select__trigger rc-filter-select__trigger"
data-disabled={disabled ? 'true' : undefined}
isDisabled={disabled}
type="button"
variant="tertiary"
>
<span className="rc-filter-select__value">
<span
className={
selectedOptions.length > 0
? 'block truncate'
: 'rc-filter-select__placeholder block truncate'
}
title={summaryText}
>
{summaryText}
</span>
</span>
<ChevronDown
className="rc-filter-select__indicator"
data-open={isOpen ? 'true' : undefined}
/>
</Button>
<Popover.Content
className="resource-filter-multi-select__popover rc-filter-options-popover"
placement="bottom start"
>
<Popover.Dialog>
<div className="flex items-center justify-between gap-3 px-2 py-1">
<span className="text-xs text-[var(--rc-text-muted)]">可多选</span>
<Button
isDisabled={disabled || selectedValues.length === 0}
size="sm"
type="button"
variant="tertiary"
onClick={handleClearSelection}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPress={handleClearSelection}
>
清空
</Button>
</div>
{searchable ? (
<div className="px-2 py-2">
<Input
aria-label={searchPlaceholder}
fullWidth
placeholder={searchPlaceholder}
type="search"
value={searchKeyword}
variant="secondary"
onChange={(event) => setSearchKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault();
}}
/>
</div>
) : null}
{visibleOptions.length > 0 ? (
<ListBox
aria-label={placeholder}
className="rc-filter-select__listbox"
selectedKeys={new Set(selectedValues)}
selectionMode="multiple"
onSelectionChange={handleSelectionChange}
>
{visibleOptions.map((option) => (
<ListBox.Item
id={option.value}
key={option.value}
className="rc-filter-option resource-filter-multi-select__option"
textValue={option.label}
>
<Label className="truncate" title={option.label}>
{option.label}
</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
) : (
<div className="rc-filter-options-popover__status">
没有匹配选项
</div>
)}
</Popover.Dialog>
</Popover.Content>
</Popover>
</span>
);
}
This diff is collapsed.
export const XINYUAN_CHART_PALETTE = Object.freeze([
'var(--rc-chart-1)',
'var(--rc-chart-2)',
'var(--rc-chart-3)',
'var(--rc-chart-4)',
'var(--rc-chart-5)',
'var(--rc-chart-6)',
'var(--rc-chart-7)',
'var(--rc-chart-8)',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
primary: 'var(--rc-chart-primary)',
risk: 'var(--rc-chart-risk)',
warning: 'var(--rc-chart-warning)',
positive: 'var(--rc-chart-positive)',
comparison: 'var(--rc-chart-comparison)',
neutral: 'var(--rc-chart-neutral)',
});
function normalizeChartKey(key) {
return String(key ?? '').trim();
}
function hashChartKey(key) {
return Array.from(key).reduce(
(value, character) => ((value * 31) + character.codePointAt(0)) >>> 0,
0,
);
}
export function getXinyuanChartColor(key, fallbackIndex = 0) {
const stableKey = normalizeChartKey(key);
if (!stableKey) {
return XINYUAN_CHART_PALETTE[fallbackIndex % XINYUAN_CHART_PALETTE.length];
}
return XINYUAN_CHART_PALETTE[hashChartKey(stableKey) % XINYUAN_CHART_PALETTE.length];
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
if (keyIndex < XINYUAN_CHART_PALETTE.length) {
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % XINYUAN_CHART_PALETTE.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
colorMap.set(key, XINYUAN_CHART_PALETTE[paletteIndex]);
});
return colorMap;
}
function isVisibleChartColor(color) {
if (typeof color !== 'string') return false;
const normalizedColor = color.trim().toLowerCase();
return Boolean(normalizedColor)
&& normalizedColor !== 'none'
&& normalizedColor !== 'transparent';
}
export function colorizeXinyuanChartTooltipPayload(payload = [], resolveColor) {
return payload.map((entry, index) => {
const rechartsPayload = entry?.payload || {};
const sourceDatum = rechartsPayload?.payload || rechartsPayload;
const key = sourceDatum?.name
?? rechartsPayload?.name
?? entry?.name
?? entry?.dataKey
?? '';
const candidateColors = [
resolveColor?.({ entry, index, key, sourceDatum }),
rechartsPayload?.fill,
sourceDatum?.fill,
entry?.fill,
entry?.color,
entry?.stroke,
getXinyuanChartColor(key, index),
];
const color = candidateColors.find(isVisibleChartColor);
return {
...entry,
color,
fill: color,
name: entry?.name ?? sourceDatum?.name ?? entry?.dataKey,
stroke: color,
};
});
}
This diff is collapsed.
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);
}
This diff is collapsed.
This diff is collapsed.
import xyLogoRed from '@/assets/identity/xy-logo-red.png'; import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app'; import { APP_DISPLAY_NAME } from '@/constants/app';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
...@@ -83,16 +84,27 @@ export default function LoginPage() { ...@@ -83,16 +84,27 @@ export default function LoginPage() {
}; };
return ( return (
<main className="login-page"> <main className="login-page rc-login-page">
<section className="login-shell"> <section className="login-shell rc-login-shell">
<Card className="login-card"> <Card className="login-card rc-login-card">
<Card.Content> <Card.Content>
<div className="login-brand login-card-brand"> <div className="login-brand login-card-brand rc-login-brand">
<img alt="鑫元基金" src={xyLogoRed} /> <span className="login-brand-logo">
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--light"
src={xyLogoRed}
/>
<img
alt="鑫元"
className="rc-brand-logo rc-brand-logo--dark"
src={xyLogoWhite}
/>
</span>
<strong>{appTitle}</strong> <strong>{appTitle}</strong>
</div> </div>
<form className="login-form" onSubmit={handleSubmit}> <form className="login-form rc-login-form" onSubmit={handleSubmit}>
<TextField <TextField
className="login-field" className="login-field"
data-has-value={identity ? 'true' : undefined} data-has-value={identity ? 'true' : undefined}
...@@ -133,25 +145,25 @@ export default function LoginPage() { ...@@ -133,25 +145,25 @@ export default function LoginPage() {
/> />
</TextField> </TextField>
<div className="login-captcha-field grid gap-2">
<Label>验证码</Label>
<div className="login-captcha-row grid gap-3">
<TextField <TextField
className="login-field min-w-0" className="login-field"
data-has-value={captchaCode ? 'true' : undefined} data-has-value={captchaCode ? 'true' : undefined}
isDisabled={loading} isDisabled={loading}
isRequired isRequired
value={captchaCode} value={captchaCode}
onChange={setCaptchaCode} onChange={setCaptchaCode}
> >
<Label>验证码</Label>
<div className="login-captcha-row">
<Input <Input
className="login-input" autoComplete="off"
className="login-input min-w-0"
data-filled={captchaCode ? 'true' : undefined} data-filled={captchaCode ? 'true' : undefined}
placeholder="请输入验证码" placeholder="请输入验证码"
variant="secondary" variant="secondary"
/> />
</TextField>
<Button <Button
aria-label="刷新验证码"
type="button" type="button"
variant="secondary" variant="secondary"
isDisabled={loading || captchaLoading} isDisabled={loading || captchaLoading}
...@@ -159,11 +171,11 @@ export default function LoginPage() { ...@@ -159,11 +171,11 @@ export default function LoginPage() {
setError(''); setError('');
refreshCaptcha(); refreshCaptcha();
}} }}
className="login-captcha-button overflow-hidden" className="login-captcha-button"
> >
{captchaData?.image ? ( {captchaData?.image ? (
<span <span
className="captcha-image flex h-full w-full items-center justify-center" className="captcha-image"
dangerouslySetInnerHTML={{ __html: captchaData.image }} dangerouslySetInnerHTML={{ __html: captchaData.image }}
/> />
) : ( ) : (
...@@ -171,7 +183,7 @@ export default function LoginPage() { ...@@ -171,7 +183,7 @@ export default function LoginPage() {
)} )}
</Button> </Button>
</div> </div>
</div> </TextField>
{error ? ( {error ? (
<Alert status="danger"> <Alert status="danger">
......
...@@ -4,9 +4,8 @@ import { ...@@ -4,9 +4,8 @@ import {
APP_LOGIN_SUBTITLE, APP_LOGIN_SUBTITLE,
APP_ROUTE_NAME, APP_ROUTE_NAME,
} from '@/constants/app'; } from '@/constants/app';
import * as Headless from '@headlessui/react'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { Close as XMarkIcon, ExclamationTriangleIcon } from './AppIcons'; import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
function normalizeLabelList(items) { function normalizeLabelList(items) {
...@@ -169,61 +168,59 @@ function ApplicationCard({ application }) { ...@@ -169,61 +168,59 @@ function ApplicationCard({ application }) {
const target = application?.url?.startsWith('http') ? '_blank' : undefined; const target = application?.url?.startsWith('http') ? '_blank' : undefined;
return ( return (
<Card className="h-full" variant="secondary">
<Card.Content>
<a <a
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
href={application.url} href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined} rel={target ? 'noreferrer' : undefined}
data-current={current ? 'true' : undefined} target={target}
className={clsx( >
'app-switcher-card group flex min-w-0 items-center gap-3 rounded-xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-3 py-3 text-left transition hover:bg-[var(--accent-soft)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)]', <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 ? ( {application.logo ? (
<img <Avatar.Image alt="" className="object-contain p-1.5" src={application.logo} />
src={application.logo} ) : null}
alt={application.title} <Avatar.Fallback>{application.title.trim().slice(0, 1) || '应'}</Avatar.Fallback>
className="app-switcher-card__logo" </Avatar>
/> </Surface>
) : (
<span className="app-switcher-card__initial">
{application.title.slice(0, 1)}
</span>
)}
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium leading-5 text-[var(--rc-text-primary)]"> <span className="block break-words font-medium leading-5" title={application.title}>
{application.title} {application.title}
</span> </span>
<span className="mt-0.5 block truncate text-xs leading-4 text-[var(--rc-text-muted)]"> <span className="block truncate" title={application.description}>
{application.description || application.name || '应用'} {application.description || application.name || '应用'}
</span> </span>
</span> </span>
{current ? ( {current ? <Chip color="accent" size="sm">当前</Chip> : null}
<span className="shrink-0 rounded-full bg-[var(--rc-brand-primary-soft)] px-2 py-0.5 text-xs font-medium text-[var(--rc-brand-primary)]">
当前
</span>
) : null}
</a> </a>
</Card.Content>
</Card>
); );
} }
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2" aria-label="正在加载应用">
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<div <Card key={index} variant="secondary">
key={index} <Card.Content className="flex items-center gap-3">
className="rounded-xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-3 py-3" <Skeleton className="size-9 shrink-0 rounded-[var(--rc-radius-control)]" />
>
<div className="animate-pulse">
<div className="flex items-center gap-3">
<div className="size-9 rounded-lg bg-zinc-200" />
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-zinc-200" /> <Skeleton className="h-4 w-2/5 rounded-full" />
<div className="h-3 w-full rounded-full bg-zinc-100" /> <Skeleton className="h-3 w-full rounded-full" />
</div>
</div>
</div>
</div> </div>
</Card.Content>
</Card>
))} ))}
</div> </div>
); );
...@@ -316,53 +313,46 @@ export default function ApplicationSwitcherDialog({ ...@@ -316,53 +313,46 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications, open]); }, [fallbackApplications, open]);
return ( return (
<Headless.Dialog <Modal.Root>
open={open} <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
onClose={onClose} <Modal.Backdrop
className="relative z-[180]" isOpen={open}
variant="blur"
onOpenChange={(isOpen) => {
if (!isOpen) onClose?.();
}}
> >
<Headless.DialogBackdrop <Modal.Container
transition className="w-full sm:w-full"
className="fixed inset-0 bg-[rgba(15,23,42,0.42)] backdrop-blur-sm transition duration-200 ease-out data-closed:opacity-0" placement="center"
/> scroll="inside"
size="lg"
<div className="fixed inset-0 overflow-y-auto p-4">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
transition
className="app-switcher-dialog flex max-h-[min(42rem,calc(100dvh-2rem))] w-full max-w-[46rem] flex-col overflow-hidden rounded-2xl border border-[var(--rc-border-default)] bg-[var(--rc-bg-surface)] shadow-[var(--rc-shadow-overlay)] transition duration-200 ease-out data-closed:translate-y-2 data-closed:scale-[0.98] data-closed:opacity-0"
> >
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-[var(--rc-border-default)] px-5 py-4"> <Modal.Dialog
<div className="min-w-0"> className="max-h-[min(42rem,calc(100dvh-2rem))]"
<Headless.DialogTitle className="text-base font-semibold leading-6 text-[var(--rc-text-primary)]"> style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
应用切换
</Headless.DialogTitle>
<p className="mt-1 text-sm leading-5 text-[var(--rc-text-muted)]">
选择你可访问的应用
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭应用切换"
className="inline-grid size-6 shrink-0 place-items-center rounded-full bg-[var(--default)] p-1 text-[var(--rc-text-secondary)] transition hover:bg-[var(--default-hover)] hover:text-[var(--rc-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus)] [&_.anticon]:grid [&_.anticon]:size-4 [&_.anticon]:place-items-center [&_.anticon]:leading-none [&_.anticon>svg]:block [&_.anticon>svg]:size-4 [&_svg]:m-0"
> >
<XMarkIcon /> <Modal.CloseTrigger aria-label="关闭应用切换" />
</button> <Modal.Header>
<div className="min-w-0">
<Modal.Heading>应用切换</Modal.Heading>
<Description>选择你可访问的应用</Description>
</div> </div>
</Modal.Header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 [scrollbar-gutter:stable]"> <Modal.Body>
{status === 'error' && errorMessage ? ( {status === 'error' && errorMessage ? (
<div className="mb-4 flex items-start gap-3 rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900"> <Alert className="mb-4" status="warning">
<ExclamationTriangleIcon className="mt-0.5 size-5 shrink-0 text-amber-500" /> <Alert.Content>
<div>{errorMessage}</div> <Alert.Description>{errorMessage}</Alert.Description>
</div> </Alert.Content>
</Alert>
) : null} ) : null}
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : applications.length > 0 ? ( ) : applications.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2">
{applications.map((application) => ( {applications.map((application) => (
<ApplicationCard <ApplicationCard
key={application.name} key={application.name}
...@@ -371,19 +361,17 @@ export default function ApplicationSwitcherDialog({ ...@@ -371,19 +361,17 @@ export default function ApplicationSwitcherDialog({
))} ))}
</div> </div>
) : ( ) : (
<div className="flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed border-[var(--rc-border-default)] bg-[var(--rc-bg-subtle)] px-6 text-center"> <EmptyState size="md">
<div className="text-base font-semibold text-[var(--rc-text-primary)]"> <EmptyState.Header>
暂无可切换应用 <EmptyState.Title>暂无可切换应用</EmptyState.Title>
</div> <EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description>
<p className="mt-2 max-w-md text-sm leading-6 text-[var(--rc-text-muted)]"> </EmptyState.Header>
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。 </EmptyState>
</p>
</div>
)} )}
</div> </Modal.Body>
</Headless.DialogPanel> </Modal.Dialog>
</div> </Modal.Container>
</div> </Modal.Backdrop>
</Headless.Dialog> </Modal.Root>
); );
} }
...@@ -79,11 +79,9 @@ function SidebarUserMenu({ userInfo }) { ...@@ -79,11 +79,9 @@ function SidebarUserMenu({ userInfo }) {
return ( return (
<Dropdown> <Dropdown>
<Button <Dropdown.Trigger
fullWidth aria-label="打开用户菜单"
className="app-sidebar-user-trigger rc-sidebar-user-trigger" className="app-sidebar-user-trigger rc-sidebar-user-trigger"
size="sm"
variant="ghost"
> >
<Avatar className="app-sidebar-user-avatar rc-sidebar-avatar"> <Avatar className="app-sidebar-user-avatar rc-sidebar-avatar">
{userInfo?.avatar ? ( {userInfo?.avatar ? (
...@@ -98,7 +96,7 @@ function SidebarUserMenu({ userInfo }) { ...@@ -98,7 +96,7 @@ function SidebarUserMenu({ userInfo }) {
<span className="app-sidebar-user-name">{user.displayName}</span> <span className="app-sidebar-user-name">{user.displayName}</span>
<span className="app-sidebar-user-description">{user.username}</span> <span className="app-sidebar-user-description">{user.username}</span>
</span> </span>
</Button> </Dropdown.Trigger>
<Dropdown.Popover placement="top start"> <Dropdown.Popover placement="top start">
<Dropdown.Menu <Dropdown.Menu
aria-label="用户菜单" aria-label="用户菜单"
...@@ -183,7 +181,9 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) { ...@@ -183,7 +181,9 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) {
<Sidebar.Content> <Sidebar.Content>
{menuGroups.map((group) => ( {menuGroups.map((group) => (
<Sidebar.Group key={group.title}> <Sidebar.Group key={group.title}>
{menuGroups.length > 1 ? (
<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) => {
const Icon = item.icon; const Icon = item.icon;
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
推荐职责划分: 推荐职责划分:
- `应用切换` - `应用切换`
- 桌面端侧栏展开时显示在应用标题右侧,侧栏收起时显示在 Logo 下方 - 桌面端侧栏展开时显示在应用标题右侧;侧栏收起后整体 offcanvas,不保留图标轨或应用入口
- 移动端显示在顶部菜单栏右侧 - 移动端显示在顶部菜单栏右侧
- 点击后打开应用切换弹框,优先调用 `/application/info` 读取当前账号可见应用 - 点击后打开应用切换弹框,优先调用 `/application/info` 读取当前账号可见应用
- 应用列表加载失败时,回退展示当前登录用户信息中的 `applications` - 应用列表加载失败时,回退展示当前登录用户信息中的 `applications`
...@@ -128,25 +128,29 @@ ...@@ -128,25 +128,29 @@
### 4.4 浏览与检索案例 ### 4.4 浏览与检索案例
1. 用户进入 `案例管理` 1. 用户进入 `案例管理`
2. 通过全文搜索框检索标题、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级 2. 通过列表工具栏中的 HeroUI primary SearchField 检索标题、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级;搜索框使用白色一级字段层级并实时查询
3. 通过案例来源、案例类型、问题属性、分类标签输入框填写组合筛选条件,输入框仅录入文本条件,不提供下拉候选 3. 通过案例来源、案例类型、问题属性、分类标签输入框填写组合筛选条件,输入框仅录入文本条件,不提供下拉候选
4. 点击 `查询` 后,前端将当前输入条件提交到 `POST /record/query` 做远程查询;仅输入文字不触发请求 4. 全文搜索与各筛选值变化后,前端实时将当前条件提交到 `POST /record/query` 做远程查询,并从第一页展示结果
5. 通过案例性质标签快速切换当前视图 5. 筛选区使用 HeroUI `Surface + Disclosure`:默认展示案例来源、案例类型和问题属性三项,点击 `展开` 后展示分类标签;折叠态不保留隐藏内容的布局高度,筛选区不展示刷新或查询按钮
6. 分类标签筛选优先匹配 `metadata.tag`,同时兼容多值 `metadata.keyword` 6. 通过 HeroUI Tabs 的案例性质标签快速切换当前视图;分类名称保持单行显示,空间不足时在 Tabs 内部横向滚动
7. 页面不对当前页案例做本地二次搜索 7. 分类标签筛选优先匹配 `metadata.tag`,同时兼容多值 `metadata.keyword`
8. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换 8. 页面不对当前页案例做本地二次搜索
9. 卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情 9. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换
10. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段 10. 卡片视图用于浏览案例摘要;每条案例使用独立一级业务卡片,分类/等级/标签、标题、类型、摘要和时间信息按层级展示,点击卡片后以弹框方式查看并编辑详情
11. 表格视图按页加载案例,支持选择每页 20、50、100 条,并显示页码、当前页范围和匹配总数 11. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段;宽表在 DataGrid 内部横向滚动,各字段只在所属单元格内单行截断,完整值通过 HeroUI Tooltip 查看,不允许跨列覆盖;首列标题仅用于展示,不承担点击操作,查看详情统一从右侧操作列进入
12. 表格视图支持单选、全选当前页案例,并显示当前勾选数量 12. 表格视图按页加载案例,固定每页 20 条,并显示页码、当前页和匹配总数;分页栏不提供每页条数选择器
13. 表格视图支持单选、全选当前页案例,并显示当前勾选数量
### 4.5 编辑案例 ### 4.5 编辑案例
1. 用户在列表中打开某个案例详情 1. 用户在列表中打开某个案例详情
2. 修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份 2. 弹框默认进入查看态,以一个二级 Surface 展示完整字段,不渲染可编辑控件
3. 关键字编辑区的输入框仅用于新增关键字,已有关键字以标签展示并支持逐个删除;保存时写入 `metadata.keyword` 字符串数组 3. 用户点击底部 `编辑` 后进入完整编辑会话;未修改任何字段时 `保存` 保持禁用
4. 前端调用 `PUT /record/:name` 4. 修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
5. 保存成功后刷新列表与统计数据 5. 关键字编辑区的输入框仅用于新增关键字,已有关键字以标签展示并支持逐个删除;保存时写入 `metadata.keyword` 字符串数组
6. 点击 `取消` 时丢弃本次草稿并恢复打开详情时的原值,不发起更新请求
7. 点击 `保存` 后,前端调用 `PUT /record/:name`
8. 保存成功后刷新列表与统计数据,并返回查看态;失败时保留当前草稿和错误提示
### 4.6 批量修改案例 ### 4.6 批量修改案例
...@@ -206,7 +210,7 @@ ...@@ -206,7 +210,7 @@
- 关键字检索优先使用多值数组 `metadata.keyword`,同时兼容历史字符串型 `metadata.keyword` 与 `metadata.tag` - 关键字检索优先使用多值数组 `metadata.keyword`,同时兼容历史字符串型 `metadata.keyword` 与 `metadata.tag`
- 关键词检索同时覆盖 `title`、`metadata.label`、`metadata.case_type`、`metadata.case_attribute`、`metadata.case_details`、`metadata.case_warning`、`metadata.keyword`、`metadata.case_source`、`metadata.case_level`、`metadata.category`、`metadata.tag` - 关键词检索同时覆盖 `title`、`metadata.label`、`metadata.case_type`、`metadata.case_attribute`、`metadata.case_details`、`metadata.case_warning`、`metadata.keyword`、`metadata.case_source`、`metadata.case_level`、`metadata.category`、`metadata.tag`
- 筛选统一走远程 `POST /record/query`;页面不使用本地数据搜索替代远程结果 - 筛选统一走远程 `POST /record/query`;页面不使用本地数据搜索替代远程结果
- 筛选条件输入后必须点击 `查询` 才发起请求,输入过程不自动请求 - 全文搜索、结构化筛选和案例性质变化后实时发起远程查询,并从第一页展示结果
- 筛选输入使用普通文本输入框,不使用 `select`、`datalist` 或其它下拉候选控件 - 筛选输入使用普通文本输入框,不使用 `select`、`datalist` 或其它下拉候选控件
--- ---
...@@ -236,6 +240,7 @@ ...@@ -236,6 +240,7 @@
- 案例来源分布:按 `metadata.case_source` 聚合,缺失时归入 `未填写来源` - 案例来源分布:按 `metadata.case_source` 聚合,缺失时归入 `未填写来源`
- 关键字词云:按 `metadata.keyword` 聚合并按热度调整字号,缺失时兼容 `metadata.tag` - 关键字词云:按 `metadata.keyword` 聚合并按热度调整字号,缺失时兼容 `metadata.tag`
- 最近 7 天更新:按 `updated_at` 统计近 7 天更新的案例数量 - 最近 7 天更新:按 `updated_at` 统计近 7 天更新的案例数量
- 最近更新:作为独立驾驶舱模块直接使用 HeroUI Pro `DataGrid`,不再嵌套外层 Card;表格按案例标题/摘要、分类、标签、更新时间四列展示最近 6 条案例,宽度不足时仅在 DataGrid 内横向滚动;该调整不改变数据来源、按更新时间倒序的排序口径或交互状态
### 6.2 案例管理 ### 6.2 案例管理
...@@ -247,10 +252,10 @@ ...@@ -247,10 +252,10 @@
- 案例类型筛选 - 案例类型筛选
- 问题属性筛选 - 问题属性筛选
- 分类标签筛选 - 分类标签筛选
- 查询按钮
- 筛选项为普通文本输入框,不提供下拉框 - 筛选项为普通文本输入框,不提供下拉框
- 默认首行展示三个筛选项,剩余筛选项通过 HeroUI `Disclosure` 展开;不展示刷新或查询按钮
- `Segment` 视图切换(卡片 / 表格) - `Segment` 视图切换(卡片 / 表格)
- 顶部快捷操作区采用紧凑工具条呈现,保留手动新建、Excel 导入、Excel 导出、重置筛选四个入口 - 顶部快捷操作区采用紧凑工具条呈现,保留手动新建、Excel 导入和 Excel 导出入口
- 手动新建按钮 - 手动新建按钮
- Excel 导入按钮 - Excel 导入按钮
- 导入弹框中的导出模板按钮 - 导入弹框中的导出模板按钮
...@@ -261,12 +266,12 @@ ...@@ -261,12 +266,12 @@
- 批量修改按钮 - 批量修改按钮
- 勾选导出按钮 - 勾选导出按钮
- 批量删除按钮 - 批量删除按钮
- 详情编辑弹框 - 详情查看 / 编辑弹框
- 删除确认弹框 - 删除确认弹框
### 6.3 案例详情 ### 6.3 案例详情
建议展示: 详情弹框默认展示:
- 标题 - 标题
- 分类 / 标签 - 分类 / 标签
...@@ -280,6 +285,8 @@ ...@@ -280,6 +285,8 @@
- 案例来源 / 案例等级 / 案例年份 - 案例来源 / 案例等级 / 案例年份
- 案例详情字数 - 案例详情字数
- 创建时间 / 更新时间 - 创建时间 / 更新时间
- 查看态底部提供单一主操作 `编辑`,删除入口继续使用危险弱化样式并保留二次确认
- 编辑态底部按 `取消`、`保存` 排列;取消恢复原值,保存继续遵守原校验和 `metadata` 写入协议
--- ---
...@@ -309,9 +316,9 @@ ...@@ -309,9 +316,9 @@
- 首屏只加载第一页,默认每页 20 条 - 首屏只加载第一页,默认每页 20 条
- 表格与卡片列表通过页码分页加载,不使用下滑自动加载 - 表格与卡片列表通过页码分页加载,不使用下滑自动加载
- 用户可选择每页 20、50、100 条;切换每页数量时从第一页重新加载 - 每页固定加载 20 条,不展示或提供每页条数选择器
- 当用户点击 `查询`、重置筛选或切换案例性质时,从第一页重新加载 - 当用户修改全文搜索、任一结构化筛选或切换案例性质时,实时从第一页重新加载
- 搜索与筛选输入变化时不自动请求;点击 `查询` 后必须发起远程查询,不在当前页结果中做本地搜索 - 搜索与筛选统一发起远程查询,不在当前页结果中做本地搜索;连续请求只采用最新筛选状态对应的结果
- 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口 - 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口
--- ---
......
...@@ -9,7 +9,6 @@ import dayjs from 'dayjs'; ...@@ -9,7 +9,6 @@ import dayjs from 'dayjs';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
export const CASE_SUBJECT = 'risk_case_library'; export const CASE_SUBJECT = 'risk_case_library';
export const CASE_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
const CASE_EXPORT_HEADERS = [ const CASE_EXPORT_HEADERS = [
......
import {
buildDemoTasks,
buildUserMap,
enrichProjectTask,
fetchProjectTasks,
fetchSystemUsersByIds,
} from './projectTaskService';
export async function loadProjectDashboardData(currentUsername = '') {
try {
const taskRecords = await fetchProjectTasks({});
const userIds = Array.from(
new Set(
taskRecords.flatMap((task) => {
const metadata = task?.metadata || {};
const memberIds = Array.isArray(metadata.member_ids)
? metadata.member_ids
: metadata.member_ids
? [metadata.member_ids]
: [];
const leaderId =
metadata.leader?._id ||
metadata.leader?.id ||
metadata.leader?.username ||
metadata.leader ||
'';
return [...memberIds, leaderId].filter(Boolean);
}),
),
);
if (currentUsername) {
userIds.push(currentUsername);
}
const users = await fetchSystemUsersByIds(userIds);
const userMap = buildUserMap(users);
const tasks = taskRecords.map((task) => enrichProjectTask(task, userMap));
if (tasks.length > 0) {
return {
tasks,
users,
isDemoMode: false,
error: '',
};
}
return {
tasks: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)),
users,
isDemoMode: true,
error: '当前未读取到项目记录,已展示演示数据,可继续浏览统计看板。',
};
} catch (error) {
const fallbackUsers = [
{ id: 'yang_admin', username: 'yang_admin', displayName: 'yang' },
{ id: 'system', username: 'system', displayName: '系统' },
];
const userMap = buildUserMap(fallbackUsers);
return {
tasks: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)),
users: fallbackUsers,
isDemoMode: true,
error: error?.message || '统计数据加载失败,已切换为演示数据。',
};
}
}
This diff is collapsed.
This diff is collapsed.
...@@ -84,7 +84,7 @@ export default function AuthWrapper() { ...@@ -84,7 +84,7 @@ export default function AuthWrapper() {
if (!loaded) { if (!loaded) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center text-sm text-muted">
正在验证登录状态... 正在验证登录状态...
</div> </div>
); );
......
...@@ -58,7 +58,7 @@ export default function SiteWrapper() { ...@@ -58,7 +58,7 @@ export default function SiteWrapper() {
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center text-sm text-slate-500"> <div className="flex min-h-screen items-center justify-center text-sm text-muted">
加载中... 加载中...
</div> </div>
); );
......
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