360 Secure SkillHub
360 安全技能中心

survey-form-generator

把一句话调研目标变成 8–12 题的结构化问卷,自动补齐互斥选项、多选上限、开放题和背景题,再用内置模板渲染成可本地填写、可复制题目清单、可打印的 HTML 文件。默认调用外部 Dify 工作流生成题目结构并内置固定令牌,失败时按本地规则兜底;该技能不承担作答回收与统计分析。

zenobiazizi
zenobiazizi
热度 19.15
最近更新 2026-08-24 14:27:33
分类:效率依赖:第三方 API / 读写文件应用场景:研究 / 模板 / 工作流

安装技能

复制下面的话术发给你的 Agent,它会通过 secure-skills 检索并安装该技能。

执行命令安装此 skill: npx secure-skills-cli add survey-form-generator
Metadata
name
survey-form-generator
description
根据用户的调研目标,生成一份结构完整的调查问卷,并渲染成可直接打开、可作答、可导出的网页。当用户提到问卷、调研、问卷设计、满意度调查、用户调研、NPS、需求调研、市场调查、投票、表单、收集反馈、想了解客户怎么想,或者说"帮我做个问卷""设计几道题""想调研一下"时,都应当使用本技能——即使他们没有明确说出"问卷"两个字。联系方式:zenobiazizi.skills@foxmail.com

问卷生成器

把一句话的调研目标,变成一份题目结构合理、可以直接发出去的问卷。

工作流程

  1. 收集调研目标(缺信息才追问,不要盘问)
  2. 调用问卷生成服务,拿到问卷结构(JSON)
  3. 校验结构;调用失败则用本文的规则在本地生成
  4. 用本文的模板渲染成 HTML 文件
  5. 交付并说明后续怎么用

第一步:收集调研目标

需要凑齐三件事才能生成一份像样的问卷:

  • 调研什么:产品、服务、活动、还是某个具体决策
  • 调研谁:老客户 / 新客户 / 潜在用户 / 内部员工 / 泛人群
  • 要拿这些答案做什么决策:定价、改产品、选新品、评估满意度、写报告

用户的第一句话通常已经包含一到两项。只追问真正缺的那一项,一次问完,不要逐条盘问。 如果三项都有了,直接进入第二步。

如果用户明显只想要"快点给我一版看看",就用合理的默认假设直接生成,并在交付时说明你假设了什么,让他来改。让人看到一版具体的东西再修改,比先回答三个问题要轻松得多。

可选但有用的信息,用户提到就用上,没提到不要专门问:题量偏好、投放渠道(微信/邮件/线下)、是否有奖励、语气偏好。


第二步:调用生成服务

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 至少两项;不足就把该题降级为 textarea
  • type 不在支持列表里的,降级为 textarea
  • 题目数超过 20,只保留前 20 道并在交付时说明

拿到结果后必须补齐

生成服务给的是一份骨架,下面几处它经常漏,你要补上——补完的问卷才是可用的:

互斥选项。 多选题里如果有"没有/都行/以上都不是/不限"这类选项,把它写进 exclusive_options。不标的话,用户可以同时勾选"我都行"和另外三项,答案自相矛盾,数据没法清洗。

多选上限。 多选题选项数达到 5 个及以上、又没有 max_choices 的,补一个 3。全都能选就没有区分度,等于白问。

开放题。 如果整份问卷全是选择题,在背景题之前补 1 道开放题("最希望改进的一点是什么""还有什么想告诉我们的"),required 设为 false。选择题只能验证你已经想到的假设,真正的意外都藏在开放题里——尤其当调研目的是研发新品或找问题时,这道题不能少。

背景题。 如果没有任何人群标签题(年龄段、身份、城市、使用时长等),补 1–2 道放在最后,required 设为 false。没有这些,回收上来的数据没法分人群交叉分析,只能看一个笼统的总体分布。

必填比例。 必填题超过总题数一半时,把非核心题的 required 改成 false。必填越多流失越高,而流失掉的往往正是那些意见最有价值的人。

本地生成规则(服务不可用时使用)

题量控制在 8–12 道,填写时间不超过 3 分钟。 这是回收率的分水岭,题目再有价值,超过这个长度就没人填完。

按这个顺序排列题目:

  1. 暖场(1–2 题):容易回答的行为或频率类问题,比如使用频率、接触渠道。先让人动起来。
  2. 核心(4–6 题):真正要拿去做决策的那些题。评价、偏好、意愿。
  3. 开放(1–2 题):让人自由说的题,比如"最希望改进的一点是什么"。放在这里是因为此时对方已经进入状态。
  4. 背景(1–3 题):人群标签,年龄、身份、所在城市。永远放最后——一上来就问个人信息,流失率会明显升高。

写题目的几条硬规矩:

  • 一题只问一件事。"你觉得价格和口味怎么样"要拆成两题,否则答案无法解读。
  • 不要诱导。"你有多喜欢我们的新功能"预设了喜欢,应该问"你对新功能的评价是"。
  • 选项互斥且尽量穷尽,需要时补一个"其他"或"以上都不是"。
  • 量表统一用 5 点,且方向一致(都从负到正,或都从正到负),不要中途翻转。
  • 单选优先于多选;多选一定要写清最多选几项。
  • 只有核心题设 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, '&quot;'); }
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>

第五步:交付

给用户这几句话,不要更多:

  • 问卷标题和题量,一句话说明结构思路(比如"前两题暖场,中间是核心评价,人群信息放在最后")
  • 如果你做了假设(调研对象、用途、语气),明确说出来
  • 打开 HTML 文件就能预览和试填;「复制题目清单」按钮可以把题目直接粘到问卷星、腾讯问卷、飞书表单里投放
  • 想改哪道题直接说,可以重新生成

说清楚一件事:这是一个本地文件,适合预览、试填和内部评审,但它不会替你收集多个人的答案。要收集回收数据,需要把题目导入问卷平台再投放。这一点提前讲清楚,比用户发出去之后才发现要好。

不要在交付时罗列全部题目——用户打开文件就看到了,重复一遍只是噪音。