feat(todo-form): 表单渲染抽共用函数+提交端点支持human_task通道——两条待办通道全产线通用
This commit is contained in:
parent
3aded613e0
commit
c714a98dd1
@ -86,6 +86,88 @@ def _btn(label, css, script, conform=None):
|
|||||||
return {"widgettype": "Button", "options": {"label": label, "css": css}, "binds": [bind]}
|
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 = ''
|
title = ''
|
||||||
badge = '待办'
|
badge = '待办'
|
||||||
badge_color = '#64748b'
|
badge_color = '#64748b'
|
||||||
@ -139,81 +221,7 @@ async with DBPools().sqlorContext(dbname) as sor:
|
|||||||
_form_fields = (_form_schema or {}).get('fields') or []
|
_form_fields = (_form_schema or {}).get('fields') or []
|
||||||
|
|
||||||
if _form_fields:
|
if _form_fields:
|
||||||
form_submit_url = entire_url("/pipeline-sdlc/api/todo_form_submit.dspy")
|
action_widgets.extend(_build_form_action(_form_fields, 'question', oid))
|
||||||
_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:
|
else:
|
||||||
action_widgets.append(_input_box('todo_answer', '你的回答(回答后该问题关闭,任务自动恢复执行)',
|
action_widgets.append(_input_box('todo_answer', '你的回答(回答后该问题关闭,任务自动恢复执行)',
|
||||||
'请填写回答内容'))
|
'请填写回答内容'))
|
||||||
@ -230,7 +238,7 @@ async with DBPools().sqlorContext(dbname) as sor:
|
|||||||
else:
|
else:
|
||||||
hrecs = await sor.sqlExe(
|
hrecs = await sor.sqlExe(
|
||||||
"SELECT id, project_id, iteration_id, bug_id, task_id, task_type, title, "
|
"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})
|
"WHERE id=${i}$", {"i": oid})
|
||||||
await sor.sqlExe("COMMIT", {})
|
await sor.sqlExe("COMMIT", {})
|
||||||
if not hrecs:
|
if not hrecs:
|
||||||
@ -245,6 +253,15 @@ async with DBPools().sqlorContext(dbname) as sor:
|
|||||||
if _s(getattr(h, 'status', '')) != 'pending':
|
if _s(getattr(h, 'status', '')) != 'pending':
|
||||||
sub_lines.append('状态:' + _s(getattr(h, 'status', '')))
|
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', ''))
|
desc = _s(getattr(h, 'description', ''))
|
||||||
if desc:
|
if desc:
|
||||||
md.append('## 待办说明')
|
md.append('## 待办说明')
|
||||||
@ -460,19 +477,23 @@ async with DBPools().sqlorContext(dbname) as sor:
|
|||||||
md.append('')
|
md.append('')
|
||||||
md.append('(无说明)')
|
md.append('(无说明)')
|
||||||
md.append('')
|
md.append('')
|
||||||
action_widgets.append(_input_box('todo_result', '处理结果(提交后进入 QC 检查)', '请填写处理结果'))
|
if _ht_form_fields:
|
||||||
do_script = (_read_input_js('todo_result') +
|
# 创建方声明了动态表单:在待办里直接上传/填写完成任务
|
||||||
"if(!cv){var mn=new bricks.Message({title:'请填写处理结果',"
|
action_widgets.extend(_build_form_action(_ht_form_fields, 'human_task', oid))
|
||||||
"message:'处理结果不能为空'});mn.open();return;}"
|
else:
|
||||||
"var rd=JSON.stringify({content:cv});" +
|
action_widgets.append(_input_box('todo_result', '处理结果(提交后进入 QC 检查)', '请填写处理结果'))
|
||||||
_post_js(complete_url, [('human_task_id', _json.dumps(oid)),
|
do_script = (_read_input_js('todo_result') +
|
||||||
('result_data', 'rd')]) +
|
"if(!cv){var mn=new bricks.Message({title:'请填写处理结果',"
|
||||||
_tail)
|
"message:'处理结果不能为空'});mn.open();return;}"
|
||||||
action_widgets.append({
|
"var rd=JSON.stringify({content:cv});" +
|
||||||
"widgettype": "HBox",
|
_post_js(complete_url, [('human_task_id', _json.dumps(oid)),
|
||||||
"options": {"width": "100%", "gap": "8px", "padding": "10px 14px", "halign": "right"},
|
('result_data', 'rd')]) +
|
||||||
"subwidgets": [_btn('提交处理结果', 'primary', do_script)]
|
_tail)
|
||||||
})
|
action_widgets.append({
|
||||||
|
"widgettype": "HBox",
|
||||||
|
"options": {"width": "100%", "gap": "8px", "padding": "10px 14px", "halign": "right"},
|
||||||
|
"subwidgets": [_btn('提交处理结果', 'primary', do_script)]
|
||||||
|
})
|
||||||
|
|
||||||
# 项目名
|
# 项目名
|
||||||
if project_id:
|
if project_id:
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
# todo_form_submit.dspy - 待办动态表单提交:校验 + 文件落项目根 + 按 schema.target 移到目标位置
|
# todo_form_submit.dspy - 待办动态表单统一提交端点(问题通道 + 人类任务通道通用)
|
||||||
# + 把「回答文本 + 已上传文件路径」写入回答,关闭问题、任务恢复执行。
|
#
|
||||||
|
# 校验必填 → 文件统一先落项目根 → 按 schema 字段的 target 移到目标位置
|
||||||
|
# → 把「文本字段 + 已上传文件路径」写入完成结果,关闭待办、任务恢复执行。
|
||||||
#
|
#
|
||||||
# 入参(JSON body 或 params_kw):
|
# 入参(JSON body 或 params_kw):
|
||||||
# question_id 待办问题 id
|
# question_id / task_id 待办 id(二选一,按 kind 决定)
|
||||||
# fields JSON 对象:{字段名: 值}。文件字段值 = base64 字符串(或 data: 前缀);
|
# kind question | human_task(默认 question)
|
||||||
# 文本字段值 = 字符串。
|
# fields JSON 对象:{字段名: 值}。文件字段值 = base64 字符串(或 data: 前缀);
|
||||||
|
# 文本字段值 = 字符串。文件名随附 {字段名}_filename。
|
||||||
#
|
#
|
||||||
# 文件处理约定(用户确认):
|
# 文件处理约定(用户确认):
|
||||||
# 统一先落到项目根目录,再按 form_schema 里该字段的 target 移动:
|
# 统一先落到项目根目录,再按 form_schema 里该字段的 target 移动:
|
||||||
@ -12,6 +15,10 @@
|
|||||||
# - target 形如 "env/"(目录,/ 结尾)→ 移到 {root}/env/{原文件名}
|
# - target 形如 "env/"(目录,/ 结尾)→ 移到 {root}/env/{原文件名}
|
||||||
# - target 为空 → 留在项目根
|
# - target 为空 → 留在项目根
|
||||||
# 目标路径相对项目根解析,禁绝对路径与 .. 穿越(服务端二次校验)。
|
# 目标路径相对项目根解析,禁绝对路径与 .. 穿越(服务端二次校验)。
|
||||||
|
#
|
||||||
|
# 安全:
|
||||||
|
# - question:沿用 question_answer(回答即恢复任务)
|
||||||
|
# - human_task:走 complete_human_task(内含同机构 + assignee 匹配校验 + 触发 QC)
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json as _json
|
import json as _json
|
||||||
@ -21,7 +28,8 @@ user_id = await get_user()
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
return _json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False)
|
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 ''
|
fields_raw = (params_kw or {}).get('fields') or ''
|
||||||
if isinstance(fields_raw, str):
|
if isinstance(fields_raw, str):
|
||||||
try:
|
try:
|
||||||
@ -33,37 +41,60 @@ else:
|
|||||||
if not isinstance(fields, dict):
|
if not isinstance(fields, dict):
|
||||||
fields = {}
|
fields = {}
|
||||||
|
|
||||||
if not question_id:
|
if kind == 'human_task':
|
||||||
return _json.dumps({"success": False, "error": "缺少 question_id"}, ensure_ascii=False)
|
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')
|
dbname = get_module_dbname('pipeline-sdlc')
|
||||||
|
|
||||||
# ── 读取待办问题 + form_schema ──
|
# ── 读取待办记录 + form_schema + 项目上下文 ──
|
||||||
|
schema_fields = []
|
||||||
|
project_id = ''
|
||||||
async with DBPools().sqlorContext(dbname) as sor:
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
qrecs = await sor.sqlExe(
|
if kind == 'human_task':
|
||||||
"SELECT id, tenant_id, task_id, context, status FROM pipeline_agent_questions WHERE id=${i}$",
|
recs = await sor.sqlExe(
|
||||||
{"i": question_id})
|
"SELECT id, project_id, form_schema, status FROM pipeline_human_tasks WHERE id=${i}$",
|
||||||
await sor.sqlExe("COMMIT", {})
|
{"i": oid})
|
||||||
if not qrecs:
|
await sor.sqlExe("COMMIT", {})
|
||||||
return _json.dumps({"success": False, "error": "待办不存在或已处理"}, ensure_ascii=False)
|
if not recs:
|
||||||
q = qrecs[0]
|
return _json.dumps({"success": False, "error": "待办不存在"}, ensure_ascii=False)
|
||||||
project_id = str(getattr(q, 'tenant_id', '') or '')
|
r = recs[0]
|
||||||
q_status = str(getattr(q, 'status', '') or '')
|
project_id = str(getattr(r, 'project_id', '') or '')
|
||||||
if q_status not in ('', 'pending', 'waiting'):
|
if str(getattr(r, 'status', '') or '') != 'pending':
|
||||||
return _json.dumps({"success": False, "error": "该待办已处理,无需重复提交"}, ensure_ascii=False)
|
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
|
if not schema_fields:
|
||||||
try:
|
return _json.dumps({"success": False, "error": "该待办没有动态表单定义"}, ensure_ascii=False)
|
||||||
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:
|
if not project_id:
|
||||||
return _json.dumps({"success": False, "error": "待办缺少项目上下文,无法定位工作空间"}, ensure_ascii=False)
|
return _json.dumps({"success": False, "error": "待办缺少项目上下文,无法定位工作空间"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# ── 项目根目录(与角色 agent 读的位置一致) ──
|
||||||
async with DBPools().sqlorContext(dbname) as sor:
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
project_dir, _base = await get_project_dir_by_id(sor, project_id)
|
project_dir, _base = await get_project_dir_by_id(sor, project_id)
|
||||||
await sor.sqlExe("COMMIT", {})
|
await sor.sqlExe("COMMIT", {})
|
||||||
@ -85,6 +116,7 @@ def _safe_rel(target):
|
|||||||
|
|
||||||
|
|
||||||
uploaded = [] # [(name, rel_path)]
|
uploaded = [] # [(name, rel_path)]
|
||||||
|
text_entries = [] # [(label, value)]
|
||||||
fatal = [] # 致命错误:必填缺失/解码失败/写盘失败 → 整体拒绝提交
|
fatal = [] # 致命错误:必填缺失/解码失败/写盘失败 → 整体拒绝提交
|
||||||
warnings = [] # 非致命:文件已落项目根但移动到 target 失败
|
warnings = [] # 非致命:文件已落项目根但移动到 target 失败
|
||||||
|
|
||||||
@ -93,11 +125,12 @@ for sf in schema_fields:
|
|||||||
ftype = str(sf.get('type') or 'text').lower()
|
ftype = str(sf.get('type') or 'text').lower()
|
||||||
required = bool(sf.get('required'))
|
required = bool(sf.get('required'))
|
||||||
val = fields.get(name)
|
val = fields.get(name)
|
||||||
|
label = str(sf.get('label') or name)
|
||||||
|
|
||||||
if ftype == 'file':
|
if ftype == 'file':
|
||||||
if not val:
|
if not val:
|
||||||
if required:
|
if required:
|
||||||
fatal.append(str(sf.get('label') or name) + ':必须上传文件')
|
fatal.append(label + ':必须上传文件')
|
||||||
continue
|
continue
|
||||||
# 解码 base64
|
# 解码 base64
|
||||||
try:
|
try:
|
||||||
@ -107,7 +140,7 @@ for sf in schema_fields:
|
|||||||
b64 = val
|
b64 = val
|
||||||
data = _b64.b64decode(b64)
|
data = _b64.b64decode(b64)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fatal.append(str(sf.get('label') or name) + ':文件解码失败 ' + str(e)[:80])
|
fatal.append(label + ':文件解码失败 ' + str(e)[:80])
|
||||||
continue
|
continue
|
||||||
# 文件名:优先取随字段附带的 {name}_filename,否则用字段名
|
# 文件名:优先取随字段附带的 {name}_filename,否则用字段名
|
||||||
fname = str(fields.get(name + '_filename') or '').strip()
|
fname = str(fields.get(name + '_filename') or '').strip()
|
||||||
@ -120,7 +153,7 @@ for sf in schema_fields:
|
|||||||
with open(root_path, 'wb') as f:
|
with open(root_path, 'wb') as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fatal.append(str(sf.get('label') or name) + ':写入失败 ' + str(e)[:80])
|
fatal.append(label + ':写入失败 ' + str(e)[:80])
|
||||||
continue
|
continue
|
||||||
# 2) 按 target 移动(失败不致命——文件已在项目根,agent 仍可读到)
|
# 2) 按 target 移动(失败不致命——文件已在项目根,agent 仍可读到)
|
||||||
rel = _safe_rel(sf.get('target'))
|
rel = _safe_rel(sf.get('target'))
|
||||||
@ -140,41 +173,52 @@ for sf in schema_fields:
|
|||||||
os.rename(root_path, dst_abs)
|
os.rename(root_path, dst_abs)
|
||||||
final_rel = dst_rel
|
final_rel = dst_rel
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
warnings.append(str(sf.get('label') or name) + ':已保存到项目根,移动到 ' +
|
warnings.append(label + ':已保存到项目根,移动到 ' + rel + ' 失败 ' + str(e)[:80])
|
||||||
rel + ' 失败 ' + str(e)[:80])
|
|
||||||
uploaded.append((name, final_rel))
|
uploaded.append((name, final_rel))
|
||||||
else:
|
else:
|
||||||
# text / textarea
|
# text / textarea
|
||||||
sv = '' if val is None else str(val).strip()
|
sv = '' if val is None else str(val).strip()
|
||||||
if not sv and required:
|
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:
|
if fatal:
|
||||||
return _json.dumps({"success": False, "error": ";".join(fatal)}, ensure_ascii=False)
|
return _json.dumps({"success": False, "error": ";".join(fatal)}, ensure_ascii=False)
|
||||||
|
|
||||||
# ── 构造回答:文本字段 + 已上传文件路径(回流给 agent 的 _build_qna_section) ──
|
# ── 构造完成结果:文本字段 + 已上传文件路径 ──
|
||||||
answer_lines = []
|
result_files = [rel for _, rel in uploaded]
|
||||||
for sf in schema_fields:
|
free_text = str(fields.get('_note') or '').strip()
|
||||||
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 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:
|
if uploaded:
|
||||||
answer_lines.append('已上传文件(位于项目工作空间,可直接 read_file):')
|
answer_lines.append('已上传文件(位于项目工作空间,可直接 read_file):')
|
||||||
for name, rel in uploaded:
|
for name, rel in uploaded:
|
||||||
answer_lines.append(' · ' + rel)
|
answer_lines.append(' · ' + rel)
|
||||||
|
|
||||||
free_text = str(fields.get('_note') or '').strip()
|
|
||||||
if free_text:
|
if free_text:
|
||||||
answer_lines.append('补充说明:' + free_text)
|
answer_lines.append('补充说明:' + free_text)
|
||||||
|
|
||||||
answer = '\n'.join(answer_lines) or '(已按要求上传/填写)'
|
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
|
ok = False
|
||||||
msg = ''
|
msg = ''
|
||||||
@ -190,7 +234,7 @@ else:
|
|||||||
if ok:
|
if ok:
|
||||||
summary = '提交成功。'
|
summary = '提交成功。'
|
||||||
if uploaded:
|
if uploaded:
|
||||||
summary += '已上传 ' + '、'.join(rel for _, rel in uploaded) + ',任务恢复执行。'
|
summary += '已上传 ' + '、'.join(result_files) + ',任务恢复执行。'
|
||||||
else:
|
else:
|
||||||
summary += '任务恢复执行。'
|
summary += '任务恢复执行。'
|
||||||
if warnings:
|
if warnings:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user