Parcourir la source

添加 添加 FloatPanel 组件 组件

cc12458 il y a 1 mois
Parent
commit
c359d78c3d

+ 302 - 0
src/composables/FloatPanel/FloatPanel.vue

@@ -0,0 +1,302 @@
+<script setup lang="ts">
+/**
+ * 浮动面板展示层:组合 Vant Overlay + FloatingPanel,关闭动画与 CSS 变量。
+ * 锚点合并 / autoHeight 逻辑见 `floatPanelAnchors.ts`、`useFloatPanelAnchorsAutoHeight.ts`。
+ */
+import { nextTick, ref } from 'vue';
+import { tryOnMounted, useElementSize } from '@vueuse/core';
+import { computeAnchorsAndSnapHeight } from './floatPanelAnchors';
+import type { FloatPanelInstance, FloatPanelProps } from './types';
+import { useFloatPanelAnchorsAutoHeight } from './useFloatPanelAnchorsAutoHeight';
+
+// --- props(v-model:panel-height / panel-anchor)---
+const {
+  closeOnClickOverlay,
+  overlay = true,
+  overlayClass,
+  overlayStyle,
+
+  containerClass,
+  containerStyle,
+
+  title,
+  maxContainerHeight = innerHeight,
+  full = false,
+  closable = false,
+
+  contentDraggable = true,
+
+  autoHeight = false,
+
+  duration = 0.3,
+} = defineProps<FloatPanelProps>();
+
+// --- 动画:秒 -> 数值;与 Vant `duration`、关闭收尾定时器共用 ---
+const durationSec = computed(() => {
+  const n = Number(duration);
+  return Number.isFinite(n) && n >= 0 ? n : 0.3;
+});
+
+/** 关闭动画期间保持遮罩,避免 height 已为 0 时立刻隐藏打断过渡 */
+const isClosing = ref(false);
+const closeTimer = ref<ReturnType<typeof setTimeout> | null>(null);
+
+function clearCloseTimer() {
+  if (closeTimer.value != null) {
+    clearTimeout(closeTimer.value);
+    closeTimer.value = null;
+  }
+}
+
+onBeforeUnmount(() => {
+  clearCloseTimer();
+  isClosing.value = false;
+});
+
+// --- 样式 class ---
+const overlayClassList = computed(() => {
+  const value = Array.isArray(overlay) ? overlayClass : overlayClass ? [overlayClass] : [];
+  return ['float-panel-overlay', ...value, { hide: !overlay }];
+});
+const containerClassList = computed(() => {
+  const value = Array.isArray(containerClass) ? containerClass : containerClass ? [containerClass] : [];
+  return ['float-panel-container', ...value, { 'float-panel--auto-height': autoHeight }];
+});
+
+const height = defineModel<number>('panel-height', { default: 20 });
+const anchors = defineModel<number[]>('panel-anchor', { default: [] });
+
+const emit = defineEmits<{
+  /** 关闭动画与遮罩收尾结束(`duration` 为 0 时为同步收尾后) */
+  closed: [];
+}>();
+
+/** 立刻把 v-model height 置 0(Vant 做过渡);遮罩用 isClosing 多留一程;结束后 `emit('closed')` */
+function applyCloseAnimation() {
+  clearCloseTimer();
+  isClosing.value = true;
+  height.value = 0;
+  const ms = durationSec.value * 1000;
+  if (ms <= 0) {
+    isClosing.value = false;
+    emit('closed');
+    return;
+  }
+  closeTimer.value = setTimeout(() => {
+    closeTimer.value = null;
+    isClosing.value = false;
+    emit('closed');
+  }, ms);
+}
+
+// --- 布局测量:头高参与 setHeight;内容外包层高度参与 autoHeight ---
+const panelHeaderRef = useTemplateRef('panel-header');
+const { height: panelHeaderHeight } = useElementSize(panelHeaderRef, { height: 30 }, { box: 'border-box' });
+
+const panelContentRootRef = useTemplateRef('panel-content-root');
+const { height: measuredContentHeight } = useElementSize(panelContentRootRef, { height: 0 }, { box: 'border-box' });
+
+const panelContentHeight = ref(0);
+
+const style = computed(() => {
+  return {
+    '--van-floating-panel-header-height': `${panelHeaderHeight.value}px`,
+    '--van-floating-panel-content-height': `${panelContentHeight.value}px`,
+  };
+});
+
+// --- 对外 API(expose / useFloatPanel ref)---
+const instance: FloatPanelInstance = {
+  getAnchors() {
+    return toValue(anchors);
+  },
+  setAnchors(values: number | number[], reset = true) {
+    const result = computeAnchorsAndSnapHeight(
+      values,
+      reset,
+      height.value,
+      maxContainerHeight,
+      anchors.value,
+    );
+    if (!result) return;
+    anchors.value = result.anchors;
+    height.value = result.height;
+  },
+  setHeight(value: number, updateAnchor = true) {
+    const total = value + panelHeaderHeight.value;
+    if (updateAnchor) {
+      instance.setAnchors(total, false);
+      height.value = total;
+      return;
+    }
+    height.value = total;
+  },
+  closeAnimated: applyCloseAnimation,
+
+  snapMin() {
+    const target = toValue(anchors).find((a) => a > 0);
+    if (target === undefined) return false;
+    if (height.value === target) return false;
+    instance.setAnchors(target, false);
+    height.value = target;
+    return true;
+  },
+  snapMax() {
+    const list = toValue(anchors);
+    const target = list[list.length - 1];
+    if (target === undefined) return false;
+    if (height.value === target) return false;
+    instance.setAnchors(target, false);
+    height.value = target;
+    return true;
+  },
+  snapFull() {
+    const target = maxContainerHeight;
+    if (height.value === target) return false;
+    instance.setAnchors(target, false);
+    height.value = target;
+    return true;
+  },
+  snapToFirstAnchor() {
+    return instance.snapMin();
+  },
+  snapToLastAnchor() {
+    return instance.snapMax();
+  },
+  snapToMaxContainerHeight() {
+    return instance.snapFull();
+  },
+};
+
+const { resetAutoHeightSyncOnReopen } = useFloatPanelAnchorsAutoHeight({
+  height,
+  measuredContentHeight,
+  panelHeaderHeight,
+  closable: () => closable,
+  full: () => full,
+  maxContainerHeight: () => maxContainerHeight,
+  autoHeight: () => autoHeight,
+  setAnchors: (v, r) => instance.setAnchors(v, r),
+  setHeight: (v, u) => instance.setHeight(v, u),
+});
+
+const onClose = () => applyCloseAnimation();
+const onClickOverlay = () => {
+  if (closeOnClickOverlay) onClose();
+};
+const onUpdateHeight = ({ height: h }) => {
+  panelContentHeight.value = h - panelHeaderHeight.value;
+};
+
+watch(height, (h, prev) => {
+  if (h !== 0) {
+    clearCloseTimer();
+    isClosing.value = false;
+  } else if (prev !== undefined && prev !== 0 && !isClosing.value) {
+    // 非 closeAnimated:等 v-model 同步到 Wrapper 后再通知(便于 `onUpdate:panelHeight` 先置 pending)
+    nextTick(() => emit('closed'));
+  }
+  resetAutoHeightSyncOnReopen(prev, h);
+});
+
+tryOnMounted(() => onUpdateHeight({ height: height.value }));
+defineExpose(instance);
+</script>
+
+<template>
+  <!-- isClosing:height 已为 0 时仍短暂展示遮罩,与 Vant 收起动画同长 -->
+  <van-overlay
+    :class="overlayClassList"
+    :style="overlayStyle"
+    :show="height !== 0 || isClosing"
+    :duration="durationSec"
+    @click="onClickOverlay"
+  >
+    <van-floating-panel
+      :class="containerClassList"
+      :style="[containerStyle, style]"
+      v-model:height="height"
+      :duration="durationSec"
+      :anchors="anchors"
+      :content-draggable="contentDraggable"
+      @click.stop
+      @height-change="onUpdateHeight"
+    >
+      <template #header>
+        <div class="van-floating-panel__header">
+          <div ref="panel-header">
+            <van-icon v-if="closable" class="van-floating-panel__header_icon" name="cross" size="18" @click.stop="onClose()" />
+            <div class="van-floating-panel__header-bar"></div>
+            <slot name="header" :title="title">
+              <div>{{ title }}</div>
+            </slot>
+          </div>
+        </div>
+      </template>
+      <template #default>
+        <div ref="panel-content-root" class="float-panel-content-root">
+          <slot name="content"></slot>
+        </div>
+      </template>
+    </van-floating-panel>
+  </van-overlay>
+</template>
+
+<style scoped lang="scss">
+.float-panel-overlay {
+  --van-overlay-z-index: var(--van-floating-panel-z-index);
+  &.hide {
+    --van-overlay-background: transparent;
+  }
+}
+.float-panel-container {
+  $gap: calc((30px - var(--van-floating-panel-bar-height)) / 2);
+
+  :deep(.van-floating-panel__header) {
+    padding: 0;
+    position: relative;
+    > div {
+      padding-top: $gap;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      width: 100%;
+      min-height: 30px;
+    }
+    .van-floating-panel__header-bar {
+      & + * {
+        padding: calc($gap / 2) 0;
+      }
+    }
+    .van-floating-panel__header_icon {
+      position: absolute;
+      top: $gap;
+      right: $gap;
+      color: var(--van-floating-panel-bar-color);
+    }
+  }
+  :deep(.van-floating-panel__content) {
+    height: var(--van-floating-panel-content-height);
+    overflow: hidden !important;
+    flex: none;
+    > * {
+      height: 100%;
+      overflow-y: auto;
+    }
+  }
+
+  &.float-panel--auto-height {
+    :deep(.van-floating-panel__content) {
+      height: auto !important;
+      overflow: visible !important;
+      flex: 1 0 auto;
+      > .float-panel-content-root {
+        height: auto !important;
+        min-height: 0;
+        overflow: visible !important;
+        box-sizing: border-box;
+      }
+    }
+  }
+}
+</style>

