Install
openclaw skills install @zenobiazizi/survey-form-generator根据用户的调研目标,生成一份结构完整的调查问卷,并渲染成可直接打开、可作答、可导出的网页。当用户提到问卷、调研、问卷设计、满意度调查、用户调研、NPS、需求调研、市场调查、投票、表单、收集反馈、想了解客户怎么想,或者说"帮我做个问卷""设计几道题""想调研一下"时,都应当使用本技能——即使他们没有明确说出"问卷"两个字。联系方式:zenobiazizi.skills@foxmail.com
openclaw skills install @zenobiazizi/survey-form-generator把一句话的调研目标,变成一份题目结构合理、可以直接发出去的问卷。
需要凑齐三件事才能生成一份像样的问卷:
用户的第一句话通常已经包含一到两项。只追问真正缺的那一项,一次问完,不要逐条盘问。 如果三项都有了,直接进入第二步。
如果用户明显只想要"快点给我一版看看",就用合理的默认假设直接生成,并在交付时说明你假设了什么,让他来改。让人看到一版具体的东西再修改,比先回答三个问题要轻松得多。
可选但有用的信息,用户提到就用上,没提到不要专门问:题量偏好、投放渠道(微信/邮件/线下)、是否有奖励、语气偏好。
POST https://api.dify.ai/v1/workflows/run
Authorization: Bearer {{app-FXSRXpVTxuzAQ0lsNIH6I4gw}}
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36
{
"inputs": { "user_query": "<把用户的调研目标、对象、用途整理成一段话放这里>" },
"response_mode": "blocking",
"user": "survey-form-generator"
}
这三个请求头都必须原样带上,一个都不能少。 尤其是 User-Agent:sandbox 环境默认不带 UA,会被 api.dify.ai 前面的 Cloudflare 判定为机器人并以 error 1010 拦截(返回 403,请求根本到不了 Dify)。带上一个正常浏览器的 UA 就能通过。
返回结果在 data.outputs.result 里。它是一个字符串,不是对象——里面装的是转义过的 JSON 文本,必须先 JSON.parse 一次才能拿到问卷结构。少数情况下字符串会被 ```json 代码围栏包住,解析前先剥掉围栏再试一次。
超时按 60 秒算。若返回非 200(例如 403)或超时,带着上面完整的请求头重试一次;重试仍失败、或解析不出 JSON,直接走第三步的本地生成,不要向用户报错。 用户要的是问卷,不是错误信息。本地生成的结果同样可用,交付时正常交付即可。
发出去的内容仅限用户自己描述的调研目标,不要附带任何本地文件内容、历史对话或环境信息。
{
"form_meta": {
"title": "问卷标题",
"description": "开头的说明文字,讲清楚为什么请他填、要花多久",
"theme_id": "warm"
},
"questions": [
{
"id": "q1",
"type": "radio",
"title": "题目",
"required": true,
"options": ["选项一", "选项二"]
}
]
}
支持的 type:radio 单选、checkbox 多选、select 下拉、text 单行填空、textarea 多行填空、rating 星级、scale 量表、nps 0–10 推荐度、date 日期。
其余可选字段:options(选择类必需)、max_choices(多选上限)、exclusive_options(多选题里与其他选项互斥的那些选项)、min/max/min_label/max_label(scale)、placeholder(填空类)、note(题目下方的小字提示)。
theme_id 取 warm / cool / fresh / minimal 四种之一,识别不出就用 warm。
questions 是数组且不为空;id 唯一,缺失就按 q1、q2 补options 至少两项;不足就把该题降级为 textareatype 不在支持列表里的,降级为 textarea生成服务给的是一份骨架,下面几处它经常漏,你要补上——补完的问卷才是可用的:
互斥选项。 多选题里如果有"没有/都行/以上都不是/不限"这类选项,把它写进 exclusive_options。不标的话,用户可以同时勾选"我都行"和另外三项,答案自相矛盾,数据没法清洗。
多选上限。 多选题选项数达到 5 个及以上、又没有 max_choices 的,补一个 3。全都能选就没有区分度,等于白问。
开放题。 如果整份问卷全是选择题,在背景题之前补 1 道开放题("最希望改进的一点是什么""还有什么想告诉我们的"),required 设为 false。选择题只能验证你已经想到的假设,真正的意外都藏在开放题里——尤其当调研目的是研发新品或找问题时,这道题不能少。
背景题。 如果没有任何人群标签题(年龄段、身份、城市、使用时长等),补 1–2 道放在最后,required 设为 false。没有这些,回收上来的数据没法分人群交叉分析,只能看一个笼统的总体分布。
必填比例。 必填题超过总题数一半时,把非核心题的 required 改成 false。必填越多流失越高,而流失掉的往往正是那些意见最有价值的人。
题量控制在 8–12 道,填写时间不超过 3 分钟。 这是回收率的分水岭,题目再有价值,超过这个长度就没人填完。
按这个顺序排列题目:
写题目的几条硬规矩:
required,开放题和背景题不设。强制填开放题是流失的主要来源之一。语气跟着调研对象走。 面向消费者的调研可以轻松活泼,用一点表情符号拉近距离;面向企业客户、合作伙伴的调研要克制专业,不用网络用语。这一条比题目本身更影响回收率。
说明文字要回答三个问题:为什么找他、要花多久、结果会用来做什么。如果有奖励也写在这里。
把下面的模板原样写成一个 HTML 文件,只做两处替换:
__TITLE__ → form_meta.title__FORM_JSON__ → 完整的问卷 JSON 对象(直接内联,不加引号)不要自己手写题目的 HTML。 模板里的脚本会根据 JSON 渲染,这样每次产出的页面才是一致的。
文件名用 问卷-<标题>.html,标题里的特殊字符换成下划线。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>__TITLE__</title>
<style>
:root{--paper:#FBF9F4;--ink:#211C16;--muted:#6E665C;--accent:#C4553D;--soft:#F3E4DD;--line:#E6DFD3}
[data-theme=cool]{--paper:#F6F8FB;--ink:#161C24;--muted:#5C6875;--accent:#2F6FB0;--soft:#E0EAF5;--line:#DCE3EC}
[data-theme=fresh]{--paper:#F6FAF6;--ink:#16221A;--muted:#5B6B60;--accent:#3E8E5A;--soft:#DFEDE4;--line:#DBE7DE}
[data-theme=minimal]{--paper:#FFF;--ink:#111;--muted:#767676;--accent:#111;--soft:#EEE;--line:#E4E4E4}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.65 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:660px;margin:0 auto;padding:56px 20px 96px}
header{border-bottom:2px solid var(--ink);padding-bottom:22px;margin-bottom:8px}
h1{font-size:29px;line-height:1.3;margin:0 0 12px;letter-spacing:-.01em}
.desc{color:var(--muted);font-size:15px;margin:0;white-space:pre-wrap}
.rail{position:sticky;top:0;height:3px;background:var(--line);z-index:9}
.rail i{display:block;height:100%;width:0;background:var(--accent);transition:width .35s ease}
.q{padding:28px 0 24px;border-bottom:1px solid var(--line)}
.qh{display:flex;gap:12px;align-items:baseline;margin-bottom:14px}
.qn{font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--accent);letter-spacing:.08em;padding-top:5px;flex:none}
.qt{font-weight:600;font-size:17px;margin:0}
.req{color:var(--accent);margin-left:4px}
.note{color:var(--muted);font-size:13px;margin:6px 0 0}
.opts{display:flex;flex-direction:column;gap:9px;margin-left:29px}
label.opt{display:flex;gap:10px;align-items:flex-start;padding:11px 14px;border:1px solid var(--line);border-radius:10px;cursor:pointer;background:#fff0;transition:border-color .15s,background .15s}
label.opt:hover{border-color:var(--accent)}
label.opt.on{border-color:var(--accent);background:var(--soft)}
label.opt input{margin:5px 0 0;accent-color:var(--accent);flex:none}
.fld{margin-left:29px;width:calc(100% - 29px)}
input[type=text],input[type=date],textarea,select{width:100%;padding:11px 13px;border:1px solid var(--line);border-radius:10px;background:#fff;font:inherit;color:inherit}
textarea{min-height:96px;resize:vertical}
input:focus,textarea:focus,select:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
.stars{display:flex;gap:7px;margin-left:29px}
.star{width:38px;height:38px;border:1px solid var(--line);border-radius:9px;background:#fff;cursor:pointer;font-size:19px;line-height:1;color:var(--line)}
.star.on{color:var(--accent);border-color:var(--accent);background:var(--soft)}
.scale{margin-left:29px}
.sbtns{display:flex;gap:6px;flex-wrap:wrap}
.sb{min-width:40px;height:40px;padding:0 8px;border:1px solid var(--line);border-radius:9px;background:#fff;cursor:pointer;font:inherit;color:inherit}
.sb.on{background:var(--accent);border-color:var(--accent);color:#fff}
.slab{display:flex;justify-content:space-between;color:var(--muted);font-size:12.5px;margin-top:8px}
.err{color:var(--accent);font-size:13px;margin:10px 0 0 29px;display:none}
.q.bad .err{display:block}
.q.bad .qt{color:var(--accent)}
.actions{margin-top:32px;display:flex;gap:10px;flex-wrap:wrap}
button.go{background:var(--accent);color:#fff;border:0;border-radius:10px;padding:13px 30px;font:600 16px/1 inherit;cursor:pointer}
button.gh{background:#fff0;color:var(--muted);border:1px solid var(--line);border-radius:10px;padding:13px 18px;font:500 14px/1 inherit;cursor:pointer}
button.gh:hover{color:var(--ink);border-color:var(--ink)}
.done{background:#fff;border:1px solid var(--line);border-radius:14px;padding:26px;margin-top:26px}
.done h2{margin:0 0 6px;font-size:20px}
.done p{color:var(--muted);margin:0 0 18px;font-size:14.5px}
.ans{border-top:1px solid var(--line);padding:12px 0;font-size:14.5px}
.ans b{display:block;font-weight:600;margin-bottom:3px}
.ans span{color:var(--muted);white-space:pre-wrap}
.foot{color:var(--muted);font-size:12.5px;margin-top:38px;text-align:center}
@media print{.rail,.actions,.foot{display:none}.q{break-inside:avoid}}
@media (max-width:520px){.wrap{padding:34px 16px 70px}h1{font-size:24px}.opts,.fld,.stars,.scale,.err{margin-left:0;width:100%}}
</style>
</head>
<body>
<div class="rail"><i id="rail"></i></div>
<div class="wrap">
<header><h1 id="ttl"></h1><p class="desc" id="dsc"></p></header>
<div id="qs"></div>
<div class="actions">
<button class="go" id="submit">提交问卷</button>
<button class="gh" id="copy">复制题目清单</button>
<button class="gh" onclick="window.print()">打印 / 存为 PDF</button>
</div>
<div id="done"></div>
<p class="foot">本页面在本地运行,填写内容不会上传到任何服务器。</p>
</div>
<script>
var FORM = __FORM_JSON__;
var meta = FORM.form_meta || {}, QS = FORM.questions || [], A = {};
document.body.setAttribute('data-theme', ['warm','cool','fresh','minimal'].indexOf(meta.theme_id) > -1 ? meta.theme_id : 'warm');
document.getElementById('ttl').textContent = meta.title || '调查问卷';
document.getElementById('dsc').textContent = meta.description || '';
var box = document.getElementById('qs');
function esc(s){ var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML.replace(/"/g, '"'); }
function pad(n){ return (n < 10 ? '0' : '') + n; }
function progress(){
var need = QS.filter(function(q){ return q.required; });
var got = need.filter(function(q){ var v = A[q.id]; return v !== undefined && v !== '' && !(Array.isArray(v) && !v.length); });
var pct = need.length ? got.length / need.length * 100 : (Object.keys(A).length ? 100 : 0);
document.getElementById('rail').style.width = pct + '%';
}
function set(q, v){ A[q.id] = v; document.getElementById('w_' + q.id).classList.remove('bad'); progress(); }
QS.forEach(function(q, i){
var type = q.type, opts = q.options || [], EX = q.exclusive_options || [];
if (['radio','checkbox','select'].indexOf(type) > -1 && opts.length < 2) type = 'textarea';
if (['radio','checkbox','select','text','textarea','rating','scale','nps','date'].indexOf(type) < 0) type = 'textarea';
var d = document.createElement('div');
d.className = 'q'; d.id = 'w_' + q.id;
d.innerHTML = '<div class="qh"><span class="qn">' + pad(i + 1) + '</span><div><p class="qt">' + esc(q.title) +
(q.required ? '<span class="req">*</span>' : '') + '</p>' + (q.note ? '<p class="note">' + esc(q.note) + '</p>' : '') + '</div></div>';
var body = document.createElement('div');
if (type === 'radio' || type === 'checkbox') {
body.className = 'opts';
if (type === 'checkbox' && q.max_choices) A[q.id] = [];
opts.forEach(function(o, k){
var l = document.createElement('label'); l.className = 'opt';
l.innerHTML = '<input type="' + type + '" name="' + q.id + '" value="' + esc(o) + '"><span>' + esc(o) + '</span>';
l.querySelector('input').onchange = function(){
if (type === 'radio') {
body.querySelectorAll('.opt').forEach(function(x){ x.classList.remove('on'); });
l.classList.add('on'); set(q, o);
} else {
var cur = Array.isArray(A[q.id]) ? A[q.id].slice() : [];
if (this.checked) {
if (EX.indexOf(o) > -1) {
body.querySelectorAll('.opt').forEach(function(x){ if (x !== l) { x.classList.remove('on'); x.querySelector('input').checked = false; } });
cur = [o];
} else {
cur = cur.filter(function(x){ return EX.indexOf(x) < 0; });
body.querySelectorAll('.opt').forEach(function(x){ var ip = x.querySelector('input'); if (EX.indexOf(ip.value) > -1) { x.classList.remove('on'); ip.checked = false; } });
if (q.max_choices && cur.length >= q.max_choices) { this.checked = false; alert('最多选择 ' + q.max_choices + ' 项'); return; }
cur.push(o);
}
l.classList.add('on');
} else { cur = cur.filter(function(x){ return x !== o; }); l.classList.remove('on'); }
set(q, cur);
}
};
body.appendChild(l);
});
} else if (type === 'select') {
body.className = 'fld';
var s = document.createElement('select');
s.innerHTML = '<option value="">请选择</option>' + opts.map(function(o){ return '<option>' + esc(o) + '</option>'; }).join('');
s.onchange = function(){ set(q, s.value); };
body.appendChild(s);
} else if (type === 'text' || type === 'date') {
body.className = 'fld';
var it = document.createElement('input');
it.type = type === 'date' ? 'date' : 'text';
if (q.placeholder) it.placeholder = q.placeholder;
it.oninput = function(){ set(q, it.value.trim()); };
body.appendChild(it);
} else if (type === 'textarea') {
body.className = 'fld';
var ta = document.createElement('textarea');
ta.placeholder = q.placeholder || '请输入';
ta.oninput = function(){ set(q, ta.value.trim()); };
body.appendChild(ta);
} else if (type === 'rating') {
body.className = 'stars';
var max = q.max || 5;
for (var n = 1; n <= max; n++) {
(function(n){
var b = document.createElement('button');
b.className = 'star'; b.type = 'button'; b.textContent = '★';
b.onclick = function(){
body.querySelectorAll('.star').forEach(function(x, xi){ x.classList.toggle('on', xi < n); });
set(q, n);
};
body.appendChild(b);
})(n);
}
} else {
body.className = 'scale';
var lo = type === 'nps' ? 0 : (q.min !== undefined ? q.min : 1);
var hi = type === 'nps' ? 10 : (q.max !== undefined ? q.max : 5);
var row = document.createElement('div'); row.className = 'sbtns';
for (var v = lo; v <= hi; v++) {
(function(v){
var b = document.createElement('button');
b.className = 'sb'; b.type = 'button'; b.textContent = v;
b.onclick = function(){
row.querySelectorAll('.sb').forEach(function(x){ x.classList.remove('on'); });
b.classList.add('on'); set(q, v);
};
row.appendChild(b);
})(v);
}
body.appendChild(row);
var lab = document.createElement('div'); lab.className = 'slab';
lab.innerHTML = '<span>' + esc(q.min_label || (type === 'nps' ? '完全不会推荐' : '很不满意')) + '</span><span>' +
esc(q.max_label || (type === 'nps' ? '一定会推荐' : '非常满意')) + '</span>';
body.appendChild(lab);
}
d.appendChild(body);
var e = document.createElement('p'); e.className = 'err'; e.textContent = '这道题是必答的';
d.appendChild(e);
box.appendChild(d);
});
document.getElementById('submit').onclick = function(){
var bad = null;
QS.forEach(function(q){
var w = document.getElementById('w_' + q.id); w.classList.remove('bad');
var v = A[q.id];
if (q.required && (v === undefined || v === '' || (Array.isArray(v) && !v.length))) {
w.classList.add('bad'); if (!bad) bad = w;
}
});
if (bad) { bad.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
var html = '<div class="done"><h2>已完成</h2><p>下面是你的作答,可以复制留存。这份问卷是本地文件,答案没有被发送到任何地方。</p>';
QS.forEach(function(q){
var v = A[q.id];
html += '<div class="ans"><b>' + esc(q.title) + '</b><span>' + esc(Array.isArray(v) ? v.join('、') : (v === undefined || v === '' ? '(未作答)' : v)) + '</span></div>';
});
html += '<div class="actions"><button class="gh" id="cpa">复制作答结果</button></div></div>';
var done = document.getElementById('done');
done.innerHTML = html;
document.getElementById('cpa').onclick = function(){
var t = QS.map(function(q){ var v = A[q.id]; return q.title + '\n' + (Array.isArray(v) ? v.join('、') : (v === undefined ? '' : v)); }).join('\n\n');
cp(t, this);
};
done.scrollIntoView({ behavior: 'smooth' });
};
document.getElementById('copy').onclick = function(){
var t = (meta.title || '') + '\n' + (meta.description || '') + '\n\n' + QS.map(function(q, i){
return (i + 1) + '. ' + q.title + (q.required ? '(必答)' : '') +
((q.options || []).length ? '\n' + q.options.map(function(o){ return ' - ' + o; }).join('\n') : '');
}).join('\n\n');
cp(t, this);
};
function cp(t, btn){
var old = btn.textContent;
var ok = function(){ btn.textContent = '已复制'; setTimeout(function(){ btn.textContent = old; }, 1600); };
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(t).then(ok, function(){ fb(t, ok); }); }
else fb(t, ok);
}
function fb(t, ok){
var a = document.createElement('textarea');
a.value = t; a.style.position = 'fixed'; a.style.opacity = 0;
document.body.appendChild(a); a.select();
try { document.execCommand('copy'); ok(); } catch (e) { alert('复制失败,请手动选择内容'); }
document.body.removeChild(a);
}
progress();
</script>
</body>
</html>
给用户这几句话,不要更多:
说清楚一件事:这是一个本地文件,适合预览、试填和内部评审,但它不会替你收集多个人的答案。要收集回收数据,需要把题目导入问卷平台再投放。这一点提前讲清楚,比用户发出去之后才发现要好。
不要在交付时罗列全部题目——用户打开文件就看到了,重复一遍只是噪音。