UI 交互难题攻克:遮挡、弹窗、动态加载

UI 交互难题攻克:遮挡、弹窗、动态加载

🎯 学习目标

  • 掌握浏览器自动化中的常见交互难题
  • 学会使用强制点击技术解决元素遮挡
  • 理解文件上传的双重验证机制
  • 能够构建抗干扰的自动化脚本

📖 核心概念

为什么 UI 交互会失败?

在浏览器自动化中,80% 的失败来自以下三类问题:

问题类型 典型场景 错误信息 发生率
元素遮挡 模态框、蒙版、悬浮层 intercepts pointer events 45%
动态加载 AJAX、懒加载、SPA Timeout waiting for element 30%
弹窗干扰 确认对话框、警告框 Dialog opened 25%

💡 问题深度解析

问题 1:元素遮挡(Element Obstruction)

现象
python 复制代码
await page.click("#publish-button")
# ❌ 错误:ElementHandle.click: Timeout 30000ms exceeded.
# Call log:
# - <div class="modal-overlay">...</div> intercepts pointer events
根本原因
复制代码
页面层级结构:
┌─────────────────────────────┐
│  Modal Overlay (z-index: 999) │ ← 点击被这里拦截
├─────────────────────────────┤
│  Publish Button (z-index: 1)   │ ← 实际要点击的目标
└─────────────────────────────┘

问题 2:文件上传双重验证

挑战

CSDN 的封面图上传需要两步操作

复制代码
1. 点击"从本地上传" → 触发文件选择器
2. 选择文件 → 弹出图片编辑模态框
3. 点击"确认上传" → 真正开始上传

陷阱

  • 第一步的文件选择器是系统级弹窗,无法用常规方式定位
  • 第二步的图片编辑器是页面内模态框,会遮挡其他元素

问题 3:抗干扰设计

场景

当你点击发布按钮时,CSDN 可能:

  • 打开新标签页(预览)
  • 弹出确认对话框
  • 触发表单验证错误提示

如果不处理,脚本会在错误的页面上下文中继续执行,导致后续操作全部失败。


🔧 解决方案

方案 1:强制点击技术(Force Click)

方法对比
python 复制代码
# ❌ 方法 1:普通点击(容易被遮挡)
await button.click()

# ✅ 方法 2:evaluate 强制点击(绕过事件监听)
await page.evaluate("""() => {
    const btn = document.querySelector('#target');
    if (btn) {
        btn.click();
        // 立即移除事件监听器防止跳转
        btn.replaceWith(btn.cloneNode(true));
    }
}""")

# ✅ 方法 3:Playwright force 选项
await button.click(force=True)

# ✅ 方法 4:JavaScript 模拟点击
await button.dispatch_event('click')

实战:CSDN 发布按钮
python 复制代码
async def click_publish_button(page):
    """
    点击 CSDN 发布按钮并防止页面跳转
    
    Returns:
        bool: 是否成功
    """
    try:
        # 等待按钮出现
        publish_btn = await page.wait_for_selector(
            'button:has-text("发布文章")',
            timeout=10000
        )
        
        # 保存当前页面引用
        main_page = page
        
        # 定义对话框处理器
        async def handle_dialog(dialog):
            print(f"✓ 已关闭对话框:{dialog.message}")
            await dialog.accept()
        
        page.on("dialog", handle_dialog)
        
        # 执行强制点击(不等待导航)
        await publish_btn.click(force=True, no_wait_after=True)
        
        # 短暂等待触发
        await asyncio.sleep(0.5)
        
        # 如果打开了新标签页,切换回来
        pages = page.context.pages
        if len(pages) > 1:
            await pages[-1].close()
            await main_page.bring_to_front()
        
        print("✓ 发布按钮已点击")
        return True
        
    except Exception as e:
        print(f"❌ 点击失败:{e}")
        return False

方案 2:文件上传双重验证

完整流程
python 复制代码
from playwright.async_api import FileChooser