+ 49 - 0
src/composables/FloatPanel/floatPanelAnchors.ts

@@ -0,0 +1,49 @@
+/**
+ * 浮动面板锚点:合并集合并按规则吸附到合法 height(无 Vue 依赖,便于测试与复用)。
+ */
+
+export type AnchorsSnapResult = { anchors: number[]; height: number };
+
+/**
+ * 根据新锚点候选合并列表,并计算吸附后的 `height`。
+ * @param values - 本次写入的锚点(或单个)
+ * @param reset - 为 `true` 时以 `values` 为基底;为 `false` 时与 `currentAnchors` 合并
+ * @param prevHeight - 吸附前的面板总高度
+ * @param maxContainerHeight - 允许的最大总高度(px)
+ * @param currentAnchors - 当前 v-model 锚点(`reset === false` 时参与合并)
+ */
+export function computeAnchorsAndSnapHeight(
+  values: number | number[],
+  reset: boolean,
+  prevHeight: number,
+  maxContainerHeight: number,
+  currentAnchors: readonly number[],
+): AnchorsSnapResult | null {
+  const list = !Array.isArray(values) ? [values] : values;
+  const set = new Set(list);
+  if (!reset) currentAnchors.forEach((a) => set.add(a));
+
+  const anchors = [...set].filter((v) => v <= maxContainerHeight).sort((a, b) => a - b);
+  if (!anchors.length) return null;
+
+  const hit = anchors.findIndex((v) => v === prevHeight);
+  if (hit >= 0) {
+    return { anchors, height: anchors[hit]! };
+  }
+
+  let nearest = anchors[0]!;
+  let minDist = Math.abs(prevHeight - nearest);
+  for (const a of anchors) {
+    const d = Math.abs(prevHeight - a);
+    if (d < minDist) {
+      minDist = d;
+      nearest = a;
+    }
+  }
+  const positives = anchors.filter((a) => a > 0);
+  if (nearest === 0 && prevHeight > 0 && positives.length) {
+    const height = positives.reduce((m, a) => (Math.abs(a - prevHeight) < Math.abs(m - prevHeight) ? a : m));
+    return { anchors, height };
+  }
+  return { anchors, height: nearest };
+}

