Repository: arco-design/arco-design (ts)

Issue:
menu组件 收缩在展开后 抽屉自动关闭了
- [ ] I'm sure this does not appear in [the issue list of the repository](https://github.com/arco-design/arco-design/issues)

## Basic Info 
- **Package Name And Version:** @arco-design/web-react@2.32.1
- **System:** mac
- **Browser:** chrome98.0.4758.102
- **Reproduction Link:** https://arco.design/react/components/menu

## What is expected?
别说什么了selectedKeys 受控模式，我又没刷新页面 ，我觉得默认应该保存这个抽屉打开关闭的状态 

## Steps to reproduce
打开官网menu组件-随便一个都这样


<!-- generated by arco-issue. DO NOT REMOVE -->

Pull request description:
fix: Menu should keep open keys after collapse/uncollapse

## Types of changes

<!-- What types of changes does this PR introduce -->
<!-- Only support choose one type, if there are multiple types, you can add the `Type` column in the Changelog. -->

- [ ] New feature
- [x] Bug fix
- [ ] Enhancement
- [ ] Documentation change
- [ ] Coding style change
- [ ] Component style change
- [ ] Refactoring
- [ ] Test cases
- [ ] Continuous integration
- [ ] Typescript definition change
- [ ] Breaking change
- [ ] Others 

## Background and context

<!-- Explain what problem does the PR solve -->
<!-- Link to related open issues if applicable -->

## Solution

<!-- Describe how the problem is fixed in detail -->

## How is the change tested?

<!-- Unit tests should be added/updated for bug fixes and new features, if applicable -->
<!-- Please describe how you tested the change. E.g. Creating/updating unit tests or attaching a screenshot of how it works with your change -->

## Changelog

| Component | Changelog(CN) | Changelog(EN) | Related issues |
| --------- | ------------- | ------------- | -------------- |
|  Menu   |  修复 `Menu` 组件 `collapse` 属性变化时，已经展开的子菜单被收起的 bug。   |  Fixed the bug that the expanded SubMenu was collapsed when the `collapse` property of the `Menu` component was changed. |    Close #814        |

<!-- If there are multiple types, you can add the `Type` column in the Changelog, the value of the column is the same as `Types of changes` -->
## Checklist:

- [x] Test suite passes (`npm run test`)
- [x] Provide changelog for relevant changes (e.g. bug fixes and new features) if applicable.
- [x] Changes are submitted to the appropriate branch (e.g. features should be submitted to `feature` branch and others should be submitted to `main` branch)

## Other information

<!-- Please describe what other information that should be taken care of. E.g. describe the impact if introduce a breaking change -->


Patch:
diff --git a/components/ConfigProvider/__demo__/basic.md b/components/ConfigProvider/__demo__/basic.md
index d4930e54..306f7e82 100644
--- a/components/ConfigProvider/__demo__/basic.md
+++ b/components/ConfigProvider/__demo__/basic.md
@@ -36,6 +36,7 @@ import frFR from '@arco-design/web-react/es/locale/fr-FR';
 import esES from '@arco-design/web-react/es/locale/es-ES';
 import deDE from '@arco-design/web-react/es/locale/de-DE';
 import itIT from '@arco-design/web-react/es/locale/it-IT';
