diff --git a/pipeline_bidding/bid_ability.py b/pipeline_bidding/bid_ability.py index 8159349..dd54d83 100644 --- a/pipeline_bidding/bid_ability.py +++ b/pipeline_bidding/bid_ability.py @@ -461,6 +461,8 @@ def register_bid_ability(): "type": "popup", "width": "88%", "height": "82%"}, {"label": "📦 标书", "icon": "", "url": "/pipeline-bidding/bid_documents/index.ui", "type": "popup", "width": "88%", "height": "82%", "require_project": True}, + {"label": "📋 任务树", "icon": "", "url": "/pipeline-bidding/bid_task_tree_popup.ui", + "type": "popup", "width": "92%", "height": "86%", "require_project": True}, ], ) register_ability(ability) @@ -497,12 +499,49 @@ async def _slash_score(args, ctx): return await ex._execute_ability_tool("bid_score_report", {}) +async def _slash_task(args, ctx): + """任务树 + 输入输出(ResourceBrowser 弹窗,与开发产线 /task 同构)。""" + base = (ctx.get("base_url") or "").rstrip("/") + tree_url = f"{base}/pipeline-bidding/api/bid_task_tree.dspy" + io_url = f"{base}/pipeline-bidding/api/bid_task_io.dspy" + return { + "widgettype": "PopupWindow", + "options": { + "title": "任务树 - 输入输出", + "width": "85%", + "height": "80%", + "auto_open": True, + }, + "subwidgets": [{ + "widgettype": "ResourceBrowser", + "id": "bid_task_rb", + "options": { + "tree_options": { + "widgettype": "Tree", + "options": { + "dataurl": tree_url, + "textField": "label", + "idField": "id", + "cfontsize": 1.0, + }, + }, + "browser_options": { + "widgettype": "urlwidget", + "options": {"url": io_url}, + }, + "tree_width": "42%", + }, + }], + } + + def register_bid_slash_commands(): for cmd in [ SlashCommand("files", "列出招标文件及解析状态", _slash_files, "pipeline", PIPELINE_ID), SlashCommand("bid", "诊断当前投标项目卡点", _slash_bid, "pipeline", PIPELINE_ID), SlashCommand("chapters", "列出标书章节及状态", _slash_chapters, "pipeline", PIPELINE_ID), SlashCommand("score", "查看最近一轮整书评分报告", _slash_score, "pipeline", PIPELINE_ID), + SlashCommand("task", "任务树(任务状态+输入输出)", _slash_task, "pipeline", PIPELINE_ID), ]: register_slash_command(cmd) diff --git a/scripts/load_path.py b/scripts/load_path.py index 8fd79f4..28e917b 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -33,6 +33,7 @@ PATHS_LOGINED = [ # 多 session 会话入口(agent 目录 + 页面,菜单 open_tab 指向 /pipeline-bidding/agent) "/%s/agent" % MOD, "/%s/agent/index.ui" % MOD, + "/%s/bid_task_tree_popup.ui" % MOD, ] for t in TABLES: PATHS_LOGINED += [ diff --git a/wwwroot/agent/index.ui b/wwwroot/agent/index.ui index dd41139..7c1c3af 100644 --- a/wwwroot/agent/index.ui +++ b/wwwroot/agent/index.ui @@ -20,6 +20,19 @@ "target": "self", "script": "var tp=bricks.getWidgetById('session_tabs',bricks.app);if(!tp)return;var sid='s'+Date.now()+'_'+Math.floor(Math.random()*100000);var n=tp.opts.items.length+1;tp.open_tab({name:'session_'+sid,label:'会话 '+n,removable:true,content:{widgettype:'VBox',options:{css:'filler',width:'100%',height:'100%'},subwidgets:[{widgettype:'urlwidget',options:{url:'/pipeline_core/api/agent_menus.dspy?session_id='+sid+'&pipeline_id=bidding_general',method:'GET'}},{widgettype:'AgentIO',options:{css:'filler',margin:'0 24px 24px 24px',url:'/pipeline_core/api/agent_chat.dspy?session_id='+sid+'&pipeline_id=bidding_general',model_dataurl:'/pipeline_core/api/agent_model_options.dspy?session_id='+sid+'&pipeline_id=bidding_general',model_cwidth:14,placeholder:'输入你的需求...'}}]}});" }] + }, + { + "widgettype": "Button", + "id": "task_tree_btn", + "options": {"label": "📋 任务树", "css": "small"}, + "binds": [{ + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "Popup", + "popup_options": {"title": "投标项目任务树", "width": "92%", "height": "88%", "auto_open": true}, + "url": "/pipeline-bidding/bid_task_tree_popup.ui" + }] } ] }, diff --git a/wwwroot/api/bid_task_io.dspy b/wwwroot/api/bid_task_io.dspy new file mode 100644 index 0000000..09d6914 --- /dev/null +++ b/wwwroot/api/bid_task_io.dspy @@ -0,0 +1,302 @@ +# bid_task_io.dspy - 投标任务详情(任务树右侧面板,与开发产线 task_io 同构) +# 入参 id 可能为:任务ID / step:步骤ID / del:交付件ID + +import json + +uid = await get_user() +if not uid: + uid = 'user-01' + +raw_id = (params_kw or {}).get('id', '').strip() + +dbname = get_module_dbname('pipeline-bidding') + +_state_colors = { + 'completed': '#16a34a', 'approved': '#16a34a', + 'running': '#2563eb', 'failed': '#dc2626', + 'waiting': '#f0a040', 'submitted': '#64748b', + 'review': '#2196f3', 'rejected': '#dc2626', + 'paused': '#f0a040', 'cancelled': '#64748b', 'qc_review': '#8b5cf6', +} + +if not raw_id: + return json.dumps({ + "widgettype": "Text", + "options": {"text": "请选择左侧任务查看输入输出", "cfontsize": 0.9, + "color": "#94a3b8", "padding": "16px"}, + }, ensure_ascii=False) + +async with DBPools().sqlorContext(dbname) as sor: + # ── 步骤/交付件节点:先按自身查,查到就展示;查不到回落任务视图 ── + task_id = raw_id + step_focus = '' + del_focus = '' + if raw_id.startswith('step:'): + sid = raw_id[5:] + recs = await sor.sqlExe( + "SELECT id, task_id, step_name, display_name, step_type, state, error_msg, " + "started_at, completed_at FROM pipeline_task_steps WHERE id=${s}$", {"s": sid}) + if recs: + s = recs[0] + task_id = getattr(s, 'task_id', '') or '' + step_focus = sid + st = getattr(s, 'state', '') or '' + subwidgets = [ + {"widgettype": "Text", + "options": {"text": "\u2699\ufe0f " + (getattr(s, 'display_name', '') or getattr(s, 'step_name', '') or ''), + "halign": "left", "cfontsize": 1.1, + "style": {"fontWeight": "bold", "color": "#1e293b"}}}, + {"widgettype": "HBox", "options": {"gap": "12px", "width": "100%"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": st, "cfontsize": 0.75, + "color": "#ffffff", "padding": "1px 10px", + "style": {"background": _state_colors.get(st, '#64748b'), + "borderRadius": "12px", "fontWeight": "bold"}}}, + {"widgettype": "Text", "options": { + "text": "开始: " + str(getattr(s, 'started_at', '') or '')[:16], + "cfontsize": 0.75, "color": "#94a3b8"}}, + {"widgettype": "Text", "options": { + "text": "结束: " + str(getattr(s, 'completed_at', '') or '')[:16], + "cfontsize": 0.75, "color": "#94a3b8"}}, + {"widgettype": "Filler"}, + ]}, + ] + if getattr(s, 'error_msg', ''): + subwidgets.append({"widgettype": "Text", + "options": {"text": "错误: " + str(getattr(s, 'error_msg', ''))[:500], + "cfontsize": 0.8, "color": "#dc2626", "wrap": True, "halign": "left"}}) + subwidgets.append({"widgettype": "Text", + "options": {"text": "(以下为所属任务详情)", "cfontsize": 0.75, + "color": "#94a3b8", "marginTop": "8px"}}) + return json.dumps({ + "widgettype": "VBox", + "options": {"width": "100%", "padding": "12px", "gap": "6px"}, + "subwidgets": subwidgets, + }, ensure_ascii=False) + task_id = raw_id + elif raw_id.startswith('del:'): + did = raw_id[4:] + recs = await sor.sqlExe( + "SELECT id, task_id, deliverable_type, title, content, file_path, review_status, " + "review_comment, quality_score FROM pipeline_deliverables WHERE id=${d}$", {"d": did}) + if recs: + d = recs[0] + task_id = getattr(d, 'task_id', '') or '' + del_focus = did + sc = '#16a34a' + subwidgets = [ + {"widgettype": "Text", + "options": {"text": "\U0001f4e4 " + (getattr(d, 'title', '') or getattr(d, 'deliverable_type', '') or ''), + "halign": "left", "cfontsize": 1.1, + "style": {"fontWeight": "bold", "color": "#1e293b"}}}, + {"widgettype": "HBox", "options": {"gap": "12px", "width": "100%"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": getattr(d, 'deliverable_type', '') or '', + "cfontsize": 0.8, "color": "#3b82f6"}}, + {"widgettype": "Text", "options": {"text": getattr(d, 'review_status', '') or '', + "cfontsize": 0.75, "color": "#94a3b8"}}, + {"widgettype": "Text", "options": {"text": "评分: " + str(getattr(d, 'quality_score', '') or '-'), + "cfontsize": 0.75, "color": "#94a3b8"}}, + {"widgettype": "Filler"}, + ]}, + ] + fp = getattr(d, 'file_path', '') or '' + if fp: + subwidgets.append({"widgettype": "Text", + "options": {"text": "文件: " + fp, "cfontsize": 0.78, + "color": "#64748b", "wrap": True, "halign": "left"}}) + subwidgets.append({"widgettype": "Text", + "options": {"text": (getattr(d, 'content', '') or '')[:8000], + "cfontsize": 0.82, "color": "#334155", "wrap": True, + "halign": "left", "css": "card"}}) + if getattr(d, 'review_comment', ''): + subwidgets.append({"widgettype": "Text", + "options": {"text": "审核意见: " + str(getattr(d, 'review_comment', ''))[:500], + "cfontsize": 0.78, "color": "#f0a040", "wrap": True, "halign": "left"}}) + return json.dumps({ + "widgettype": "VBox", + "options": {"width": "100%", "padding": "12px", "gap": "6px", "overflowY": "auto"}, + "subwidgets": subwidgets, + }, ensure_ascii=False) + task_id = raw_id + + # ── 任务视图 ── + recs = await sor.sqlExe( + "SELECT id, title, role, state, params, last_error, created_at, updated_at " + "FROM pipeline_tasks WHERE id=${t}$", {"t": task_id}) + if not recs: + return json.dumps({ + "widgettype": "Text", + "options": {"text": "任务不存在", "cfontsize": 0.9, "color": "#94a3b8", "padding": "16px"}, + }, ensure_ascii=False) + + task = recs[0] + title = getattr(task, 'title', '') or '' + role = getattr(task, 'role', '') or '' + state = getattr(task, 'state', '') or '' + last_error = getattr(task, 'last_error', '') or '' + updated_at = str(getattr(task, 'updated_at', '') or '')[:16] + created_at = str(getattr(task, 'created_at', '') or '')[:16] + + # 输入:params 里的任务描述与 QC 改进意见 + params_str = getattr(task, 'params', '') or '' + description = '' + qc_imp = '' + stage = '' + dim = '' + try: + p = json.loads(params_str) if params_str else {} + description = p.get('description', '') or p.get('input_text', '') or p.get('prompt', '') or '' + qc_imp = p.get('qc_improvements', '') or '' + if isinstance(qc_imp, list): + qc_imp = '\n'.join(str(x) for x in qc_imp) + stage = p.get('stage', '') or '' + dim = p.get('analysis_dim', '') or '' + except Exception: + description = params_str + + # 输出:交付件 + dels = await sor.sqlExe( + "SELECT deliverable_type, title, content, file_path, review_status, quality_score, review_comment " + "FROM pipeline_deliverables WHERE task_id=${t}$ ORDER BY created_at DESC", {"t": task_id}) + + # 执行步骤 + steps = await sor.sqlExe( + "SELECT step_name, display_name, state, error_msg, started_at, completed_at " + "FROM pipeline_task_steps WHERE task_id=${t}$ ORDER BY step_order ASC", {"t": task_id}) + + sc = _state_colors.get(state, '#64748b') + subwidgets = [] + + # 标题行 + subwidgets.append({ + "widgettype": "Text", + "options": {"text": title, "halign": "left", "cfontsize": 1.15, + "style": {"fontWeight": "bold", "color": "#1e293b"}}, + }) + info_row = [ + {"widgettype": "Text", "options": {"text": state, "cfontsize": 0.75, + "color": "#ffffff", "padding": "1px 10px", + "style": {"background": sc, "borderRadius": "12px", "fontWeight": "bold"}}}, + {"widgettype": "Text", "options": {"text": "角色: " + role, + "cfontsize": 0.8, "color": "#64748b"}}, + ] + if stage: + info_row.append({"widgettype": "Text", "options": {"text": "阶段: " + stage + ((" / " + dim) if dim else ""), + "cfontsize": 0.75, "color": "#94a3b8"}}) + info_row.append({"widgettype": "Text", "options": {"text": "创建: " + created_at + " | 更新: " + updated_at, + "cfontsize": 0.72, "color": "#94a3b8"}}) + info_row.append({"widgettype": "Filler"}) + subwidgets.append({"widgettype": "HBox", "options": {"gap": "12px", "width": "100%"}, "subwidgets": info_row}) + + if last_error: + subwidgets.append({ + "widgettype": "Text", + "options": {"text": "\u274c 错误: " + last_error[:500], + "cfontsize": 0.8, "color": "#dc2626", "wrap": True, "halign": "left", "css": "card"}, + }) + + # 输入 + subwidgets.append({ + "widgettype": "Text", + "options": {"text": "\u2500\u2500 输入 \u2500\u2500", "cfontsize": 0.85, + "style": {"fontWeight": "bold", "color": "#3b82f6", "marginTop": "8px"}}, + }) + if description: + subwidgets.append({ + "widgettype": "Text", + "options": {"text": description[:4000], "cfontsize": 0.85, + "color": "#334155", "wrap": True, "halign": "left", "css": "card"}, + }) + if qc_imp: + subwidgets.append({ + "widgettype": "Text", + "options": {"text": "QC改进意见(重做输入):\n" + str(qc_imp)[:3000], + "cfontsize": 0.82, "color": "#b45309", "wrap": True, "halign": "left", "css": "card"}, + }) + if not description and not qc_imp: + subwidgets.append({"widgettype": "Text", + "options": {"text": "(无任务描述)", "cfontsize": 0.8, "color": "#94a3b8"}}) + + # 执行步骤 + if steps: + subwidgets.append({ + "widgettype": "Text", + "options": {"text": "\u2500\u2500 执行步骤 \u2500\u2500", "cfontsize": 0.85, + "style": {"fontWeight": "bold", "color": "#8b5cf6", "marginTop": "8px"}}, + }) + for s in steps: + sn = getattr(s, 'step_name', '') or '' + dn = getattr(s, 'display_name', '') or '' + st = getattr(s, 'state', '') or '' + err = getattr(s, 'error_msg', '') or '' + label = dn or sn + s_color = _state_colors.get(st, '#64748b') + subwidgets.append({ + "widgettype": "HBox", + "options": {"gap": "8px", "width": "100%", "padding": "4px 8px", "css": "card"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": label[:80], + "cfontsize": 0.82, "color": "#1e293b"}}, + {"widgettype": "Filler"}, + {"widgettype": "Text", "options": {"text": st, + "cfontsize": 0.75, "color": s_color}}, + ], + }) + if err: + subwidgets.append({"widgettype": "Text", + "options": {"text": " " + err[:300], "cfontsize": 0.72, + "color": "#dc2626", "wrap": True, "halign": "left"}}) + + # 输出(交付件) + subwidgets.append({ + "widgettype": "Text", + "options": {"text": "\u2500\u2500 输出(交付件)\u2500\u2500", "cfontsize": 0.85, + "style": {"fontWeight": "bold", "color": "#16a34a", "marginTop": "8px"}}, + }) + if not dels: + subwidgets.append({"widgettype": "Text", + "options": {"text": "(暂无交付件)", "cfontsize": 0.8, "color": "#94a3b8"}}) + else: + for d in dels: + d_type = getattr(d, 'deliverable_type', '') or '' + d_title = getattr(d, 'title', '') or '' + d_content = getattr(d, 'content', '') or '' + d_file = getattr(d, 'file_path', '') or '' + d_status = getattr(d, 'review_status', '') or '' + d_score = getattr(d, 'quality_score', '') or '' + d_comment = getattr(d, 'review_comment', '') or '' + card = { + "widgettype": "VBox", + "options": {"css": "card", "gap": "4px", "width": "100%", "padding": "8px"}, + "subwidgets": [ + {"widgettype": "HBox", "options": {"gap": "8px", "width": "100%"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": d_type, + "cfontsize": 0.8, "color": "#3b82f6"}}, + {"widgettype": "Text", "options": {"text": d_title[:60], + "cfontsize": 0.8, "color": "#1e293b"}}, + {"widgettype": "Filler"}, + {"widgettype": "Text", "options": {"text": (d_status or '') + ((" " + str(d_score)) if d_score else ''), + "cfontsize": 0.75, "color": "#94a3b8"}}, + ]}, + {"widgettype": "Text", "options": {"text": d_content[:5000], + "cfontsize": 0.78, "color": "#475569", "wrap": True, "halign": "left"}}, + ], + } + if d_file: + card["subwidgets"].append({"widgettype": "Text", + "options": {"text": "\U0001f4ce " + d_file, "cfontsize": 0.75, + "color": "#64748b", "wrap": True, "halign": "left"}}) + if d_comment: + card["subwidgets"].append({"widgettype": "Text", + "options": {"text": "审核意见: " + d_comment[:300], + "cfontsize": 0.75, "color": "#f0a040", "wrap": True, "halign": "left"}}) + subwidgets.append(card) + +return json.dumps({ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "12px", + "gap": "6px", "overflowY": "auto"}, + "subwidgets": subwidgets, +}, ensure_ascii=False) diff --git a/wwwroot/api/bid_task_tree.dspy b/wwwroot/api/bid_task_tree.dspy new file mode 100644 index 0000000..1ab277c --- /dev/null +++ b/wwwroot/api/bid_task_tree.dspy @@ -0,0 +1,186 @@ +# bid_task_tree.dspy - 投标产线任务树(懒加载,与开发产线 /task 同构) +# 树状结构:项目 → 阶段(角色分组) → 任务 → 步骤/交付件 +# 协议:无 id → 根节点;__root__ → 阶段;role:xxx → 任务;裸任务ID → 步骤+交付件 + +import json + +uid = await get_user() +if not uid: + uid = 'user-01' + +node_id = (params_kw or {}).get('id', '').strip() + +dbname = get_module_dbname('pipeline-bidding') + +_state_icons = { + 'completed': '\u2705', 'approved': '\u2705', 'running': '\U0001f7e1', + 'failed': '\u274c', 'waiting': '\u26a0\ufe0f', 'submitted': '\u2b1c', + 'review': '\U0001f440', 'rejected': '\U0001f6ab', 'paused': '\u23f8\ufe0f', + 'cancelled': '\U0001f6d1', 'qc_review': '\U0001f50d', +} +_state_zh = { + 'completed': '已完成', 'approved': '已批准', 'running': '运行中', + 'failed': '失败', 'waiting': '等待中', 'submitted': '已提交', + 'review': '审核中', 'rejected': '已驳回', 'paused': '已暂停', + 'cancelled': '已取消', 'qc_review': '审核中', +} + +# 投标产线角色分组(按业务流转顺序) +_ROLE_GROUPS = [ + ('agent.tender_analyst', '\U0001f50d 招标解析'), + ('agent.qc', '\U0001f4cb QC契合度审核'), + ('agent.bid_prep', '\U0001f4c4 资料准备'), + ('agent.bid_writer', '\u270d\ufe0f 技术章编写'), + ('agent.bid_biz_writer', '\u270d\ufe0f 商务章编写'), + ('agent.bid_reviewer', '\U0001f50e 章节评审'), + ('agent.bid_compositor', '\U0001f4e6 标书合成'), + ('agent.bid_scorer', '\U0001f3af 整书评分'), + ('agent.pm', '\U0001f91d 项目管理'), +] +_ROLE_SET = set(r for r, _ in _ROLE_GROUPS) + +async with DBPools().sqlorContext(dbname) as sor: + # ── 解析当前投标项目(三级兜底) ── + pid = '' + # 1) 投标产线默认会话的项目绑定 + recs = await sor.sqlExe( + "SELECT current_project_id FROM pipeline_session_settings " + "WHERE user_id=${u}$ AND session_id='default_bidding_general' LIMIT 1", {"u": uid}) + if recs: + pid = getattr(recs[0], 'current_project_id', '') or '' + # 2) agent 全局设置 + if not pid: + recs = await sor.sqlExe( + "SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$", {"u": uid}) + if recs: + pid = getattr(recs[0], 'current_project_id', '') or '' + # 3) 最近一个进行中的投标项目 + if not pid: + recs = await sor.sqlExe( + "SELECT id FROM sd_projects WHERE pipeline_id='bidding_general' " + "AND status='in_progress' ORDER BY updated_at DESC LIMIT 1") + if recs: + pid = getattr(recs[0], 'id', '') or '' + + if not pid: + if not node_id: + return json.dumps([{"id": "__root__", "label": "请先在会话中切换/创建投标项目", "is_leaf": True}], ensure_ascii=False) + return json.dumps([], ensure_ascii=False) + + pname = '' + precs = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${p}$", {"p": pid}) + if precs: + pname = getattr(precs[0], 'name', '') or '' + + if not node_id: + # 根节点 + return json.dumps([{"id": "__root__", "label": pname or "投标项目", "is_leaf": False}], ensure_ascii=False) + + # 一次查全部任务 + 步骤数 + 交付件数(项目内任务量不大,Python 分组) + all_tasks = await sor.sqlExe( + "SELECT id, title, role, state, params, parent_id, created_at, updated_at " + "FROM pipeline_tasks WHERE tenant_id=${p}$ ORDER BY created_at ASC", {"p": pid}) or [] + step_cnt = {} + for r in await sor.sqlExe( + "SELECT task_id, COUNT(*) AS c FROM pipeline_task_steps " + "WHERE task_id IN (SELECT id FROM pipeline_tasks WHERE tenant_id=${p}$) GROUP BY task_id", + {"p": pid}) or []: + step_cnt[getattr(r, 'task_id', '')] = int(getattr(r, 'c', 0) or 0) + del_cnt = {} + for r in await sor.sqlExe( + "SELECT task_id, COUNT(*) AS c FROM pipeline_deliverables WHERE project_id=${p}$ GROUP BY task_id", + {"p": pid}) or []: + del_cnt[getattr(r, 'task_id', '')] = int(getattr(r, 'c', 0) or 0) + + if node_id == '__root__': + # 阶段列表(按角色分组,只列有任务的) + role_cnt = {} + for t in all_tasks: + if (getattr(t, 'parent_id', '') or '').strip(): + continue + r = (getattr(t, 'role', '') or '').strip() + role_cnt[r] = role_cnt.get(r, 0) + 1 + nodes = [] + for rn, label in _ROLE_GROUPS: + if rn in role_cnt: + nodes.append({"id": "role:" + rn, + "label": "{} ({})".format(label, role_cnt[rn]), "is_leaf": False}) + other_cnt = sum(v for k, v in role_cnt.items() if k not in _ROLE_SET) + if other_cnt > 0: + nodes.append({"id": "role:__other__", + "label": "\U0001f4ad 其他 ({})".format(other_cnt), "is_leaf": False}) + if not nodes: + nodes = [{"id": "__empty__", "label": "暂无任务", "is_leaf": True}] + return json.dumps(nodes, ensure_ascii=False) + + if node_id.startswith('role:'): + # 该角色分组下的任务(只列顶层任务) + role = node_id[5:] + nodes = [] + for t in all_tasks: + r = (getattr(t, 'role', '') or '').strip() + if role == '__other__': + if r in _ROLE_SET: + continue + elif r != role: + continue + if (getattr(t, 'parent_id', '') or '').strip(): + continue + tid = getattr(t, 'id', '') + title = getattr(t, 'title', '') or '' + state = getattr(t, 'state', '') or '' + if not tid or not title: + continue + icon = _state_icons.get(state, '\u2b1c') + zh = _state_zh.get(state, state) + upd = str(getattr(t, 'updated_at', '') or '')[5:16] + has_child = step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0 or \ + any((getattr(c, 'parent_id', '') or '').strip() == tid for c in all_tasks) + nodes.append({ + "id": tid, + "label": "{} [{}] {}".format(icon, zh, title[:60]), + "is_leaf": not has_child, + "upd": upd, + }) + return json.dumps(nodes, ensure_ascii=False) + + # ── 裸任务 ID:子任务 + 执行步骤 + 交付件 ── + nodes = [] + for t in all_tasks: + if (getattr(t, 'parent_id', '') or '').strip() != node_id: + continue + tid = getattr(t, 'id', '') + title = getattr(t, 'title', '') or '' + state = getattr(t, 'state', '') or '' + if not tid or not title: + continue + icon = _state_icons.get(state, '\u2b1c') + zh = _state_zh.get(state, state) + nodes.append({"id": tid, "label": "{} [{}] {}".format(icon, zh, title[:60]), + "is_leaf": not (step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0)}) + + steps = await sor.sqlExe( + "SELECT id, step_name, display_name, state FROM pipeline_task_steps " + "WHERE task_id=${t}$ ORDER BY step_order ASC", {"t": node_id}) or [] + for s in steps: + sid = getattr(s, 'id', '') + label = getattr(s, 'display_name', '') or getattr(s, 'step_name', '') or '' + st = getattr(s, 'state', '') or '' + if label: + nodes.append({"id": "step:" + sid, + "label": " {} [{}] \u2699\ufe0f {}".format(_state_icons.get(st, '\u2b1c'), + _state_zh.get(st, st), label[:50]), + "is_leaf": True}) + + dels = await sor.sqlExe( + "SELECT id, deliverable_type, title FROM pipeline_deliverables " + "WHERE task_id=${t}$ ORDER BY created_at DESC", {"t": node_id}) or [] + for d in dels: + did = getattr(d, 'id', '') + dt = getattr(d, 'deliverable_type', '') or '' + dtitle = getattr(d, 'title', '') or '' + nodes.append({"id": "del:" + did, + "label": " \U0001f4e4 {}: {}".format(dt, (dtitle or '')[:40]), + "is_leaf": True}) + + return json.dumps(nodes, ensure_ascii=False) diff --git a/wwwroot/bid_task_tree_popup.ui b/wwwroot/bid_task_tree_popup.ui new file mode 100644 index 0000000..c416fbd --- /dev/null +++ b/wwwroot/bid_task_tree_popup.ui @@ -0,0 +1,32 @@ +{ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "0"}, + "subwidgets": [ + { + "widgettype": "Text", + "options": {"text": "左树:项目 → 阶段 → 任务 → 步骤/交付件;点击任务节点查看右侧输入输出。", + "cfontsize": 0.8, "color": "#94a3b8", "padding": "4px 12px"} + }, + { + "widgettype": "ResourceBrowser", + "id": "bid_task_rb", + "options": { + "css": "filler", + "tree_options": { + "widgettype": "Tree", + "options": { + "dataurl": "{{entire_url('/pipeline-bidding/api/bid_task_tree.dspy')}}", + "textField": "label", + "idField": "id", + "cfontsize": 0.95 + } + }, + "browser_options": { + "widgettype": "urlwidget", + "options": {"url": "{{entire_url('/pipeline-bidding/api/bid_task_io.dspy')}}"} + }, + "tree_width": "42%" + } + } + ] +}