async def upload_cover_image(page, image_path):
    """
    上传封面图(处理两步验证)
    
    Args:
        page: Playwright page
        image_path: 图片路径
    
    Returns:
        bool: 是否成功
    """
    import os
    from pathlib import Path
    
    # 检查图片是否存在
    if not Path(image_path).exists():
        print(f"❌ 封面图不存在:{image_path}")
        return False
    
    try:
        # ========== 第一步:触发文件选择器 ==========
        upload_button = await page.query_selector('text=从本地上传')
        
        if not upload_button:
            print("❌ 未找到上传按钮")
            return False
        
        # 设置文件选择器监听(关键!)
        async with page.expect_file_chooser() as fc_info:
            # 强制点击触发文件选择器
            await upload_button.click(force=True)
        
        # 获取文件选择器
        file_chooser = await fc_info.value
        
        # 设置文件
        await file_chooser.set_files(image_path)
        
        print(f"✓ 封面图已选择:{os.path.basename(image_path)}")
        
        # ========== 第二步:点击图片编辑模态框 ==========
        
        # 等待模态框出现
        try:
            modal = await page.wait_for_selector(
                '.image-editor-modal',
                timeout=5000
            )
            print("✓ 图片编辑器已打开")
        except:
            print("⚠ 未检测到编辑器,可能无需编辑")
            return True
        
        # 点击确认按钮
        confirm_btn = await modal.query_selector('text=确认上传')
        if confirm_btn:
            await confirm_btn.click()
            print("✓ 已确认上传")
        
        # 等待上传完成
        await asyncio.sleep(3)
        
        return True
        
    except Exception as e:
        print(f"❌ 上传失败:{e}")
        return False

方案 3:抗干扰设计

多页面管理
python 复制代码
class PageStateManager:
    """页面状态管理器"""
    
    def __init__(self, page):
        self.main_page = page
        self.popup_pages = []
    
    async def handle_new_page(self, page):
        """处理新打开的标签页"""
        self.popup_pages.append(page)
        print(f"ℹ️  检测到新标签页:{page.url}")
        
        # 如果是预览页,立即关闭
        if "preview" in page.url or "success" in page.url:
            print("✓ 自动关闭预览页")
            await page.close()
            self.popup_pages.remove(page)
    
    def get_main_page(self):
        """获取主页面(带检查)"""
        if self.main_page.is_closed():
            # 如果主页面意外关闭,使用最后一个页面
            return self.popup_pages[-1] if self.popup_pages else None
        return self.main_page
    
    async def bring_main_to_front(self):
        """将主页面置于前台"""
        main = self.get_main_page()
        if main:
            await main.bring_to_front()
            return main
        return None


# 使用示例
async def robust_automation():
    browser = await p.chromium.launch()
    page = await browser.new_page()
    
    # 创建状态管理器
    state_manager = PageStateManager(page)
    
    # 监听新标签页
    page.context.on("page", lambda p: asyncio.ensure_future(
        state_manager.handle_new_page(p)
    ))
    
    # 执行操作...
    await click_publish_button(state_manager.get_main_page())
    
    # 确保在主页面继续操作
    current_page = await state_manager.bring_main_to_front()
    await current_page.fill('#title', 'My Article')

对话框处理
python 复制代码
async def setup_dialog_handler(page):
    """
    设置全局对话框处理器
    
    自动处理:
    - alert()
    - confirm()
    - prompt()
    """
    
    @page.on("dialog")
    async def handle_dialog(dialog):
        dialog_type = dialog.type
        message = dialog.message
        
        print(f"🔔 检测到对话框:{dialog_type}")
        print(f"   内容:{message[:100]}")
        
        # 自动接受所有确认框
        if dialog_type == "confirm":
            await dialog.accept()
            print("✓ 已自动确认")
        
        # 对于提示框,提供默认值
        elif dialog_type == "prompt":
            await dialog.accept("默认值")
            print("✓ 已填入默认值")
        
        # 其他类型直接接受
        else:
            await dialog.accept()
    
    print("✓ 对话框自动处理器已设置")

💻 实战:完整的 CSDN 发布流程

Step-by-Step 实现

python 复制代码
import asyncio
from playwright.async_api import async_playwright, Page

