From 475ea79f6c0cef97c14bffa912e62979cc5d0285 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 3 Sep 2026 16:58:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E6=A0=91QC=E6=8C=89?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E8=AE=B0=E5=BD=95=E5=88=86=E7=BB=84=EF=BC=88?= =?UTF-8?q?=E5=90=8C=E4=BB=BB=E5=8A=A1=E5=A4=9A=E8=BD=AE=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=3D=E7=BB=84=E8=8A=82=E7=82=B9=EF=BC=89+rev:=E8=BD=AE=E6=AC=A1?= =?UTF-8?q?=E5=8F=B6=E5=AD=90=E8=AF=A6=E6=83=85=EF=BC=88MdWidget=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=AE=A1=E6=A0=B8=E8=AF=B4=E6=98=8E/=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E6=84=8F=E8=A7=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wwwroot/api/bid_task_io.dspy | 67 ++++++++++++++++- wwwroot/api/bid_task_tree.dspy | 128 ++++++++++++++++++++++++++------- 2 files changed, 166 insertions(+), 29 deletions(-) diff --git a/wwwroot/api/bid_task_io.dspy b/wwwroot/api/bid_task_io.dspy index ab544ca..727e4c0 100644 --- a/wwwroot/api/bid_task_io.dspy +++ b/wwwroot/api/bid_task_io.dspy @@ -27,11 +27,74 @@ if not raw_id: }, ensure_ascii=False) async with DBPools().sqlorContext(dbname) as sor: - # ── 步骤/交付件节点:先按自身查,查到就展示;查不到回落任务视图 ── + # ── 步骤/交付件/QC审核轮次节点:先按自身查,查到就展示;查不到回落任务视图 ── task_id = raw_id step_focus = '' del_focus = '' - if raw_id.startswith('step:'): + if raw_id.startswith('rev:'): + rid = raw_id[4:] + recs = await sor.sqlExe( + "SELECT id, task_id, qc_type, round, reviewer, fit_score, pass_score, passed, " + "improvement, comments, created_at FROM bid_qc_reviews WHERE id=${r}$", {"r": rid}) + if recs: + rv = recs[0] + passed = str(getattr(rv, 'passed', '0')) == '1' + sc = float(getattr(rv, 'fit_score', 0) or 0) + ps = float(getattr(rv, 'pass_score', 0) or 0) + task_id = getattr(rv, 'task_id', '') or '' + subwidgets = [ + {"widgettype": "Text", + "options": {"text": "第%s轮 %s QC 契合度审核" % ( + getattr(rv, 'round', ''), getattr(rv, 'qc_type', '') or ''), + "halign": "left", "cfontsize": 1.1, + "style": {"fontWeight": "bold", "color": "#1e293b"}}}, + {"widgettype": "HBox", "options": {"gap": "12px", "width": "100%"}, + "subwidgets": [ + {"widgettype": "Text", "options": { + "text": ("%.1f / %.1f %s" % (sc, ps, '→ 通过' if passed else '→ 未通过')), + "cfontsize": 0.85, "color": "#ffffff", "padding": "1px 10px", + "style": {"background": '#16a34a' if passed else '#dc2626', + "borderRadius": "12px", "fontWeight": "bold"}}}, + {"widgettype": "Text", "options": { + "text": "审核: " + (getattr(rv, 'reviewer', '') or ''), + "cfontsize": 0.75, "color": "#94a3b8"}}, + {"widgettype": "Text", "options": { + "text": str(getattr(rv, 'created_at', '') or '')[:16], + "cfontsize": 0.75, "color": "#94a3b8"}}, + {"widgettype": "Filler"}, + ]}, + {"widgettype": "Text", + "options": {"text": "\u2500\u2500 审核说明 \u2500\u2500", "cfontsize": 0.85, + "style": {"fontWeight": "bold", "color": "#8b5cf6", "marginTop": "8px"}}}, + {"widgettype": "VScrollPanel", + "options": {"css": "filler", "width": "100%", "minHeight": "100px", + "maxHeight": "360px", "padding": "6px 12px", "bgcolor": "#ffffff", + "border": "1px solid #e2e8f0", "borderRadius": "6px"}, + "subwidgets": [{"widgettype": "MdWidget", + "options": {"mdtext": getattr(rv, 'comments', '') or '(无)', + "width": "100%"}}]}, + ] + imp = getattr(rv, 'improvement', '') or '' + if imp: + subwidgets.append({"widgettype": "Text", + "options": {"text": "\u2500\u2500 改进意见 \u2500\u2500", "cfontsize": 0.85, + "style": {"fontWeight": "bold", "color": "#b45309", "marginTop": "8px"}}}) + subwidgets.append({"widgettype": "VScrollPanel", + "options": {"css": "filler", "width": "100%", "minHeight": "100px", + "maxHeight": "360px", "padding": "6px 12px", "bgcolor": "#ffffff", + "border": "1px solid #e2e8f0", "borderRadius": "6px"}, + "subwidgets": [{"widgettype": "MdWidget", + "options": {"mdtext": imp, "width": "100%"}}]}) + 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", "overflowY": "auto"}, + "subwidgets": subwidgets, + }, ensure_ascii=False) + task_id = raw_id + elif 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, " diff --git a/wwwroot/api/bid_task_tree.dspy b/wwwroot/api/bid_task_tree.dspy index 65b377d..e8dac33 100644 --- a/wwwroot/api/bid_task_tree.dspy +++ b/wwwroot/api/bid_task_tree.dspy @@ -150,28 +150,19 @@ async with DBPools().sqlorContext(dbname) as sor: role_tasks.append(t) # ── 维度/类型分组 ── - if role in ('agent.tender_analyst', 'agent.qc'): + if role == 'agent.tender_analyst': groups = {} for t in role_tasks: - if role == 'agent.tender_analyst': - key = (_params_of(t).get('analysis_dim') or '').strip() or '__legacy__' - else: - key = qc_map.get(getattr(t, 'id', ''), ('', 0))[0] or '__none__' + key = (_params_of(t).get('analysis_dim') or '').strip() or '__legacy__' groups.setdefault(key, []).append(t) for key in groups: groups[key].sort(key=lambda x: str(getattr(x, 'created_at', '') or '')) - dim_labels = { 'scoring': '评分项与得分规则', 'quals': '资质清单与要求', 'reqs_outline': '投标文件要求与章节骨架', 'cost_benefit': '成本收益分析', '__legacy__': '全量解析(维度拆分前)', - 'scoring_items': '评分项 QC', 'qualifications': '资质 QC', - 'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC', - '__none__': 'QC 审核', } - order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', '__legacy__', - 'scoring_items', 'qualifications', 'doc_requirements', - 'chapter_outline', '__none__'] + order = ['scoring', 'quals', 'reqs_outline', 'cost_benefit', '__legacy__'] nodes = [] for key in order + [k for k in groups if k not in order]: if key not in groups: @@ -179,7 +170,6 @@ async with DBPools().sqlorContext(dbname) as sor: ts = groups[key] label = dim_labels.get(key, key) if len(ts) == 1: - # 一次过:叶子节点 t = ts[0] st = getattr(t, 'state', '') or '' nodes.append({ @@ -190,7 +180,6 @@ async with DBPools().sqlorContext(dbname) as sor: del_cnt.get(getattr(t, 'id', ''), 0) > 0), }) else: - # 多轮:组节点 + 轮次升序子节点 final_state = getattr(ts[-1], 'state', '') or '' nodes.append({ "id": "grp:" + role + ":" + key, @@ -200,6 +189,66 @@ async with DBPools().sqlorContext(dbname) as sor: }) return json.dumps(nodes, ensure_ascii=False) + if role == 'agent.qc': + # QC 按审核记录分组(同一任务可多轮复审):一次过=叶子,多轮=组节点 + revs = await sor.sqlExe( + "SELECT id, task_id, qc_type, round, fit_score, passed FROM bid_qc_reviews " + "WHERE project_id=${p}$ ORDER BY qc_type, round ASC", {"p": pid}) or [] + groups = {} + for rv in revs: + groups.setdefault(getattr(rv, 'qc_type', '') or '__none__', []).append(rv) + reviewed_tids = set(getattr(rv, 'task_id', '') for rv in revs) + legacy = [t for t in role_tasks if getattr(t, 'id', '') not in reviewed_tids] + if legacy: + groups.setdefault('__legacy__', []) + qc_labels = { + 'scoring_items': '评分项 QC', 'qualifications': '资质 QC', + 'doc_requirements': '投标文件要求 QC', 'chapter_outline': '章节骨架 QC', + '__none__': 'QC 审核', '__legacy__': 'QC 审核(早期)', + } + order = ['scoring_items', 'qualifications', 'doc_requirements', + 'chapter_outline', '__none__', '__legacy__'] + nodes = [] + for key in order + [k for k in groups if k not in order]: + if key not in groups: + continue + rs = groups[key] + label = qc_labels.get(key, key) + if legacy and key == '__legacy__': + if not rs and legacy: + # 只有遗留任务无审核记录:多任务=组,单任务=叶 + if len(legacy) == 1: + t = legacy[0] + st = getattr(t, 'state', '') or '' + nodes.append({"id": getattr(t, 'id', ''), + "label": "{} [{}] {}".format(_state_icons.get(st, '\u2b1c'), + _state_zh.get(st, st), label), + "is_leaf": True}) + else: + nodes.append({"id": "grp:" + role + ":" + key, + "label": "{}({} 个早期任务)".format(label, len(legacy)), + "is_leaf": False}) + continue + if len(rs) == 1 and str(getattr(rs[0], 'passed', '0')) == '1': + # 一次过:叶子 + t_id = getattr(rs[0], 'task_id', '') + sc = getattr(rs[0], 'fit_score', '') + nodes.append({ + "id": t_id, + "label": "\u2705 [通过] {}({:.1f} 分)".format(label, float(sc or 0)), + "is_leaf": not (del_cnt.get(t_id, 0) > 0), + }) + else: + n_rounds = len(rs) if rs else len(legacy) + last_passed = str(getattr(rs[-1], 'passed', '0')) == '1' if rs else False + nodes.append({ + "id": "grp:" + role + ":" + key, + "label": "{}({} 轮)最新[{}]".format(label, n_rounds, + '通过' if last_passed else '未通过'), + "is_leaf": False, + }) + return json.dumps(nodes, ensure_ascii=False) + # ── 非维度型角色:平铺任务列表(原逻辑)── nodes = [] for t in role_tasks: @@ -227,23 +276,50 @@ async with DBPools().sqlorContext(dbname) as sor: except Exception: return {} - qc_map = {} if role == 'agent.qc': - for r in await sor.sqlExe( - "SELECT task_id, qc_type, round FROM bid_qc_reviews WHERE project_id=${p}$", - {"p": pid}) or []: - qc_map[getattr(r, 'task_id', '')] = (getattr(r, 'qc_type', '') or '', - int(getattr(r, 'round', 0) or 0)) + # QC 组:按审核记录轮次升序展开(同一任务多轮复审各占一行) + revs = await sor.sqlExe( + "SELECT id, task_id, qc_type, round, fit_score, passed, created_at " + "FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${k}$ " + "ORDER BY round ASC", {"p": pid, "k": key}) or [] + if revs: + nodes = [] + for rv in revs: + rnd = int(getattr(rv, 'round', 0) or 0) + passed = str(getattr(rv, 'passed', '0')) == '1' + sc = float(getattr(rv, 'fit_score', 0) or 0) + upd = str(getattr(rv, 'created_at', '') or '')[5:16] + icon = '\u2705' if passed else '\U0001f6ab' + nodes.append({ + "id": "rev:" + getattr(rv, 'id', ''), + "label": "第%d轮 %s [%s] %.1f 分 %s" % ( + rnd, icon, '通过' if passed else '未通过', sc, upd), + "is_leaf": True, + }) + return json.dumps(nodes, ensure_ascii=False) + # 无审核记录的早期 QC 任务:平铺 + nodes = [] + for t in all_tasks: + if (getattr(t, 'role', '') or '').strip() != role: + continue + if (getattr(t, 'parent_id', '') or '').strip(): + continue + st = getattr(t, 'state', '') or '' + nodes.append({"id": getattr(t, 'id', ''), + "label": "{} [{}] {}".format(_state_icons.get(st, '\u2b1c'), + _state_zh.get(st, st), + str(getattr(t, 'updated_at', '') or '')[5:16]), + "is_leaf": not (del_cnt.get(getattr(t, 'id', ''), 0) > 0)}) + return json.dumps(nodes, ensure_ascii=False) + + # analyst 组:按任务创建序(=轮次升序)展开 ts = [] for t in all_tasks: if (getattr(t, 'role', '') or '').strip() != role: continue if (getattr(t, 'parent_id', '') or '').strip(): continue - if role == 'agent.tender_analyst': - k = (_params_of2(t).get('analysis_dim') or '').strip() or '__legacy__' - else: - k = qc_map.get(getattr(t, 'id', ''), ('', 0))[0] or '__none__' + k = (_params_of2(t).get('analysis_dim') or '').strip() or '__legacy__' if k == key: ts.append(t) ts.sort(key=lambda x: str(getattr(x, 'created_at', '') or '')) @@ -251,12 +327,10 @@ async with DBPools().sqlorContext(dbname) as sor: for i, t in enumerate(ts): tid = getattr(t, 'id', '') st = getattr(t, 'state', '') or '' - rnd = qc_map.get(tid, ('', 0))[1] if role == 'agent.qc' else 0 - rnd_label = "第%d轮" % (rnd or (i + 1)) upd = str(getattr(t, 'updated_at', '') or '')[5:16] nodes.append({ "id": tid, - "label": "{} {} [{}] {}".format(rnd_label, _state_icons.get(st, '\u2b1c'), + "label": "第%d轮 %s [%s] %s" % (i + 1, _state_icons.get(st, '\u2b1c'), _state_zh.get(st, st), upd), "is_leaf": not (step_cnt.get(tid, 0) > 0 or del_cnt.get(tid, 0) > 0), })