+ 7 - 0
src/composables/FloatPanel/index.ts

@@ -0,0 +1,7 @@
+/**
+ * 底部浮动面板:封装 Vant `FloatingPanel` + `Overlay`,并提供 `useFloatPanel` 命令式封装。
+ */
+export { default as FloatPanel } from './FloatPanel.vue';
+export { injectFloatPanelContext, useFloatPanel } from './useFloatPanel';
+
+export * from './types';

+ 141 - 0
src/composables/FloatPanel/types.ts

@@ -0,0 +1,141 @@
+import type { InjectionKey, Ref } from 'vue';
+
+/**
+ * 底部浮动面板(基于 Vant `FloatingPanel`)的 props。
+ * 对外 v-model:`panel-height`、`panel-anchor`。
+ *
+ * 事件:`closed` — 关闭动画与遮罩收尾结束(见组件 `defineEmits`)。
+ */
+export interface FloatPanelProps {
+  /** 是否显示遮罩层;为 `false` 时仍占位但背景可透明(配合 `overlayClass`) */
+  overlay?: boolean;
+  /** 遮罩层额外 class */
+  overlayClass?: Array<string | object> | string | object;
+  /** 遮罩层内联样式 */
+  overlayStyle?: CSSStyleValue;
+  /** 点击遮罩是否触发关闭(将 height 置 0) */
+  closeOnClickOverlay?: boolean;
+
+  /** 面板容器额外 class */
+  containerClass?: Array<string | object> | string | object;
+  /** 面板容器内联样式 */
+  containerStyle?: CSSStyleValue;
+
+  /** 标题文案(默认 header 插槽) */
+  title?: string;
+  /**
+   * 为 `true` 时在锚点中加入 `maxContainerHeight`,可拖到全屏高度。
+   */
+  full?: boolean;
+  /**
+   * 为 `true` 时在锚点中加入 `0`,展示关闭图标,并允许收起到高度 0。
+   */
+  closable?: boolean;
+  /** 是否允许拖拽内容区改变高度(仅头部可拖时为 `false`) */
+  contentDraggable?: boolean;
+  /**
+   * 为 `true` 时用 `useElementSize` 测量 `#content` 外包层高度,并同步面板 `height`。
+   * 仅在测量值变化时调整,避免与用户拖拽冲突。
+   */
+  autoHeight?: boolean;
+  /**
+   * 动画时长(秒)。`0` 表示无过渡。
+   * 用于 Vant Overlay / FloatingPanel,并与关闭时 `isClosing` 时长一致。
+   */
+  duration?: number | string;
+  /** 面板允许的最大高度(px),锚点与 autoHeight 裁剪均受此限制 */
+  maxContainerHeight?: number;
+}
+
+/**
+ * 通过 `defineExpose` 暴露给父组件或 `ref` 的实例能力。
+ * `setHeight` 的入参为**内容区**高度(不含头部);默认将总高合并进锚点,见 `setHeight` 的 `updateAnchor`。
+ */
+export interface FloatPanelInstance {
+  /** 当前锚点数组(只读快照) */
+  getAnchors(): number[];
+
+  /**
+   * 更新锚点集合。
+   * @param value - 一个或多个锚点(px)
+   * @param reset - `true` 时用 `value` 替换;`false` 时与现有锚点合并
+   */
+  setAnchors(value: number | number[], reset?: boolean): void;
+
+  /**
+   * 按**内容区**高度(不含头)换算为总高度后更新 `panel-height`。
+   * @param value - 内容区高度(px)
+   * @param updateAnchor - 默认 `true`:将总高合并进锚点(`setAnchors(total, false)`)再设 `height`;
+   *   `false` 时仅写入 `height`,不修改锚点列表(可能影响磁力吸附,按需使用)
+   */
+  setHeight(value: number, updateAnchor?: boolean): void;
+
+  /**
+   * 触发关闭动画:立即将 `panel-height` 置 `0`,由 Vant 在 `duration` 内过渡;
+   * 同时维持遮罩至动画结束,避免提前卸载打断过渡。
+   */
+  closeAnimated(): void;
+
+  /**
+   * 将总高设为当前**已排序锚点列表里第一个大于 `0` 的项**(跳过关闭态 `0`)。
+   * @returns 若无任何 `> 0` 的锚点、或已在目标高度,则为 `false`
+   */
+  snapMin(): boolean;
+
+  /**
+   * 将总高设为当前**已排序锚点列表的最后一个**(通常最大)。
+   * @returns 若 `height` 发生变化则为 `true`;无锚点或已在目标高度则为 `false`
+   */
+  snapMax(): boolean;
+
+  /**
+   * 将总高设为 **`maxContainerHeight` 上限**(会合并进锚点,与 `full` 锚点边界一致)。
+   * @returns 若 `height` 发生变化则为 `true`;已在该高度则为 `false`
+   */
+  snapFull(): boolean;
+
+  /** 同 {@link FloatPanelInstance.snapMin}(第一个 `> 0` 的锚点) */
+  snapToFirstAnchor(): boolean;
+
+  /** 同 {@link FloatPanelInstance.snapMax} */
+  snapToLastAnchor(): boolean;
+
+  /** 同 {@link FloatPanelInstance.snapFull} */
+  snapToMaxContainerHeight(): boolean;
+}
+
+/**
+ * `useFloatPanel` 注入到内容子树的 key,值为指向 `FloatPanelInstance` 的 ref(面板挂载后才有值)。
+ */
+export const floatPanelContextKey: InjectionKey<Ref<FloatPanelInstance | undefined>> =
+  Symbol('floatPanelContext');
+
+/**
+ * `useFloatPanel` 返回的操作对象:在 `open` 返回的 Promise 上接收 `complete` / `cancel` 或 `close(result)` 的结果。
+ */
+export interface FloatPanelApi<P extends Record<string, any>, R = any> extends FloatPanelInstance {
+  /**
+   * 打开面板并传入内容区 props;返回的 Promise 在关闭时 resolve。
+   * @param props - 传给 `Content` 或默认插槽作用域的对象
+   */
+  open(props: P | void | null): Promise<R | void>;
+
+  /**
+   * 关闭面板并 resolve `open` 的 Promise。
+   * @param result - 可选结果,会作为 `open` Promise 的 resolve 值
+   */
+  close(result?: R): void;
+}
+
+/**
+ * 合并 `open(props)` 与面板注入的关闭回调后,传给内容组件或默认插槽的最终 props。
+ *
+ * - 内容组件可 `emit('complete', result?)` / `emit('cancel')`
+ * - 与直接使用 `onComplete` / `onCancel` 等价(由 `mergeProps` 合并)
+ */
+export type FloatPanelContentBindings<P extends Record<string, any>, R = unknown> = P & {
+  /** 确认关闭,可选传入 `open` Promise 的结果 */
+  onComplete: (result?: R) => void;
+  /** 取消关闭,`open` Promise resolve 为 `undefined` */
+  onCancel: () => void;
+};

