Appearance
好的,我给你一个可落地的实现方案:点击 Bid/Ask 单元格时,在单元格旁边弹出交易面板;若屏幕较小(如 ≤768px),自动改为 底部 Drawer 展示。方案要点:
- 定位与避免遮挡:用
@floating-ui/dom(更稳健的 Popper 替代)做锚点定位,自动flip/shift/offset/size,保证面板不超出视口。 - 滚动/缩放自适应:用
autoUpdate绑定 window 和滚动容器,实时重算位置。 - 渲染层级与性能:面板
Teleport到body(脱离表格 DOM),避免虚拟滚动/溢出隐藏的裁切问题。 - 小屏体验:用
a-drawer底部弹出(建议 80vh),手势/返回键关闭。 - 关闭逻辑:点击空白处、ESC 关闭;支持回收监听器。
下面只给必要的修改片段(遵循你“只给改动,不贴整文件”的偏好)。复杂逻辑处,我在上一行加注释。
1) 安装依赖
bash
pnpm add @floating-ui/dom2) 列定义:在 Bid/Ask 单元格里,传出“锚点元素 + 行数据 + 点击侧别”
在
getSymbolListColumns的入参里增加onOpenTradePanel回调,并在Bid/Ask的根容器上绑定onClick。
ts
// ✅ 仅补充这段:给函数入参加一个 onOpenTradePanel
export const getSymbolListColumns = ({
t,
onToggleFav,
allSymbolMarketStatus,
// 在外层页面实现此回调,用于打开面板
onOpenTradePanel,
}: {
t: (key: string) => string
onToggleFav: (row: SymbolListRow) => void
allSymbolMarketStatus: Ref<Record<string, MarketState>>
onOpenTradePanel: (opts: {
row: SymbolListRow
anchorEl: HTMLElement
// 点击的是哪个列,决定预设买/卖侧
side: 'buy' | 'sell'
}) => void
}) => {tsx
// ✅ 仅修改 Bid 单元格:把最外层 <div> 增加 onClick,传出当前元素与侧别
// 复杂:e.currentTarget 作为锚点元素,确保定位到可见 DOM 节点
<div
class={["cell-quote font-number", flashClass]}
data-trend={bidTrend ?? "none"}
onClick={(e: MouseEvent) => {
// 复杂逻辑:从事件里取到触发元素,作为浮窗锚点
const anchor = e.currentTarget as HTMLElement;
onOpenTradePanel?.({
row: (e as any).currentTarget?.["__row"] ?? ({} as any),
anchorEl: anchor,
side: "sell",
});
}}
>
<PriceTrendCell value={bid} trend={bidTrend} />
</div>tsx
// ✅ 仅修改 Ask 单元格:同理传出 side='buy'
<div
class={["cell-quote font-number text-ink-900", flashClass]}
data-trend={askTrend ?? "none"}
onClick={(e: MouseEvent) => {
// 复杂逻辑:锚点 + 侧别(买)
const anchor = e.currentTarget as HTMLElement;
onOpenTradePanel?.({
row: (e as any).currentTarget?.["__row"] ?? ({} as any),
anchorEl: anchor,
side: "buy",
});
}}
>
<PriceTrendCell value={ask} trend={askTrend} class="text-ink-900" />
</div>注:
(e as any).currentTarget?.['__row']是一个占位写法。若你已有row作用域,可在外层作用域捕获row.original,再闭包带入;或者使用你项目里的“把 row 附着到 DOM”的现成方式。若需要,我可以按你实际表格封装补齐这一行。
3) 新增 composable:useTradePanelOverlay.ts
负责:开/关、定位、监听滚动与 Resize、小屏判断。复杂逻辑均在上一行加注释。
ts
// src/composables/useTradePanelOverlay.ts
import { ref, shallowRef, onBeforeUnmount, computed } from "vue";
import {
autoUpdate,
computePosition,
flip,
shift,
offset,
size,
} from "@floating-ui/dom";
// 简单媒体查询(也可切到更通用的 useMediaQuery)
const isSmallScreen = () => window.matchMedia("(max-width: 768px)").matches;
export function useTradePanelOverlay() {
const open = ref(false);
const usingDrawer = ref(false);
const anchorEl = shallowRef<HTMLElement | null>(null);
const floatingEl = shallowRef<HTMLElement | null>(null);
const style = ref<{
top: string;
left: string;
minWidth?: string;
maxHeight?: string;
}>({ top: "0px", left: "0px" });
let cleanup: (() => void) | null = null;
// 复杂:根据可视区域与锚点动态定位(右侧优先,不够则 flip)
const updatePosition = async () => {
if (!anchorEl.value || !floatingEl.value) return;
await computePosition(anchorEl.value, floatingEl.value, {
placement: "right-start",
middleware: [
// 复杂:与锚点留出 8px 间距
offset(8),
// 复杂:当右侧空间不足时自动翻转到左/上/下
flip(),
// 复杂:将浮层保持在视口内
shift({ padding: 8 }),
// 复杂:根据视口动态裁剪高度,并回写到样式
size({
apply({ availableHeight, elements }) {
const el = elements.floating as HTMLElement;
el.style.maxHeight = Math.max(280, availableHeight - 16) + "px";
},
padding: 8,
}),
],
}).then(({ x, y }) => {
style.value = { top: `${y}px`, left: `${x}px` };
});
};
// 复杂:开启时使用 autoUpdate 绑定滚动/resize/布局变化自动重算
const beginAutoUpdate = () => {
if (!anchorEl.value || !floatingEl.value) return;
cleanup = autoUpdate(anchorEl.value, floatingEl.value, updatePosition);
};
const endAutoUpdate = () => {
cleanup?.();
cleanup = null;
};
const openAt = (anchor: HTMLElement) => {
anchorEl.value = anchor;
usingDrawer.value = isSmallScreen();
open.value = true;
// 复杂:Drawer 模式无需定位
if (!usingDrawer.value) {
// 复杂:首次定位 + 监听
requestAnimationFrame(() => {
updatePosition();
beginAutoUpdate();
});
}
};
const close = () => {
open.value = false;
endAutoUpdate();
};
onBeforeUnmount(endAutoUpdate);
return {
open,
usingDrawer,
anchorEl,
floatingEl,
style,
openAt,
close,
};
}4) 新增覆盖层组件:TradePanelOverlay.vue
用
Teleport到body。桌面显示锚点浮窗,小屏显示 Drawer。里面直接复用你给的“交易面板组件”。
vue
<!-- src/components/TradePanelOverlay.vue -->
<template>
<Teleport to="body">
<!-- 桌面端:锚点浮窗 -->
<transition name="fade">
<div
v-if="open && !usingDrawer"
class="fixed z-[1500]"
:style="style"
ref="floatingEl"
>
<!-- 复杂:点击浮层外部关闭(盖一层透明背景) -->
<div class="fixed inset-0 z-[-1]" @click="onBackdropClick"></div>
<!-- 内容容器,宽度可根据面板需要微调 -->
<div
class="rounded-md shadow-lg bg-white w-[320px] max-h-[80vh] overflow-auto"
>
<TradePanel
:symbolInfo="symbolInfo"
:accountInfo="accountInfo"
@close="close"
/>
</div>
</div>
</transition>
<!-- 小屏:底部 Drawer -->
<a-drawer
v-model:open="drawerOpen"
placement="bottom"
:height="drawerHeight"
:closable="false"
:mask-closable="true"
@close="close"
>
<TradePanel
:symbolInfo="symbolInfo"
:accountInfo="accountInfo"
@close="close"
/>
</a-drawer>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, ref } from "vue";
import { useTradePanelOverlay } from "@/composables/useTradePanelOverlay";
import TradePanel from "@/path/to/你的交易面板组件.vue"; // ← 指向你贴的那个组件文件
const props = defineProps<{
symbolInfo: any;
accountInfo: any;
}>();
const { open, usingDrawer, floatingEl, style, close } = useTradePanelOverlay();
// 复杂:Drawer 的受控开关从 overlay 的 open/usingDrawer 派生
const drawerOpen = computed({
get: () => open.value && usingDrawer.value,
set: (v: boolean) => {
if (!v) close();
},
});
// 复杂:小屏建议高度 80vh,可按实际微调
const drawerHeight = computed(() => "80vh");
const emit = defineEmits(["backdropClick"]);
const onBackdropClick = () => emit("backdropClick");
// 暴露方法给外部控制打开(通过 defineExpose)
const openAt = (anchor: HTMLElement) => {
// 复杂:把 openAt 暴露给父组件,父组件传入锚点元素
// 实际逻辑在 composable 里
(useTradePanelOverlay() as any).openAt(anchor);
};
defineExpose({ openAt, close, open, usingDrawer, floatingEl, style });
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.12s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>若你喜欢保持组件“纯展示”,也可以把
useTradePanelOverlay搬去父组件,只在这里接收open/usingDrawer/style/...作为 props;我给的是“自包含”版本,便于直接用。
5) 父级页面接入(持表格的页面)
在渲染表格的父页面里放一个
TradePanelOverlay单例,通过ref控制它。 只给新增/修改片段:
ts
// ✅ 新增:状态与回调
import { ref } from "vue";
import TradePanelOverlay from "@/components/TradePanelOverlay.vue";
const overlayRef = ref<InstanceType<typeof TradePanelOverlay> | null>(null);
const overlaySymbolInfo = ref<any>(null);
const overlayAccountInfo = ref<any>(null); // 你的账户信息来源
// 复杂:从列点击进入时设置默认买/卖侧,可通过 props 传给面板或在面板内 watch 决定
const overlayDefaultSide = ref<"buy" | "sell">("buy");
// ✅ 新增:提供给列定义的回调
const onOpenTradePanel = ({
row,
anchorEl,
side,
}: {
row: SymbolListRow;
anchorEl: HTMLElement;
side: "buy" | "sell";
}) => {
overlaySymbolInfo.value = row; // 若 row 里就带了 symbolInfo
overlayDefaultSide.value = side;
// 复杂:通过 ref 调用浮层的 openAt,传入锚点元素
overlayRef.value?.openAt(anchorEl);
};
// ✅ 传给列定义
const columns = getSymbolListColumns({
t,
onToggleFav,
allSymbolMarketStatus,
onOpenTradePanel,
});vue
<!-- ✅ 在父模板里挂一个浮层实例(单例即可) -->
<TradePanelOverlay
ref="overlayRef"
:symbolInfo="overlaySymbolInfo"
:accountInfo="accountInfo"
/>如果你的交易面板需要拿到
defaultSide预设买卖,可以把overlayDefaultSide也作为 prop 传下去,在面板watch设置state.tradeSide。
6) 交易面板组件里的小改动(可选)
当从
Bid打开时默认SELL,从Ask打开时默认BUY。如果你要在面板内部处理,只加下面这段:
ts
// ✅ 仅在 <script setup> 里新增:接收 defaultSide(buy/sell)
const props = withDefaults(
defineProps<{
/* ...原有 props... */
defaultSide?: "buy" | "sell";
}>(),
{ defaultSide: "buy" }
);
// 复杂:面板初次打开或 defaultSide 变化时,设置 tradeSide
watch(
() => props.defaultSide,
(val) => {
// 复杂逻辑:与枚举映射
state.tradeSide = val === "buy" ? TradeSide.BUY : TradeSide.SELL;
},
{ immediate: true }
);如果你选择父组件直接改
state.tradeSide,那这里就不需要改。
7) 关键细节与取舍说明(调研结论)
定位库选择
- AntD 的
a-popover/a-dropdown适合小内容;你的面板较长、交互多,推荐@floating-ui/dom,具备flip/shift/size/autoUpdate,在复杂容器(虚拟滚动、overflow: hidden)中更稳。
- AntD 的
Teleport 到 body
- 表格容器常有
overflow或虚拟滚动;把浮层Teleport到body,能避免裁切与定位偏差。
- 表格容器常有
滚动容器
autoUpdate会监听最近滚动容器与 window;对表格内滚动/外层滚动都能自动重算,无需手写滚动监听。
小屏 Drawer
- 大面板在手机端贴边浮出可用性差,底部 Drawer(80vh)更好;AntD Drawer 自带遮罩、关闭动效、焦点管理。
关闭策略
- 点击遮罩、ESC 关闭;桌面浮窗加一层“全屏透明层”用于点击外部关闭(我在上面代码里加了)。
性能
- 面板只在打开时挂载;
autoUpdate只在打开时激活,关闭即清理监听,避免泄漏。
- 面板只在打开时挂载;
尺寸自适应
size()中写回maxHeight,配合容器overflow-auto;小屏交由 Drawer 处理高度。
可访问性
- Drawer 天生可聚焦;浮窗可在打开时把焦点
focus()到面板第一个可交互元素(如AccountSelector),需要的话可再补一行onMounted的nextTick(() => el.focus())。
- Drawer 天生可聚焦;浮窗可在打开时把焦点
8) 常见坑位与解决
- 单元格点击拿不到 row:在 TSX 渲染器作用域里提前捕获
row.original到闭包变量,再传给onOpenTradePanel。我在上面留了占位注释。 - z-index 冲突:AntD 遮罩是 1000/1050 级别;给浮窗根元素
z-[1500](或自定义更高)避免被覆盖。 - 容器 transform 影响定位:
@floating-ui使用getBoundingClientRect,不受 transform 影响;避免自己写offsetTop之类计算。 - 面板过宽:可在桌面端给固定宽度(如 320~360px),在
size()里只控制高度;更宽就更像侧边抽屉,不建议贴边弹出。
如果你把父页面/列定义文件结构贴出来(尤其是如何获取 row、账户信息在哪),我可以按你的文件结构把上面的占位和类型补齐成可直接粘贴运行的版本。