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

refine material center controls and surfaces

parent 811086b9
......@@ -53,6 +53,11 @@ paths are not valid delivery mechanisms.
Filters use a direct HeroUI `Surface`; lists remain unframed around the
DataGrid. Cards are reserved for discrete business objects and analytical tools.
Material archive filters use an explicit-submit rhythm: draft fields stay local
until `查询`, with a compact secondary reset icon button immediately before the
primary red query button and the native Disclosure `展开` control to its right.
Reset and category Tab changes submit their corresponding complete filter state;
typing alone never reloads the list.
## Component Rules
......@@ -60,7 +65,14 @@ DataGrid. Cards are reserved for discrete business objects and analytical tools.
their documented `@heroui-pro/react/*` subpaths.
- Routine text buttons are text-only; icon actions use Ant Design icons,
tooltips and accessible names.
- Business page, module and field titles stay visually clean without adjacent
information-tip icons; reserve Tooltips for actual icon actions and controls.
- Dropdown-like selection uses HeroUI Autocomplete/ListBox contracts.
- Form checkboxes on light surfaces use HeroUI `Checkbox` with
`variant="secondary"` so the unchecked control has a visible neutral fill
against white surfaces. Keep the official `Checkbox.Content` ->
`Checkbox.Control` -> `Checkbox.Indicator` compound structure; selected
controls retain the brand accent, and field values/handlers remain unchanged.
- Date and date-time fields use HeroUI DatePicker and preserve the existing
string protocol at the page boundary.
- Files use HeroUI Pro DropZone while preserving the page-owned `File` object.
......@@ -69,6 +81,9 @@ DataGrid. Cards are reserved for discrete business objects and analytical tools.
- Routine success feedback is a fixed, auto-dismissing Toast.
- Modal view mode uses one secondary Surface for the complete read-only field
group; edit mode removes that Surface and renders secondary fields directly.
- Detail view modal footers expose only the contextual edit action; destructive
material actions stay in the DataGrid row menu or batch ActionBar to reduce
accidental deletion from a read-only view.
- Empty results stay empty and request failures stay errors. UI code must never
populate dashboard or list surfaces with fabricated records or numbers.
......
import { Avatar, Button, Chip, Pagination, Spinner, Tooltip } from "@heroui/react";
import { Button, Chip, Pagination, Spinner, Tooltip } from "@heroui/react";
import { ActionBar } from "@heroui-pro/react/action-bar";
import { DataGrid } from "@heroui-pro/react/data-grid";
import { EmptyState } from "@heroui-pro/react/empty-state";
......@@ -240,18 +240,6 @@ export function DataTableTextPair({ primary, secondary, className = "" }) {
);
}
function getPersonFallback(name) {
const normalized = String(name ?? "").trim();
if (!normalized || normalized === "-") {
return "?";
}
const compact = normalized.replace(/\s+/g, "");
if (/^[\u3400-\u9fff]/.test(compact)) {
return Array.from(compact)[0];
}
return Array.from(compact).slice(0, 2).join("").toUpperCase();
}
export function DataTablePersonIdentity({
name,
account,
......@@ -265,15 +253,11 @@ export function DataTablePersonIdentity({
return (
<Tooltip delay={0}>
<div
className={["flex min-w-0 items-center gap-2", className]
className={["flex min-w-0 items-center", className]
.filter(Boolean)
.join(" ")}
tabIndex={0}
>
<Avatar size="sm" variant="soft" className="shrink-0">
{avatar ? <Avatar.Image alt={primaryText} src={avatar} /> : null}
<Avatar.Fallback>{getPersonFallback(primaryText)}</Avatar.Fallback>
</Avatar>
<span className="grid min-w-0 gap-1">
<span className="min-w-0 truncate text-sm font-medium">
{primaryText}
......@@ -347,7 +331,7 @@ export function DataTablePagination({
{hasPageSizePicker ? (
<XinyuanAutocomplete
aria-label="每页条数"
className="w-24 shrink-0"
className="w-32 shrink-0"
isDisabled={isDisabled}
options={pageSizeOptionsForPicker}
placeholder="每页条数"
......
......@@ -41,11 +41,13 @@ export default function XinyuanAutocomplete({
fullWidth = true,
inputValue,
isDisabled = false,
isClearable = false,
isLoading = false,
isOpen,
isRequired = false,
name,
onInputChange,
onClear,
onOpenChange,
onSelectionChange,
options = [],
......@@ -80,6 +82,7 @@ export default function XinyuanAutocomplete({
(selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join("、")
: placeholder);
const hasSelection = Array.from(selectedKeyStringSet).some(Boolean);
const rootSelectionProps =
selectionMode === "multiple" ? { selectedKeys } : { selectedKey };
const listBoxSelectionProps =
......@@ -118,6 +121,7 @@ export default function XinyuanAutocomplete({
isOpen={isOpen}
isRequired={isRequired}
name={name}
onClear={onClear}
selectionMode={selectionMode}
variant={variant}
onOpenChange={onOpenChange}
......@@ -129,6 +133,11 @@ export default function XinyuanAutocomplete({
{resolvedValueText}
</span>
</Autocomplete.Value>
{isClearable && hasSelection ? (
<Autocomplete.ClearButton
aria-label={`清除${ariaLabel || placeholder}`}
/>
) : null}
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
......
......@@ -19,11 +19,11 @@ import {
} from "@/components/TableActionButton";
import { UserContext } from "@/wrapper/Auth";
import {
ArrowPathIcon,
ArrowTopRightOnSquareIcon,
ChevronDownIcon,
ClipboardDocumentIcon,
FolderIcon,
InformationCircleIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from "@/components/AppIcons";
......@@ -51,7 +51,6 @@ import { DropZone } from "@heroui-pro/react/drop-zone";
import {
startTransition,
useContext,
useDeferredValue,
useEffect,
useMemo,
useState,
......@@ -235,35 +234,12 @@ function buildDetailForm(material) {
};
}
function TitleInfoTooltip({ label, text, tone = "neutral" }) {
return (
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={`${label}说明`}
className={clsx(
"rc-title-info-trigger",
tone === "warning" && "text-warning",
)}
type="button"
variant="ghost"
>
<InformationCircleIcon size={14} />
</Button>
<Tooltip.Content>{text}</Tooltip.Content>
</Tooltip>
);
}
function FormSection({ title, description, children, className }) {
return (
<section className={clsx("min-w-0 space-y-4", className)}>
<div className="min-w-0">
<div className="inline-flex items-center gap-1.5 text-base font-medium">
<span>{title}</span>
{description ? (
<TitleInfoTooltip label={title} text={description} />
) : null}
</div>
</div>
<div>{children}</div>
......@@ -288,9 +264,6 @@ function FieldLabel({ children, required = false, infoText, infoTone }) {
{required ? (
<span className="text-danger">*</span>
) : null}
{infoText ? (
<TitleInfoTooltip label={label} text={infoText} tone={infoTone} />
) : null}
</div>
);
}
......@@ -350,6 +323,7 @@ function ConfiguredSelect({
emptyText={`暂无${fieldLabel}配置`}
errorText={error}
isDisabled={disabled || loading || Boolean(error)}
isClearable={String(value ?? "") !== ""}
isLoading={loading}
options={[
{ id: "", label: emptyLabel },
......@@ -368,6 +342,9 @@ function ConfiguredSelect({
onSelectionChange={(key) => {
props.onChange?.({ target: { value: String(key ?? "") } });
}}
onClear={() => {
props.onChange?.({ target: { value: "" } });
}}
/>
{showError && error ? (
<div className="mt-2 text-xs text-danger" role="alert">
......@@ -415,9 +392,10 @@ export default function ResourceBookingPage() {
const [materialSearchForm, setMaterialSearchForm] = useState(
buildEmptyMaterialSearchForm,
);
const [submittedMaterialSearchForm, setSubmittedMaterialSearchForm] =
useState(buildEmptyMaterialSearchForm);
const [isMaterialFilterExpanded, setIsMaterialFilterExpanded] =
useState(false);
const deferredMaterialSearchForm = useDeferredValue(materialSearchForm);
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(0);
......@@ -509,7 +487,7 @@ export default function ResourceBookingPage() {
async function runFirstPage() {
setLoading(true);
const result = await loadMaterialPage({
...deferredMaterialSearchForm,
...submittedMaterialSearchForm,
page,
pageSize,
});
......@@ -546,7 +524,7 @@ export default function ResourceBookingPage() {
return () => {
cancelled = true;
};
}, [activeMaterialId, deferredMaterialSearchForm, page, pageSize]);
}, [activeMaterialId, submittedMaterialSearchForm, page, pageSize]);
const sourceOptions = useMemo(() => {
const dynamicOptions = (dashboard.sourceSuggestions || []).filter(Boolean);
......@@ -763,12 +741,18 @@ export default function ResourceBookingPage() {
...prev,
[fieldName]: value,
}));
}
function submitMaterialSearch() {
setSubmittedMaterialSearchForm({ ...materialSearchForm });
setActiveCategory(ALL_CATEGORY_KEY);
setPage(0);
}
function resetMaterialSearch() {
setMaterialSearchForm(buildEmptyMaterialSearchForm());
const emptySearchForm = buildEmptyMaterialSearchForm();
setMaterialSearchForm(emptySearchForm);
setSubmittedMaterialSearchForm(emptySearchForm);
setActiveCategory(ALL_CATEGORY_KEY);
setPage(0);
setIsMaterialFilterExpanded(false);
......@@ -776,16 +760,18 @@ export default function ResourceBookingPage() {
function handleCategoryTabClick(categoryKey) {
setActiveCategory(categoryKey);
setMaterialSearchForm((prev) => ({
...prev,
const nextSearchForm = {
...submittedMaterialSearchForm,
archiveCategory: categoryKey === ALL_CATEGORY_KEY ? "" : categoryKey,
}));
};
setMaterialSearchForm(nextSearchForm);
setSubmittedMaterialSearchForm(nextSearchForm);
setPage(0);
}
async function reloadMaterials(options = {}) {
const result = await loadMaterialPage({
...deferredMaterialSearchForm,
...submittedMaterialSearchForm,
page: options.page ?? page,
pageSize,
});
......@@ -1293,6 +1279,12 @@ export default function ResourceBookingPage() {
isExpanded={shouldShowAdvancedMaterialFilters}
onExpandedChange={setIsMaterialFilterExpanded}
>
<form
onSubmit={(event) => {
event.preventDefault();
submitMaterialSearch();
}}
>
<div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="min-w-0">
......@@ -1344,7 +1336,32 @@ export default function ResourceBookingPage() {
</div>
<div className="flex min-w-0 flex-wrap items-end gap-2 md:justify-end xl:flex-nowrap">
<Disclosure.Trigger className="button button--sm button--ghost">
<Tooltip delay={0}>
<Button
aria-label="重置筛选"
isDisabled={loading}
isIconOnly
size="sm"
type="button"
variant="secondary"
onPress={resetMaterialSearch}
>
<ArrowPathIcon size={16} />
</Button>
<Tooltip.Content>重置筛选</Tooltip.Content>
</Tooltip>
<Button
isDisabled={loading}
size="sm"
type="submit"
variant="primary"
>
查询
</Button>
<Disclosure.Trigger
className="button button--sm button--ghost"
type="button"
>
<span>{shouldShowAdvancedMaterialFilters ? "收起" : "展开"}</span>
<Disclosure.Indicator>
<ChevronDownIcon aria-hidden="true" size={14} />
......@@ -1463,6 +1480,7 @@ export default function ResourceBookingPage() {
</div>
</Disclosure.Content>
</div>
</form>
</Disclosure>
</Surface>
......@@ -1660,13 +1678,7 @@ export default function ResourceBookingPage() {
</Modal.CloseTrigger>
<Modal.Header>
<div className="min-w-0">
<div className="inline-flex items-center gap-1.5">
<Modal.Heading>材料详情</Modal.Heading>
<TitleInfoTooltip
label="材料详情"
text="默认以只读方式查看材料元数据;点击编辑后再修改,保存成功后生效。"
/>
</div>
</div>
</Modal.Header>
<Modal.Body>
......@@ -1888,6 +1900,7 @@ export default function ResourceBookingPage() {
<div className="grid gap-3 sm:grid-cols-2">
<HeroCheckbox
className="min-h-18"
variant="secondary"
isSelected={detailForm.isConfidential}
isDisabled={saving}
onChange={(checked) =>
......@@ -1913,6 +1926,7 @@ export default function ResourceBookingPage() {
</HeroCheckbox>
<HeroCheckbox
className="min-h-18"
variant="secondary"
isSelected={detailForm.isAuditDeptMaterial}
isDisabled={saving}
onChange={(checked) =>
......@@ -1966,14 +1980,6 @@ export default function ResourceBookingPage() {
)}
</Modal.Body>
<Modal.Footer>
<Button
variant="danger-soft"
className="cursor-pointer"
onPress={handleRemoveActiveMaterial}
isDisabled={saving || !activeMaterial}
>
{saving ? "处理中..." : "删除材料"}
</Button>
{isDetailEditing ? (
<>
<Button
......@@ -2051,6 +2057,7 @@ export default function ResourceBookingPage() {
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={batchEditFields.archiveYear}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2097,6 +2104,7 @@ export default function ResourceBookingPage() {
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={batchEditFields.sourceApp}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2144,6 +2152,7 @@ export default function ResourceBookingPage() {
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={batchEditFields.sourceSystem}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2193,6 +2202,7 @@ export default function ResourceBookingPage() {
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={batchEditFields.archiveCategory}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2242,6 +2252,7 @@ export default function ResourceBookingPage() {
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={batchEditFields.provideDepartment}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2288,6 +2299,7 @@ export default function ResourceBookingPage() {
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={batchEditFields.provideTime}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2339,6 +2351,7 @@ export default function ResourceBookingPage() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-3">
<HeroCheckbox
variant="secondary"
isSelected={batchEditFields.isConfidential}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2368,6 +2381,7 @@ export default function ResourceBookingPage() {
</HeroCheckbox.Content>
</HeroCheckbox>
<HeroCheckbox
variant="secondary"
isSelected={batchEditForm.isConfidential}
onChange={(checked) =>
setBatchEditForm((prev) => ({
......@@ -2394,6 +2408,7 @@ export default function ResourceBookingPage() {
<div className="space-y-3">
<HeroCheckbox
variant="secondary"
isSelected={batchEditFields.isAuditDeptMaterial}
onChange={(checked) =>
setBatchEditFields((prev) => ({
......@@ -2423,6 +2438,7 @@ export default function ResourceBookingPage() {
</HeroCheckbox.Content>
</HeroCheckbox>
<HeroCheckbox
variant="secondary"
isSelected={batchEditForm.isAuditDeptMaterial}
onChange={(checked) =>
setBatchEditForm((prev) => ({
......@@ -2566,16 +2582,10 @@ export default function ResourceBookingPage() {
>
<Modal.Header>
<Modal.Heading>上传材料</Modal.Heading>
<Description>
创建页已按最新对象文档补齐字段,上传完成后会统一保存到材料档案。
</Description>
</Modal.Header>
<Modal.Body>
<div className="space-y-6">
<FormSection
title="上传文件"
description="先完成文件选择,系统会自动尝试用文件名回填材料标题。"
>
<div>
<DropZone className="min-h-48">
<DropZone.Area
onDrop={async (event) => {
......@@ -2619,7 +2629,7 @@ export default function ResourceBookingPage() {
</div>
) : null}
</DropZone>
</FormSection>
</div>
{selectedFileIsZip ? (
<FormSection
......@@ -2844,6 +2854,7 @@ export default function ResourceBookingPage() {
<div className="grid gap-4 sm:grid-cols-2">
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={uploadForm.isConfidential}
onChange={(checked) =>
setUploadForm((prev) => ({
......@@ -2874,6 +2885,7 @@ export default function ResourceBookingPage() {
</HeroCheckbox>
<HeroCheckbox
className="min-h-20"
variant="secondary"
isSelected={uploadForm.isAuditDeptMaterial}
onChange={(checked) =>
setUploadForm((prev) => ({
......
......@@ -13,7 +13,6 @@ import {
CircleStackIcon,
DocumentTextIcon,
FolderOpenIcon,
InformationCircleIcon,
ShieldCheckIcon,
ShieldExclamationIcon,
} from "@/components/AppIcons";
......@@ -29,7 +28,6 @@ import {
Chip,
ProgressBar as HeroProgressBar,
Spinner,
Tooltip,
} from "@heroui/react";
import { EmptyState } from "@heroui-pro/react/empty-state";
import { KPI } from "@heroui-pro/react/kpi";
......@@ -48,9 +46,6 @@ function Panel({ title, titleInfo, className, children }) {
<Card.Title className="rc-module-title min-w-0 flex-1">
{title}
</Card.Title>
{titleInfo ? (
<TitleInfoTooltip label={title} text={titleInfo} />
) : null}
</Card.Header>
) : null}
<Card.Content className={className}>{children}</Card.Content>
......@@ -65,9 +60,6 @@ function GovernanceCard({ title, titleInfo, icon: Icon, tone, children }) {
<div className="flex w-full min-w-0 items-center justify-between gap-4">
<Card.Title className="rc-module-title inline-flex min-w-0 items-center gap-1.5">
<span>{title}</span>
{titleInfo ? (
<TitleInfoTooltip label={title} text={titleInfo} />
) : null}
</Card.Title>
<span
aria-hidden="true"
......@@ -85,23 +77,6 @@ function GovernanceCard({ title, titleInfo, icon: Icon, tone, children }) {
);
}
function TitleInfoTooltip({ label, text }) {
return (
<Tooltip delay={0}>
<Button
isIconOnly
aria-label={`${label}说明`}
className="rc-title-info-trigger"
type="button"
variant="ghost"
>
<InformationCircleIcon size={14} />
</Button>
<Tooltip.Content>{text}</Tooltip.Content>
</Tooltip>
);
}
function MetricCard({ icon: Icon, label, value, hint, tone }) {
const numericValue = Number(value || 0);
......@@ -111,7 +86,6 @@ function MetricCard({ icon: Icon, label, value, hint, tone }) {
<KPI.Title>
<span className="inline-flex items-center gap-1.5">
{label}
{hint ? <TitleInfoTooltip label={label} text={hint} /> : null}
</span>
</KPI.Title>
</KPI.Header>
......@@ -135,9 +109,6 @@ function SectionHeader({ title, description }) {
return (
<h2 className="rc-section-title inline-flex items-center gap-1.5">
<span>{title}</span>
{description ? (
<TitleInfoTooltip label={title} text={description} />
) : null}
</h2>
);
}
......
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