+ 159 - 0
src/composables/FloatPanel/useFloatPanel.ts

@@ -0,0 +1,159 @@
+import type { Component, Ref } from 'vue';
+import { defineComponent, h, inject, mergeProps, provide, ref, shallowRef } from 'vue';
+import __FloatPanel_vue from './FloatPanel.vue';
+import type { FloatPanelApi, FloatPanelContentBindings, FloatPanelInstance } from './types';
+import { floatPanelContextKey } from './types';
+
+/**
+ * 在 `useFloatPanel` 提供的内容子树内调用,获取指向 `FloatPanelInstance` 的 ref。
+ * @throws 若在 provide 范围外调用
+ */
+export function injectFloatPanelContext(): Ref<FloatPanelInstance | undefined> {
+  const injected = inject(floatPanelContextKey, undefined);
+  if (injected === undefined) {
+    throw new Error('injectFloatPanelContext() 须在 useFloatPanel 的 Wrapper 内容树内使用');
+  }
+  return injected;
+}
+
+/**
+ * 声明式挂载浮动面板:返回 `[Wrapper, api]`。
+ *
+ * - **Wrapper**:放入模板根或任意位置;`open()` 后才会渲染内部 `FloatPanel`。
+ * - **api**:`open` / `close` 及与 `FloatPanelInstance` 一致的面板操作。
+ *
+ * 关闭卸载由 `FloatPanel` 在动画结束后 **`emit('closed')`** 触发,不在此重复定时逻辑。
+ *
+ * @param Content - 可选内容组件;不传则用 Wrapper 的默认插槽(作用域参数含 `onComplete` / `onCancel`)
+ */
+export function useFloatPanel<P extends Record<string, any> = Record<string, unknown>, R = P>(
+  Content?: Component,
+) {
+  let pending: PromiseWithResolvers<R | void> | undefined;
+  /** `closeAnimated` 结束时交给 `onClosed` 再 `settle` */
+  let dismissResult: R | void | undefined;
+
+  const show = ref(false);
+  /** 为 true 表示正在等待 FloatPanel `closed`,用于忽略重开后的陈旧 `closed` */
+  const pendingCloseUnmount = ref(false);
+  const panelOpenKey = ref(0);
+  const innerProps = shallowRef<P>();
+  const floatPanelRef = ref<FloatPanelInstance>();
+
+  function settle(result?: R | void) {
+    pending?.resolve(result);
+    pending = undefined;
+  }
+
+  function closePanel(result?: R) {
+    const inst = floatPanelRef.value;
+    if (inst?.closeAnimated) {
+      pendingCloseUnmount.value = true;
+      dismissResult = result;
+      inst.closeAnimated();
+    } else {
+      pendingCloseUnmount.value = false;
+      dismissResult = undefined;
+      settle(result);
+      show.value = false;
+    }
+  }
+
+  const api: FloatPanelApi<P, R> = {
+    open(props: P): Promise<R | void> {
+      pending?.resolve(void 0);
+      pending = Promise.withResolvers<R | void>();
+      dismissResult = undefined;
+      pendingCloseUnmount.value = false;
+      innerProps.value = props;
+      panelOpenKey.value += 1;
+      show.value = true;
+      return pending.promise;
+    },
+    close(result?: R) {
+      closePanel(result);
+    },
+    getAnchors() {
+      return floatPanelRef.value?.getAnchors() ?? [];
+    },
+    setAnchors(value, reset) {
+      floatPanelRef.value?.setAnchors(value, reset);
+    },
+    setHeight(value, updateAnchor) {
+      floatPanelRef.value?.setHeight(value, updateAnchor);
+    },
+    closeAnimated() {
+      floatPanelRef.value?.closeAnimated();
+    },
+    snapMin() {
+      return floatPanelRef.value?.snapMin() ?? false;
+    },
+    snapMax() {
+      return floatPanelRef.value?.snapMax() ?? false;
+    },
+    snapFull() {
+      return floatPanelRef.value?.snapFull() ?? false;
+    },
+    snapToFirstAnchor() {
+      return floatPanelRef.value?.snapToFirstAnchor() ?? false;
+    },
+    snapToLastAnchor() {
+      return floatPanelRef.value?.snapToLastAnchor() ?? false;
+    },
+    snapToMaxContainerHeight() {
+      return floatPanelRef.value?.snapToMaxContainerHeight() ?? false;
+    },
+  };
+
+  const Wrapper = defineComponent({
+    name: 'FloatPanelWrapper',
+    inheritAttrs: false,
+    setup(_, { attrs, slots }) {
+      provide(floatPanelContextKey, floatPanelRef);
+
+      return () => {
+        if (!show.value) return null;
+
+        const attrObj = attrs as Record<string, unknown>;
+        const panelBindings = mergeProps(attrObj, {
+          'onUpdate:panelHeight': (value: number) => {
+            if (value !== 0) {
+              show.value = true;
+              return;
+            }
+            if (!pendingCloseUnmount.value) {
+              pendingCloseUnmount.value = true;
+            }
+          },
+          onClosed: () => {
+            if (!pendingCloseUnmount.value) return;
+            pendingCloseUnmount.value = false;
+            const r = dismissResult;
+            dismissResult = undefined;
+            settle(r);
+            show.value = false;
+          },
+        });
+
+        return h(
+          __FloatPanel_vue,
+          { ref: floatPanelRef, key: panelOpenKey.value, closable: true, ...panelBindings },
+          {
+            header: slots.header,
+            content: () => {
+              const slotProps = innerProps.value ?? ({} as P);
+              const contentBindings = mergeProps(slotProps as Record<string, unknown>, {
+                onComplete: (result?: R) => closePanel(result),
+                onCancel: () => closePanel(),
+              });
+              if (Content) return h(Content, contentBindings);
+              return slots.default?.(contentBindings as FloatPanelContentBindings<P, R>) ?? null;
+            },
+          },
+        );
+      };
+    },
+  });
+
+  return [Wrapper, api] as const;
+}

