fix: calc scrollheight dynamically for tabs change

This commit is contained in:
jialin
2025-11-10 20:40:15 +08:00
parent 285e274818
commit 9eb1dba4a1
14 changed files with 283 additions and 77 deletions
+32 -8
View File
@@ -217,12 +217,6 @@ export default function useOverlayScroller(data?: {
// const currentScroll = instanceRef.current?.scroll().position.y;
const targetPos = targetRect.top - containerRect.top + currentScroll;
console.log(
'target=======',
currentScroll,
targetPos,
instanceRef.current?.options()
);
scrollEventElement.current.scroll({
y: targetPos - offset,
@@ -231,6 +225,35 @@ export default function useOverlayScroller(data?: {
instanceRef.current?.update?.();
};
const getScrollElementScrollableHeight = () => {
if (!instanceRef.current || !scrollEventElement.current) {
instanceRef.current = instance?.();
scrollEventElement.current =
instanceRef.current?.elements()?.scrollEventElement;
}
const scrollerState = instanceRef.current?.state();
const overflowAmount = scrollerState?.overflowAmount;
const scrollOffsetElement = instanceRef.current?.elements().viewport;
console.log('osInstance==========', overflowAmount, scrollOffsetElement, {
top:
Math.round(
(overflowAmount?.y - scrollOffsetElement?.scrollTop || 0) /
overflowAmount?.y
) * overflowAmount?.y,
scrollTop: scrollOffsetElement?.scrollTop,
scrollLeft: scrollOffsetElement?.scrollLeft,
scrollHeight:
scrollOffsetElement?.scrollHeight - scrollOffsetElement?.clientHeight
});
return {
scrollTop: scrollOffsetElement?.scrollTop,
scrollHeight:
scrollOffsetElement?.scrollHeight - scrollOffsetElement?.clientHeight
};
};
useEffect(() => {
return () => {
instanceRef.current?.destroy?.();
@@ -240,9 +263,10 @@ export default function useOverlayScroller(data?: {
return {
initialize: createInstance,
instance: instanceRef.current,
scrollEventElement: scrollEventElement.current,
instance: instanceRef,
scrollEventElement: scrollEventElement,
initialized: initialized.current,
getScrollElementScrollableHeight,
generateInstance,
destroyInstance: destroyInstance,
updateScrollerPosition: throttledUpdateScrollerPosition,
+4 -2
View File
@@ -45,7 +45,8 @@ export default {
'models.form.scheduletype': 'Schedule Type',
'models.form.categories': 'Model Category',
'models.form.scheduletype.auto': 'Auto',
'models.form.scheduletype.manual': 'Specify GPU',
'models.form.scheduletype.manual': 'Manual',
'models.form.scheduletype.gpu': 'Specify GPU',
'models.form.scheduletype.gpuType': 'Specify GPU Type',
'models.form.scheduletype.auto.tips':
'Automatically deploys model instances to appropriate GPUs based on current resource conditions.',
@@ -242,5 +243,6 @@ export default {
'models.form.rules.name':
'Up to 63 characters; letters, numbers, dots (.), underscores (_), and hyphens (-) only; must start and end with an alphanumeric character.',
'models.catalog.button.explore': 'Explore Other Models',
'models.catalog.precision': 'Precision'
'models.catalog.precision': 'Precision',
'models.form.gpuPerReplica.tips': 'Enter a custom number'
};
+7 -3
View File
@@ -47,7 +47,8 @@ export default {
'models.form.scheduletype': 'スケジュールタイプ',
'models.form.categories': 'モデルカテゴリ',
'models.form.scheduletype.auto': '自動',
'models.form.scheduletype.manual': 'GPUを指定',
'models.form.scheduletype.manual': '手動',
'models.form.scheduletype.gpu': 'GPUを指定',
'models.form.scheduletype.gpuType': 'GPUタイプを指定',
'models.form.scheduletype.auto.tips':
'現在のリソース状況に基づいて、モデルインスタンスを適切なGPUに自動的にデプロイします。',
@@ -242,7 +243,8 @@ export default {
'models.form.rules.name':
'Up to 63 characters; letters, numbers, dots (.), underscores (_), and hyphens (-) only; must start and end with an alphanumeric character.',
'models.catalog.button.explore': 'Explore Other Models',
'models.catalog.precision': 'Precision'
'models.catalog.precision': 'Precision',
'models.form.gpuPerReplica.tips': 'Enter a custom number'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
@@ -313,5 +315,7 @@ export default {
// 48. 'models.form.backend.custom': 'User Defined',
// 49. 'models.form.rules.name': 'Up to 63 characters; letters, numbers, dots (.), underscores (_), and hyphens (-) only; must start and end with an alphanumeric character.',
// 50. 'models.catalog.button.explore': 'Explore Other Models',
// 51. 'models.catalog.precision': 'Precision'
// 51. 'models.catalog.precision': 'Precision',
// 52. 'models.form.gpuPerReplica.tips': 'Enter a custom number'
// ========== End of To-Do List ==========
+6 -3
View File
@@ -45,7 +45,8 @@ export default {
'models.form.scheduletype': 'Тип планирования',
'models.form.categories': 'Категория модели',
'models.form.scheduletype.auto': 'Авто',
'models.form.scheduletype.manual': 'Указать GPU',
'models.form.scheduletype.manual': 'Ручной',
'models.form.scheduletype.gpu': 'Указать GPU',
'models.form.scheduletype.gpuType': 'Указать тип GPU',
'models.form.scheduletype.auto.tips':
'Автоматическое развертывание инстансов модели на подходящие GPU в зависимости от текущих ресурсов.',
@@ -243,9 +244,11 @@ export default {
'models.form.rules.name':
'Up to 63 characters; letters, numbers, dots (.), underscores (_), and hyphens (-) only; must start and end with an alphanumeric character.',
'models.catalog.button.explore': 'Изучить другие модели',
'models.catalog.precision': 'Точность'
'models.catalog.precision': 'Точность',
'models.form.gpuPerReplica.tips': 'Enter a custom number'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
// 1. 'models.form.rules.name': 'Up to 63 characters; letters, numbers, dots (.), underscores (_), and hyphens (-) only; must start and end with an alphanumeric character.'
// 1. 'models.form.rules.name': 'Up to 63 characters; letters, numbers, dots (.), underscores (_), and hyphens (-) only; must start and end with an alphanumeric character.',
// 2. 'models.form.gpuPerReplica.tips': 'Enter a custom number'
// ========== End of To-Do List ==========
+4 -2
View File
@@ -46,7 +46,8 @@ export default {
'models.form.categories': '模型类别',
'models.form.scheduletype': '调度方式',
'models.form.scheduletype.auto': '自动',
'models.form.scheduletype.manual': '指定 GPU',
'models.form.scheduletype.manual': '手动',
'models.form.scheduletype.gpu': '指定 GPU',
'models.form.scheduletype.gpuType': '指定 GPU 类型',
'models.form.scheduletype.auto.tips':
'自动根据当前资源情况部署模型实例到合适的 GPU 上。',
@@ -229,5 +230,6 @@ export default {
'models.form.rules.name':
'长度不超过 63 个字符,只能包含字母、数字、点(.)、下划线(_)和连字符(-),且必须以字母或数字开头和结尾。',
'models.catalog.button.explore': '浏览其他模型',
'models.catalog.precision': '精度'
'models.catalog.precision': '精度',
'models.form.gpuPerReplica.tips': '输入自定义数值'
};
+21 -8
View File
@@ -22,14 +22,20 @@ const ColumnWrapper: React.FC<ColumnWrapperProps> = ({
styles = {}
}) => {
const scroller = React.useRef<any>(null);
const { initialize, instance, scrollToBottom, scrollToTarget } =
useOverlayScroller({
options: {
scrollbars: {
autoHide: 'move'
}
const {
initialize,
instance,
scrollEventElement,
scrollToBottom,
scrollToTarget,
getScrollElementScrollableHeight
} = useOverlayScroller({
options: {
scrollbars: {
autoHide: 'move'
}
});
}
});
React.useEffect(() => {
if (scroller.current) {
@@ -39,7 +45,14 @@ const ColumnWrapper: React.FC<ColumnWrapperProps> = ({
return (
<WrapperContext.Provider
value={{ osInstance: instance, scrollToBottom, scrollToTarget }}
value={{
scroller: scroller,
osInstance: instance,
scrollEventElement,
getScrollElementScrollableHeight,
scrollToBottom,
scrollToTarget
}}
>
<div
className="column-wrapper-footer"
@@ -2,8 +2,14 @@ import { createContext, useContext } from 'react';
interface WrapperContextProps {
osInstance?: any;
scroller?: any;
scrollEventElement?: any;
scrollToBottom?: () => void;
scrollToTop?: () => void;
getScrollElementScrollableHeight?: () => {
scrollHeight: number;
scrollTop: number;
};
scrollToTarget?: (target: any, offset?: number) => void;
}
+3 -2
View File
@@ -29,7 +29,7 @@ const BackendFields: React.FC = () => {
const { onValuesChange, backendOptions, onBackendChange } = useFormContext();
const backend = Form.useWatch('backend', form);
const handleBackendVersionOnBlur = () => {
const handleBackendVersionOnChange = () => {
onValuesChange?.({}, form.getFieldsValue());
};
@@ -137,13 +137,14 @@ const BackendFields: React.FC = () => {
{backendOptionsMap.custom !== backend && (
<Form.Item name="backend_version">
<SealSelect
allowClear
options={backendVersions}
optionRender={optionRender}
labelRender={labelRender}
placeholder={intl.formatMessage({
id: 'models.form.backendVersion.holder'
})}
onBlur={handleBackendVersionOnBlur}
onChange={handleBackendVersionOnChange}
label={intl.formatMessage({ id: 'models.form.backendVersion' })}
description={intl.formatMessage(
{
+4 -15
View File
@@ -4,33 +4,22 @@ import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import React from 'react';
import { DeployFormKeyMap } from '../config';
import { useCatalogFormContext, useFormContext } from '../config/form-context';
import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types';
/**
* It can access some props from CatalogFormContext
*/
const CatalogForm: React.FC = () => {
const intl = useIntl();
const formCtx = useFormContext();
const catalogFormCtx = useCatalogFormContext();
const { getRuleMessage } = useAppUtils();
const { formKey } = formCtx;
const {
sizeOptions,
quantizationOptions,
onSizeChange,
onQuantizationChange
} = catalogFormCtx;
if (formKey !== DeployFormKeyMap.CATALOG) {
return null;
}
const handleSizeChange = (val: any) => {
onSizeChange?.(val);
};
const handleOnQuantizationChange = (val: any) => {
onQuantizationChange?.(val);
};
return (
<>
<Form.Item<FormData>
+27 -17
View File
@@ -1,6 +1,7 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import CollapsePanel from '@/pages/_components/collapse-panel';
import { useWrapperContext } from '@/pages/_components/column-wrapper/use-wrapper-context';
import { useIntl } from '@umijs/max';
import { Form, Segmented } from 'antd';
import _ from 'lodash';
@@ -72,6 +73,13 @@ interface DataFormProps {
onBackendChange?: (value: string) => void;
}
const TABKeysMap = {
BASIC: 'basic',
SCHEDULING: 'scheduling',
PERFORMANCE: 'performance',
ADVANCED: 'advanced'
};
const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
const {
action,
@@ -89,6 +97,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
onValuesChange,
onOk
} = props;
const { getScrollElementScrollableHeight } = useWrapperContext();
const { backendOptions, getBackendOptions } = useQueryBackends();
const { getGPUOptionList, gpuOptions, workerLabelOptions } =
useGenerateGPUOptions();
@@ -96,14 +105,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]);
const scheduleType = Form.useWatch('scheduleType', form);
const [target, setTarget] = React.useState<string>('basic');
const TABKeysMap = {
BASIC: 'basic',
SCHEDULING: 'scheduling',
PERFORMANCE: 'performance',
ADVANCED: 'advanced'
};
const [target, setTarget] = React.useState<string>(TABKeysMap.BASIC);
const segmentOptions = [
{
@@ -128,14 +130,12 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
}
];
const { scrollToSegment } = useFieldScroll({
form,
activeKey,
setActiveKey,
segmentOptions
});
console.log(
'getScrollElementScrollableHeight',
getScrollElementScrollableHeight?.()
);
const SegmentedTop = useMemo(() => {
const segmentedTop = useMemo(() => {
if (
modelSourceMap.local_path_value === source ||
action === PageAction.EDIT ||
@@ -153,6 +153,15 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
};
}, [source, formKey, action]);
const { scrollToSegment, holderHeight } = useFieldScroll({
form,
activeKey,
setActiveKey,
segmentOptions,
segmentedTop: segmentedTop,
getScrollElementScrollableHeight: getScrollElementScrollableHeight
});
const handleSumit = () => {
form.submit();
};
@@ -288,7 +297,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
const handleTargetChange = async (val: any) => {
setTarget(val);
await scrollToSegment(val, { offsetTop: SegmentedTop.offsetTop });
await scrollToSegment(val, { offsetTop: segmentedTop.offsetTop });
};
const handleOnFinishFailed = (errorInfo: any) => {
@@ -389,7 +398,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
onBackendChange: handleBackendChange
}}
>
<SegmentedHeader $top={SegmentedTop.top}>
<SegmentedHeader $top={segmentedTop.top}>
<SegmentedInner
defaultValue="basic"
value={target}
@@ -465,6 +474,7 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
}
]}
></CollapsePanel>
<div className="holder" style={{ height: holderHeight }}></div>
</Form>
</FormContext.Provider>
);
+41 -6
View File
@@ -5,9 +5,10 @@ import SealSelect from '@/components/seal-form/seal-select';
import TooltipList from '@/components/tooltip-list';
import useAppUtils from '@/hooks/use-app-utils';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { Form, InputNumber } from 'antd';
import _ from 'lodash';
import React from 'react';
import styled from 'styled-components';
import GPUCard from '../components/gpu-card';
import {
placementStrategyOptions,
@@ -18,6 +19,10 @@ import { backendOptionsMap } from '../config/backend-parameters';
import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types';
const InputWrapper = styled.div`
padding: 8px 4px;
`;
const placementStrategyTips = [
{
title: 'Spread',
@@ -70,6 +75,10 @@ const ScheduleTypeForm: React.FC = () => {
const form = Form.useFormInstance();
const scheduleType = Form.useWatch('scheduleType', form);
const workerSelector = Form.useWatch('worker_selector', form);
const GPUsPerReplicas = Form.useWatch(
['gpu_selector', 'gpus_per_replica'],
form
);
const handleScheduleTypeChange = async (value: string) => {
if (value === ScheduleValueMap.Auto) {
@@ -81,6 +90,15 @@ const ScheduleTypeForm: React.FC = () => {
form.setFieldValue(['gpu_selector', 'gpus_per_replica'], null);
}
};
const handleGpusPerReplicasChange = (val: string | number | null) => {
if (val === null) {
form.setFieldValue(['gpu_selector', 'gpus_per_replica'], null);
} else {
form.setFieldValue(['gpu_selector', 'gpus_per_replica'], val);
}
onValuesChange?.({}, form.getFieldsValue());
};
const handleGpuSelectorChange = (value: any[]) => {
if (value.length > 0) {
@@ -191,15 +209,32 @@ const ScheduleTypeForm: React.FC = () => {
{ label: '2', value: 2 },
{ label: '4', value: 4 },
{ label: '8', value: 8 },
{ label: '16', value: 16 },
{ label: '32', value: 32 },
{ label: '64', value: 64 },
{ label: '128', value: 128 },
{ label: '256', value: 256 }
{ label: '16', value: 16 }
]}
description={
<TooltipList list={GPUsPerReplicaTips}></TooltipList>
}
popupRender={(originNode) => (
<div>
{originNode}
<InputWrapper>
<InputNumber
min={1}
max={16}
step={1}
style={{ width: '100%' }}
defaultValue={
GPUsPerReplicas === -1 ? null : GPUsPerReplicas
}
placeholder={intl.formatMessage({
id: 'models.form.gpuPerReplica.tips'
})}
value={GPUsPerReplicas === -1 ? null : GPUsPerReplicas}
onChange={handleGpusPerReplicasChange}
/>
</InputWrapper>
</div>
)}
/>
</Form.Item>
</>
+56 -9
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { useMemoizedFn } from 'ahooks';
import { useCallback, useRef, useState } from 'react';
interface ScrollOptions {
wait?: number;
@@ -12,14 +13,27 @@ export default function useScrollAfterExpand({
activeKey,
setActiveKey,
segmentOptions,
defaultWait = 300
defaultWait = 300,
segmentedTop = { top: 0, offsetTop: 96 },
getScrollElementScrollableHeight
}: {
form: any;
activeKey: string[];
setActiveKey: React.Dispatch<React.SetStateAction<string[]>>;
segmentOptions: { value: string; field: string }[];
getScrollElementScrollableHeight?: () => {
scrollHeight: number;
scrollTop: number;
};
defaultWait?: number;
segmentedTop: {
top: number;
offsetTop: number;
};
}) {
const [holderHeight, setHolderHeight] = useState<number>(0);
const boxHeightRef = useRef<number>(0);
const scrollToElement = useCallback(
(
el: HTMLElement,
@@ -46,7 +60,11 @@ export default function useScrollAfterExpand({
[]
);
const scrollToSegment = useCallback(
/**
* due to the scrollheight changes after expanding the segment and including the holder height.
*
*/
const scrollToSegment = useMemoizedFn(
async (val: string, options?: ScrollOptions) => {
if (!activeKey.includes(val)) {
setActiveKey((prev) => [...prev, val]);
@@ -55,19 +73,48 @@ export default function useScrollAfterExpand({
});
}
await new Promise(requestAnimationFrame);
const current = segmentOptions.find((item) => item.value === val);
if (!current?.field) return;
const el = document.querySelector(
await new Promise(requestAnimationFrame);
const el: HTMLElement | null = document.querySelector(
`[data-field="${current.field}"]`
) as HTMLElement | null;
const targetRectTop = el?.getBoundingClientRect().top || 0;
const scroller = getScrollElementScrollableHeight?.() || {
scrollHeight: 0,
scrollTop: 0
};
// remaining scroll height
const remainingScrollHeight = scroller.scrollHeight - scroller.scrollTop;
// total distance from the top of the scroller to the target element
const offsetDistance =
targetRectTop - segmentedTop.offsetTop - segmentedTop.top;
let boxHeight = 0;
// verify boxHeight is correct, if setting the boxHeight causes the element to be hidden, use the previous boxHeight
if (offsetDistance <= 0) {
boxHeight = boxHeightRef.current;
} else {
boxHeight =
offsetDistance - remainingScrollHeight + boxHeightRef.current;
}
// update boxHeightRef
boxHeightRef.current = boxHeight;
setHolderHeight(boxHeight);
await new Promise(requestAnimationFrame);
if (el) scrollToElement(el, options);
},
[activeKey, setActiveKey, segmentOptions, defaultWait, scrollToElement]
}
);
return { scrollToSegment };
return { scrollToSegment, holderHeight };
}
@@ -17,11 +17,11 @@ export default function useQueryBackends() {
value: item.backend_name,
label:
item.backend_name === backendOptionsMap.custom
? intl.formatMessage({ id: 'backend.quickConfig' })
? intl.formatMessage({ id: 'backend.custom' })
: item.backend_name,
title:
item.backend_name === backendOptionsMap.custom
? intl.formatMessage({ id: 'backend.quickConfig' })
? intl.formatMessage({ id: 'backend.custom' })
: item.backend_name.replace(/-custom$/, ''),
default_backend_param: item.default_backend_param || [],
default_version: item.default_version,
@@ -0,0 +1,70 @@
import HighlightCode from '@/components/highlight-code';
import { useIntl } from '@umijs/max';
import React, { useMemo } from 'react';
import { addWorkerGuide } from '../config';
type ViewModalProps = { token: string };
const AddWorker: React.FC<ViewModalProps> = (props) => {
const intl = useIntl();
const origin = window.location.origin;
const labels = useMemo(
() => ({
step1: intl.formatMessage({ id: 'resources.worker.add.step1' }),
step2: intl.formatMessage({ id: 'resources.worker.add.step2' }),
step2Tips: intl.formatMessage({ id: 'resources.worker.add.step2.tips' }),
step3: intl.formatMessage({ id: 'resources.worker.add.step3' }),
linuxOrMac: intl.formatMessage({ id: 'resources.worker.linuxormaxos' })
}),
[intl]
);
return (
<div className="script-install">
<h3 className="font-size-14 font-600">
1. <span dangerouslySetInnerHTML={{ __html: labels.step1 }}></span>
</h3>
<h4 className="font-size-13">{labels.linuxOrMac}</h4>
<HighlightCode
code={addWorkerGuide.mac.getToken}
theme="dark"
></HighlightCode>
<h4 className="m-t-6 font-size-13">Windows </h4>
<HighlightCode
code={addWorkerGuide.win.getToken}
theme="dark"
></HighlightCode>
<h3 className="m-t-10 font-size-14 font-600">
2. {labels.step2}{' '}
<span
className="font-size-12"
style={{ color: 'var(--ant-color-text-tertiary)' }}
dangerouslySetInnerHTML={{
__html: `${labels.step2Tips}`
}}
></span>
</h3>
<h4 className="font-size-13">{labels.linuxOrMac}</h4>
<HighlightCode
code={addWorkerGuide.mac.registerWorker({
server: origin,
token: '${token}'
})}
theme="dark"
></HighlightCode>
<h4 className="m-t-6 font-size-13">Windows</h4>
<HighlightCode
theme="dark"
code={addWorkerGuide.win.registerWorker({
server: origin,
token: '${token}'
})}
></HighlightCode>
<h3 className="m-b-0 m-t-10 font-size-14 font-600">3. {labels.step3}</h3>
</div>
);
};
export default AddWorker;