class CSDNPublisher:
    def __init__(self, headless=True):
        self.headless = headless
        self.browser = None
        self.page = None
    
    async def initialize(self):
        """初始化浏览器"""
        playwright = await async_playwright().start()
        self.browser = await playwright.chromium.launch(
            headless=self.headless
        )
        
        self.page = await self.browser.new_page()
        
        # 设置对话框处理器
        await self.setup_dialog_handler()
    
    async def setup_dialog_handler(self):
        """设置对话框自动处理"""
        @self.page.on("dialog")
        async def handle_dialog(dialog):
            print(f"🔔 对话框:{dialog.type}")
            await dialog.accept()
    
    async def find_and_click(self, selector, method="force"):
        """
        智能点击(根据情况选择最佳方法)
        
        Args:
            selector: CSS 选择器
            method: "normal" | "force" | "evaluate"
        """
        element = await self.page.query_selector(selector)
        
        if not element:
            raise Exception(f"元素未找到:{selector}")
        
        try:
            if method == "normal":
                await element.click(timeout=3000)
            
            elif method == "force":
                await element.click(force=True, timeout=3000)
            
            elif method == "evaluate":
                await self.page.evaluate(f"""() => {{
                    const el = document.querySelector('{selector}');
                    if (el) el.click();
                }}""")
            
            print(f"✓ 已点击:{selector}")
            
        except Exception as e:
            # 如果第一种方法失败,尝试下一种
            if method != "evaluate":
                print(f"⚠ {method} 失败,尝试下一种方法")
                return await self.find_and_click(selector, "evaluate")
            else:
                raise e
    
    async def upload_with_verification(self, file_path):
        """
        带双重验证的文件上传
        
        Args:
            file_path: 文件路径
        
        Returns:
            bool: 是否成功
        """
        from pathlib import Path
        
        if not Path(file_path).exists():
            print(f"❌ 文件不存在:{file_path}")
            return False
        
        try:
            # 步骤 1:触发文件选择器
            async with self.page.expect_file_chooser() as fc_info:
                await self.find_and_click('text=从本地上传', method="force")
            
            file_chooser = await fc_info.value
            await file_chooser.set_files(file_path)
            
            print(f"✓ 文件已选择:{file_path}")
            
            # 步骤 2:等待并确认
            await asyncio.sleep(1)
            
            # 查找确认按钮(最多等待 5 秒)
            try:
                confirm_btn = await self.page.wait_for_selector(
                    'text=确认上传',
                    timeout=5000
                )
                await confirm_btn.click()
                print("✓ 已确认上传")
            except:
                print("ℹ️  无需确认,直接上传")
            
            # 等待上传完成
            await asyncio.sleep(3)
            
            return True
            
        except Exception as e:
            print(f"❌ 上传失败:{e}")
            return False
    
    async def publish_article(self, title, content, tags):
        """
        完整的发布流程
        
        Args:
            title: 文章标题
            content: Markdown 内容
            tags: 标签列表
        """
        print("\n" + "="*60)
        print("开始发布文章")
        print("="*60)
        
        try:
            # 1. 填写标题
            await self.page.fill('#article-title', title)
            print(f"✓ 标题已填写:{title}")
            
            # 2. 填充内容
            editor = await self.page.query_selector('[class*="editor"]')
            await editor.focus()
            await self.page.keyboard.type(content)
            print(f"✓ 内容已填充 ({len(content)}字符)")
            
            # 3. 添加标签
            for tag in tags:
                tag_input = await self.page.query_selector(
                    'input[placeholder*="标签"]'
                )
                await tag_input.fill(tag)
                await self.page.keyboard.press("Enter")
                await asyncio.sleep(0.5)
            print(f"✓ 标签已添加:{tags}")
            
            # 4. 点击发布按钮(处理遮挡)
            print("正在点击发布按钮...")
            await self.find_and_click('button:has-text("发布文章")', method="force")
            await asyncio.sleep(1)
            
            # 5. 处理弹出窗口
            popup_handled = False
            for _ in range(5):  # 最多尝试 5 次
                pages = self.page.context.pages
                if len(pages) > 1:
                    # 切换到弹出窗口
                    await pages[-1].bring_to_front()
                    self.page = pages[-1]
                    popup_handled = True
                    print("✓ 已切换到弹出窗口")
                    break
                await asyncio.sleep(0.5)
            
            if not popup_handled:
                print("⚠ 未检测到弹出窗口")
            
            # 6. 上传封面图
            cover_path = "cover_image.png"
            if Path(cover_path).exists():
                success = await self.upload_with_verification(cover_path)
                if success:
                    print("✓ 封面图已上传")
            
            # 7. 最终确认
            print("正在点击确认发布...")
            await self.find_and_click('button:has-text("确认发布")', method="force")
            
            # 8. 等待发布成功
            await asyncio.sleep(3)
            
            # 9. 验证结果
            current_url = self.page.url
            if "success" in current_url:
                article_id = current_url.split("/")[-1]
                print(f"\n✅ 发布成功!")
                print(f"文章 ID: {article_id}")
                print(f"链接:{current_url}")
                return article_id
            else:
                print("⚠ URL 不包含成功标识,请手动检查")
                return None
                
        except Exception as e:
            print(f"\n❌ 发布失败:{e}")
            import traceback
            traceback.print_exc()
            return None
        
        finally:
            # 截图保存
            await self.page.screenshot(path="publish_result.png")
            print("✓ 已保存结果截图")
    
    async def close(self):
        """关闭浏览器"""
        if self.browser:
            await self.browser.close()