+ 91 - 0
src/composables/FloatPanel/useFloatPanelAnchorsAutoHeight.ts

@@ -0,0 +1,91 @@
+import type { Ref } from 'vue';
+import { ref, watch } from 'vue';
+
+export interface FloatPanelAnchorsAutoHeightOptions {
+  height: Ref<number>;
+  measuredContentHeight: Ref<number>;
+  panelHeaderHeight: Ref<number>;
+  closable: () => boolean;
+  full: () => boolean;
+  maxContainerHeight: () => number;
+  autoHeight: () => boolean;
+  setAnchors: (values: number | number[], reset?: boolean) => void;
+  setHeight: (contentHeight: number, updateAnchor?: boolean) => void;
+}
+
+/**
+ * 边界锚点 + 默认锚点合并,以及 autoHeight 与测量联动。
+ * 与关闭动画、`isClosing` 无关。
+ */
+export function useFloatPanelAnchorsAutoHeight(options: FloatPanelAnchorsAutoHeightOptions) {
+  const {
+    height,
+    measuredContentHeight,
+    panelHeaderHeight,
+    closable,
+    full,
+    maxContainerHeight,
+    autoHeight,
+    setAnchors,
+    setHeight,
+  } = options;
+
+  /** 区分「首次从 0 按内容撑起」与「用户已关到 0 勿再被测量顶开」 */
+  const autoHeightSyncedOnce = ref(false);
+
+  watch(
+    [
+      () => closable(),
+      () => full(),
+      () => maxContainerHeight(),
+      measuredContentHeight,
+      panelHeaderHeight,
+      () => autoHeight(),
+    ],
+    () => {
+      const boundary = new Set<number>();
+      if (closable()) boundary.add(0);
+      if (full()) boundary.add(maxContainerHeight());
+      const defaultAnchors = [100, innerHeight * 0.6];
+      setAnchors([...new Set([...boundary, ...defaultAnchors])], false);
+
+      if (!autoHeight()) {
+        autoHeightSyncedOnce.value = false;
+        return;
+      }
+
+      const ch = measuredContentHeight.value;
+      if (ch <= 0) return;
+      const header = panelHeaderHeight.value;
+      const maxContent = Math.max(0, maxContainerHeight() - header);
+      const contentClamped = Math.min(ch, maxContent);
+      const targetPanel = contentClamped + header;
+
+      if (height.value === 0) {
+        if (!autoHeightSyncedOnce.value) {
+          setHeight(contentClamped, true);
+          autoHeightSyncedOnce.value = true;
+        }
+        return;
+      }
+
+      autoHeightSyncedOnce.value = true;
+      if (Math.abs(height.value - targetPanel) > 0.5) {
+        setHeight(contentClamped, true);
+      }
+    },
+    { flush: 'post', immediate: true },
+  );
+
+  /** 供外层对 `height` 的 watch 调用:从关闭态再次打开时允许 autoHeight 重新同步 */
+  function resetAutoHeightSyncOnReopen(prev: number | undefined, h: number) {
+    if (prev === 0 && h !== 0) {
+      autoHeightSyncedOnce.value = false;
+    }
+  }
+
+  return {
+    autoHeightSyncedOnce,
+    resetAutoHeightSyncOnReopen,
+  };
+}