# 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)命名的下载。 改为弹出内嵌 MdWidget(md_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_widget;md_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 转 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' 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_proposal,2026-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 }] }