🔍 调试技巧

技巧 1:慢动作回放

python 复制代码
async def slow_motion_click(selector, delay=1):
    """
    慢动作点击(便于观察)
    
    Args:
        selector: 选择器
        delay: 每步间隔(秒)
    """
    element = await page.query_selector(selector)
    
    # 高亮元素
    await element.evaluate("""el => {
        el.style.transition = 'all 0.3s';
        el.style.boxShadow = '0 0 20px red';
    }""")
    
    await asyncio.sleep(delay)
    
    # 滚动到视图
    await element.scroll_into_view_if_needed()
    await asyncio.sleep(delay)
    
    # 点击
    await element.click()
    print(f"✓ 已点击:{selector}")

技巧 2:网络请求监控

python 复制代码
async def monitor_network_during_action(page, action_func):
    """
    监控操作期间的网络请求
    
    Args:
        page: Playwright page
        action_func: 要执行的操作(协程函数)
    """
    requests = []
    
    def log_request(request):
        requests.append({
            'url': request.url,
            'method': request.method,
            'time': asyncio.get_event_loop().time()
        })
        print(f"📡 {request.method} {request.url}")
    
    # 设置监听器
    page.on("request", log_request)
    
    # 执行操作
    start_time = asyncio.get_event_loop().time()
    await action_func()
    end_time = asyncio.get_event_loop().time()
    
    # 分析报告
    print(f"\n网络请求分析:")
    print(f"总请求数:{len(requests)}")
    print(f"耗时:{end_time - start_time:.2f}秒")
    
    failed_requests = [r for r in requests if r['method'] == 'POST' and 'error' in r['url'].lower()]
    if failed_requests:
        print(f"\n⚠ 发现{len(failed_requests)}个失败请求:")
        for req in failed_requests:
            print(f"  - {req['url']}")

技巧 3:Console 日志捕获

python 复制代码
async def capture_console_logs(page, duration=10):
    """
    捕获 Console 日志
    
    Args:
        page: Playwright page
        duration: 捕获时长(秒)
    """
    logs = []
    
    def log_console(msg):
        logs.append({
            'type': msg.type,
            'text': msg.text,
            'time': asyncio.get_event_loop().time()
        })
        print(f"🖥️ [{msg.type}] {msg.text}")
    
    page.on("console", log_console)
    
    # 等待指定时长
    await asyncio.sleep(duration)
    
    # 导出日志
    import json
    with open('console_logs.json', 'w', encoding='utf-8') as f:
        json.dump(logs, f, indent=2, ensure_ascii=False)
    
    print(f"\n✓ 已捕获{len(logs)}条日志,保存到 console_logs.json")
    
    return logs

⚠️ 常见问题

Q1: 如何判断元素是否被遮挡?

A : 使用element.is_visible() 或检查 z-index:

python 复制代码
async def is_element_obstructed(page, selector):
    """检查元素是否被遮挡"""
    
    result = await page.evaluate(f"""() => {{
        const el = document.querySelector('{selector}');
        if (!el) return {{"error": "not_found"}};
        
        const rect = el.getBoundingClientRect();
        const point = {{x: rect.left + rect.width/2, y: rect.top + rect.height/2}};
        
        const topEl = document.elementFromPoint(point.x, point.y);
        const isTop = topEl === el || el.contains(topEl);
        
        return {{
            isVisible: true,
            isTopLayer: isTop,
            blockingElement: isTop ? null : topEl.tagName
        }};
    }}""")
    
    if result.get('error'):
        return False
    
    return not result['isTopLayer']

