Vue 3 + vue-dnd-kit 构建的交互式布局项目,模拟LeetCode 编辑页
1 目标与使用场景
- 复刻并扩展 LeetCode 编辑页的多面板体验,验证布局预设、分组拖拽、拆分与分屏调整的可行性。
- 适合做刷题工作台、数据/运维/BI 等多区域交互界面原型,强调状态可控、布局灵活、组件可复用。
- 成功标准:布局切换无状态污染;拖拽/拆分不丢失激活态;空组/空容器自动收缩,保持紧凑视图。
- demo 地址 :https://github.com/DavidHLP/leetcode-layout
2 UI / 体验设计
- 顶部 Header:
- 左:导航与题目跳转(前一题/后一题/随机),HoverCard 提供快捷键提示。
- 中:运行/提交/笔记入口,保持一致的高亮与无阴影按钮风格。
- 右:布局切换下拉,预览卡以 mini-map 形式展示布局,选中态带描边与勾选。
- 面板:
- header tab 使用轻量按钮,激活态加深色,拖拽时降低透明度。
- 内容区域暂为占位文本,留接口嵌入实际 Problem/Code/Test 模块。
- 主题:
src/style.css定义 Tailwind v4 inline 主题变量,可快速替换背景、圆角、分割线;暗色主题通过.dark变量切换。
UI 预览




