资讯动态

大模型赋能UI自动化测试:Qwen3-14B与Playwright实战

发布时间:2026/9/20 12:41:13 来源:尧图企业网站定制
1. 为什么要把大模型塞进UI自动化测试里UI自动化测试这件事做过的人都懂。写脚本本身不难难的是维护。一个按钮的文案从“提交”改成“确认提交”一个弹窗的关闭图标从右上角挪到了左上角或者某个列表页的分页组件换了个UI库你之前写好的那一堆page.click(text提交)就全废了。我见过最夸张的一个项目前端两周一次迭代自动化脚本的维护成本比手工回归还高最后团队直接把UI自动化给停了。这个问题的根子在于传统UI自动化依赖的是确定性定位器——ID、class、XPath、CSS选择器、文本内容。这些东西本质上都是“写死的坐标”页面结构一变坐标就失效。而人做测试的时候靠的是什么靠的是语义理解。你看到一个按钮上面写着“确认提交订单”你不会去关心它的id是btn-submit-001还是btn_confirm你只关心“这是一个提交订单的按钮”。大模型恰好擅长干这件事——把自然语言描述映射到页面元素上。Qwen3-14B这个模型值得单独说一下。14B这个参数量在本地部署场景里是个甜点位置比7B的理解能力强不少尤其在中文语义和结构化输出上表现稳定又比32B、72B的模型对显存友好得多。一张24G显存的卡比如4090或者A10用4-bit量化就能跑起来推理速度在可接受范围内。对于UI自动化测试这种“每次调用只需要处理一小段DOM片段”的场景14B完全够用没必要上更大的模型。我这次实践的核心思路很简单用Playwright负责页面操作和元素信息采集用Qwen3-14B负责语义理解和定位决策用pytest负责测试组织和断言。三者各司其职大模型不直接操作浏览器只做“翻译”——把“点击登录按钮”翻译成具体的Playwright定位器。这样做的好处是即使大模型偶尔抽风Playwright的稳定性和pytest的测试框架能力依然在兜底。这套方案适合谁如果你正在被UI自动化脚本的维护成本折磨或者你的团队前端迭代频繁、页面元素经常变动又或者你想尝试把大模型落地到实际工程场景而不是只跑个demo那这套东西值得你花时间搭一遍。下面我把整个实践过程拆开讲包括环境搭建、核心实现、踩过的坑和优化技巧。2. 整体架构设计与技术选型考量2.1 三层架构的职责划分整套系统我分成了三层每层的边界划得很清楚第一层是页面感知层由Playwright负责。它的任务不是执行操作而是“看清楚页面上有什么”。具体来说每次需要定位元素时Playwright会提取当前页面的可交互元素信息——包括按钮、输入框、链接、下拉框等把它们的标签名、文本内容、placeholder、aria-label、role属性、位置信息等整理成结构化数据。这一步很关键因为大模型再聪明你给它一堆HTML源码它也抓瞎必须先把信息做减法。第二层是语义决策层由Qwen3-14B负责。它接收两个输入一是测试用例里的自然语言描述比如“在用户名输入框中输入admin”二是页面感知层传来的元素列表。模型的任务是输出一个JSON告诉系统“应该操作哪个元素、用什么方式定位、执行什么动作”。这里我强制要求模型输出结构化JSON而不是自由文本原因后面会讲。第三层是执行与断言层由pytest和Playwright共同完成。pytest负责测试用例的组织、参数化、fixture管理、断言和报告生成Playwright根据决策层返回的定位信息执行实际的操作。这一层是纯确定性的不涉及任何模型调用保证测试结果的可靠性。三层之间的数据流是这样的pytest触发测试用例 → 页面感知层采集元素 → 语义决策层返回定位方案 → 执行层操作并断言 → 结果回传给pytest。整个链路里大模型只在第二步介入而且只做“选择题”不做“问答题”这大大降低了不确定性。2.2 为什么选Qwen3-14B而不是其他模型选型这件事我前前后后试了四五个模型最后定在Qwen3-14B上理由有这么几条中文语义理解到位。UI自动化测试的场景里页面文案基本都是中文测试用例的描述也是中文。有些开源模型英文能力很强但中文语义的细腻程度不够比如“点击右上角的设置图标”和“点击设置按钮”它分不清“右上角”是位置约束还是修饰语。Qwen3系列在中文上的表现明显更稳。结构化输出可靠。我要求模型输出严格的JSON格式包含element_index、locator_type、locator_value、action这几个字段。Qwen3-14B在few-shot提示下JSON格式的遵循率能到95%以上偶尔格式错了重试一次基本就能修正。相比之下有些模型虽然理解能力不错但输出格式总是飘解析起来很头疼。本地部署成本可控。14B模型用vLLM部署4-bit量化后显存占用大概在10-12G左右一张消费级显卡就能跑。推理速度方面输入一段500token左右的元素列表加指令输出50token左右的JSON在4090上大概1-2秒。对于UI自动化测试来说这个延迟完全可以接受因为瓶颈通常在页面加载和操作等待上不在模型推理上。生态成熟。Qwen系列的tokenizer、chat template、量化方案都有现成的工具链支持vLLM、Ollama、llama.cpp都能直接跑不用自己折腾格式转换。2.3 Playwright相比Selenium的优势这个项目里我选了Playwright而不是Selenium主要看中几点自动等待机制。Playwright的locator自带智能等待元素不可见、不可交互时会自动重试不需要像Selenium那样到处写WebDriverWait。这在UI自动化里太重要了因为页面加载速度不稳定是常态。元素信息采集方便。Playwright可以在页面上下文里直接执行JavaScript一次性把页面上所有可交互元素的信息捞出来比Selenium的find_elements逐个遍历高效得多。多浏览器支持一致。Chromium、Firefox、WebKit用同一套API不用为不同浏览器写不同的适配代码。网络请求监听能力强。Playwright可以监听和拦截页面请求这在处理动态加载的iframe或者异步渲染的组件时特别有用。有些页面元素是懒加载的等DOM稳定后再采集元素信息准确率会高很多。2.4 大模型在测试链路中的定位有一点必须说清楚大模型不是用来替代Playwright的而是用来增强定位能力的。我见过一些方案让大模型直接生成Playwright代码然后执行生成的代码。这种做法看起来很酷但实际用起来问题很大——模型生成的代码可能语法错误、可能逻辑不对、可能引用了不存在的API调试成本极高。我的做法是让模型只做“元素匹配”这一件事给定一个自然语言指令和一组候选元素返回最匹配的那个元素的索引和推荐定位方式。模型不生成代码不执行操作只输出一个结构化的决策结果。这样即使模型判断错了影响范围也仅限于“点错了元素”而不是“执行了一段错误的代码”。定位错了可以重试、可以加校验、可以回退到传统定位器风险完全可控。3. 环境搭建与核心实现细节3.1 本地部署Qwen3-14B的完整步骤先说模型部署。我用的是vLLM来跑Qwen3-14B的AWQ量化版本原因是vLLM的吞吐量比Ollama高不少而且支持OpenAI兼容的API接口调用起来方便。环境准备这块基础依赖是Python 3.10、CUDA 12.1、PyTorch 2.1。显存方面AWQ 4-bit量化后的Qwen3-14B大概需要10-12G显存加上KV Cache的占用建议至少准备16G显存。如果显存紧张可以调小--max-model-len参数UI自动化场景下单次输入不会超过2048个token设成4096足够了。安装vLLM的命令pip install vllm0.4.0 pip install autoawq启动模型服务的命令python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen3-14B-AWQ \ --quantization awq \ --dtype float16 \ --max-model-len 4096 \ --gpu-memory-utilization 0.85 \ --port 8000启动之后你会得到一个OpenAI兼容的API端点http://localhost:8000/v1。用curl测一下curl http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d { model: Qwen/Qwen3-14B-AWQ, messages: [{role: user, content: 你好}], max_tokens: 50 }能正常返回就说明部署成功了。注意如果你的显卡不支持AWQ量化可以改用GPTQ版本或者用llama.cpp跑GGUF格式。但vLLM对AWQ的支持最好推理速度也最快有条件的话优先选AWQ。3.2 Playwright环境配置与元素采集Playwright的安装很简单pip install playwright pytest-playwright playwright install chromium元素采集是整个方案的基础采集质量直接决定模型判断的准确率。我的做法是在页面上下文里执行一段JavaScript把所有可交互元素的信息提取出来EXTRACT_ELEMENTS_JS () { const selectors button, input, textarea, select, a, [rolebutton], [rolelink], [roletab], [rolemenuitem]; const elements document.querySelectorAll(selectors); const result []; elements.forEach((el, index) { const rect el.getBoundingClientRect(); if (rect.width 0 || rect.height 0) return; result.push({ index: index, tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || ).trim().slice(0, 50), placeholder: el.placeholder || , ariaLabel: el.getAttribute(aria-label) || , role: el.getAttribute(role) || , type: el.type || , name: el.name || , id: el.id || , className: el.className || , x: Math.round(rect.x), y: Math.round(rect.y), visible: rect.width 0 rect.height 0 }); }); return result; } 这段JS做了几件事筛选出所有可交互元素、过滤掉不可见的元素、提取关键属性、记录位置信息。为什么要记录位置因为有些场景下“右上角的按钮”这种描述需要靠坐标来判断。采集到的元素列表大概长这样[ {index: 0, tag: input, text: , placeholder: 请输入用户名, type: text, x: 320, y: 180}, {index: 1, tag: input, text: , placeholder: 请输入密码, type: password, x: 320, y: 240}, {index: 2, tag: button, text: 登录, type: submit, x: 320, y: 300}, {index: 3, tag: a, text: 忘记密码, x: 420, y: 300} ]这个列表会被序列化成JSON连同测试指令一起发给Qwen3-14B。3.3 提示词工程让模型稳定输出JSON提示词设计是这套方案里最需要打磨的部分。我试过很多版本最后定下来的提示词结构是这样的SYSTEM_PROMPT 你是一个UI自动化测试的定位助手。你的任务是根据用户的自然语言指令和当前页面的可交互元素列表选择最匹配的元素并返回定位方案。 你必须严格按照以下JSON格式输出不要输出任何其他内容 { element_index: 元素在列表中的索引, locator_type: 定位方式可选值text, placeholder, role, css, xpath, locator_value: 定位值, action: 操作类型可选值click, fill, select, hover, check, confidence: 置信度0-1之间的小数, reason: 选择理由一句话 } 规则 1. 优先使用text定位如果元素有明确的文本内容 2. 输入框优先使用placeholder定位 3. 如果text和placeholder都不明确使用rolename组合定位 4. 如果以上都不行使用css选择器 5. confidence低于0.6时说明定位不确定需要人工确认 用户消息的构造def build_user_message(instruction, elements): elements_json json.dumps(elements, ensure_asciiFalse, indent2) return f当前页面可交互元素列表 {elements_json} 测试指令{instruction} 请返回定位方案JSON这里有几个设计决策值得解释为什么要求输出element_index因为模型直接输出CSS选择器容易出错但让它从列表里选一个索引准确率高得多。拿到索引之后系统可以自己根据元素属性生成可靠的定位器。为什么要求输出confidence这是给系统一个“不确定就说不确定”的出口。当置信度低于阈值时系统可以回退到传统定位方式或者标记该步骤需要人工介入而不是硬着头皮点错元素。为什么要求输出reason调试的时候特别有用。当模型定位错了看它的理由就能知道是理解偏差还是元素信息采集不全方便针对性优化。3.4 定位器生成与回退策略模型返回element_index之后系统需要生成实际的Playwright定位器。我的策略是多级回退第一优先级是用模型推荐的locator_type和locator_value。如果模型说用text定位那就用page.get_by_text(locator_value)。第二优先级是用元素的其他属性。如果text定位失败比如页面上有多个相同文本的元素就尝试用placeholder、aria-label、role等属性。第三优先级是用元素在列表中的位置信息。如果所有语义定位都失败就用page.locator(selector).nth(index)这种基于顺序的定位方式兜底。第四优先级是回退到传统定位器。如果连顺序定位都失败说明页面结构可能变了这时候会抛出一个明确的异常提示需要人工检查。def resolve_locator(page, decision, elements): idx decision[element_index] element elements[idx] locator_strategies [] if decision[locator_type] text and element.get(text): locator_strategies.append((text, element[text])) if element.get(placeholder): locator_strategies.append((placeholder, element[placeholder])) if element.get(ariaLabel): locator_strategies.append((aria-label, element[ariaLabel])) if element.get(role) and element.get(text): locator_strategies.append((role, element[role], element[text])) for strategy in locator_strategies: try: if strategy[0] text: loc page.get_by_text(strategy[1], exactTrue) elif strategy[0] placeholder: loc page.get_by_placeholder(strategy[1]) elif strategy[0] aria-label: loc page.get_by_label(strategy[1]) elif strategy[0] role: loc page.get_by_role(strategy[1], namestrategy[2]) if loc.count() 1: return loc except Exception: continue # 兜底基于索引 return page.locator(body).locator(button, input, a, select, textarea).nth(idx)这套回退策略实测下来定位成功率能从单一定位方式的85%左右提升到97%以上。4. 完整实操流程与关键环节实现4.1 项目目录结构先看一下整个项目的目录结构方便你对照搭建smart-ui-test/ ├── conftest.py # pytest fixtures ├── pytest.ini # pytest配置 ├── requirements.txt # 依赖清单 ├── config/ │ └── settings.py # 模型API地址、超时等配置 ├── core/ │ ├── element_extractor.py # 元素采集 │ ├── llm_client.py # 大模型调用封装 │ ├── locator_resolver.py # 定位器解析与回退 │ └── action_executor.py # 操作执行 ├── tests/ │ ├── test_login.py # 登录测试用例 │ └── test_order.py # 下单测试用例 └── utils/ └── logger.py # 日志这个结构不复杂核心逻辑集中在core/目录下的四个模块里。4.2 大模型调用封装llm_client.py负责和vLLM服务通信我用了OpenAI的Python SDK因为vLLM兼容OpenAI接口from openai import OpenAI import json import re class LLMClient: def __init__(self, base_urlhttp://localhost:8000/v1, modelQwen/Qwen3-14B-AWQ): self.client OpenAI(base_urlbase_url, api_keynot-needed) self.model model def locate_element(self, instruction, elements, retry2): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: build_user_message(instruction, elements)} ] for attempt in range(retry 1): try: response self.client.chat.completions.create( modelself.model, messagesmessages, temperature0.1, max_tokens256, response_format{type: json_object} ) content response.choices[0].message.content decision json.loads(content) # 校验必要字段 required [element_index, locator_type, locator_value, action] if all(k in decision for k in required): return decision except (json.JSONDecodeError, KeyError) as e: if attempt retry: messages.append({role: assistant, content: content}) messages.append({role: user, content: 输出格式有误请严格按照JSON格式重新输出。}) else: raise RuntimeError(f模型输出解析失败: {e})这里有几个细节temperature设成0.1而不是0是因为完全贪婪解码有时候会陷入重复输出的死循环0.1的随机性刚好能避免这个问题又不影响稳定性。response_format设成json_object是vLLM支持的JSON模式能进一步提高格式遵循率。4.3 测试用例的编写方式用了大模型之后测试用例的写法可以变得非常“自然语言化”import pytest from core.element_extractor import extract_elements from core.llm_client import LLMClient from core.locator_resolver import resolve_locator from core.action_executor import execute_action llm LLMClient() def smart_action(page, instruction): 智能执行一个操作 elements extract_elements(page) decision llm.locate_element(instruction, elements) if decision[confidence] 0.6: pytest.skip(f定位置信度过低({decision[confidence]})跳过: {instruction}) locator resolve_locator(page, decision, elements) execute_action(locator, decision[action], instruction) return decision def test_login_success(page): page.goto(https://example.com/login) smart_action(page, 在用户名输入框中输入admin) smart_action(page, 在密码输入框中输入123456) smart_action(page, 点击登录按钮) page.wait_for_url(**/dashboard) assert page.get_by_text(欢迎回来).is_visible()对比一下传统写法def test_login_traditional(page): page.goto(https://example.com/login) page.fill(#username, admin) page.fill(#password, 123456) page.click(button[typesubmit]) page.wait_for_url(**/dashboard) assert page.get_by_text(欢迎回来).is_visible()传统写法里#username、#password、button[typesubmit]这三个定位器是硬编码的页面一改就失效。智能写法里定位器是运行时动态生成的页面结构变了只要元素还在、语义没变脚本就能继续跑。4.4 操作执行模块的实现action_executor.py负责把决策结果转换成实际的Playwright操作def execute_action(locator, action, instruction): if action click: locator.click(timeout5000) elif action fill: value extract_value_from_instruction(instruction) locator.fill(value, timeout5000) elif action select: value extract_value_from_instruction(instruction) locator.select_option(value, timeout5000) elif action hover: locator.hover(timeout5000) elif action check: locator.check(timeout5000) else: raise ValueError(f不支持的操作类型: {action})extract_value_from_instruction这个函数负责从自然语言指令里提取要填入的值。比如“在用户名输入框中输入admin”需要提取出“admin”。这个提取逻辑可以用正则也可以让大模型顺便做了。我选择用正则因为输入值的格式相对固定import re def extract_value_from_instruction(instruction): patterns [ r输入[:]\s*(.), r输入\s(.?)(?:$||。), r填入[:]\s*(.), r选择[:]\s*(.), ] for pattern in patterns: match re.search(pattern, instruction) if match: return match.group(1).strip() return 4.5 处理动态加载和iframe实际项目里经常遇到元素是异步加载的或者嵌在iframe里。Playwright在这方面的能力比Selenium强不少。对于异步加载我的做法是在采集元素之前先等页面稳定def wait_for_stable(page, timeout3000): page.wait_for_load_state(networkidle, timeouttimeout) page.wait_for_timeout(500) # 额外等500ms让动画结束对于iframe需要先切换到对应的frame再采集元素def extract_elements_with_frames(page): all_elements [] # 主页面元素 main_elements page.evaluate(EXTRACT_ELEMENTS_JS) for el in main_elements: el[frame] main all_elements.extend(main_elements) # iframe元素 for i, frame in enumerate(page.frames): if frame page.main_frame: continue try: frame_elements frame.evaluate(EXTRACT_ELEMENTS_JS) for el in frame_elements: el[frame] fframe_{i} all_elements.extend(frame_elements) except Exception: continue return all_elements执行操作的时候需要根据frame字段切换到对应的framedef get_target(page, element): if element.get(frame) main: return page frame_idx int(element[frame].split(_)[1]) return page.frames[frame_idx]4.6 置信度阈值与人工介入机制置信度阈值我设的是0.6低于这个值就跳过并标记。这个阈值不是拍脑袋定的是跑了一轮回归测试之后统计出来的置信度在0.6以上的定位准确率在95%以上0.6以下的准确率掉到70%左右。与其让模型硬猜不如直接跳过让测试报告里明确标出“这一步需要人工确认”。实际跑下来大概有5%-8%的步骤会触发低置信度跳过。这些步骤通常是页面上有多个相似元素、元素文本太短没有区分度、或者指令本身描述不够明确。对于这些情况我会回头优化测试用例的描述或者补充元素的aria-label属性而不是去调模型参数。4.7 性能优化缓存与批量处理每次操作都调一次模型在测试用例多的时候会比较慢。我加了两个优化元素列表缓存。同一个页面上连续执行多个操作时如果页面没有发生导航或刷新元素列表可以复用不需要每次都重新采集。class PageContext: def __init__(self, page): self.page page self._elements None self._url None property def elements(self): current_url self.page.url if self._elements is None or self._url ! current_url: self._elements extract_elements(self.page) self._url current_url return self._elements def invalidate(self): self._elements None批量定位。如果一个测试用例里有多个操作步骤可以把它们合并成一次模型调用让模型一次性返回所有步骤的定位方案。这样能把模型调用次数从N次降到1次整体测试时间能缩短40%左右。def batch_locate(self, instructions, elements): 一次性定位多个指令 instructions_text \n.join([f{i1}. {inst} for i, inst in enumerate(instructions)]) user_message f当前页面可交互元素列表 {json.dumps(elements, ensure_asciiFalse)} 请为以下每个指令返回定位方案输出JSON数组 {instructions_text} # ... 调用模型并解析数组结果5. 常见问题与排查技巧实录5.1 模型定位错误的典型场景跑了一段时间之后我整理了几类最常见的定位错误错误类型典型表现根因解决方案相似元素混淆点了“取消”而不是“确认”页面上有多个文本相近的按钮在指令中补充位置描述如“点击弹窗右下角的确认按钮”动态文本误判定位到了包含用户名的欢迎语元素文本包含动态内容采集元素时对动态文本做脱敏或截断处理隐藏元素误选定位到了不可见的元素元素在DOM中存在但被CSS隐藏采集时过滤display:none和visibility:hidden的元素输入框类型混淆在搜索框里输入了登录密码页面上有多个input元素在指令中明确输入框的placeholder或标签文本5.2 模型输出格式异常的排查虽然Qwen3-14B的JSON遵循率很高但偶尔还是会出问题。最常见的两种异常输出被截断。如果max_tokens设得太小JSON输出到一半就断了。解决办法是把max_tokens设到256以上或者在提示词里明确要求“输出简洁不要有多余解释”。输出了Markdown代码块。有时候模型会把JSON包在json里。解决办法是在提示词里强调“直接输出JSON不要用代码块包裹”或者在解析前先用正则把代码块标记去掉def clean_json_output(content): content content.strip() if content.startswith(): content re.sub(r^\w*\n, , content) content re.sub(r\n$, , content) return content.strip()5.3 页面元素采集不全的问题元素采集不全是最容易被忽视的问题。有几种情况会导致采集遗漏Shadow DOM里的元素。有些Web Components把元素藏在Shadow DOM里普通的querySelectorAll查不到。解决办法是在采集JS里递归遍历Shadow Rootfunction getAllElements(root) { let elements Array.from(root.querySelectorAll(selectors)); const shadowHosts root.querySelectorAll(*); shadowHosts.forEach(host { if (host.shadowRoot) { elements elements.concat(getAllElements(host.shadowRoot)); } }); return elements; }懒加载元素。有些元素要滚动到可视区域才会渲染。解决办法是在采集之前先滚动页面到底部再滚回来def scroll_to_load(page): page.evaluate(window.scrollTo(0, document.body.scrollHeight)) page.wait_for_timeout(500) page.evaluate(window.scrollTo(0, 0)) page.wait_for_timeout(300)Canvas绘制的元素。如果页面是用Canvas画的那DOM里根本没有对应的元素节点这套方案就不适用了。这种情况需要走图像识别或者坐标点击的路子不在本文讨论范围内。5.4 模型推理延迟的优化如果测试用例很多模型推理的延迟会累积。除了前面说的批量定位还有几个优化方向用更小的量化精度。AWQ 4-bit已经比较激进了如果还嫌慢可以试试GPTQ 3-bit但精度会下降需要重新评估定位准确率。限制元素列表长度。如果页面上可交互元素超过50个可以按位置或类型做预筛选只把最相关的元素发给模型。比如指令是“点击登录按钮”那就只发按钮类元素输入框和链接可以过滤掉。并发调用。如果测试用例之间没有依赖关系可以并发执行每个用例独立调用模型。vLLM本身支持并发推理只要显存够并发数可以开到4-8。5.5 实操避坑清单最后整理一份避坑清单都是实际踩过的坑不要用temperature0会偶发重复输出死循环用0.1更稳。元素采集一定要过滤不可见元素否则模型会被隐藏元素干扰。提示词里一定要给few-shot示例哪怕只有一个示例格式遵循率也会明显提升。置信度阈值不要设太高0.6-0.7之间比较合适设到0.8以上会频繁跳过正常步骤。测试用例的指令描述要尽量具体“点击按钮”不如“点击页面底部的提交按钮”。模型服务要加健康检查vLLM偶尔会因为显存碎片化而OOM加个自动重启机制。每次模型调用都要记日志包括输入的元素列表、指令、模型输出、最终执行的定位器出问题的时候方便回溯。不要指望大模型100%准确把它当成一个“能力很强的助手”而不是“完全可靠的组件”该有的校验和回退一个都不能少。这套方案我在两个项目里实际跑过一个后台管理系统和一个电商前台。后台管理系统的页面结构相对稳定智能定位的成功率在96%以上电商前台的页面变动频繁传统脚本每周都要修换成智能定位之后维护频率降到了每月一次。模型推理带来的额外延迟大概在每次操作1.5秒左右对于回归测试来说完全可以接受。如果你也在被UI自动化的维护成本困扰建议从一个小模块开始试跑通了再逐步扩大范围。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价