From c714a98dd19e2d86a5f8348742b5502196671ea2 Mon Sep 17 00:00:00 2001 From: ymq Date: Tue, 1 Sep 2026 13:47:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(todo-form):=20=E8=A1=A8=E5=8D=95=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=8A=BD=E5=85=B1=E7=94=A8=E5=87=BD=E6=95=B0+?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=AB=AF=E7=82=B9=E6=94=AF=E6=8C=81human=5Ft?= =?UTF-8?q?ask=E9=80=9A=E9=81=93=E2=80=94=E2=80=94=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=BE=85=E5=8A=9E=E9=80=9A=E9=81=93=E5=85=A8=E4=BA=A7=E7=BA=BF?= =?UTF-8?q?=E9=80=9A=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wwwroot/api/todo_detail.dspy | 199 +++++++++++++++++------------- wwwroot/api/todo_form_submit.dspy | 146 ++++++++++++++-------- 2 files changed, 205 insertions(+), 140 deletions(-) diff --git a/wwwroot/api/todo_detail.dspy b/wwwroot/api/todo_detail.dspy index f4e35b7..f05872a 100644 --- a/wwwroot/api/todo_detail.dspy +++ b/wwwroot/api/todo_detail.dspy @@ -86,6 +86,88 @@ def _btn(label, css, script, conform=None): return {"widgettype": "Button", "options": {"label": label, "css": css}, "binds": [bind]} +def _build_form_action(schema_fields, kind, oid): + """按 form_schema 生成动态表单操作区(问题通道与人类任务通道通用)。 + + file 字段 → UiFile 上传框(FileReader 转 base64);text/textarea → 输入框。 + 提交 → todo_form_submit.dspy(kind 区分通道),落项目根 + 按 target 移动 + 回流。 + """ + form_submit_url = entire_url("/pipeline-sdlc/api/todo_form_submit.dspy") + _field_widgets = [] + _script_parts = ["var _f={};"] + for _fi in schema_fields: + _fn = _s(_fi.get('name')) + _ft = (_s(_fi.get('type')) or 'text').lower() + _fl = _s(_fi.get('label')) or _fn + _freq = bool(_fi.get('required')) + _wid = 'tf_' + _fn + if _ft == 'file': + _acc = _s(_fi.get('accept')) + _fops = {"name": _wid, "width": "100%", "preview": True} + if _acc: + _fops["accept"] = _acc + _field_widgets.append({ + "widgettype": "VBox", + "options": {"width": "100%", "gap": "4px", "padding": "8px 14px 0 14px"}, + "subwidgets": [ + {"widgettype": "Text", + "options": {"text": _fl + ('(必填)' if _freq else ''), + "cfontsize": 0.75, "color": "#64748b"}}, + {"widgettype": "UiFile", "id": _wid, "options": _fops} + ] + }) + _script_parts.append( + "var _w" + _fn + "=bricks.getWidgetById(" + _json.dumps(_wid) + ",bricks.app);" + "var _v" + _fn + "=(_w" + _fn + "&&typeof _w" + _fn + ".resultValue==='function')?" + "_w" + _fn + ".resultValue():null;" + "if(!_v" + _fn + "){" + + ("var _m" + _fn + "=new bricks.Message({title:'请上传文件'," + "message:" + _json.dumps(_fl + " 是必填项,请上传文件后再提交") + "});_m" + + _fn + ".open();return;}" if _freq else "") + + "if(_v" + _fn + "){" + "if(_v" + _fn + ".size>10*1024*1024){var _ms" + _fn + + "=new bricks.Message({title:'文件过大',message:'单个文件不能超过 10MB'});_ms" + + _fn + ".open();return;}" + "_f[" + _json.dumps(_fn + '_filename') + "]=_v" + _fn + ".name;" + "_f[" + _json.dumps(_fn) + "]=await new Promise(function(_res,_rej){" + "var _rd=new FileReader();" + "_rd.onload=function(){_res(_rd.result);};_rd.onerror=_rej;" + "_rd.readAsDataURL(_v" + _fn + ");});}") + else: + _field_widgets.append(_input_box(_wid, _fl + ('(必填)' if _freq else ''), + '请填写' + _fl)) + _script_parts.append( + "var _tw" + _fn + "=bricks.getWidgetById(" + _json.dumps(_wid) + ",bricks.app);" + "var _tv" + _fn + "=(_tw" + _fn + "&&(typeof _tw" + _fn + + ".resultValue==='function'))?_tw" + _fn + ".resultValue():" + "((_tw" + _fn + "&&_tw" + _fn + ".dom_element&&_tw" + _fn + + ".dom_element.value)||'');" + "_tv" + _fn + "=(_tv" + _fn + "===null||_tv" + _fn + "===undefined)?'':" + "String(_tv" + _fn + ").trim();" + + ("if(!_tv" + _fn + "){var _mt" + _fn + "=new bricks.Message({title:'请填写'," + "message:" + _json.dumps(_fl + " 是必填项") + "});_mt" + _fn + + ".open();return;}" if _freq else "") + + "_f[" + _json.dumps(_fn) + "]=_tv" + _fn + ";") + _id_param = 'task_id' if kind == 'human_task' else 'question_id' + _script_parts.append( + "var _body=new URLSearchParams();" + "_body.append('kind'," + _json.dumps(kind) + ");" + "_body.append(" + _json.dumps(_id_param) + "," + _json.dumps(oid) + ");" + "_body.append('fields',JSON.stringify(_f));" + "var _rp=await fetch(" + _json.dumps(form_submit_url) + + ",{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}," + "body:_body});var d=await _rp.json();" + _tail) + return [{ + "widgettype": "VBox", + "options": {"width": "100%", "gap": "0px"}, + "subwidgets": _field_widgets + [{ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"}, + "subwidgets": [_btn('提交(在待办中完成)', 'primary', ''.join(_script_parts))] + }] + }] + + title = '' badge = '待办' badge_color = '#64748b' @@ -139,81 +221,7 @@ async with DBPools().sqlorContext(dbname) as sor: _form_fields = (_form_schema or {}).get('fields') or [] if _form_fields: - form_submit_url = entire_url("/pipeline-sdlc/api/todo_form_submit.dspy") - _field_widgets = [] - # 提交脚本:逐字段取值(file→FileReader base64,text→resultValue),POST JSON - _script_parts = ["var _f={};"] - for _fi in _form_fields: - _fn = _s(_fi.get('name')) - _ft = (_s(_fi.get('type')) or 'text').lower() - _fl = _s(_fi.get('label')) or _fn - _freq = bool(_fi.get('required')) - _wid = 'tf_' + _fn - if _ft == 'file': - _acc = _s(_fi.get('accept')) - _fops = {"name": _wid, "width": "100%", "preview": True} - if _acc: - _fops["accept"] = _acc - _field_widgets.append({ - "widgettype": "VBox", - "options": {"width": "100%", "gap": "4px", "padding": "8px 14px 0 14px"}, - "subwidgets": [ - {"widgettype": "Text", - "options": {"text": _fl + ('(必填)' if _freq else ''), - "cfontsize": 0.75, "color": "#64748b"}}, - {"widgettype": "UiFile", "id": _wid, "options": _fops} - ] - }) - _script_parts.append( - "var _w" + _fn + "=bricks.getWidgetById(" + _json.dumps(_wid) + ",bricks.app);" - "var _v" + _fn + "=(_w" + _fn + "&&typeof _w" + _fn + ".resultValue==='function')?" - "_w" + _fn + ".resultValue():null;" - "if(!_v" + _fn + "){" + - ("var _m" + _fn + "=new bricks.Message({title:'请上传文件'," - "message:" + _json.dumps(_fl + " 是必填项,请上传文件后再提交") + "});_m" + - _fn + ".open();return;}" if _freq else "") + - "if(_v" + _fn + "){" - "if(_v" + _fn + ".size>10*1024*1024){var _ms" + _fn + - "=new bricks.Message({title:'文件过大',message:'单个文件不能超过 10MB'});_ms" + - _fn + ".open();return;}" - "_f[" + _json.dumps(_fn + '_filename') + "]=_v" + _fn + ".name;" - "_f[" + _json.dumps(_fn) + "]=await new Promise(function(_res,_rej){" - "var _rd=new FileReader();" - "_rd.onload=function(){_res(_rd.result);};_rd.onerror=_rej;" - "_rd.readAsDataURL(_v" + _fn + ");});}") - else: - _h = "70px" if _ft == 'textarea' else "36px" - _field_widgets.append(_input_box(_wid, _fl + ('(必填)' if _freq else ''), - '请填写' + _fl)) - _script_parts.append( - "var _tw" + _fn + "=bricks.getWidgetById(" + _json.dumps(_wid) + ",bricks.app);" - "var _tv" + _fn + "=(_tw" + _fn + "&&(typeof _tw" + _fn + - ".resultValue==='function'))?_tw" + _fn + ".resultValue():" - "((_tw" + _fn + "&&_tw" + _fn + ".dom_element&&_tw" + _fn + - ".dom_element.value)||'');" - "_tv" + _fn + "=(_tv" + _fn + "===null||_tv" + _fn + "===undefined)?'':" - "String(_tv" + _fn + ").trim();" + - ("if(!_tv" + _fn + "){var _mt" + _fn + "=new bricks.Message({title:'请填写'," - "message:" + _json.dumps(_fl + " 是必填项") + "});_mt" + _fn + - ".open();return;}" if _freq else "") + - "_f[" + _json.dumps(_fn) + "]=_tv" + _fn + ";") - _script_parts.append( - "var _body=new URLSearchParams();" - "_body.append('question_id'," + _json.dumps(oid) + ");" - "_body.append('fields',JSON.stringify(_f));" - "var _rp=await fetch(" + _json.dumps(form_submit_url) + - ",{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}," - "body:_body});var d=await _rp.json();" + _tail) - submit_script = ''.join(_script_parts) - action_widgets.append({ - "widgettype": "VBox", - "options": {"width": "100%", "gap": "0px"}, - "subwidgets": _field_widgets + [{ - "widgettype": "HBox", - "options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"}, - "subwidgets": [_btn('提交(在待办中完成)', 'primary', submit_script)] - }] - }) + action_widgets.extend(_build_form_action(_form_fields, 'question', oid)) else: action_widgets.append(_input_box('todo_answer', '你的回答(回答后该问题关闭,任务自动恢复执行)', '请填写回答内容')) @@ -230,7 +238,7 @@ async with DBPools().sqlorContext(dbname) as sor: else: hrecs = await sor.sqlExe( "SELECT id, project_id, iteration_id, bug_id, task_id, task_type, title, " - "description, status, created_at, submitted_by FROM pipeline_human_tasks " + "description, form_schema, status, created_at, submitted_by FROM pipeline_human_tasks " "WHERE id=${i}$", {"i": oid}) await sor.sqlExe("COMMIT", {}) if not hrecs: @@ -245,6 +253,15 @@ async with DBPools().sqlorContext(dbname) as sor: if _s(getattr(h, 'status', '')) != 'pending': sub_lines.append('状态:' + _s(getattr(h, 'status', ''))) + # 动态表单声明(创建方写入,任意产线通用) + _ht_form_fields = [] + try: + _ht_fs_raw = _s(getattr(h, 'form_schema', '')) + if _ht_fs_raw: + _ht_form_fields = (_json.loads(_ht_fs_raw) or {}).get('fields') or [] + except Exception: + _ht_form_fields = [] + desc = _s(getattr(h, 'description', '')) if desc: md.append('## 待办说明') @@ -460,19 +477,23 @@ async with DBPools().sqlorContext(dbname) as sor: md.append('') md.append('(无说明)') md.append('') - action_widgets.append(_input_box('todo_result', '处理结果(提交后进入 QC 检查)', '请填写处理结果')) - do_script = (_read_input_js('todo_result') + - "if(!cv){var mn=new bricks.Message({title:'请填写处理结果'," - "message:'处理结果不能为空'});mn.open();return;}" - "var rd=JSON.stringify({content:cv});" + - _post_js(complete_url, [('human_task_id', _json.dumps(oid)), - ('result_data', 'rd')]) + - _tail) - action_widgets.append({ - "widgettype": "HBox", - "options": {"width": "100%", "gap": "8px", "padding": "10px 14px", "halign": "right"}, - "subwidgets": [_btn('提交处理结果', 'primary', do_script)] - }) + if _ht_form_fields: + # 创建方声明了动态表单:在待办里直接上传/填写完成任务 + action_widgets.extend(_build_form_action(_ht_form_fields, 'human_task', oid)) + else: + action_widgets.append(_input_box('todo_result', '处理结果(提交后进入 QC 检查)', '请填写处理结果')) + do_script = (_read_input_js('todo_result') + + "if(!cv){var mn=new bricks.Message({title:'请填写处理结果'," + "message:'处理结果不能为空'});mn.open();return;}" + "var rd=JSON.stringify({content:cv});" + + _post_js(complete_url, [('human_task_id', _json.dumps(oid)), + ('result_data', 'rd')]) + + _tail) + action_widgets.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "8px", "padding": "10px 14px", "halign": "right"}, + "subwidgets": [_btn('提交处理结果', 'primary', do_script)] + }) # 项目名 if project_id: diff --git a/wwwroot/api/todo_form_submit.dspy b/wwwroot/api/todo_form_submit.dspy index eec2437..f678b76 100644 --- a/wwwroot/api/todo_form_submit.dspy +++ b/wwwroot/api/todo_form_submit.dspy @@ -1,10 +1,13 @@ -# todo_form_submit.dspy - 待办动态表单提交:校验 + 文件落项目根 + 按 schema.target 移到目标位置 -# + 把「回答文本 + 已上传文件路径」写入回答,关闭问题、任务恢复执行。 +# todo_form_submit.dspy - 待办动态表单统一提交端点(问题通道 + 人类任务通道通用) +# +# 校验必填 → 文件统一先落项目根 → 按 schema 字段的 target 移到目标位置 +# → 把「文本字段 + 已上传文件路径」写入完成结果,关闭待办、任务恢复执行。 # # 入参(JSON body 或 params_kw): -# question_id 待办问题 id -# fields JSON 对象:{字段名: 值}。文件字段值 = base64 字符串(或 data: 前缀); -# 文本字段值 = 字符串。 +# question_id / task_id 待办 id(二选一,按 kind 决定) +# kind question | human_task(默认 question) +# fields JSON 对象:{字段名: 值}。文件字段值 = base64 字符串(或 data: 前缀); +# 文本字段值 = 字符串。文件名随附 {字段名}_filename。 # # 文件处理约定(用户确认): # 统一先落到项目根目录,再按 form_schema 里该字段的 target 移动: @@ -12,6 +15,10 @@ # - target 形如 "env/"(目录,/ 结尾)→ 移到 {root}/env/{原文件名} # - target 为空 → 留在项目根 # 目标路径相对项目根解析,禁绝对路径与 .. 穿越(服务端二次校验)。 +# +# 安全: +# - question:沿用 question_answer(回答即恢复任务) +# - human_task:走 complete_human_task(内含同机构 + assignee 匹配校验 + 触发 QC) import os import json as _json @@ -21,7 +28,8 @@ user_id = await get_user() if not user_id: return _json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False) -question_id = ((params_kw or {}).get('question_id') or '').strip() +kind = ((params_kw or {}).get('kind') or 'question').strip() +oid = '' fields_raw = (params_kw or {}).get('fields') or '' if isinstance(fields_raw, str): try: @@ -33,37 +41,60 @@ else: if not isinstance(fields, dict): fields = {} -if not question_id: - return _json.dumps({"success": False, "error": "缺少 question_id"}, ensure_ascii=False) +if kind == 'human_task': + oid = ((params_kw or {}).get('task_id') or '').strip() +else: + oid = ((params_kw or {}).get('question_id') or '').strip() +if not oid: + return _json.dumps({"success": False, "error": "缺少待办 id"}, ensure_ascii=False) dbname = get_module_dbname('pipeline-sdlc') -# ── 读取待办问题 + form_schema ── +# ── 读取待办记录 + form_schema + 项目上下文 ── +schema_fields = [] +project_id = '' async with DBPools().sqlorContext(dbname) as sor: - qrecs = await sor.sqlExe( - "SELECT id, tenant_id, task_id, context, status FROM pipeline_agent_questions WHERE id=${i}$", - {"i": question_id}) - await sor.sqlExe("COMMIT", {}) -if not qrecs: - return _json.dumps({"success": False, "error": "待办不存在或已处理"}, ensure_ascii=False) -q = qrecs[0] -project_id = str(getattr(q, 'tenant_id', '') or '') -q_status = str(getattr(q, 'status', '') or '') -if q_status not in ('', 'pending', 'waiting'): - return _json.dumps({"success": False, "error": "该待办已处理,无需重复提交"}, ensure_ascii=False) + if kind == 'human_task': + recs = await sor.sqlExe( + "SELECT id, project_id, form_schema, status FROM pipeline_human_tasks WHERE id=${i}$", + {"i": oid}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return _json.dumps({"success": False, "error": "待办不存在"}, ensure_ascii=False) + r = recs[0] + project_id = str(getattr(r, 'project_id', '') or '') + if str(getattr(r, 'status', '') or '') != 'pending': + return _json.dumps({"success": False, "error": "该待办已处理,无需重复提交"}, ensure_ascii=False) + try: + _fs_raw = str(getattr(r, 'form_schema', '') or '') + if _fs_raw: + schema_fields = (_json.loads(_fs_raw) or {}).get('fields') or [] + except Exception: + schema_fields = [] + else: + recs = await sor.sqlExe( + "SELECT id, tenant_id, context, status FROM pipeline_agent_questions WHERE id=${i}$", + {"i": oid}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return _json.dumps({"success": False, "error": "待办不存在或已处理"}, ensure_ascii=False) + r = recs[0] + project_id = str(getattr(r, 'tenant_id', '') or '') + if str(getattr(r, 'status', '') or '') not in ('', 'pending', 'waiting'): + return _json.dumps({"success": False, "error": "该待办已处理,无需重复提交"}, ensure_ascii=False) + try: + _ctx_raw = str(getattr(r, 'context', '') or '') + if _ctx_raw: + schema_fields = (_json.loads(_ctx_raw) or {}).get('form_schema', {}).get('fields') or [] + except Exception: + schema_fields = [] -schema = None -try: - ctx = getattr(q, 'context', '') or '' - if ctx: - schema = (_json.loads(ctx) or {}).get('form_schema') -except Exception: - schema = None -schema_fields = (schema or {}).get('fields') or [] - -# ── 项目根目录(与角色 agent 读的位置一致) ── +if not schema_fields: + return _json.dumps({"success": False, "error": "该待办没有动态表单定义"}, ensure_ascii=False) if not project_id: return _json.dumps({"success": False, "error": "待办缺少项目上下文,无法定位工作空间"}, ensure_ascii=False) + +# ── 项目根目录(与角色 agent 读的位置一致) ── async with DBPools().sqlorContext(dbname) as sor: project_dir, _base = await get_project_dir_by_id(sor, project_id) await sor.sqlExe("COMMIT", {}) @@ -85,6 +116,7 @@ def _safe_rel(target): uploaded = [] # [(name, rel_path)] +text_entries = [] # [(label, value)] fatal = [] # 致命错误:必填缺失/解码失败/写盘失败 → 整体拒绝提交 warnings = [] # 非致命:文件已落项目根但移动到 target 失败 @@ -93,11 +125,12 @@ for sf in schema_fields: ftype = str(sf.get('type') or 'text').lower() required = bool(sf.get('required')) val = fields.get(name) + label = str(sf.get('label') or name) if ftype == 'file': if not val: if required: - fatal.append(str(sf.get('label') or name) + ':必须上传文件') + fatal.append(label + ':必须上传文件') continue # 解码 base64 try: @@ -107,7 +140,7 @@ for sf in schema_fields: b64 = val data = _b64.b64decode(b64) except Exception as e: - fatal.append(str(sf.get('label') or name) + ':文件解码失败 ' + str(e)[:80]) + fatal.append(label + ':文件解码失败 ' + str(e)[:80]) continue # 文件名:优先取随字段附带的 {name}_filename,否则用字段名 fname = str(fields.get(name + '_filename') or '').strip() @@ -120,7 +153,7 @@ for sf in schema_fields: with open(root_path, 'wb') as f: f.write(data) except Exception as e: - fatal.append(str(sf.get('label') or name) + ':写入失败 ' + str(e)[:80]) + fatal.append(label + ':写入失败 ' + str(e)[:80]) continue # 2) 按 target 移动(失败不致命——文件已在项目根,agent 仍可读到) rel = _safe_rel(sf.get('target')) @@ -140,41 +173,52 @@ for sf in schema_fields: os.rename(root_path, dst_abs) final_rel = dst_rel except Exception as e: - warnings.append(str(sf.get('label') or name) + ':已保存到项目根,移动到 ' + - rel + ' 失败 ' + str(e)[:80]) + warnings.append(label + ':已保存到项目根,移动到 ' + rel + ' 失败 ' + str(e)[:80]) uploaded.append((name, final_rel)) else: # text / textarea sv = '' if val is None else str(val).strip() if not sv and required: - fatal.append(str(sf.get('label') or name) + ':不能为空') + fatal.append(label + ':不能为空') + elif sv: + text_entries.append((label, sv)) -# 致命错误 → 拒绝提交(文件可能已落盘,但回答不写,待办不关闭,让用户补齐重来) +# 致命错误 → 拒绝提交(回答/结果不写,待办不关闭,让用户补齐重来) if fatal: return _json.dumps({"success": False, "error": ";".join(fatal)}, ensure_ascii=False) -# ── 构造回答:文本字段 + 已上传文件路径(回流给 agent 的 _build_qna_section) ── -answer_lines = [] -for sf in schema_fields: - if str(sf.get('type') or 'text').lower() == 'file': - continue - name = str(sf.get('name') or '') - sv = '' if fields.get(name) is None else str(fields.get(name)).strip() - if sv: - answer_lines.append((sf.get('label') or name) + ':' + sv) +# ── 构造完成结果:文本字段 + 已上传文件路径 ── +result_files = [rel for _, rel in uploaded] +free_text = str(fields.get('_note') or '').strip() +if kind == 'human_task': + result_data = { + 'files': result_files, + 'fields': {label: v for label, v in text_entries}, + } + if free_text: + result_data['note'] = free_text + ok, msg = await complete_human_task(oid, result_data, operator_id=user_id) + if ok: + summary = '提交成功,进入 QC 检查。' + if uploaded: + summary += '已上传:' + '、'.join(result_files) + '。' + if warnings: + summary += '(部分提示:' + ';'.join(warnings) + ')' + return _json.dumps({"success": True, "message": summary}, ensure_ascii=False) + return _json.dumps({"success": False, "error": str(msg) or "提交失败"}, ensure_ascii=False) + +# ── question 通道:回答文本回流给 _build_qna_section ── +answer_lines = [label + ':' + v for label, v in text_entries] if uploaded: answer_lines.append('已上传文件(位于项目工作空间,可直接 read_file):') for name, rel in uploaded: answer_lines.append(' · ' + rel) - -free_text = str(fields.get('_note') or '').strip() if free_text: answer_lines.append('补充说明:' + free_text) - answer = '\n'.join(answer_lines) or '(已按要求上传/填写)' -res = await question_answer(question_id, answer, answered_by=user_id, answer_source='owner.superuser') +res = await question_answer(oid, answer, answered_by=user_id, answer_source='owner.superuser') ok = False msg = '' @@ -190,7 +234,7 @@ else: if ok: summary = '提交成功。' if uploaded: - summary += '已上传 ' + '、'.join(rel for _, rel in uploaded) + ',任务恢复执行。' + summary += '已上传 ' + '、'.join(result_files) + ',任务恢复执行。' else: summary += '任务恢复执行。' if warnings: