842 lines
46 KiB
Plaintext
842 lines
46 KiB
Plaintext
# 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
|
||
}]
|
||
}
|