# 使用
if await is_element_obstructed(page, '#publish-btn'):
    print("⚠ 元素被遮挡,使用强制点击")
    await page.evaluate(f"""() => {{
        document.querySelector('#publish-btn').click();
    }}""")

Q2: 文件选择器不触发怎么办?

A : 检查是否是真·文件输入 还是自定义 UI

python 复制代码
# 检查元素类型
input_type = await page.evaluate("""() => {
    const input = document.querySelector('input[type="file"]');
    return input ? {
        type: input.type,
        display: window.getComputedStyle(input).display,
        visible: input.offsetParent !== null
    } : null;
}""")

print(input_type)
# {'type': 'file', 'display': 'none', 'visible': False} 
# → 说明是隐藏的文件输入,需要特殊处理

解决方案 :直接调用setFiles API:

python 复制代码
await page.evaluate("""async () => {
    const input = document.querySelector('input[type="file"]');
    const dataTransfer = new DataTransfer();
    
    // 读取文件
    const response = await fetch('/path/to/file.png');
    const blob = await response.blob();
    const file = new File([blob], 'file.png', {type: 'image/png'});
    
    dataTransfer.items.add(file);
    input.files = dataTransfer.files;
    
    // 触发 change 事件
    input.dispatchEvent(new Event('change', {bubbles: true}));
}""")

Q3: 如何处理 SPA 的动态加载?

A : 使用wait_for_load_state()wait_for_selector() 组合:

