pipeline-sdlc/wwwroot/api/todo_detail.dspy

842 lines
46 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# todo_detail.dspy - 待办详情:把要人类看的正文用 MdWidget 渲染VScrollPanel 可滚动),
# 操作按钮与正文一起给出(先看内容再确认,不再只给两个按钮)。
# 入参kind=human_task|question, id=<待办id>
import json as _json
import os
from urllib.parse import quote as _quote
user_id = await get_user()
if not user_id:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录后查看待办"}}
kind = ((params_kw or {}).get('kind') or 'human_task').strip()
oid = ((params_kw or {}).get('id') or '').strip()
if not oid:
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "缺少待办 id"}}
dbname = get_module_dbname('pipeline-sdlc')
TYPE_LABEL = {
'requirement_confirmation': '需求确认',
'design_confirmation': '设计确认',
'bug_acceptance': 'Bug 验收',
'kb_ingest_confirm': '知识库入库建议',
'flow_plan_confirm': '流程裁剪确认',
'tech_template_confirm': '技术方案骨架确认',
'material_supply': '实体材料缺口',
'bid_delivery_confirm': '交付确认',
}
TYPE_COLOR = {
'requirement_confirmation': '#2563eb',
'design_confirmation': '#2563eb',
'bug_acceptance': '#f0a040',
'kb_ingest_confirm': '#10b981',
'flow_plan_confirm': '#7c3aed',
'tech_template_confirm': '#0891b2',
'material_supply': '#d97706',
'bid_delivery_confirm': '#059669',
}
def _s(v):
if v is None:
return ''
return str(v)
confirm_url = entire_url("/pipeline-sdlc/api/stage_confirm.dspy")
bug_url = entire_url("/pipeline-sdlc/api/bug_accept.dspy")
complete_url = entire_url("/pipeline-sdlc/api/human_task_complete.dspy")
answer_url = entire_url("/pipeline-sdlc/api/question_answer.dspy")
# 处理成功:关详情弹窗 + 关列表弹窗 + 刷新角标
_tail = ("if(d.success){"
"var pw=bricks.getWidgetById('todo_detail_pw',bricks.app);if(pw){pw.destroy();}"
"var lw=bricks.getWidgetById('my_todos_pw',bricks.app);if(lw){lw.destroy();}"
"if(window.refreshTodo){window.refreshTodo();}"
"var mo=new bricks.Message({title:'已处理',message:d.message||'处理成功'});mo.open();"
"}else{var mf=new bricks.Message({title:'处理失败',message:d.error||'处理失败'});mf.open();}")
def _read_input_js(wid):
return ("var cw=bricks.getWidgetById(" + _json.dumps(wid) + ",bricks.app);var cv='';"
"if(cw){cv=(typeof cw.resultValue==='function')?cw.resultValue():"
"((cw.dom_element&&cw.dom_element.value)||'');}"
"cv=(cv===null||cv===undefined)?'':String(cv).trim();")
def _post_js(url, fields):
js = "var body=new URLSearchParams();"
for k, v in fields:
js += "body.append(" + _json.dumps(k) + "," + v + ");"
js += ("var rp=await fetch(" + _json.dumps(url) + ",{method:'POST',"
"headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body});"
"var d=await rp.json();")
return js
def _input_box(wid, label, tip):
return {
"widgettype": "VBox",
"options": {"width": "100%", "gap": "4px", "padding": "8px 14px 0 14px"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": label, "cfontsize": 0.75, "color": "#64748b"}},
{"widgettype": "UiText", "id": wid,
"options": {"name": wid, "width": "100%", "cfontsize": 0.85,
"placeholder": tip, "height": "70px"}}
]
}
def _btn(label, css, script, conform=None):
bind = {"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}
if conform:
bind["conform"] = conform
return {"widgettype": "Button", "options": {"label": label, "css": css}, "binds": [bind]}
def _file_link_row(fname, project_id, fpath):
"""待办中的输出文件行:文件名 + 打开/下载按钮(链接到项目空间内的真实文件)。
打开2026-09-16 修复md/txt 点「打开」曾直接 window.open(project_file.dspy)
——浏览器不能原生渲染 md落成以 URL 名project_file.dspy命名的下载。
改为弹出内嵌 MdWidgetmd_url 拉取渲染)的在线查看窗口(对齐 2026-09-03
交付件渲染规范md/txt → MdWidget 渲染 + 下载链接)。
office/pdf 等浏览器不可内联渲染的类型「打开」等同下载(如实标注按钮文案)。
"""
open_url = (entire_url("/pipeline-sdlc/api/project_file.dspy")
+ "?project_id=" + _quote(project_id) + "&path=" + _quote(fpath))
dl_url = open_url + "&download=1"
ext = os.path.splitext(fname or '')[1].lower()
if ext in ('.md', '.txt', '.markdown'):
# 在线查看PopupWindow + widgetBuild(MdWidget)(对齐 cockpit_cards.dspy 实证模式:
# JS 构造器不认 subwidgets必须 content_w.add_widgetmd_url 拉取渲染,
# 超长 md 也不撑爆 widget JSON
open_script = (
"var pw=new bricks.PopupWindow({title:" + _json.dumps("查看 " + fname) +
",cwidth:70,cheight:36,auto_open:true});"
"bricks.widgetBuild({widgettype:'MdWidget',options:{md_url:" +
_json.dumps(open_url) + ",width:'100%'}},pw.content_w)"
".then(function(w){if(w)pw.content_w.add_widget(w);});")
open_btn = _btn("在线查看", "small", open_script)
else:
open_btn = _btn("下载查看", "small",
"window.open(" + _json.dumps(dl_url) + ",'_blank');")
return {"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "6px 14px",
"alignItems": "center", "bgcolor": "#f8fafc",
"border": "1px solid #e2e8f0", "borderRadius": "6px"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "📎 " + fname,
"cfontsize": 0.85, "color": "var(--ink-2)", "flex": "1 1 auto",
"overflow": "hidden", "textOverflow": "ellipsis", "whiteSpace": "nowrap"}},
open_btn,
_btn("下载", "small", "window.open(" + _json.dumps(dl_url) + ",'_blank');")
]}
def _build_form_action(schema_fields, kind, oid):
"""按 form_schema 生成动态表单操作区(问题通道与人类任务通道通用)。
file 字段 → UiFile 上传框FileReader 转 base64text/textarea → 输入框。
提交 → todo_form_submit.dspykind 区分通道),落项目根 + 按 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'
sub_lines = []
md = []
action_widgets = []
_file_rows = [] # 输出文件链接行(打开/下载)——待办中出现的输出文件必须可直达
async with DBPools().sqlorContext(dbname) as sor:
project_id = ''
if kind == 'question':
qrecs = await sor.sqlExe(
"SELECT id, tenant_id, task_id, from_role, question, problem_type, "
"current_handler_role, context, created_at FROM pipeline_agent_questions "
"WHERE id=${i}$", {"i": oid})
await sor.sqlExe("COMMIT", {})
if not qrecs:
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "问题不存在或已处理"}}
q = qrecs[0]
project_id = _s(getattr(q, 'tenant_id', ''))
badge = '待答问题'
badge_color = '#8b5cf6'
qtext = _s(getattr(q, 'question', ''))
title = qtext[:60] if qtext else '冒泡问题'
sub_lines.append('提问角色:' + (_s(getattr(q, 'from_role', '')) or '-'))
sub_lines.append('问题类型:' + (_s(getattr(q, 'problem_type', '')) or '-'))
sub_lines.append('提问时间:' + _s(getattr(q, 'created_at', '')))
md.append('## 问题内容')
md.append('')
md.append(qtext or '(无正文)')
md.append('')
rel_task = _s(getattr(q, 'task_id', ''))
if rel_task:
trecs = await sor.sqlExe("SELECT title, state FROM pipeline_tasks WHERE id=${t}$",
{"t": rel_task})
await sor.sqlExe("COMMIT", {})
if trecs:
md.append('## 关联任务')
md.append('')
md.append('- 任务:' + _s(getattr(trecs[0], 'title', '')))
md.append('- 状态:' + _s(getattr(trecs[0], 'state', '')))
md.append('')
# ── 动态表单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:
action_widgets.extend(_build_form_action(_form_fields, 'question', oid))
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, "
"description, form_schema, status, created_at, submitted_by FROM pipeline_human_tasks "
"WHERE id=${i}$", {"i": oid})
await sor.sqlExe("COMMIT", {})
if not hrecs:
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "待办不存在或已处理"}}
h = hrecs[0]
task_type = _s(getattr(h, 'task_type', ''))
project_id = _s(getattr(h, 'project_id', ''))
title = _s(getattr(h, 'title', '')) or '待办'
badge = TYPE_LABEL.get(task_type, '人类任务')
badge_color = TYPE_COLOR.get(task_type, '#2563eb')
sub_lines.append('创建时间:' + _s(getattr(h, 'created_at', '')))
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('## 待办说明')
md.append('')
md.append(desc)
md.append('')
src_task_id = _s(getattr(h, 'task_id', ''))
bug_id = _s(getattr(h, 'bug_id', ''))
if task_type in ('requirement_confirmation', 'design_confirmation'):
drecs = []
pm_comment = ''
if src_task_id:
trecs = await sor.sqlExe(
"SELECT title, state FROM pipeline_tasks WHERE id=${t}$", {"t": src_task_id})
await sor.sqlExe("COMMIT", {})
if trecs:
sub_lines.append('原任务:' + _s(getattr(trecs[0], 'title', '')))
# 角色 agent 产出的交付件(排除 pm_review —— 那是审核记录不是待确认内容)
drecs = await sor.sqlExe(
"SELECT title, deliverable_type, content, file_path, review_status, "
"review_comment, created_by, created_at FROM pipeline_deliverables "
"WHERE task_id=${t}$ AND deliverable_type<>'pm_review' "
"ORDER BY created_at DESC LIMIT 3", {"t": src_task_id})
await sor.sqlExe("COMMIT", {})
# PM 审核意见单独取content 是 JSON取其中 comment
prrecs = await sor.sqlExe(
"SELECT content, review_comment, created_at FROM pipeline_deliverables "
"WHERE task_id=${t}$ AND deliverable_type='pm_review' "
"ORDER BY created_at DESC LIMIT 1", {"t": src_task_id})
await sor.sqlExe("COMMIT", {})
if prrecs:
pm_comment = _s(getattr(prrecs[0], 'review_comment', ''))
raw = _s(getattr(prrecs[0], 'content', ''))
if raw:
try:
_pj = _json.loads(raw)
pm_comment = _s(_pj.get('comment')) or pm_comment
except Exception:
pm_comment = pm_comment or raw
if pm_comment:
md.append('## PM 审核意见')
md.append('')
md.append(pm_comment)
md.append('')
if drecs:
for _i in range(len(drecs)):
dx = drecs[_i]
_head = '## 待确认交付件' if _i == 0 else '## 交付件(更早版本)'
md.append(_head + '' + (_s(getattr(dx, 'title', '')) or '(未命名)'))
md.append('')
md.append('- 类型:' + (_s(getattr(dx, 'deliverable_type', '')) or '-')
+ ' 产出:' + (_s(getattr(dx, 'created_by', '')) or '-')
+ ' 时间:' + _s(getattr(dx, 'created_at', ''))
+ ' 评审:' + (_s(getattr(dx, 'review_status', '')) or '-'))
_fp = _s(getattr(dx, 'file_path', ''))
if _fp:
md.append('- 输出文件:`' + os.path.basename(_fp) + '`(项目空间 deliverables 目录)')
_file_rows.append(_file_link_row(os.path.basename(_fp), project_id, _fp))
md.append('')
md.append('---')
md.append('')
_content = _s(getattr(dx, 'content', ''))
if len(_content) > 20000:
_content = _content[:20000] + '\n\n…内容过长已截断完整版见上面的文件路径'
md.append(_content or '(交付件正文为空,请到项目工作空间 deliverables 目录查看文件)')
md.append('')
if _i == 0 and len(drecs) > 1:
md.append('---')
md.append('')
else:
md.append('## 待确认内容')
md.append('')
md.append('未找到该任务的交付件记录,请到项目工作空间 deliverables 目录查看产出文件。')
md.append('')
action_widgets.append(_input_box(
'todo_comment', '修改意见(确认通过可留空;退回重做必须填写)', '请填写修改意见'))
ok_script = (_read_input_js('todo_comment') +
_post_js(confirm_url, [('human_task_id', _json.dumps(oid)),
('confirmed', "'1'"), ('comment', 'cv')]) +
_tail)
no_script = (_read_input_js('todo_comment') +
"if(!cv){var mn=new bricks.Message({title:'请填写修改意见',"
"message:'退回重做前请说明需要修改什么'});mn.open();return;}" +
_post_js(confirm_url, [('human_task_id', _json.dumps(oid)),
('confirmed', "'0'"), ('comment', 'cv')]) +
_tail)
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "10px 14px", "halign": "right"},
"subwidgets": [
_btn('确认通过', 'primary', ok_script,
conform={"title": "确认通过", "message": "确认该阶段成果满足要求,继续后续任务?",
"conform": {"label": "确认通过"}, "discard": {"label": "再看看"}}),
_btn('不确定,退回重做', 'small', no_script)
]
})
elif task_type == 'opp_report_confirm':
# 商机产线研发报告确认:正文=报告全文(确认人先看内容再拍板)
opp_confirm_url = entire_url("/pipeline-opportunity/api/opp_confirm_report.dspy")
opp_ppt_url = entire_url("/pipeline-opportunity/api/opp_report_ppt.dspy")
rrecs = await sor.sqlExe(
"SELECT id, software, title, content, status, ppt_path FROM opp_reports "
"WHERE confirm_task_id=${t}$", {"t": oid})
await sor.sqlExe("COMMIT", {})
if rrecs:
r0 = rrecs[0]
rep_id = _s(getattr(r0, 'id', ''))
md.append('## 报告信息')
md.append('')
md.append('- 方向:' + (_s(getattr(r0, 'software', '')) or '-')
+ ' 状态:' + _s(getattr(r0, 'status', '')))
md.append('- 数据来源:数据爬取平台(每条引用可查证)')
md.append('')
md.append('## 报告全文(请审阅后确认)')
md.append('')
_content = _s(getattr(r0, 'content', ''))
if len(_content) > 40000:
_content = _content[:40000] + '\n\n…内容过长已截断完整内容见 PPT 下载)'
md.append(_content or '(报告正文为空)')
md.append('')
# PPT 下载按钮(报告以 PPT 存储,确认人可下载审阅)
_ppt_path = _s(getattr(r0, 'ppt_path', ''))
if _ppt_path:
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "4px 14px", "alignItems": "center"},
"subwidgets": [
{"widgettype": "Text", "options": {"otext": "📑 报告已存为 PPT", "text": "📑 报告已存为 PPT", "i18n": True,
"cfontsize": 0.8, "color": "#64748b", "halign": "left"}},
{"widgettype": "Button", "options": {"label": "下载 PPT", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(opp_ppt_url + "?report_id=" + rep_id) + ",'_blank');"}]}
]
})
else:
md.append('## 报告信息')
md.append('')
md.append('未找到该确认任务对应的研发报告。')
md.append('')
ok_script = (_post_js(opp_confirm_url, [('human_task_id', _json.dumps(oid)),
('decision', "'approve'")]) + _tail)
no_script = (_read_input_js('opp_confirm_comment') +
_post_js(opp_confirm_url, [('human_task_id', _json.dumps(oid)),
('decision', "'reject'"),
('comment', 'cv')]) + _tail)
action_widgets.append(_input_box('opp_confirm_comment', '退回意见(退回时填写,帮助修改)', '可选:修改意见'))
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"},
"subwidgets": [
_btn('确认通过(发起研发审批)', 'primary', ok_script,
conform={"title": "确认报告", "message": "确认该研发报告内容无误,进入研发审批?",
"conform": {"label": "确认通过"}, "discard": {"label": "取消"}}),
_btn('退回修改', 'small', no_script)
]
})
elif task_type == 'bug_acceptance':
if bug_id:
brecs = await sor.sqlExe(
"SELECT title, description, severity, priority, status, fix_description, "
"fix_commit, assignee_id, created_at FROM sd_bugs WHERE id=${b}$", {"b": bug_id})
await sor.sqlExe("COMMIT", {})
else:
brecs = []
if brecs:
b = brecs[0]
md.append('## Bug' + (_s(getattr(b, 'title', '')) or '(无标题)'))
md.append('')
md.append('- 严重程度:' + (_s(getattr(b, 'severity', '')) or '-')
+ ' 优先级:' + (_s(getattr(b, 'priority', '')) or '-')
+ ' 状态:' + (_s(getattr(b, 'status', '')) or '-'))
md.append('- 处理人:' + (_s(getattr(b, 'assignee_id', '')) or '-')
+ ' 提交时间:' + _s(getattr(b, 'created_at', '')))
md.append('')
md.append('### 问题描述')
md.append('')
md.append(_s(getattr(b, 'description', '')) or '(无)')
md.append('')
md.append('### 修复说明')
md.append('')
md.append(_s(getattr(b, 'fix_description', '')) or '(开发未填写修复说明)')
fc = _s(getattr(b, 'fix_commit', ''))
if fc:
md.append('')
md.append('- 修复提交:`' + fc + '`')
md.append('')
else:
md.append('## Bug 信息')
md.append('')
md.append('未找到关联 Bug 记录bug_id=' + (bug_id or '空') + ')。')
md.append('')
ok_script = (_post_js(bug_url, [('bug_id', _json.dumps(bug_id)), ('accept', "'1'")]) + _tail)
no_script = (_post_js(bug_url, [('bug_id', _json.dumps(bug_id)), ('accept', "'0'")]) + _tail)
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"},
"subwidgets": [
_btn('验收通过(关闭 Bug', 'primary', ok_script,
conform={"title": "验收通过", "message": "确认该 Bug 已修复并关闭?",
"conform": {"label": "验收通过"}, "discard": {"label": "取消"}}),
_btn('不通过(重新打开)', 'small', no_script)
]
})
elif task_type == 'bid_delivery_confirm':
# 投标产线交付确认:展示最新标书文件(打开/下载链接)+ 确认按钮
bdoc = []
if project_id:
brecs = await sor.sqlExe(
"SELECT doc_name, file_path, version, total_score, max_score, status, updated_at "
"FROM bid_documents WHERE project_id=${p}$ AND file_path<>'' "
"ORDER BY version DESC LIMIT 1", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if brecs:
bdoc = brecs
if bdoc:
b0 = bdoc[0]
md.append('## 待确认标书')
md.append('')
md.append('- 名称:' + (_s(getattr(b0, 'doc_name', '')) or '-')
+ ' 版本v' + _s(getattr(b0, 'version', ''))
+ ' 评分:' + _s(getattr(b0, 'total_score', '')) + '/'
+ _s(getattr(b0, 'max_score', ''))
+ ' 状态:' + (_s(getattr(b0, 'status', '')) or '-'))
md.append('')
md.append('请下载标书后核对用印/密封/份数/递交要求,确认无误后点击确认交付。')
md.append('')
_bf = _s(getattr(b0, 'file_path', ''))
if _bf:
# 2026-09-16标书 md可在线查看与 docx最终交付同名产物
# 都给出——file_path 可能指向其中任一,按 basename 派生两个候选,
# 实际存在的才出行md 走在线查看、docx 走下载)。
_base, _ext = os.path.splitext(_bf)
for _cand in (_base + '.md', _base + '.docx'):
if _cand and os.path.isfile(_cand):
_file_rows.append(_file_link_row(os.path.basename(_cand), project_id, _cand))
if not any(os.path.isfile(c) for c in (_base + '.md', _base + '.docx')):
_file_rows.append(_file_link_row(os.path.basename(_bf), project_id, _bf))
else:
md.append('## 待确认标书')
md.append('')
md.append('未找到该项目的标书文件记录。')
md.append('')
# 确认交付/退回按钮2026-09-16 修复:此分支曾只渲染文件行,忘了 action
# 按钮,通用兜底分支又被 elif 挡住 → 待办成死卡片无法确认。
# 2026-09-17 用户报障补退回通道:原来只有「确认」单按钮,复核发现问题
# 无法打回——对齐 tech_template_confirm 的确认/驳回双按钮模式)。
# 确认通道 = human_task_complete同机构+assignee 校验);置 done 后对账器
# G 段自动收尾:标书 delivered + 项目 completed。
# 退回通道 = /pipeline-bidding/api/bid_delivery_reject.dspy用户专属
# agent 无此工具):待办置 rejected + 标书 rejected + 派评分员按意见
# 打回问题章节 → 既有链路自动重写→重审→重合成→重评分→重发交付确认。
_bd_status = _s(getattr(h, 'status', ''))
if _bd_status != 'pending':
md.append('## 待办状态')
md.append('')
md.append('该确认已处理(当前状态:' + (_bd_status or '-') + '),无需再操作。')
md.append('')
else:
action_widgets.append(_input_box(
'bid_confirm_note', '交付备注(确认可留空;退回重做必须写明哪里要修改)',
'备注/退回意见:如用印密封核对说明,或退回时指明问题章节与修改要求'))
_ok_script = (_read_input_js('bid_confirm_note') +
"var rd=JSON.stringify({content:(cv||'确认交付')});" +
_post_js(complete_url, [('human_task_id', _json.dumps(oid)),
('result_data', 'rd')]) + _tail)
_bdj_url = entire_url("/pipeline-bidding/api/bid_delivery_reject.dspy")
_no_script = (_read_input_js('bid_confirm_note') +
"if(!cv){var mn=new bricks.Message({title:'请填写退回意见',"
"message:'退回前请说明标书哪里需要修改(评分员按意见打回章节重写)'});mn.open();return;}" +
_post_js(_bdj_url, [('human_task_id', _json.dumps(oid)),
('comment', 'cv')]) + _tail)
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"},
"subwidgets": [
_btn('确认交付(项目完成)', 'primary', _ok_script,
conform={"title": "确认交付", "message": "确认标书已复核(用印/密封/份数/递交)?确认后标书置为已交付、项目完成。",
"conform": {"label": "确认交付"}, "discard": {"label": "再看看"}}),
_btn('退回重做', 'small', _no_script,
conform={"title": "退回重做", "message": "退回后评分员将按你的意见打回问题章节重写,重新合成、评分后再次请你确认。确认退回?",
"conform": {"label": "退回重做"}, "discard": {"label": "取消"}})
]
})
elif task_type == 'flow_plan_confirm':
# 产线流程裁剪确认正文description已含裁剪方案对照表propose 时渲染),
# 这里补确认/驳回按钮。plan 由 confirm_task_id 反查。
fp_url = entire_url("/pipeline-sdlc/api/flow_plan_confirm.dspy")
fprecs = await sor.sqlExe(
"SELECT id, status, version FROM pipeline_flow_plans WHERE confirm_task_id=${t}$ "
"ORDER BY version DESC LIMIT 1", {"t": oid})
await sor.sqlExe("COMMIT", {})
if fprecs:
fp = fprecs[0]
plan_id = _s(getattr(fp, 'id', ''))
fp_status = _s(getattr(fp, 'status', ''))
sub_lines.append('计划版本v' + _s(getattr(fp, 'version', '')))
if fp_status != 'pending_confirm':
md.append('## 计划状态')
md.append('')
md.append('该计划已处理(当前状态:' + fp_status + '),无需再确认。')
md.append('')
else:
action_widgets.append(_input_box(
'fp_comment', '驳回意见(驳回时必填,助手按意见修订流程后重新提案)',
'请填写驳回意见'))
ok_script = (_post_js(fp_url, [('plan_id', _json.dumps(plan_id)),
('decision', "'confirm'"), ('comment', "''")]) + _tail)
no_script = (_read_input_js('fp_comment') +
"if(!cv){var mn=new bricks.Message({title:'请填写驳回意见',"
"message:'驳回前请说明需要怎么改(助手按意见修订重提)'});mn.open();return;}" +
_post_js(fp_url, [('plan_id', _json.dumps(plan_id)),
('decision', "'reject'"), ('comment', 'cv')]) + _tail)
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"},
"subwidgets": [
_btn('确认并启用', 'primary', ok_script,
conform={"title": "确认流程裁剪", "message": "确认后流程按裁剪方案生效,且不可再修改。确认?",
"conform": {"label": "确认启用"}, "discard": {"label": "再看看"}}),
_btn('驳回(按意见修订)', 'small', no_script)
]
})
else:
md.append('## 计划状态')
md.append('')
md.append('未找到对应的流程裁剪计划记录。')
md.append('')
elif task_type == 'tech_template_confirm':
# 技术方案书章节骨架确认(投标产线纯技术方案流程 bid_tech_proposal2026-09-15
# description 已含骨架预览表propose_tech_template 渲染),这里只补确认/驳回按钮。
# 端点在投标模块自持(/pipeline-bidding/api/tech_template_confirm.dspy
tt_url = entire_url("/pipeline-bidding/api/tech_template_confirm.dspy")
_tt_status = _s(getattr(h, 'status', ''))
if _tt_status != 'pending':
md.append('## 骨架状态')
md.append('')
md.append('该确认已处理(当前状态:' + (_tt_status or '-') + '),无需再确认。')
md.append('')
else:
action_widgets.append(_input_box(
'tt_comment', '驳回意见(驳回时必填,助手按意见修订骨架后重新提案)',
'请填写驳回意见:换模版/调章节/补覆盖…'))
tt_ok = (_post_js(tt_url, [('human_task_id', _json.dumps(oid)),
('decision', "'confirm'"), ('comment', "''")]) + _tail)
tt_no = (_read_input_js('tt_comment') +
"if(!cv){var mn=new bricks.Message({title:'请填写驳回意见',"
"message:'驳回前请说明骨架要怎么改(助手按意见修订重提)'});mn.open();return;}" +
_post_js(tt_url, [('human_task_id', _json.dumps(oid)),
('decision', "'reject'"), ('comment', 'cv')]) + _tail)
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"},
"subwidgets": [
_btn('确认骨架,开始编写', 'primary', tt_ok,
conform={"title": "确认章节骨架", "message": "确认后骨架落库并开始分章编写,确认?",
"conform": {"label": "确认开写"}, "discard": {"label": "再看看"}}),
_btn('驳回(按意见修订)', 'small', tt_no)
]
})
elif task_type == 'kb_ingest_confirm':
# PM 建议文件入知识库:展示建议详情 + 文件(打开/下载)+ 批准/驳回
_sg = None
try:
_fsj = _s(getattr(h, 'form_schema', ''))
if _fsj:
_sg = (_json.loads(_fsj) or {}).get('suggestion_id') or ''
except Exception:
_sg = ''
sgrecs = []
if _sg:
sgrecs = await sor.sqlExe(
"SELECT kb_id, file_path, file_name, reason, status FROM "
"pipeline_kb_suggestions WHERE id=${i}$", {"i": _sg})
await sor.sqlExe("COMMIT", {})
if sgrecs:
sg0 = sgrecs[0]
_fname = _s(getattr(sg0, 'file_name', '')) or '-'
_fpath = _s(getattr(sg0, 'file_path', ''))
_reason = _s(getattr(sg0, 'reason', '')) or '(未填写理由)'
_kbid = _s(getattr(sg0, 'kb_id', ''))
_kbname = _kbid
if _kbid:
krecs = await sor.sqlExe(
"SELECT name FROM rag_knowledge_bases WHERE id=${k}$", {"k": _kbid})
await sor.sqlExe("COMMIT", {})
if krecs:
_kbname = _s(getattr(krecs[0], 'name', '')) or _kbid
md.append('## 入库建议')
md.append('')
md.append('- 文件:`' + _fname + '`')
md.append('- 目标知识库:' + _kbname + 'ID: ' + _kbid + '')
md.append('')
md.append('### PM 给出的理由')
md.append('')
md.append(_reason)
md.append('')
md.append('批准后系统将以项目 owner 身份把该文件上传入库(后台解析,稍后可检索)。')
md.append('')
if _fpath:
_file_rows.append(_file_link_row(_fname, project_id, _fpath))
else:
md.append('## 入库建议')
md.append('')
md.append('未找到对应的建议记录。')
md.append('')
kbi_url = entire_url("/pipeline-sdlc/api/kb_ingest_decide.dspy")
ok_script = (_post_js(kbi_url, [('suggestion_id', _json.dumps(_sg or '')),
('human_task_id', _json.dumps(oid)),
('approve', "'1'")]) + _tail)
no_script = (_read_input_js('kb_ingest_comment') +
_post_js(kbi_url, [('suggestion_id', _json.dumps(_sg or '')),
('human_task_id', _json.dumps(oid)),
('approve', "'0'"), ('comment', 'cv')]) +
_tail)
action_widgets.append(_input_box('kb_ingest_comment', '驳回意见(驳回时填写)', '可选:驳回原因'))
action_widgets.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "padding": "12px 14px", "halign": "right"},
"subwidgets": [
_btn('批准入库', 'primary', ok_script,
conform={"title": "批准入库", "message": "确认将该文件上传到目标知识库?",
"conform": {"label": "批准"}, "discard": {"label": "再看看"}}),
_btn('驳回', 'small', no_script)
]
})
else:
if not desc:
md.append('## 待办说明')
md.append('')
md.append('(无说明)')
md.append('')
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:
precs = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${p}$", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if precs:
sub_lines.insert(0, '项目:' + (_s(getattr(precs[0], 'name', '')) or project_id))
head_widgets = [{
"widgettype": "HBox",
"options": {"width": "100%", "gap": "8px", "alignItems": "center", "padding": "12px 14px 4px 14px"},
"subwidgets": [
{"widgettype": "Text",
"options": {"text": badge, "cfontsize": 0.75, "color": "#ffffff", "padding": "2px 10px",
"bgcolor": badge_color, "borderRadius": "10px", "whiteSpace": "nowrap"}},
{"widgettype": "Text",
"options": {"text": title, "cfontsize": 1.05, "color": "var(--ink-0)"}}
]
}]
if sub_lines:
head_widgets.append({
"widgettype": "Text",
"options": {"text": ' '.join(sub_lines), "cfontsize": 0.75, "color": "#94a3b8",
"padding": "0 14px 6px 14px", "width": "100%"}
})
# 输出文件区(待办中出现的输出文件必须给打开/下载链接)
_file_section = []
if _file_rows:
_file_section = [{
"widgettype": "VBox",
"options": {"width": "100%", "gap": "6px", "padding": "8px 14px 2px 14px"},
"subwidgets": [
{"widgettype": "Text", "options": {"otext": "📎 输出文件", "text": "📎 输出文件", "i18n": True, "cfontsize": 0.85,
"fontWeight": "bold", "color": "var(--ink-2)"}}
] + _file_rows
}]
return {
"widgettype": "PopupWindow",
"id": "todo_detail_pw",
"options": {"title": badge + " - " + title[:40], "width": "86%", "height": "86%",
"auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%", "gap": "0px"},
"subwidgets": head_widgets + [
{
"widgettype": "VBox",
"options": {"width": "100%", "flex": "1 1 auto", "minHeight": "180px",
"bgcolor": "#ffffff", "border": "1px solid #e2e8f0",
"borderRadius": "6px", "overflow": "hidden",
"margin": "0 14px"},
"subwidgets": [{
"widgettype": "VScrollPanel",
"options": {"css": "filler", "width": "100%", "padding": "6px 12px"},
"subwidgets": [{
"widgettype": "MdWidget",
"options": {"mdtext": '\n'.join(md), "width": "100%"}
}]
}]
}
] + _file_section + action_widgets
}]
}