feat(todo-form): 待办详情按form_schema渲染动态表单+提交端点(落项目根→按target移动→路径回流)

This commit is contained in:
ymq 2026-09-01 12:38:55 +08:00
parent 3d59849d3e
commit 3aded613e0
2 changed files with 300 additions and 13 deletions

View File

@ -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 base64text→resultValuePOST 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, "

View File

@ -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)