python 复制代码
async def wait_for_dynamic_content(page, target_selector, timeout=30000):
    """
    等待动态内容加载
    
    适用于:
    - React/Vue/Angular 等 SPA
    - 懒加载图片
    - AJAX 异步数据
    """
    
    # 1. 等待网络空闲
    await page.wait_for_load_state("networkidle")
    
    # 2. 等待元素出现
    try:
        await page.wait_for_selector(target_selector, timeout=timeout)
        print(f"✓ 元素已加载:{target_selector}")
        return True
    except:
        print(f"❌ 元素未找到:{target_selector}")
        return False
    
    # 或者使用轮询
    for i in range(timeout // 1000):
        exists = await page.evaluate(f"""() => {{
            return !!document.querySelector('{target_selector}');
        }}""")
        
        if exists:
            print(f"✓ 元素已出现 ({i+1}s)")
            return True
        
        await asyncio.sleep(1)
    
    raise TimeoutError(f"等待{timeout}ms 后元素仍未加载")

🌟 高级技巧

技巧 1:智能重试机制

python 复制代码
import asyncio
from functools import wraps

def retry_on_failure(max_retries=3, delay=2):
    """
    重试装饰器
    
    用法:
        @retry_on_failure(max_retries=3, delay=2)
        async def click_element(selector):
            ...
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            last_exception = None
            
            for attempt in range(1, max_retries + 1):
                try:
                    print(f"尝试 {attempt}/{max_retries}")
                    return await func(*args, **kwargs)
                
                except Exception as e:
                    last_exception = e
                    print(f"失败:{e}")
                    
                    if attempt < max_retries:
                        print(f"{delay}秒后重试...")
                        await asyncio.sleep(delay)
                    else:
                        print("已达到最大重试次数")
            
            raise last_exception
        return wrapper
    return decorator

# 使用
@retry_on_failure(max_retries=3, delay=2)
async def robust_upload(file_path):
    await upload_cover_image(file_path)

技巧 2:可视化调试工具

python 复制代码
async def highlight_elements(page, selector, color="red"):
    """
    高亮显示匹配的元素(便于调试)
    
    Args:
        page: Playwright page
        selector: CSS 选择器
        color: 高亮颜色
    """
    await page.evaluate(f"""() => {{
        const elements = document.querySelectorAll('{selector}');
        elements.forEach((el, i) => {{
            const originalStyle = el.getAttribute('style') || '';
            el.setAttribute('data-original-style', originalStyle);
            el.style.transition = 'all 0.3s';
            el.style.outline = `3px solid ${color}`;
            el.style.backgroundColor = `${color}33`; // 半透明背景
            
            // 添加序号标签
            const badge = document.createElement('span');
            badge.textContent = i + 1;
            badge.style.position = 'absolute';
            badge.style.top = '0';
            badge.style.left = '0';
            badge.style.background = 'red';
            badge.style.color = 'white';
            badge.style.padding = '2px 6px';
            badge.style.fontSize = '12px';
            badge.style.borderRadius = '50%';
            el.style.position = 'relative';
            el.appendChild(badge);
        }});
        console.log(`找到${{elements.length}}个匹配元素`);
    }}""")
    
    print(f"✓ 已高亮 {selector} 匹配的元素")
    
    # 截图保存
    await page.screenshot(path="highlighted_elements.png", full_page=True)
    print("✓ 已保存调试截图")

技巧 3:性能优化

python 复制代码
# 禁用不必要的资源加载
async def optimize_performance(context):
    """
    优化浏览器性能
    
    可提升速度:30-50%
    """
    
    # 屏蔽图片和字体
    await context.route("**/*.{png,jpg,jpeg,gif,webp}", lambda route: route.abort())
    await context.route("**/*.woff", lambda route: route.abort())
    await context.route("**/*.woff2", lambda route: route.abort())
    
    # 屏蔽 CSS(谨慎使用)
    # await context.route("**/*.css", lambda route: route.abort())
    
    # 屏蔽第三方脚本
    await context.route("**://*.google-analytics.com/**", lambda route: route.abort())
    await context.route("**://*.googletagmanager.com/**", lambda route: route.abort())
    
    # 减少视口大小
    await context.set_viewport_size({"width": 1280, "height": 720})
    
    # 设置超时
    context.set_default_timeout(30000)
    
    print("✓ 性能优化已启用")

🚀 课后作业

基础题

  1. 实现一个函数,自动检测并关闭页面上的所有弹窗
  2. 为文件上传功能添加进度条显示

进阶题

  1. 创建一个通用的"防遮挡点击"工具函数
  2. 实现智能等待:根据元素类型自动选择等待策略

挑战题

  1. 开发一个可视化的元素检查工具(类似 Chrome DevTools)
  2. 构建分布式爬虫系统:多浏览器实例并行工作

📚 延伸阅读


💬 总结

核心要点

  1. 🎯 强制点击:解决元素遮挡的银弹
  2. 📁 双重验证:文件上传的标准流程
  3. 🛡️ 抗干扰设计:多页面管理和对话框处理
  4. 🔍 可视化调试:高亮、截图、日志三位一体

行动清单

  • ✅ 为你的自动化脚本添加重试机制
  • ✅ 实现通用的防遮挡点击函数
  • ✅ 建立调试工具箱(高亮、截图、日志)
  • ✅ 学习 Playwright 高级 API

下篇预告:《AI 封面图生成:GLM-Image 多模态实践》

  • 文生图模型原理详解
  • Prompt 构建的艺术
  • 图片质量评估与优化
  • 实战:根据文章标题自动生成封面

敬请期待!🎉

相关推荐
喵手1 小时前
Python爬虫实战:降维打击 - 用 Playwright 嗅探网络层抓取douyin无水印视频!
爬虫·python·爬虫实战·抖音·playwright·零基础python爬虫教学·采集抖音无水印视频
大Mod_abfun1 小时前
AntdUI教程#1ChatList交互(vb.net)
服务器·前端·ui·交互·antdui·聊天框
少云清2 小时前
【UI自动化测试】2_IOS自动化测试 _使用模拟器
ui·ios
Cxiaomu2 小时前
深入解析 WebSocket 语音交互:状态流转、表情联动与音频上下行处理实践
websocket·音视频·交互
MIXLLRED2 小时前
Ubuntu 22.04 + ROS2 Humble 环境下设计图形化交互界面
linux·ubuntu·交互·图形界面
witAI2 小时前
**即梦仿真人剧2025推荐,沉浸式情感交互新体验**据
python·交互
stolentime2 小时前
洛谷P15652 [省选联考 2026] 排列游戏 / perm题解
c++·算法·交互·洛谷·联合省选2026
伊玛目的门徒2 小时前
【浏览器MCP组件】 chrome-devtools的快捷方式和MCP配置
浏览器自动化·mcp·chrome-devtools·浏览器mcp
修炼前端秘籍的小帅13 天前
Stitch——Google热门的免费AI UI设计工具
前端·人工智能·ui