diff --git a/wwwroot/api/my_todos_popup.dspy b/wwwroot/api/my_todos_popup.dspy new file mode 100644 index 0000000..50bf408 --- /dev/null +++ b/wwwroot/api/my_todos_popup.dspy @@ -0,0 +1,141 @@ +# my_todos_popup.dspy - 我的待办列表(返回 bricks widget 描述,由 todo_badge.js 构建) +# 列表只做导航:每条待办点「查看内容并处理」→ todo_detail.dspy 渲染正文后再确认。 + +import json as _json + +user_id = await get_user() +if not user_id: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录后查看待办"}} + +todos = await list_my_human_todos(user_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', +} + +detail_url = entire_url("/pipeline-sdlc/api/todo_detail.dspy") + + +def _s(v): + if v is None: + return '' + return str(v) + + +def _clip(s, n): + s = ' '.join(_s(s).split()) + if len(s) > n: + return s[:n] + '…' + return s + + +# 项目名(一次查全,避免逐条查库) +proj_ids = [] +for t in (todos or []): + pid = _s(t.get('project_id')) + if pid and pid not in proj_ids: + proj_ids.append(pid) + +proj_names = {} +if proj_ids: + id_list = ','.join(["'" + p.replace("'", "") + "'" for p in proj_ids]) + async with DBPools().sqlorContext(dbname) as sor: + precs = await sor.sqlExe("SELECT id, name FROM sd_projects WHERE id IN (" + id_list + ")", {}) + await sor.sqlExe("COMMIT", {}) + for r in (precs or []): + proj_names[_s(getattr(r, 'id', ''))] = _s(getattr(r, 'name', '')) + +cards = [] +for t in (todos or []): + src = _s(t.get('source')) + tid = _s(t.get('id')) + kind = 'question' if src == 'question' else 'human_task' + task_type = _s(t.get('task_type')) + if src == 'question': + badge = '待答问题' + badge_color = '#8b5cf6' + title = _clip(t.get('question'), 80) or '冒泡问题' + summary = '提问角色:' + (_s(t.get('from_role')) or '-') + ' | 类型:' + (_s(t.get('problem_type')) or '-') + else: + badge = TYPE_LABEL.get(task_type, '人类任务') + badge_color = TYPE_COLOR.get(task_type, '#2563eb') + title = _clip(t.get('title'), 80) or '待办任务' + summary = _clip(t.get('description'), 110) + + pid = _s(t.get('project_id')) + meta_bits = [] + pname = proj_names.get(pid) or _s(t.get('project_name')) or pid + if pname: + meta_bits.append('项目:' + pname) + if t.get('created_at'): + meta_bits.append(_s(t.get('created_at'))) + + open_script = ("var du=" + _json.dumps(detail_url) + "+'?kind=" + kind + "&id='+encodeURIComponent(" + + _json.dumps(tid) + ");" + "var rp=await fetch(du);var dd=await rp.json();" + "await bricks.widgetBuild(dd,bricks.app);") + + sub = [ + { + "widgettype": "HBox", + "options": {"width": "100%", "gap": "8px", "alignItems": "center"}, + "subwidgets": [ + {"widgettype": "Text", + "options": {"text": badge, "cfontsize": 0.7, "color": "#ffffff", "padding": "1px 8px", + "style": {"background": badge_color, "borderRadius": "10px", + "fontWeight": "bold", "whiteSpace": "nowrap"}}}, + {"widgettype": "Text", + "options": {"text": title, "cfontsize": 0.9, "color": "#1e293b", "fontWeight": "bold"}} + ] + } + ] + if meta_bits: + sub.append({"widgettype": "Text", + "options": {"text": ' | '.join(meta_bits), "cfontsize": 0.7, "color": "#94a3b8"}}) + if summary: + sub.append({"widgettype": "Text", + "options": {"text": summary, "cfontsize": 0.78, "color": "#475569", "width": "100%"}}) + sub.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "6px", "halign": "right"}, + "subwidgets": [{ + "widgettype": "Button", + "options": {"label": "查看内容并处理", "css": "primary small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": open_script}] + }] + }) + + cards.append({ + "widgettype": "VBox", + "options": {"width": "100%", "bgcolor": "#ffffff", "border": "1px solid #e2e8f0", + "borderRadius": "8px", "padding": "10px 14px", "gap": "6px", + "marginBottom": "6px"}, + "subwidgets": sub + }) + +if not cards: + cards = [{"widgettype": "Text", + "options": {"text": "暂无待办", "cfontsize": 0.9, "color": "#94a3b8", "padding": "16px"}}] + +return { + "widgettype": "PopupWindow", + "id": "my_todos_pw", + "options": {"title": "我的待办(" + str(len(todos or [])) + ")", "width": "76%", "height": "80%", + "auto_open": True, "resizable": True}, + "subwidgets": [{ + "widgettype": "VScrollPanel", + "options": {"css": "filler", "width": "100%", "height": "100%", + "padding": "12px", "gap": "8px"}, + "subwidgets": cards + }] +} diff --git a/wwwroot/api/question_answer.dspy b/wwwroot/api/question_answer.dspy new file mode 100644 index 0000000..273c754 --- /dev/null +++ b/wwwroot/api/question_answer.dspy @@ -0,0 +1,38 @@ +# question_answer.dspy - 我的待办里直接回答冒泡问题(回答后问题关闭、任务恢复执行) + +import json as _json + +user_id = await get_user() +if not user_id: + return _json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False) + +question_id = ((params_kw or {}).get('question_id') or '').strip() +answer = ((params_kw or {}).get('answer') or '').strip() + +if not question_id: + return _json.dumps({"success": False, "error": "缺少 question_id"}, ensure_ascii=False) +if not answer: + return _json.dumps({"success": False, "error": "回答内容不能为空"}, ensure_ascii=False) + +res = await question_answer(question_id, answer, answered_by=user_id, answer_source='human') + +ok = False +msg = '' +if isinstance(res, tuple) or isinstance(res, list): + ok = bool(res[0]) if len(res) > 0 else False + msg = str(res[1]) if len(res) > 1 else '' +elif isinstance(res, dict): + if 'success' in res: + ok = bool(res.get('success')) + elif 'ok' in res: + ok = bool(res.get('ok')) + else: + ok = True + msg = str(res.get('message') or res.get('error') or '') +else: + ok = bool(res) + +if ok: + return _json.dumps({"success": True, "message": msg or "回答已提交,问题关闭,任务恢复执行"}, + ensure_ascii=False) +return _json.dumps({"success": False, "error": msg or "回答提交失败"}, ensure_ascii=False) diff --git a/wwwroot/api/todo_detail.dspy b/wwwroot/api/todo_detail.dspy new file mode 100644 index 0000000..2302717 --- /dev/null +++ b/wwwroot/api/todo_detail.dspy @@ -0,0 +1,353 @@ +# 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'): + 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', ''))) + drecs = await sor.sqlExe( + "SELECT title, deliverable_type, content, review_status, review_comment, " + "created_by, created_at FROM pipeline_deliverables " + "WHERE task_id=${t}$ ORDER BY created_at DESC LIMIT 5", {"t": src_task_id}) + await sor.sqlExe("COMMIT", {}) + else: + drecs = [] + if drecs: + d0 = drecs[0] + md.append('## 待确认交付件:' + (_s(getattr(d0, 'title', '')) or '(未命名)')) + md.append('') + md.append('- 类型:' + (_s(getattr(d0, 'deliverable_type', '')) or '-') + + ' | 产出:' + (_s(getattr(d0, 'created_by', '')) or '-') + + ' | 时间:' + _s(getattr(d0, 'created_at', ''))) + rc = _s(getattr(d0, 'review_comment', '')) + if rc: + md.append('- PM 评审意见:' + rc) + md.append('') + md.append('---') + md.append('') + md.append(_s(getattr(d0, 'content', '')) or '(交付件正文为空,请到工作空间查看文件)') + md.append('') + if len(drecs) > 1: + md.append('---') + md.append('') + md.append('## 历史版本') + md.append('') + for dx in drecs[1:]: + md.append('- ' + _s(getattr(dx, 'created_at', '')) + ' | ' + + (_s(getattr(dx, 'title', '')) or '-') + ' | ' + + (_s(getattr(dx, 'review_status', '')) or '-')) + 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", + "style": {"background": badge_color, "borderRadius": "10px", "fontWeight": "bold", + "whiteSpace": "nowrap"}}}, + {"widgettype": "Text", + "options": {"text": title, "cfontsize": 1.0, "color": "#1e293b", "fontWeight": "bold"}} + ] +}] +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": "VScrollPanel", + "options": {"css": "filler", "width": "100%", "padding": "0 14px", + "style": {"background": "#ffffff", "border": "1px solid #e2e8f0", + "borderRadius": "6px"}}, + "subwidgets": [{ + "widgettype": "MdWidget", + "options": {"mdtext": '\n'.join(md), "width": "100%", "padding": "6px 10px"} + }] + } + ] + action_widgets + }] +}