# todo_detail.dspy - 待办详情:把要人类看的正文用 MdWidget 渲染(VScrollPanel 可滚动), # 操作按钮与正文一起给出(先看内容再确认,不再只给两个按钮)。 # 入参:kind=human_task|question, id=<待办id> import json as _json 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 验收', } TYPE_COLOR = { 'requirement_confirmation': '#2563eb', 'design_confirmation': '#2563eb', 'bug_acceptance': '#f0a040', } 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]} title = '' badge = '待办' badge_color = '#64748b' sub_lines = [] md = [] action_widgets = [] 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, 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('') 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, 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', ''))) 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('- 文件:`' + _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 == '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) ] }) else: if not desc: md.append('## 待办说明') md.append('') md.append('(无说明)') md.append('') 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": "#0f172a"}} ] }] 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%"} }) 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%", "height": "62vh", "minHeight": "220px", "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%"} }] }] } ] + action_widgets }] }