From fc7350b7e29a74b4e7766f97c5dce02d546f88bc Mon Sep 17 00:00:00 2001 From: jialin Date: Tue, 21 Oct 2025 19:58:45 +0800 Subject: [PATCH] style: scroll to field --- src/hooks/use-table-fetch.ts | 7 ++ src/pages/llmodels/forms/advance-config.tsx | 8 +- src/pages/llmodels/forms/basic.tsx | 1 + src/pages/llmodels/forms/index.tsx | 109 +++++++++++++------ src/pages/llmodels/forms/kv-cache.tsx | 2 +- src/pages/llmodels/hooks/use-field-scroll.ts | 73 +++++++++++++ 6 files changed, 166 insertions(+), 34 deletions(-) create mode 100644 src/pages/llmodels/hooks/use-field-scroll.ts diff --git a/src/hooks/use-table-fetch.ts b/src/hooks/use-table-fetch.ts index 195015fc..76d68239 100644 --- a/src/hooks/use-table-fetch.ts +++ b/src/hooks/use-table-fetch.ts @@ -129,6 +129,12 @@ export default function useTableFetch( ..._.pickBy(query || queryParams, (val: any) => !!val) }; const res = await fetchAPI(params); + if (!dataSource.loadend) { + // add a delay to avoid flash + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + } if ( !res.items.length && params.page > res.pagination.totalPage && @@ -139,6 +145,7 @@ export default function useTableFetch( page: res.pagination.totalPage }; const newRes = await fetchAPI(newParams); + setDataSource({ dataList: loadmore ? [...dataSource.dataList, ...(newRes.items || [])] diff --git a/src/pages/llmodels/forms/advance-config.tsx b/src/pages/llmodels/forms/advance-config.tsx index f2518655..aad9e1d5 100644 --- a/src/pages/llmodels/forms/advance-config.tsx +++ b/src/pages/llmodels/forms/advance-config.tsx @@ -81,7 +81,13 @@ const AdvanceConfig = () => { return ( <> - name="categories"> + + name="categories" + data-field="categories" + style={{ + scrollMarginTop: 200 + }} + > = (props) => { return ( <> + data-field="name" name="name" rules={[ { diff --git a/src/pages/llmodels/forms/index.tsx b/src/pages/llmodels/forms/index.tsx index 21267bea..6cd2ce16 100644 --- a/src/pages/llmodels/forms/index.tsx +++ b/src/pages/llmodels/forms/index.tsx @@ -1,9 +1,10 @@ import { PageActionType } from '@/config/types'; import CollapsePanel from '@/pages/_components/collapse-panel'; import { useIntl } from '@umijs/max'; -import { Form } from 'antd'; +import { Form, Segmented } from 'antd'; import _ from 'lodash'; import React, { forwardRef, useImperativeHandle } from 'react'; +import styled from 'styled-components'; import { excludeFields, gpusCountTypeMap, ScheduleValueMap } from '../config'; import { backendOptionsMap } from '../config/backend-parameters'; import { FormContext } from '../config/form-context'; @@ -14,13 +15,35 @@ import { SourceType } from '../config/types'; import { generateGPUIds } from '../config/utils'; +import useFieldScroll from '../hooks/use-field-scroll'; import { useGenerateGPUOptions } from '../hooks/use-form-initial-values'; import useQueryBackends from '../hooks/use-query-backends'; import AdvanceConfig from './advance-config'; import BasicForm from './basic'; import Performance from './performance'; -const requiredFields = ['gpu_selector', 'backend']; +const advancedRequiredFields = [ + 'gpu_selector', + 'backend', + 'image_name', + 'run_command' +]; +const performanceRequiredFields = ['speculative_config']; + +const SegmentedInner = styled(Segmented)` + width: 100%; + border-radius: 0; + .ant-segmented-item { + flex: 1; + } +`; + +const SegmentedHeader = styled.div` + position: sticky; + top: 0; + z-index: 10; + margin-bottom: 16px; +`; interface DataFormProps { initialValues?: any; @@ -64,6 +87,31 @@ const DataForm: React.FC = forwardRef((props, ref) => { const performanceRef = React.useRef(null); const advanceRef = React.useRef(null); + const segmentOptions = [ + { + value: 'basic', + label: intl.formatMessage({ id: 'common.title.basicInfo' }), + field: 'name' + }, + { + value: 'performance', + label: intl.formatMessage({ id: 'models.form.performance' }), + field: 'extended_kv_cache.enabled' + }, + { + value: 'advanced', + label: intl.formatMessage({ id: 'resources.form.advanced' }), + field: 'categories' + } + ]; + + const { scrollToSegment } = useFieldScroll({ + form, + activeKey, + setActiveKey, + segmentOptions + }); + const handleSumit = () => { form.submit(); }; @@ -155,21 +203,34 @@ const DataForm: React.FC = forwardRef((props, ref) => { const handleOnFinishFailed = (errorInfo: any) => { const { errorFields } = errorInfo; if (errorFields && errorFields.length > 0) { + const collapseKeys: string[] = []; const names = errorFields.map((item: any) => item.name[0]); - const isRequired = names.some((name: string) => - requiredFields.includes(name) + const isAdvancedRequired = names.some((name: string) => + advancedRequiredFields.includes(name) ); - if (isRequired) { - setActiveKey(['advanced']); + + const isPerformanceRequired = names.some((name: string) => + performanceRequiredFields.includes(name) + ); + + if (isPerformanceRequired) { + collapseKeys.push('performance'); } + + if (isAdvancedRequired) { + collapseKeys.push('advanced'); + } + + setActiveKey((prev: string[]) => [ + ...new Set([...prev, ...collapseKeys]) + ]); } }; - const handleTargetChange = (val: string) => { - form.scrollToField(val, { - behavior: 'smooth', - block: 'center' - }); + const handleTargetChange = async (val: any) => { + setTarget(val); + + await scrollToSegment(val, { offsetTop: 96 }); }; useImperativeHandle(ref, () => { @@ -214,30 +275,14 @@ const DataForm: React.FC = forwardRef((props, ref) => { onBackendChange: handleBackendChange }} > - {/*
- + -
*/} +
{ <>
+ data-field="extended_kv_cache.enabled" name={['extended_kv_cache', 'enabled']} valuePropName="checked" style={{ padding: '0 10px', marginBottom: 0 }} - noStyle > >; + segmentOptions: { value: string; field: string }[]; + defaultWait?: number; +}) { + const scrollToElement = useCallback( + ( + el: HTMLElement, + { behavior = 'smooth', offsetTop = 0 }: ScrollOptions = {} + ) => { + // find the nearest scrollable parent + const scrollParent = (() => { + let node: HTMLElement | null = el; + while (node) { + const { overflowY } = getComputedStyle(node); + if (overflowY === 'auto' || overflowY === 'scroll') return node; + node = node.parentElement; + } + return document.scrollingElement || document.documentElement; + })(); + + const parentRect = scrollParent.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const top = + elRect.top - parentRect.top + scrollParent.scrollTop - offsetTop; + + scrollParent.scrollTo({ top, behavior }); + }, + [] + ); + + const scrollToSegment = useCallback( + async (val: string, options?: ScrollOptions) => { + if (!activeKey.includes(val)) { + setActiveKey((prev) => [...prev, val]); + await new Promise((r) => { + setTimeout(r, options?.wait ?? defaultWait); + }); + } + + await new Promise(requestAnimationFrame); + + const current = segmentOptions.find((item) => item.value === val); + if (!current?.field) return; + + const el = document.querySelector( + `[data-field="${current.field}"]` + ) as HTMLElement | null; + + if (el) scrollToElement(el, options); + }, + [activeKey, setActiveKey, segmentOptions, defaultWait, scrollToElement] + ); + + return { scrollToSegment }; +}