+import viVN from '@arco-design/web-react/es/locale/vi-VN';
 
 function Demo() {
   const [locale, setLocale] = useState('zh-CN');
@@ -64,6 +65,8 @@ function Demo() {
         return deDE;
       case 'it-IT':
         return itIT;
+      case 'vi-VN':
+        return viVN;
       default:
         return zhCN;
     }
@@ -73,7 +76,7 @@ function Demo() {
     <ConfigProvider locale={getLocale()}>
       <Radio.Group
         value={locale}
-        options={['zh-CN', 'en-US', 'zh-HK', 'ja-JP', 'ko-KR', 'id-ID', 'th-TH', 'fr-FR', 'es-ES', 'de-DE', 'it-IT']}
+        options={['zh-CN', 'en-US', 'zh-HK', 'ja-JP', 'ko-KR', 'id-ID', 'th-TH', 'fr-FR', 'es-ES', 'de-DE', 'it-IT', 'vi-VN']}
         name="locale"
         type="button"
         mode="fill"
diff --git a/components/Form/control.tsx b/components/Form/control.tsx
index 109a7f29..0104c96d 100644
--- a/components/Form/control.tsx
+++ b/components/Form/control.tsx
@@ -65,6 +65,7 @@ export default class Control<
       const innerMethods = store.getInnerMethods(true);
       this.removeRegisterField = innerMethods.registerField(this);
     }
+    this.isDestroyed = false;
   }
 
   componentWillUnmount() {
diff --git a/components/Form/form-item.tsx b/components/Form/form-item.tsx
index 0fc55927..e43edd7a 100644
--- a/components/Form/form-item.tsx
+++ b/components/Form/form-item.tsx
@@ -144,6 +144,8 @@ const Item = <
       : updateInnerFormItem;
 
   useEffect(() => {
+    isDestroyed.current = false;
+
     return () => {
       isDestroyed.current = true;
       setErrors(null);
diff --git a/components/Form/store.tsx b/components/Form/store.tsx
index 6d6649b2..c3d43735 100644
--- a/components/Form/store.tsx
+++ b/components/Form/store.tsx
@@ -192,8 +192,8 @@ class Store<
     if (!field) return;
     const prev = cloneDeep(this.store);
     set(this.store, field, value);
-    this.triggerValuesChange(({ [field]: value } as unknown) as Partial<FormData>);
-    this.triggerTouchChange(({ [field]: value } as unknown) as Partial<FormData>);
+    this.triggerValuesChange({ [field]: value } as unknown as Partial<FormData>);
+    this.triggerTouchChange({ [field]: value } as unknown as Partial<FormData>);
 
     this.notify('innerSetValue', { prev, field, ...options, changeValues: { [field]: value } });
   };
@@ -240,16 +240,14 @@ class Store<
   };
 
   // 外部调用，设置多个表单控件的值，以及 error，touch 信息。
-  public setFields = (
-    obj: {
-      [field in FieldKey]?: {
-        value?: FieldValue;
-        error?: FieldError<FieldValue>;
-        touched?: boolean;
-        warning?: React.ReactNode;
-      };
-    }
-  ) => {
+  public setFields = (obj: {
+    [field in FieldKey]?: {
+      value?: FieldValue;
+      error?: FieldError<FieldValue>;
+      touched?: boolean;
+      warning?: React.ReactNode;
+    };
+  }) => {
     const fields = Object.keys(obj) as FieldKey[];
     const changeValues = {} as any;
     fields.forEach((field) => {
@@ -340,7 +338,7 @@ class Store<
     if (fields && isArray(fields)) {
       const changeValues = {} as any;
       fields.forEach((field) => {
-        set(this.store, field, this.initialValues[(field as unknown) as keyof FormData]);
+        set(this.store, field, this.initialValues[field as unknown as keyof FormData]);
         changeValues[field] = get(this.store, field);
       });
 
diff --git a/components/Menu/index.tsx b/components/Menu/index.tsx
index 66ce87fb..2d1f1a3f 100644
--- a/components/Menu/index.tsx
+++ b/components/Menu/index.tsx
@@ -80,7 +80,7 @@ function Menu(baseProps: MenuProps, ref) {
   const mergedCollapse = siderCollapsed || collapse || inDropdown || mode === 'popButton';
   const theme = propTheme || menuContext.theme || DEFAULT_THEME;
 
-  const refInlineMenuKeys = useRef<string[]>([]);
+  const refSubMenuKeys = useRef<string[]>([]);
   const refPrevSubMenuKeys = useRef<string[]>([]);
   const forceUpdate = useForceUpdate();
 
@@ -113,16 +113,16 @@ function Menu(baseProps: MenuProps, ref) {
   // autoOpen 时，初次渲染展开所有的子菜单
   useEffect(() => {
     // 从 openKeys 中过滤已经不存在的 subMenuKey
-    let validOpenKeys = openKeys.filter((key) => refInlineMenuKeys.current.indexOf(key) !== -1);
+    let validOpenKeys = openKeys.filter((key) => refSubMenuKeys.current.indexOf(key) !== -1);
     if (autoOpen) {
-      const keysAdded = refInlineMenuKeys.current.filter(
+      const keysAdded = refSubMenuKeys.current.filter(
         (key) => refPrevSubMenuKeys.current.indexOf(key) === -1
       );
       validOpenKeys = openKeys.concat(keysAdded);
     }
     setOpenKeys(accordion ? validOpenKeys.slice(0, 1) : validOpenKeys);
-    refPrevSubMenuKeys.current = refInlineMenuKeys.current.slice();
-  }, [refInlineMenuKeys.current.toString()]);
+    refPrevSubMenuKeys.current = refSubMenuKeys.current.slice();
+  }, [refSubMenuKeys.current.toString()]);
 
   const mergedHasCollapseButton =
     mode !== 'horizontal' && mode !== 'popButton' && !inDropdown && hasCollapseButton;
@@ -211,9 +211,9 @@ function Menu(baseProps: MenuProps, ref) {
           clearHotkeyInfo,
           collectInlineMenuKeys: (key, unmount) => {
             if (unmount) {
-              refInlineMenuKeys.current = refInlineMenuKeys.current.filter((x) => x !== key);
+              refSubMenuKeys.current = refSubMenuKeys.current.filter((x) => x !== key);
             } else {
-              refInlineMenuKeys.current.push(key);
+              refSubMenuKeys.current.push(key);
             }
             forceUpdate();
           },
diff --git a/components/Menu/sub-menu/index.tsx b/components/Menu/sub-menu/index.tsx
index dfbf7ac2..b7cfd3d8 100644
--- a/components/Menu/sub-menu/index.tsx
+++ b/components/Menu/sub-menu/index.tsx
@@ -1,4 +1,4 @@
-import React, { forwardRef, useContext } from 'react';
+import React, { forwardRef, useContext, useEffect } from 'react';
 import SubMenuInline from './inline';
 import SubMenuPop from './pop';
 import { MenuSubMenuProps } from '../interface';
@@ -6,12 +6,19 @@ import MenuContext from '../context';
 
 function SubMenu(props: MenuSubMenuProps, ref) {
   const { children, popup, level } = props;
-  const { mode, collapse, inDropdown } = useContext(MenuContext);
+  const { mode, collapse, inDropdown, collectInlineMenuKeys } = useContext(MenuContext);
 
   const forcePopup = !!(typeof popup === 'function' ? popup(level) : popup);
   const mergedPopup = forcePopup || collapse || inDropdown || mode !== 'vertical';
   const MergedMenu = mergedPopup ? SubMenuPop : SubMenuInline;
 
+  useEffect(() => {
+    collectInlineMenuKeys(props._key);
+    return () => {
+      collectInlineMenuKeys(props._key, true);
+    };
+  }, []);
+
   return (
     <MergedMenu forwardedRef={ref} {...props}>
       {children}
diff --git a/components/Menu/sub-menu/inline.tsx b/components/Menu/sub-menu/inline.tsx
index 0c9c947b..22c7bc30 100644
--- a/components/Menu/sub-menu/inline.tsx
+++ b/components/Menu/sub-menu/inline.tsx
@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useMemo } from 'react';
+import React, { useContext, useMemo } from 'react';
 import { CSSTransition } from 'react-transition-group';
 import cs from '../../_util/classNames';
 import useStateWithPromise from '../../_util/hooks/useStateWithPromise';
@@ -24,7 +24,6 @@ const SubMenuInline = (props: MenuSubMenuProps & { forwardedRef }) => {
     icons,
     onClickSubMenu,
     onClickMenuItem,
-    collectInlineMenuKeys,
   } = useContext(MenuContext);
 
   const baseClassName = `${prefixCls}-inline`;
@@ -51,13 +50,6 @@ const SubMenuInline = (props: MenuSubMenuProps & { forwardedRef }) => {
     return id;
   }, []);
 
-  useEffect(() => {
-    collectInlineMenuKeys(props._key);
-    return () => {
-      collectInlineMenuKeys(props._key, true);
-    };
-  }, []);
-
   // Should omit these properties in Menu.Item
   const childrenList = processChildren(children, {
     ...pick(rest, PROPS_NEED_TO_BE_PASSED_IN_SUBMENU),
diff --git a/components/Select/select.tsx b/components/Select/select.tsx
index da3db75a..ed0a201c 100644
--- a/components/Select/select.tsx
+++ b/components/Select/select.tsx
@@ -183,7 +183,7 @@ function Select(baseProps: SelectProps, ref) {
 
   const scrollIntoView = (optionValue) => {
     const activeOption = optionInfoMap.get(optionValue);
-    if (refWrapper.current && activeOption && activeOption.child.props) {
+    if (refWrapper.current && activeOption?.child?.props) {
       refWrapper.current.scrollTo({ key: activeOption.child.props._key });
     }
   };
diff --git a/components/_util/getHighlightText.tsx b/components/_util/getHighlightText.tsx
index fbdaee13..c08b6bd6 100644
--- a/components/_util/getHighlightText.tsx
+++ b/components/_util/getHighlightText.tsx
@@ -1,6 +1,6 @@
-import React from 'react';
+import { escapeRegExp } from 'lodash';
+import React, { cloneElement } from 'react';
 import { isArray } from './is';
-import cs from './classNames';
 
 export default function getHighlightText<T>({
   nodeList,
@@ -8,8 +8,8 @@ export default function getHighlightText<T>({
   highlightClassName,
 }: {
   nodeList: T;
-  pattern: string | RegExp;
-  highlightClassName: string | string[];
+  pattern: string;
+  highlightClassName: string;
 }): T {
   if (!pattern) {
     return nodeList;
@@ -17,37 +17,15 @@ export default function getHighlightText<T>({
 
   const transformNode = (node) => {
     if (node && node.props && typeof node.props.children === 'string') {
-      const { children } = node.props;
-      return React.cloneElement(node, {
-        children: (() => {
-          let indexOfNextRegTest = 0;
-          const result = [];
-
-          // 首先进行正则查询，将匹配项和匹配项之间的字符串依次拼接
-          children.replace(pattern, (...args) => {
-            const match = args[0];
-            const index = args[args.length - 2];
-
-            // 与上次匹配项之间的内容
-            if (index > indexOfNextRegTest) {
-              result.push(children.slice(indexOfNextRegTest, index));
-            }
-
-            // 当前匹配项
-            result.push(
-              <span key={index} className={cs(highlightClassName)}>
-                {match}
-              </span>
-            );
-            indexOfNextRegTest = index + match.length;
-          });
-
-          // 最后将剩余未被匹配的字符串拼接到最后
-          result.push(children.slice(indexOfNextRegTest));
-
-          return result;
-        })(),
-      });
+      return cloneElement(
+        node,
+        undefined,
+        <HighlightText
+          text={node.props.children}
+          keyword={pattern}
+          highlightClassName={highlightClassName}
+        />
+      );
     }
 
     return node;
@@ -55,3 +33,34 @@ export default function getHighlightText<T>({
 
   return isArray(nodeList) ? nodeList.map((node) => transformNode(node)) : transformNode(nodeList);
 }
+
+function HighlightText({
+  text,
+  keyword,
+  highlightClassName,
+}: {
+  text: string;
+  keyword?: string;
+  highlightClassName?: string;
+}) {
+  if (!keyword) return <>{text}</>;
+
+  // 注意这里的括号，这里使用了带capture group功能的正则，来split字符串
+  // 从而在strArr中可以保留匹配文本
+  const re = new RegExp(`(${escapeRegExp(keyword)})`, 'i');
+  const strArr = text.split(re);
+
+  return (
+    <>
+      {strArr.map((item, index) =>
+        re.test(item) ? (
+          <span key={index} className={highlightClassName}>
+            {item}
+          </span>
+        ) : (
+          <span key={index}>{item}</span>
+        )
+      )}
+    </>
+  );
+}
diff --git a/components/locale/ja-JP.tsx b/components/locale/ja-JP.tsx
index 2ef965b2..5fa6dbad 100644
--- a/components/locale/ja-JP.tsx
+++ b/components/locale/ja-JP.tsx
@@ -163,4 +163,52 @@ export default {
     zoomOut: 'ズームアウトする',
     originalSize: 'オリジナルサイズ',
   },
+  Form: {
+    validateMessages: {
+      required: '#{field} が必要です',
+      type: {
+        string: 'は有効なテキストタイプではありません',
+        number: 'は有効な数値タイプではありません',
+        boolean: 'は有効なブール型ではありません',
+        array: 'は有効な配列型ではありません',
+        object: 'は有効なオブジェクトタイプではありません',
+        url: 'は有効なURLアドレスではありません',
+        email: 'は有効なメールアドレスではありません',
+        ip: 'は有効なIPアドレスではありません',
+      },
+      number: {
+        min: '`#{value}` は最小値 `#{min}` 未満です',
+        max: '`#{value}` は最大値 `#{max}` より大きい',
+        equal: '`#{value}` は `#{equal}` と等しくありません',
+        range: '`#{value}` は範囲 `#{min} ~ #{max}` にありません',
+        positive: '`#{value}` は正の数ではありません',
+        negative: '`#{value}` は負の数ではありません',
+      },
+      array: {
+        length: '`#{value}` は #{length} と等しくありません',
+        minLength: '`#{value}` は少なくとも #{minLength}',
+        maxLength: '`#{value}`は最大 #{maxLength}',
+        includes: '#{value} には #{includes} は含まれません',
+        deepEqual: '#{value} は #{deepEqual} と等しくありません',
+        empty: '`#{value}` は空の配列ではありません',
+      },
+      string: {
+        minLength: '最小文字数は #{minLength}',
+        maxLength: '最大文字数は #{maxLength}',
+        length: '`#{value}` 文字数が #{length} と等しくありません',
+        match: '`#{value}` はパターン #{pattern} と一致しません',
+        uppercase: '`#{value}` はすべて大文字ではありません',
+        lowercase: '`#{value}` はすべて小文字ではありません',
+      },
+      object: {
+        deepEqual: '`#{value}`は#{deepEqual}と等しくありません',
+        hasKeys: '`#{value}` にはフィールド #{keys} が含まれていません',
+        empty: '`#{value}`はオブジェクトではありません',
+      },
+      boolean: {
+        true: '`true` であると期待します',
+        false: '`false` であると期待します',
+      },
+    },
+  },
 };
diff --git a/components/locale/vi-VN.tsx b/components/locale/vi-VN.tsx
new file mode 100644
index 00000000..2155ebe2
--- /dev/null
+++ b/components/locale/vi-VN.tsx
@@ -0,0 +1,168 @@
+import 'dayjs/locale/vi';
+// 越南语
+const Calendar = {
+  formatYear: 'YYYY',
+  formatMonth: 'MMMM YYYY',
+  today: 'Hôm nay',
+  view: {
+    month: 'Tháng',
+    year: 'Năm',
+    week: 'Tuần',
+    day: 'Ngày',
+  },
+  month: {
+    long: {
+      January: 'Tháng Một (Giêng)',
+      February: 'Tháng Hai',
+      March: 'Tháng Ba',
+      April: 'Tháng Tư',
+      May: 'Tháng Năm',
+      June: 'Tháng Sáu',
+      July: 'Tháng Bảy',
+      August: 'Tháng Tám',
+      September: 'Tháng Chín',
+      October: 'Tháng Mười',
+      November: 'Tháng Mười Một',
+      December: 'Tháng Mười Hai',
+    },
+    short: {
+      January: 'Thg1',
+      February: 'Thg2',
+      March: 'Thg3',
+      April: 'Thg4',
+      May: 'Thg5',
+      June: 'Thg6',
+      July: 'Thg7',
+      August: 'Thg8',
+      September: 'Thg9',
+      October: 'Thg10',
+      November: 'Thg11',
+      December: 'Thg12',
+    },
+  },
+  week: {
+    long: {
+      self: 'Tuần',
+      monday: 'Thứ Hai',
+      tuesday: 'Thứ Ba',
+      wednesday: 'Thứ Tư',
+      thursday: 'Thứ Năm',
+      friday: 'Thứ Sáu',
+      saturday: 'Thứ Bảy',
+      sunday: 'Chủ Nhật',
+    },
+    short: {
+      self: 'Tuần',
+      monday: 'T2',
+      tuesday: 'T3',
+      wednesday: 'Tư',
+      thursday: 'Năm',
+      friday: 'Sáu',
+      saturday: 'Bảy',
+      sunday: 'CN',
+    },
+  },
+};
+
+export default {
+  locale: 'vi-VN',
+  dayjsLocale: 'vi',
+  Calendar,
+  DatePicker: {
+    Calendar,
+    placeholder: {
+      date: 'Vui lòng chọn một ngày',
+      week: 'Vui lòng chọn một tuần',
+      month: 'Vui lòng chọn một tháng',
+      year: 'Vui lòng chọn một năm',
+      quarter: 'Vui lòng chọn một phần tư',
+    },
+    placeholders: {
+      date: ['ngày bắt đầu', 'ngày kết thúc'],
+      week: ['đầu tuần', 'cuối tuần'],
+      month: ['đầu tháng', 'cuối tháng'],
+      year: ['năm bắt đầu', 'năm kết thúc'],
+      quarter: ['quý đầu', 'quý kết thúc'],
+    },
+    selectTime: 'Vui lòng chọn thời gian',
+    selectDate: 'Vui lòng chọn một ngày',
+    today: 'Hôm nay',
+    now: 'Hiện nay',
+    ok: 'Đảm bảo',
+  },
+  Drawer: {
+    okText: 'Đảm bảo',
+    cancelText: 'Hủy bỏ',
+  },
+  Empty: {
+    noData: 'Không có dữ liệu',
+  },
+  Modal: {
+    okText: 'Đảm bảo',
+    cancelText: 'Hủy bỏ',
+  },
+  Pagination: {
+    goto: 'Di đến',
+    page: 'Trang',
+    countPerPage: '/Trang',
+    total: '{0} trong tổng số',
+    prev: 'Trang trước',
+    next: 'Trang tiếp theo',
+    currentPage: 'Trang {0}',
+    prevSomePages: 'Chuyển tiếp {0} trang',
+    nextSomePages: 'Quay lại {0} trang',
+    pageSize: 'Số trang',
+  },
+  Popconfirm: {
+    okText: 'Đảm bảo',
+    cancelText: 'Hủy bỏ',
+  },
+  Table: {
+    okText: 'Đảm bảo',
+    resetText: 'Cài lại',
+    sortAscend: 'Nhấp để sắp xếp theo thứ tự tăng dần',
+    sortDescend: 'Bấm để sắp xếp theo thứ tự giảm dần',
+    cancelSort: 'Bấm để bỏ sắp xếp',
+  },
+  TimePicker: {
+    ok: 'Đảm bảo',
+    placeholder: 'Hãy chọn một thời điểm',
+    placeholders: ['Thời gian bắt đầu', 'Thời gian kết thúc'],
+    now: 'Hiện nay',
+  },
+  Progress: {
+    success: 'Sự thành công',
+    error: 'Thất bại',
+  },
+  Upload: {
+    start: 'Khởi đầu',
+    cancel: 'Hủy bỏ',
+    delete: 'Xóa bỏ',
+    reupload: 'Nhấp để thử lại',
+
+    upload: 'Nhấp để tải lên',
+    preview: 'Xem trước',
+
+    drag: 'Nhấp hoặc kéo tệp để tải lên đây',
+    dragHover: 'Giải phóng tệp và bắt đầu tải lên',
+    error: 'Tải lên không thành công',
+  },
+  Typography: {
+    copy: 'Sao chép',
+    copied: 'Đã sao chép',
+    edit: 'Biên tập',
+    fold: 'Nếp gấp',
+    unfold: 'Mở rộng',
+  },
+  Transfer: {
+    resetText: 'Cài lại',
+  },
+  ImagePreview: {
+    fullScreen: 'Toàn màn hình',
+    rotateRight: 'Xoay phải',
+    rotateLeft: 'Xoay sang trái',
+    zoomIn: 'Phóng to',
+    zoomOut: 'Thu nhỏ',
+    originalSize: 'Kích thước ban đầu',
+  },
+};
diff --git a/components/locale/zh-HK.tsx b/components/locale/zh-HK.tsx
index 57400172..f6e95705 100644
--- a/components/locale/zh-HK.tsx
+++ b/components/locale/zh-HK.tsx
@@ -163,4 +163,52 @@ export default {
     zoomOut: '縮小',
     originalSize: '原始尺寸',
   },
+  Form: {
+    validateMessages: {
+      required: '#{field} 是必填項',
+      type: {
+        string: '#{value} 不是合法的文本類型',
+        number: '#{value} 不是合法的數字類型',
+        boolean: '#{value} 不是合法的布爾類型',
+        array: '#{value} 不是合法的數組類型',
+        object: '#{value} 不是合法的對像類型',
+        url: '#{value} 不是合法的 url 地址',
+        email: '#{value} 不是合法的郵箱地址',
+        ip: '#{value} 不是合法的 IP 地址',
+      },
+      number: {
+        min: '`#{value}` 小於最小值 `#{min}`',
+        max: '`#{value}` 大於最大值 `#{max}`',
+        equal: '`#{value}` 不等於 `#{equal}`',
+        range: '`#{value}` 不在 `#{min} ~ #{max}` 範圍內',
+        positive: '`#{value}` 不是正數',
+        negative: '`#{value}` 不是負數',
+      },
+      array: {
+        length: '`#{value}` 個數不等於 #{length}',
+        minLength: '`#{value}` 個數最少為 #{minLength}',
+        maxLength: '`#{value}` 個數最多為 #{maxLength}',
+        includes: '#{value} 不包含 #{includes}',
+        deepEqual: '#{value} 不等於 #{deepEqual}',
+        empty: '`#{value}` 不是空數組',
+      },
+      string: {
+        minLength: '字符數最少為 #{minLength}',
+        maxLength: '字符數最多為 #{maxLength}',
+        length: '`#{value}` 字符數不等於 #{length}',
+        match: '`#{value}` 不符合模式 #{pattern}',
+        uppercase: '`#{value}` 不是全大寫',
+        lowercase: '`#{value}` 不是全小寫',
+      },
+      object: {
+        deepEqual: '`#{value}` 不等於 #{deepEqual}',
+        hasKeys: '`#{value}` 不包含字段 #{keys}',
+        empty: '`#{value}` 不是對象',
+      },
+      boolean: {
+        true: '期望是 `true`',
+        false: '期望是 `false`',
+      },
+    },
+  },
 };
diff --git a/components/locale/zh-TW.tsx b/components/locale/zh-TW.tsx
index da8a7d0d..1f3cc8e5 100644
--- a/components/locale/zh-TW.tsx
+++ b/components/locale/zh-TW.tsx
@@ -163,4 +163,52 @@ export default {
     zoomOut: '縮小',
     originalSize: '原始尺寸',
   },
+  Form: {
+    validateMessages: {
+      required: '#{field} 是必填項',
+      type: {
+        string: '#{value} 不是合法的文本類型',
+        number: '#{value} 不是合法的數字類型',
+        boolean: '#{value} 不是合法的布爾類型',
+        array: '#{value} 不是合法的數組類型',
+        object: '#{value} 不是合法的對像類型',
+        url: '#{value} 不是合法的 url 地址',
+        email: '#{value} 不是合法的郵箱地址',
+        ip: '#{value} 不是合法的 IP 地址',
+      },
+      number: {
+        min: '`#{value}` 小於最小值 `#{min}`',
+        max: '`#{value}` 大於最大值 `#{max}`',
+        equal: '`#{value}` 不等於 `#{equal}`',
+        range: '`#{value}` 不在 `#{min} ~ #{max}` 範圍內',
+        positive: '`#{value}` 不是正數',
+        negative: '`#{value}` 不是負數',
+      },
+      array: {
+        length: '`#{value}` 個數不等於 #{length}',
+        minLength: '`#{value}` 個數最少為 #{minLength}',
+        maxLength: '`#{value}` 個數最多為 #{maxLength}',
+        includes: '#{value} 不包含 #{includes}',
+        deepEqual: '#{value} 不等於 #{deepEqual}',
+        empty: '`#{value}` 不是空數組',
+      },
+      string: {
+        minLength: '字符數最少為 #{minLength}',
+        maxLength: '字符數最多為 #{maxLength}',
+        length: '`#{value}` 字符數不等於 #{length}',
+        match: '`#{value}` 不符合模式 #{pattern}',
+        uppercase: '`#{value}` 不是全大寫',
+        lowercase: '`#{value}` 不是全小寫',
+      },
+      object: {
+        deepEqual: '`#{value}` 不等於 #{deepEqual}',
+        hasKeys: '`#{value}` 不包含字段 #{keys}',
+        empty: '`#{value}` 不是對象',
+      },
+      boolean: {
+        true: '期望是 `true`',
+        false: '期望是 `false`',
+      },
+    },
+  },
 };
diff --git a/site/docs/i18n.en-US.md b/site/docs/i18n.en-US.md
index bdd10471..a86da3e4 100644
--- a/site/docs/i18n.en-US.md
+++ b/site/docs/i18n.en-US.md
@@ -25,15 +25,20 @@ ReactDOM.render(
 
 ## Language
 
-|Language|Language Code|
-|---|---|
-|Simplified Chinese|zh-CN|
-|English|en-US|
-|Japanese|ja-JP|
-|Korean|ko-KR|
-|Indonesian|id-ID|
-|Thai|th-TH|
-|Hong Kong, China|zh-HK|
-|Taiwan, China|zh-TW|
+|Language|Language Code|Version|
+|---|---|---|
+|Simplified Chinese|zh-CN|-|
+|English|en-US|-|
+|Japanese|ja-JP|-|
+|Korean|ko-KR|-|
+|Indonesian|id-ID|-|
+|Thai|th-TH|-|
+|Hong Kong, China|zh-HK|-|
+|Taiwan, China|zh-TW|-|
+|French, France|fr-FR|2.28.0|
+|German, Germany|de-DE|2.28.0|
+|Italian, Italy|it-IT|2.28.0|
+|Spanish|es-ES|2.28.0|
+|Vietnamese|vi-VN|2.33.0|
 
 If you have a new language requirement, please raise an issue~
diff --git a/site/docs/i18n.zh-CN.md b/site/docs/i18n.zh-CN.md
index f5e6b0f2..fe2fcdd1 100644
--- a/site/docs/i18n.zh-CN.md
+++ b/site/docs/i18n.zh-CN.md
@@ -25,15 +25,21 @@ ReactDOM.render(
 
 ## 支持的语言
 
-|语言|语言编码|
-|---|---|
-|简体中文|zh-CN|
-|英语|en-US|
-|日语|ja-JP|
-|韩语|ko-KR|
-|印度尼西亚语|id-ID|
-|泰语|th-TH|
-|繁体中文 (中国香港)|zh-HK|
-|繁体中文 (中国台湾)|zh-TW|
+|语言|语言编码|版本|
+|---|---|---|
+|简体中文|zh-CN|-|
+|英语|en-US|-|
+|日语|ja-JP|-|
+|韩语|ko-KR|-|
+|印度尼西亚语|id-ID|-|
+|泰语|th-TH|-|
+|繁体中文 (中国香港)|zh-HK|-|
+|繁体中文 (中国台湾)|zh-TW|-|
+|法语（法国）|fr-FR|2.28.0|
+|德语（德国）|de-DE|2.28.0|
+|意大利语(意大利)|it-IT|2.28.0|
+|西班牙语|es-ES|2.28.0|
+|越南语|vi-VN|2.33.0|
+
 
 如果有新的语言需求，请提 issue～


Test patch:
diff --git a/components/ConfigProvider/__test__/__snapshots__/demo.test.ts.snap b/components/ConfigProvider/__test__/__snapshots__/demo.test.ts.snap
index ec22113a..0254d4d4 100644
--- a/components/ConfigProvider/__test__/__snapshots__/demo.test.ts.snap
+++ b/components/ConfigProvider/__test__/__snapshots__/demo.test.ts.snap
@@ -161,6 +161,20 @@ Array [
         it-IT
       </span>
     </label>
+    <label
+      class="arco-radio-button"
+    >
+      <input
+        name="locale"
+        type="radio"
+        value="vi-VN"
+      />
+      <span
+        class="arco-radio-button-inner"
+      >
+        vi-VN
+      </span>
+    </label>
   </div>,
   <br />,
   <div
diff --git a/components/Menu/__test__/index.test.tsx b/components/Menu/__test__/index.test.tsx
index 4eb38ff3..7a331666 100644
--- a/components/Menu/__test__/index.test.tsx
+++ b/components/Menu/__test__/index.test.tsx
@@ -1,4 +1,4 @@
-import React, { useRef } from 'react';
+import React, { useRef, useState } from 'react';
 import { mount } from 'enzyme';
 import { act } from 'react-dom/test-utils';
 import mountTest from '../../../tests/mountTest';
@@ -195,17 +195,38 @@ describe('Menu', () => {
   });
 
   it('collapse', () => {
-    const component = mount(
-      <Menu mode="vertical" collapse defaultSelectedKeys={['3']}>
-        <MenuItem key="1">设计指南</MenuItem>
-        <SubMenu key="layout" title={<span>布局组件</span>}>
-          <MenuItem key="11">栅格</MenuItem>
-          <MenuItem key="12">分隔符</MenuItem>
-          <MenuItem key="13">布局</MenuItem>
-        </SubMenu>
-      </Menu>
-    );
+    const Demo = () => {
+      const [collapse, setCollapse] = useState(false);
+      return (
+        <div>
+          <button id="collapse" onClick={() => setCollapse(!collapse)}>
+            Collapse
+          </button>
+          <Menu mode="vertical" collapse={collapse} defaultOpenKeys={['layout']}>
+            <MenuItem key="1">设计指南</MenuItem>
+            <SubMenu key="layout" title={<span>布局组件</span>}>
+              <MenuItem key="11">栅格</MenuItem>
+              <MenuItem key="12">分隔符</MenuItem>
+              <MenuItem key="13">布局</MenuItem>
+            </SubMenu>
+          </Menu>
+        </div>
+      );
+    };
+
+    const component = mount(<Demo />);
+
+    expect(
+      component.find('.arco-menu-inline-content').at(0).getDOMNode().getAttribute('style')
+    ).toBe('height: auto;');
+
+    component.find('#collapse').simulate('click');
     expect(component.find(Menu.Item).at(0).find(Tooltip).exists()).toBe(true);
+
+    component.find('#collapse').simulate('click');
+    expect(
+      component.find('.arco-menu-inline-content').at(0).getDOMNode().getAttribute('style')
+    ).toBe('height: auto;');
   });
 
   it('overflowItems should be pack up', () => {