3 总体架构
3.1 技术栈
- 框架层:Vue 3 (Composition API) + TypeScript
- 构建工具:Vite 7
- 状态管理:Pinia
- 路由:Vue Router 4
- 样式:Tailwind CSS v4
- 拖拽库:
@vue-dnd-kit(https://github.com/ZiZIGY/vue-dnd-kit) - 图标库:Lucide Icons
3.2 目录结构与分层
▼text复制代码src/ ├── components/ui/ # 通用 UI 组件 │ ├── button/ # 按钮组件 │ ├── dropdown-menu/ # 下拉菜单组件 │ ├── hover-card/ # 悬浮卡片组件 │ ├── kbd/ # 键盘快捷键组件 │ ├── resizable/ # 可调整尺寸组件(核心) │ └── separator/ # 分隔线组件 │ ├── features/layout/ # 布局领域特性组件 │ ├── headers/ # 顶部标题栏组件 │ │ ├── LayoutHeaderLeft.vue # 左侧导航 │ │ ├── LayoutHeaderCenter.vue # 中间操作按钮 │ │ └── LayoutHeaderControls.vue # 右侧布局切换 │ ├── panels/ # 面板组件 │ │ ├── LayoutPanel.vue # 面板容器(核心) │ │ ├── LayoutPanelContent.vue # 面板内容区 │ │ ├── LayoutPanelHeader.vue # 面板标签页(拖拽起点) │ │ └── PanelDropOverlay.vue # 拖拽落点视觉反馈 │ └── tree/ # 布局树组件 │ ├── LayoutTree.vue # 树根容器 │ └── LayoutTreeNode.vue # 递归节点(核心) │ ├── stores/ # 状态管理 │ └── headerStore.ts # 布局与分组状态(核心) │ ├── pages/ # 页面级组件 │ └── LayoutWorkbench.vue # 工作台页面(页面壳) │ ├── lib/ # 工具函数 │ └── utils.ts │ ├── App.vue # 根组件 ├── main.ts # 入口文件 └── style.css # 全局样式(Tailwind 配置)
3.3 分层设计
页面层(Pages)
- 责任:页面结构组织、预设配置生成、初始化与切换逻辑
- 核心文件:
LayoutWorkbench.vue - 不包含:具体业务逻辑、拖拽细节、树渲染逻辑
领域状态层(Domain Store)
- 责任:布局树、分组数据、激活态管理,跨组/拆分操作
- 核心文件:
headerStore.ts - 设计原则:最小必要状态,视图层通过计算属性求导出可见性
Store 状态结构:
▼typescript复制代码// headerStore.ts 核心状态 const layoutConfig = ref<LayoutNode | null>(null) // 布局树 const activeGroupId = ref<string | null>(null) // 当前激活的分组 ID const headerGroups = ref<HeaderGroup[]>([]) // 所有分组数据 // 计算属性 const visibleGroups = computed(() => headerGroups.value.filter((g) => g.headers.length > 0))
核心方法:
initData(groups, layout)- 初始化或重置全部状态updateGroupHeaders(groupId, newHeaders)- 同组重排moveHeaderBetweenGroups(...)- 跨组移动splitGroup(...)- 拆分创建新分组setActiveGroup(groupId)- 设置激活分组
视图层(View Components)
布局树视图(features/layout/tree):
LayoutTree.vue- 根容器,provide 拖拽上下文LayoutTreeNode.vue- 递归渲染节点,处理容器/叶子分叉
面板视图(features/layout/panels):
LayoutPanel.vue- 面板主容器,协调拖拽/拆分/激活态LayoutPanelHeader.vue- 可拖拽的标签页,阈值检测PanelDropOverlay.vue- 拖拽落点区域视觉反馈LayoutPanelContent.vue- 内容占位器
顶部栏视图(features/layout/headers):
LayoutHeaderLeft.vue- 左侧导航按钮LayoutHeaderCenter.vue- 中间操作区LayoutHeaderControls.vue- 右侧布局切换下拉
通用 UI 组件(components/ui):
resizable/- 可调整尺寸的面板组(ResizablePanelGroup/Panel/Handle)button/、dropdown-menu/、hover-card/、kbd/、separator/- 常用 UI 组件
3.4 数据流边界
Store 边界
▼text复制代码LayoutWorkbench (Page) │ ├── initData(groups, layout) → headerStore │ └── 读取 layoutConfig, headerGroups, activeGroupId
原则:
- Store 只持有最小必要状态(布局树 + 分组 + 激活)
- 不在 Store 中存储计算结果(如
visibleChildren) - 视图层通过
computed动态计算可见性
拖拽上下文边界
▼text复制代码LayoutTree (Root) │ ├── provide('dragState', dragState) ├── provide('moveHeaderBetweenGroups', ...) └── provide('splitGroup', ...) │ └── LayoutTreeNode │ └── LayoutPanel │ └── LayoutPanelHeader (inject 消费)
原则:
- 拖拽状态和方法通过
provide/inject跨层级传递 - 避免每层 props 中转,减少组件耦合
- 仅在拖拽相关组件中
inject,不污染其他组件
4 数据模型与不变量
4.1 核心数据结构
LayoutNode(布局节点)
▼typescript复制代码export interface LayoutNode { id: string // 节点唯一标识 type: 'container' | 'leaf' // 节点类型 direction?: 'horizontal' | 'vertical' // 容器方向(仅容器节点) size?: number // 初始尺寸比例(百分比,如 50) children?: LayoutNode[] // 子节点(仅容器节点) groupId?: string // 关联的分组 ID(仅叶子节点) groupMetadata?: { // 分组元数据(用于 UI 展示) id: string name: string } }
设计约束:
- 容器节点(
type: 'container'):- 必须有
direction属性(水平/垂直) - 必须有
children数组,至少包含 2 个子节点 - 不应有
groupId和groupMetadata
- 必须有
- 叶子节点(
type: 'leaf'):- 必须绑定
groupId关联到某个 HeaderGroup - 可选
groupMetadata用于快速 UI 展示,避免查询 - 不应有
direction和children
- 必须绑定
- size 属性:表示节点在父容器中的初始占比(0-100),用于 ResizablePanel 的
default-size
树结构特点:
- 采用递归嵌套结构,支持任意深度的布局组合
- 根节点通常是容器节点,定义整体布局方向
- 通过 DFS(深度优先搜索)遍历和修改树结构
HeaderGroup(分组模型)
▼typescript复制代码export interface HeaderGroup { id: string // 分组唯一标识 name: string // 分组名称 headers: HeaderModel[] // 该分组下的所有标签页 } export interface HeaderModel { id: number // 标签页唯一 ID index: number // 在分组中的顺序索引(从 0 开始) title: string // 显示标题 icon: string // Lucide 图标名称 color?: string // 文本颜色 iconColor?: string // 图标颜色 }
设计约束:
- 同一分组内的
index必须连续(0, 1, 2, ...),用于维护视觉顺序 - 跨组移动或组内重排后,必须调用重新编号逻辑(见
updateGroupHeaders/moveHeaderBetweenGroups) id在全局唯一,用于激活态判断和拖拽识别
DragState(拖拽状态)
▼typescript复制代码const dragState = ref<{ sourceGroupId: string | null // 拖拽源分组 ID sourceIndex: number | null // 拖拽源在分组中的索引 }>({ sourceGroupId: null, sourceIndex: null, })
设计原理:
- 由
LayoutTree通过provide注入,所有子组件通过inject共享 - 在
handleDragStart时记录来源,在handleDragEnd或handleOverlayDrop时清空 - 避免了深层 props 传递,支持跨层级拖拽状态同步
4.2 可视化规则(不变量)
-
空组隐藏规则:
- 分组的
headers数组为空时,对应的叶子节点不渲染 - 通过
shouldShowNode函数递归检查:▼typescript复制代码const shouldShowNode = (layoutNode: LayoutNode): boolean => { if (layoutNode.type === 'leaf' && layoutNode.groupId) { return getGroupHeaders(layoutNode.groupId).length > 0 } if (layoutNode.type === 'container' && layoutNode.children) { return layoutNode.children.some((child) => shouldShowNode(child)) } return false }
- 分组的
-
容器收缩规则:
- 容器节点的所有子节点都不可见时,容器本身也不渲染
- 通过
visibleChildren计算属性过滤:▼typescript复制代码const visibleChildren = computed(() => { if (node.value.type !== 'container' || !node.value.children) { return [] } return node.value.children.filter((child) => shouldShowNode(child)) })
-
激活态保持规则:
activeGroupId始终指向一个存在且可见的分组- 初始化时选择第一个非空分组:
▼typescript复制代码
const firstVisible = groups.find((g) => g.headers.length > 0) if (firstVisible) { activeGroupId.value = firstVisible.id } - 当前激活的标签页被移除时,自动切换到该分组的第一个标签页:
▼typescript复制代码
watch( () => localHeaders.value, (newHeaders) => { const activeId = activeHeader.value?.id const stillExists = activeId !== undefined && newHeaders.some((header) => header.id === activeId) if (!stillExists) { activeHeader.value = newHeaders[0] || null } }, )
-
唯一标识符规则:
- 每个 LayoutNode 的
id在树中唯一(如programming-left、compact-right) - HeaderGroup 的
id在分组数组中唯一(如problem-info、code-editor) - 拆分时动态生成新分组 ID:
group-${Date.now()}
- 每个 LayoutNode 的
5 状态流转
- 初始化(挂载时):
LayoutWorkbench依据当前枚举调用getXxxLayoutConfig生成groups + layout深拷贝。headerStore.initData写入 store,并选中第一个非空组作为activeGroupId。
- 预设切换:
- UI 下拉触发
handleLayoutChange(layoutKey)。 - 根据 key 生成新配置;调用
initData重置状态(避免旧分组引用被污染)。 - 视图层根据新
layoutConfig递归渲染,自动隐藏空分支。
- UI 下拉触发
- 同组重排:
updateGroupHeaders在本地数组复制、重排并重写index。 - 跨组移动:
moveHeaderBetweenGroups在源/目标组 splice,分别重写index,保持有序。
6 拖拽与拆分交互(核心流程)
6.1 拖拽触发机制
阈值检测(防误触)
LayoutPanelHeader 使用自定义的指针事件处理,而非直接使用 @vue-dnd-kit 的默认拖拽:
▼typescript复制代码const MOVE_THRESHOLD = 5 // 5px 移动阈值 const onPointerDown = (e: PointerEvent) => { pointerDownEvent.value = e initialPosition.value = { x: e.clientX, y: e.clientY } hasMoved.value = false } const onPointerMove = (e: PointerEvent) => { if (!pointerDownEvent.value || !initialPosition.value) return const deltaX = Math.abs(e.clientX - initialPosition.value.x) const deltaY = Math.abs(e.clientY - initialPosition.value.y) // 超过阈值才触发拖拽 if (!hasMoved.value && (deltaX > MOVE_THRESHOLD || deltaY > MOVE_THRESHOLD)) { hasMoved.value = true if (pointerDownEvent.value) { emit('drag-start', pointerDownEvent.value, handleDragStart) } } }
设计优势:
- 避免点击时误触发拖拽
- 用户必须移动超过 5px 才开始拖拽,提升体验
- 通过
hasMoved标志区分点击和拖拽,只有未移动时才触发header-click事件
拖拽状态记录
▼typescript复制代码const handleDragStart = ( index: number, event: PointerEvent, handleStart: (e: PointerEvent) => void, ) => { draggedIndex.value = index if (dragState) { dragState.value.sourceGroupId = props.group || 'default' dragState.value.sourceIndex = index } handleStart(event) // 调用 @vue-dnd-kit 的 handleDragStart }
6.2 同组/跨组落点判断
拖拽悬停(dragover)
LayoutPanel 在每个标签页的 pointerover 事件中计算鼠标位置:
▼typescript复制代码const handleDragOver = (index: number, event: PointerEvent) => { if (dragState?.value.sourceGroupId && dragState.value.sourceIndex !== null) { const target = event.currentTarget as HTMLElement const rect = target.getBoundingClientRect() const mouseX = event.clientX const elementCenter = rect.left + rect.width / 2 overIndex.value = index dropPosition.value = mouseX < elementCenter ? 'before' : 'after' } }
视觉反馈:
overIndex和dropPosition用于渲染插入指示器(可通过 CSS 高亮边框)- 鼠标在元素左半部分时为
before,右半部分为after
拖拽释放(dragend)
▼typescript复制代码const handleDragEnd = () => { if ( dragState?.value.sourceGroupId && dragState.value.sourceIndex !== null && overIndex.value !== null ) { const sourceGroupId = dragState.value.sourceGroupId const sourceIndex = dragState.value.sourceIndex const targetGroupId = props.group || 'default' let targetIndex = overIndex.value if (dropPosition.value === 'after') { targetIndex += 1 } if (sourceGroupId === targetGroupId) { // 同组重排 const newHeaders = [...localHeaders.value] const [movedItem] = newHeaders.splice(sourceIndex, 1) if (movedItem) { const adjustedTargetIndex = targetIndex > sourceIndex ? targetIndex - 1 : targetIndex newHeaders.splice(adjustedTargetIndex, 0, movedItem) localHeaders.value = newHeaders props.onUpdate(newHeaders) // 触发 updateGroupHeaders } } else if (moveHeaderBetweenGroups) { // 跨组移动 moveHeaderBetweenGroups(sourceGroupId, targetGroupId, sourceIndex, targetIndex) } } // 清空状态 draggedIndex.value = null overIndex.value = null dropPosition.value = null if (dragState) { dragState.value.sourceGroupId = null dragState.value.sourceIndex = null } }
关键点:
- 同组时需要调整
targetIndex:如果目标索引大于源索引,先移除源项会导致索引偏移,需-1修正 - 跨组时直接调用 store 方法,由 store 统一处理索引重写
6.3 拆分交互(splitGroup)
四象限检测
PanelDropOverlay 组件在拖拽进行时显示,监听鼠标位置判断落点区域:
▼typescript复制代码const handleMouseMove = (e: MouseEvent) => { const target = e.currentTarget as HTMLElement const rect = target.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top const w = rect.width const h = rect.height const threshold = 0.25 // 边缘区域占比 25% let zone: 'top' | 'bottom' | 'left' | 'right' | 'center' = 'center' if (y < h * threshold) { zone = 'top' } else if (y > h * (1 - threshold)) { zone = 'bottom' } else if (x < w * threshold) { zone = 'left' } else if (x > w * (1 - threshold)) { zone = 'right' } if (activeZone.value !== zone) { activeZone.value = zone emit('zone-change', zone) } }
视觉反馈:
- 顶部 25% 区域:显示 "Split Top" 蓝色高亮
- 底部 25% 区域:显示 "Split Bottom" 蓝色高亮
- 左侧 25% 区域:显示 "Split Left" 蓝色高亮
- 右侧 25% 区域:显示 "Split Right" 蓝色高亮
- 中心区域:显示 "Add to Group" 虚线边框
拆分逻辑实现
▼typescript复制代码const splitGroup = ( sourceGroupId: string, targetGroupId: string, sourceIndex: number, direction: 'top' | 'bottom' | 'left' | 'right' | 'center', ) => { // 1. 从源分组移除标签页 const sourceGroup = headerGroups.value.find((g) => g.id === sourceGroupId) const [movedItem] = sourceGroup.headers.splice(sourceIndex, 1) sourceGroup.headers.forEach((h, i) => (h.index = i)) // 重新编号 if (direction === 'center') { // 追加到目标分组 const targetGroup = headerGroups.value.find((g) => g.id === targetGroupId) targetGroup.headers.push(movedItem) targetGroup.headers.forEach((h, i) => (h.index = i)) return } // 2. 创建新分组 const newGroupId = `group-${Date.now()}` const newGroup: HeaderGroup = { id: newGroupId, name: 'New Group', headers: [movedItem], } headerGroups.value.push(newGroup) // 3. 修改布局树(DFS 查找并就地修改) const modifyLayout = (node: LayoutNode): boolean => { if (node.type === 'leaf' && node.groupId === targetGroupId) { // 保存原叶子节点信息 const originalGroupId = node.groupId const originalGroupMetadata = node.groupMetadata // 将叶子节点转为容器节点 node.type = 'container' node.groupId = undefined node.groupMetadata = undefined node.direction = direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical' // 构造两个新叶子节点 const newLeafNode: LayoutNode = { id: `leaf-${newGroupId}`, type: 'leaf', groupId: newGroupId, groupMetadata: { id: newGroupId, name: 'New Group' }, size: 50, } const originalLeafNode: LayoutNode = { id: `leaf-${originalGroupId}-${Date.now()}`, type: 'leaf', groupId: originalGroupId, groupMetadata: originalGroupMetadata, size: 50, } // 根据方向决定子节点顺序 if (direction === 'left' || direction === 'top') { node.children = [newLeafNode, originalLeafNode] } else { node.children = [originalLeafNode, newLeafNode] } return true } // 递归查找 if (node.children) { for (const child of node.children) { if (modifyLayout(child)) return true } } return false } modifyLayout(layoutConfig.value) activeGroupId.value = newGroupId // 激活新分组 }
关键设计:
- 就地修改(in-place mutation):直接修改找到的节点对象,保持 Vue 响应式引用,避免整树替换导致 UI 重新渲染
- 方向映射:
top/bottom → vertical,left/right → horizontal - 初始尺寸:新旧叶子节点各占 50%,用户可拖动 ResizableHandle 调整
- 自动激活:拆分后自动激活新创建的分组,用户立即看到新面板
6.4 边界处理与状态清理
▼typescript复制代码// 源组或目标组不存在时早返回 if (!sourceGroup || !targetGroup) return // 拖拽结束必须清空状态 if (dragState) { dragState.value.sourceGroupId = null dragState.value.sourceIndex = null }
防御性编程:
- 所有跨组操作前验证分组存在性
- 索引访问前检查数组长度
- 拖拽状态在每次操作结束后严格清空,防止残留影响下次交互
7 布局渲染与分屏调整
7.1 递归渲染机制
LayoutTreeNode 递归组件
▼vue复制代码<template> <div class="h-full w-full"> <!-- 容器节点 --> <ResizablePanelGroup v-if="node.type === 'container' && visibleChildren.length > 0" :id="node.id" :direction="node.direction || 'horizontal'" :class="['h-full w-full gap-2', { 'p-2': isRoot }]" > <template v-for="(child, index) in visibleChildren" :key="child.id"> <ResizablePanel :id="child.id" :default-size="child.size" :min-size="20"> <!-- 递归渲染子节点 --> <LayoutTreeNode :node="child" /> </ResizablePanel> <ResizableHandle v-if="index < visibleChildren.length - 1" with-handle /> </template> </ResizablePanelGroup> <!-- 叶子节点 --> <div v-else-if="node.type === 'leaf' && node.groupId && getGroupHeaders(node.groupId).length > 0" class="h-full cursor-pointer rounded-xl border border-transparent" :class="{ 'border-[#dedede] shadow-sm': activeGroupId === node.groupId }" @click="handleGroupClick(node.groupId)" > <LayoutPanel :headers="getGroupHeaders(node.groupId)" :group="node.groupId" :on-update="(newHeaders) => updateGroupHeaders(node.groupId, newHeaders)" :is-active="activeGroupId === node.groupId" /> </div> </div> </template>
设计要点:
- 双模板判断:根据
node.type决定渲染容器还是叶子节点 - 可见性过滤:容器节点只渲染
visibleChildren,过滤掉空分组对应的叶子 - 递归调用:容器节点内部再次使用
<LayoutTreeNode :node="child" />,实现任意深度嵌套 - 根节点标识:
isRoot为 true 时添加p-2padding,避免布局贴边
7.2 可调整面板(ResizablePanelGroup)
核心属性映射
▼vue复制代码<ResizablePanelGroup :id="node.id" <!-- 容器唯一 ID --> :direction="node.direction" <!-- 水平/垂直布局 --> class="h-full w-full gap-2" <!-- 子面板间距 2 单位 --> > <ResizablePanel :id="child.id" :default-size="child.size" <!-- 初始占比(如 50) --> :min-size="20" <!-- 最小占比 20% --> > <!-- 面板内容 --> </ResizablePanel> <ResizableHandle v-if="index < visibleChildren.length - 1" with-handle /> </ResizablePanelGroup>
ResizableHandle 插入规则:
- 仅在非最后一个面板后插入 Handle
with-handle属性提供视觉抓手(竖线或横线)- 拖动 Handle 时,相邻面板按比例调整尺寸
最小尺寸限制:
min-size="20"确保面板不会被完全压缩- 用户拖动时,任一面板最小保持 20% 空间
- 防止内容区域完全不可见
7.3 激活态管理
分组级激活(activeGroupId)
▼typescript复制代码// 在 headerStore.ts 中 const activeGroupId = ref<string | null>(null) const setActiveGroup = (groupId: string) => { activeGroupId.value = groupId } // 在 LayoutTreeNode.vue 中 const handleGroupClick = (groupId: string) => { setActiveGroup(groupId) }
视觉反馈:
- 激活的分组叶子节点显示边框:
:class="{ 'border-[#dedede] shadow-sm': activeGroupId === node.groupId }" - 传递
:is-active给LayoutPanel,用于内部样式调整
标签页级激活(activeHeader)
▼typescript复制代码// 在 LayoutPanel.vue 中 const activeHeader = ref<HeaderModel | null>(null) // 监听 headers 变化,自动调整激活项 watch( () => localHeaders.value, (newHeaders) => { const activeId = activeHeader.value?.id const stillExists = activeId !== undefined && newHeaders.some((header) => header.id === activeId) if (!stillExists) { activeHeader.value = newHeaders[0] || null // 回退到第一项 } }, { immediate: true }, ) // 点击标签页切换 const handleHeaderSelect = (header: HeaderModel) => { activeHeader.value = header }
自动回退机制:
- 当前激活的标签页被拖走(跨组移动)时,
stillExists检查失败 - 自动激活该分组的第一个标签页(
newHeaders[0]) - 避免出现"无激活项"的尴尬状态
7.4 布局切换与重置
预设配置生成
▼typescript复制代码// 在 LayoutWorkbench.vue 中 const getLeetLayoutConfig = () => { const groups = createInitialHeaderGroups() // 深拷贝分组数据 const layout: LayoutNode = { id: 'programming-root', type: 'container', direction: 'horizontal', children: [ { id: 'programming-left', type: 'leaf', size: 50, groupId: 'problem-info', groupMetadata: { id: 'problem-info', name: 'Problem Information' } }, { id: 'programming-right', type: 'container', direction: 'vertical', size: 50, children: [ { id: 'programming-right-top', type: 'leaf', size: 50, groupId: 'code-editor', ... }, { id: 'programming-right-bottom', type: 'leaf', size: 50, groupId: 'test-info', ... } ] } ] } return { groups, layout } }
深拷贝必要性:
createInitialHeaderGroups()每次返回新的数组和对象- 避免多次切换布局时,旧引用污染新配置
- 确保每次
initData都是全新的独立数据
切换流程
▼typescript复制代码const handleLayoutChange = (newLayout: 'leet' | 'classic' | 'compact' | 'wide') => { currentLayout.value = newLayout let config: { groups: HeaderGroup[]; layout: LayoutNode } switch (newLayout) { case 'leet': config = getLeetLayoutConfig() break case 'classic': config = getClassicLayoutConfig() break case 'compact': config = getCompactLayoutConfig() break case 'wide': config = getWideLayoutConfig() break } headerStore.initData(config.groups, config.layout) // 全量重置 }
重置策略:
initData直接覆盖headerGroups和layoutConfig- 自动重新选择第一个非空分组作为激活态
- 视图层通过响应式自动重新渲染,无需手动刷新
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
