From 3aded613e0431fa8026617678f628a4d643d3c0a Mon Sep 17 00:00:00 2001 From: ymq Date: Tue, 1 Sep 2026 12:38:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(todo-form):=20=E5=BE=85=E5=8A=9E=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=8C=89form=5Fschema=E6=B8=B2=E6=9F=93=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E8=A1=A8=E5=8D=95+=E6=8F=90=E4=BA=A4=E7=AB=AF?= =?UTF-8?q?=E7=82=B9(=E8=90=BD=E9=A1=B9=E7=9B=AE=E6=A0=B9=E2=86=92?= =?UTF-8?q?=E6=8C=89target=E7=A7=BB=E5=8A=A8=E2=86=92=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=9B=9E=E6=B5=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wwwroot/api/todo_detail.dspy | 114 +++++++++++++++-- wwwroot/api/todo_form_submit.dspy | 199 ++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 wwwroot/api/todo_form_submit.dspy diff --git a/wwwroot/api/todo_detail.dspy b/wwwroot/api/todo_detail.dspy index efda5e0..f4e35b7 100644 --- a/wwwroot/api/todo_detail.dspy +++ b/wwwroot/api/todo_detail.dspy @@ -98,7 +98,7 @@ async with DBPools().sqlorContext(dbname) as sor: if kind == 'question': qrecs = await sor.sqlExe( "SELECT id, tenant_id, task_id, from_role, question, problem_type, " - "current_handler_role, created_at FROM pipeline_agent_questions " + "current_handler_role, context, created_at FROM pipeline_agent_questions " "WHERE id=${i}$", {"i": oid}) await sor.sqlExe("COMMIT", {}) if not qrecs: @@ -127,18 +127,106 @@ async with DBPools().sqlorContext(dbname) as sor: md.append('- 任务:' + _s(getattr(trecs[0], 'title', ''))) md.append('- 状态:' + _s(getattr(trecs[0], 'state', ''))) md.append('') - action_widgets.append(_input_box('todo_answer', '你的回答(回答后该问题关闭,任务自动恢复执行)', - '请填写回答内容')) - answer_script = (_read_input_js('todo_answer') + - "if(!cv){var mn=new bricks.Message({title:'请填写回答'," - "message:'回答内容不能为空'});mn.open();return;}" + - _post_js(answer_url, [('question_id', _json.dumps(oid)), ('answer', 'cv')]) + - _tail) - action_widgets.append({ - "widgettype": "HBox", - "options": {"width": "100%", "gap": "8px", "padding": "10px 14px", "halign": "right"}, - "subwidgets": [_btn('提交回答', 'primary', answer_script)] - }) + + # ── 动态表单(form_schema 由提问方声明):在待办里直接上传文件/填写信息完成任务 ── + _form_schema = None + try: + _ctx_raw = _s(getattr(q, 'context', '')) + if _ctx_raw: + _form_schema = (_json.loads(_ctx_raw) or {}).get('form_schema') + except Exception: + _form_schema = None + _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)] + }] + }) + else: + action_widgets.append(_input_box('todo_answer', '你的回答(回答后该问题关闭,任务自动恢复执行)', + '请填写回答内容')) + answer_script = (_read_input_js('todo_answer') + + "if(!cv){var mn=new bricks.Message({title:'请填写回答'," + "message:'回答内容不能为空'});mn.open();return;}" + + _post_js(answer_url, [('question_id', _json.dumps(oid)), ('answer', 'cv')]) + + _tail) + action_widgets.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "8px", "padding": "10px 14px", "halign": "right"}, + "subwidgets": [_btn('提交回答', 'primary', answer_script)] + }) else: hrecs = await sor.sqlExe( "SELECT id, project_id, iteration_id, bug_id, task_id, task_type, title, " diff --git a/wwwroot/api/todo_form_submit.dspy b/wwwroot/api/todo_form_submit.dspy new file mode 100644 index 0000000..eec2437 --- /dev/null +++ b/wwwroot/api/todo_form_submit.dspy @@ -0,0 +1,199 @@ +# todo_form_submit.dspy - 待办动态表单提交:校验 + 文件落项目根 + 按 schema.target 移到目标位置 +# + 把「回答文本 + 已上传文件路径」写入回答,关闭问题、任务恢复执行。 +# +# 入参(JSON body 或 params_kw): +# question_id 待办问题 id +# fields JSON 对象:{字段名: 值}。文件字段值 = base64 字符串(或 data: 前缀); +# 文本字段值 = 字符串。 +# +# 文件处理约定(用户确认): +# 统一先落到项目根目录,再按 form_schema 里该字段的 target 移动: +# - target 形如 "env/test.json"(带文件名)→ 移到 {root}/env/test.json +# - target 形如 "env/"(目录,/ 结尾)→ 移到 {root}/env/{原文件名} +# - target 为空 → 留在项目根 +# 目标路径相对项目根解析,禁绝对路径与 .. 穿越(服务端二次校验)。 + +import os +import json as _json +import base64 as _b64 + +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() +fields_raw = (params_kw or {}).get('fields') or '' +if isinstance(fields_raw, str): + try: + fields = _json.loads(fields_raw) if fields_raw.strip() else {} + except Exception: + fields = {} +else: + fields = fields_raw or {} +if not isinstance(fields, dict): + fields = {} + +if not question_id: + return _json.dumps({"success": False, "error": "缺少 question_id"}, ensure_ascii=False) + +dbname = get_module_dbname('pipeline-sdlc') + +# ── 读取待办问题 + form_schema ── +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) + +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 project_id: + return _json.dumps({"success": False, "error": "待办缺少项目上下文,无法定位工作空间"}, ensure_ascii=False) +async with DBPools().sqlorContext(dbname) as sor: + project_dir, _base = await get_project_dir_by_id(sor, project_id) + await sor.sqlExe("COMMIT", {}) +if not project_dir or not os.path.isdir(project_dir): + return _json.dumps({"success": False, "error": "项目工作空间不可用"}, ensure_ascii=False) + + +def _safe_rel(target): + """校验相对路径:禁绝对路径、禁 .. 穿越。返回规整后的相对路径或 ''。""" + t = (target or '').strip() + if not t: + return '' + if t.startswith('/'): + return '' + parts = [p for p in t.split('/') if p not in ('', '.')] + if '..' in parts: + return '' + return '/'.join(parts) + + +uploaded = [] # [(name, rel_path)] +fatal = [] # 致命错误:必填缺失/解码失败/写盘失败 → 整体拒绝提交 +warnings = [] # 非致命:文件已落项目根但移动到 target 失败 + +for sf in schema_fields: + name = str(sf.get('name') or '') + ftype = str(sf.get('type') or 'text').lower() + required = bool(sf.get('required')) + val = fields.get(name) + + if ftype == 'file': + if not val: + if required: + fatal.append(str(sf.get('label') or name) + ':必须上传文件') + continue + # 解码 base64 + try: + if isinstance(val, str) and val.startswith('data:'): + _, b64 = val.split(',', 1) + else: + b64 = val + data = _b64.b64decode(b64) + except Exception as e: + fatal.append(str(sf.get('label') or name) + ':文件解码失败 ' + str(e)[:80]) + continue + # 文件名:优先取随字段附带的 {name}_filename,否则用字段名 + fname = str(fields.get(name + '_filename') or '').strip() + fname = os.path.basename(fname) if fname else '' + if not fname: + fname = name + # 1) 先落项目根 + root_path = os.path.join(project_dir, fname) + try: + 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]) + continue + # 2) 按 target 移动(失败不致命——文件已在项目根,agent 仍可读到) + rel = _safe_rel(sf.get('target')) + final_rel = fname + try: + if rel: + if '/' in rel and not rel.endswith('/'): + # target 含文件名(如 env/test.json)→ 直接用 + dst_rel = rel + else: + # target 是目录(如 env/)→ 保留原文件名 + dst_rel = rel.rstrip('/') + '/' + fname + dst_abs = os.path.join(project_dir, dst_rel) + os.makedirs(os.path.dirname(dst_abs), exist_ok=True) + if os.path.exists(dst_abs): + os.remove(dst_abs) # 覆盖(补齐/替换场景) + 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]) + 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) + ':不能为空') + +# 致命错误 → 拒绝提交(文件可能已落盘,但回答不写,待办不关闭,让用户补齐重来) +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) + +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') + +ok = False +msg = '' +if isinstance(res, tuple) or isinstance(res, list): + ok = bool(res[0]) if len(res) > 0 else False + msg = str(res[1]) if len(res) > 1 else '' +elif isinstance(res, dict): + ok = bool(res.get('success', res.get('ok', True))) + msg = str(res.get('message') or res.get('error') or '') +else: + ok = bool(res) + +if ok: + summary = '提交成功。' + if uploaded: + summary += '已上传 ' + '、'.join(rel for _, rel in uploaded) + ',任务恢复执行。' + else: + summary += '任务恢复执行。' + if warnings: + summary += '(部分提示:' + ';'.join(warnings) + ')' + return _json.dumps({"success": True, "message": summary}, ensure_ascii=False) +return _json.dumps({"success": False, "error": msg or "提交失败"}, ensure_ascii=False)