From 25348fa2e1ef420a2d9aa345095d2dd8bde57cc7 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 12 Sep 2026 11:00:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(mining-ui):=20P2=E9=9C=80=E6=B1=82?= =?UTF-8?q?=E6=8C=96=E6=8E=98=E4=B8=89=E7=BA=A7=E4=B8=8B=E9=92=BBUI?= =?UTF-8?q?=E2=80=94=E2=80=94=E2=9B=8F=E9=9C=80=E6=B1=82=E6=8C=96=E6=8E=98?= =?UTF-8?q?=E8=8F=9C=E5=8D=95(=E6=97=A0require=5Fproject,=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=BA=A7=E6=89=B9=E6=AC=A1=E4=B9=9F=E5=8F=AF=E8=A7=81?= =?UTF-8?q?)=E2=86=92=E6=89=B9=E6=AC=A1=E5=88=97=E8=A1=A8(=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=85=A8=E9=87=8F/=E6=8C=87=E5=AE=9A=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=8C=89=E9=92=AE+=E7=8A=B6=E6=80=81=E8=89=B2?= =?UTF-8?q?=E6=A0=87+done=E8=A1=8C=E4=B8=8B=E9=92=BB)=E2=86=92=E7=B1=BB?= =?UTF-8?q?=E5=88=AB=E6=8E=92=E5=90=8DPopupWindow(=E6=8E=92=E5=90=8D/?= =?UTF-8?q?=E5=8D=A0=E6=AF=94/=E5=91=BD=E5=90=8D=E4=BE=9D=E6=8D=AE)?= =?UTF-8?q?=E2=86=92=E7=B1=BB=E5=86=85=E6=98=8E=E7=BB=86(=E6=AF=8F?= =?UTF-8?q?=E6=9D=A1=E5=B8=A6=E6=9D=A5=E6=BA=90,src=5Fid=E5=A4=8D=E7=94=A8?= =?UTF-8?q?opp=5Fitem=5Fdetail=E8=AF=A6=E6=83=85=E5=BC=B9=E7=AA=97);opp=5F?= =?UTF-8?q?mining=5Fstart.dspy=E5=A4=8D=E7=94=A8start=5Fmining=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=80=9A=E9=81=93=E5=90=8C=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?;cluster=5Fdetail=E8=A1=A5src=5Fid=E5=AD=97=E6=AE=B5;load=5Fpat?= =?UTF-8?q?h=E6=B3=A8=E5=86=8C4=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_opportunity/opp_ability.py | 2 + pipeline_opportunity/opp_mining_flow.py | 2 +- scripts/load_path.py | 4 + wwwroot/api/opp_mining_clusters.dspy | 116 +++++++++++++++ wwwroot/api/opp_mining_items.dspy | 120 ++++++++++++++++ wwwroot/api/opp_mining_popup.dspy | 181 ++++++++++++++++++++++++ wwwroot/api/opp_mining_start.dspy | 48 +++++++ 7 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 wwwroot/api/opp_mining_clusters.dspy create mode 100644 wwwroot/api/opp_mining_items.dspy create mode 100644 wwwroot/api/opp_mining_popup.dspy create mode 100644 wwwroot/api/opp_mining_start.dspy diff --git a/pipeline_opportunity/opp_ability.py b/pipeline_opportunity/opp_ability.py index 6b0be69..26d7e0c 100644 --- a/pipeline_opportunity/opp_ability.py +++ b/pipeline_opportunity/opp_ability.py @@ -550,6 +550,8 @@ def register_opp_ability(): "type": "popup", "width": "88%", "height": "82%", "require_project": True}, {"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui", "type": "popup", "width": "88%", "height": "82%", "require_project": True}, + {"label": "⛏ 需求挖掘", "icon": "", "url": "/pipeline-opportunity/api/opp_mining_popup.dspy", + "type": "popup", "width": "76%", "height": "82%"}, {"label": "📋 数据参考", "icon": "", "url": "/pipeline-opportunity/api/opp_references_popup.dspy", "type": "popup", "width": "70%", "height": "76%", "require_project": True}, ], diff --git a/pipeline_opportunity/opp_mining_flow.py b/pipeline_opportunity/opp_mining_flow.py index 112791f..fd85cc9 100644 --- a/pipeline_opportunity/opp_mining_flow.py +++ b/pipeline_opportunity/opp_mining_flow.py @@ -280,7 +280,7 @@ async def cluster_detail(sor, ctx, cluster_id, limit=20): if not ok: return False, err items = rows_to_dicts(await sor.sqlExe( - "SELECT id, title, source, budget_wan, url, publish_time FROM opp_demand_snap " + "SELECT id, src_id, title, source, budget_wan, url, publish_time FROM opp_demand_snap " "WHERE cluster_id=${c}$ ORDER BY budget_wan DESC LIMIT ${n}$", {"c": cluster_id, "n": int(limit)}), limit=int(limit)) await sor.sqlExe("COMMIT", {}) diff --git a/scripts/load_path.py b/scripts/load_path.py index ad5fb52..a293917 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -35,6 +35,10 @@ PATHS_LOGINED = [ "/%s/api/opp_reports_list.dspy" % MOD, "/%s/api/opp_report_detail.dspy" % MOD, "/%s/api/opp_report_file_dl.dspy" % MOD, + "/%s/api/opp_mining_popup.dspy" % MOD, + "/%s/api/opp_mining_start.dspy" % MOD, + "/%s/api/opp_mining_clusters.dspy" % MOD, + "/%s/api/opp_mining_items.dspy" % MOD, ] for t in TABLES: PATHS_LOGINED += [ diff --git a/wwwroot/api/opp_mining_clusters.dspy b/wwwroot/api/opp_mining_clusters.dspy new file mode 100644 index 0000000..e4a0020 --- /dev/null +++ b/wwwroot/api/opp_mining_clusters.dspy @@ -0,0 +1,116 @@ +# opp_mining_clusters.dspy - 挖掘批次的类别排名(三级下钻第二级) +# 入参:batch_id。可见性规则源 = opp_mining_flow.list_clusters(含批次项目/org 校验)。 +# 整行点击 → opp_mining_items.dspy 弹出类内需求明细(第三级,每条带来源URL)。 +# 弹窗容器 id=opp_mine_clusters_pw(与列表页下钻脚本约定一致)。 + +import json as _json + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +batch_id = ((params_kw or {}).get("batch_id") or "").strip() +if not batch_id: + return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 batch_id"}} + +dbname = get_module_dbname('pipeline-opportunity') + +session_id = (params_kw or {}).get('session_id', '') or '' +pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general' +_cur_pid = '' +_uorg = '' +try: + from pipeline_service.workspace import get_session_project_id + async with DBPools().sqlorContext(dbname) as sor: + _cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or '' + _urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid}) + await sor.sqlExe("COMMIT", {}) + if _urecs: + _uorg = str(getattr(_urecs[0], "orgid", "") or "") +except Exception: + pass + +_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg, + "pipeline_id": pipeline_id, "session_id": session_id} + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_mining_flow import list_clusters + _ok, _res = await list_clusters(sor, _ctx, batch_id) + await sor.sqlExe("COMMIT", {}) + +items_url = entire_url("/pipeline-opportunity/api/opp_mining_items.dspy") + +rows = [] +if not _ok: + rows.append({"widgettype": "Text", "options": { + "text": str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}}) +else: + clusters = _res if isinstance(_res, list) else [] + if not clusters: + rows.append({"widgettype": "Text", "options": { + "text": "该批次没有聚类结果(可能范围内无需求数据)", + "color": "#64748b", "padding": "12px", "halign": "left"}}) + for c in clusters: + cid = str(c.get("id") or "") + name = str(c.get("name") or "未命名") + cnt = int(c.get("doc_count") or 0) + share = float(c.get("share") or 0) * 100 + rank = int(c.get("heat_rank") or 0) + try: + ev = _json.loads(c.get("naming_evidence") or "{}") + except Exception: + ev = {} + samples = ev.get("samples") or [] + ev_txt = ("命名依据:" + " / ".join(str(s)[:24] for s in samples[:3])) if samples else "" + by_txt = "(规则命名)" if ev.get("by") == "rule" else "" + + q_extra = [] + if session_id: + q_extra.append("session_id=" + session_id) + if pipeline_id: + q_extra.append("pipeline_id=" + pipeline_id) + qstr = ("&" + "&".join(q_extra)) if q_extra else "" + script = ("var old=bricks.getWidgetById('opp_mine_items_pw',bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var r=await fetch(" + _json.dumps(items_url) + "+'?cluster_id='+encodeURIComponent(" + _json.dumps(cid) + ")+" + _json.dumps(qstr) + ");" + "var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}") + rows.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "10px", "alignItems": "center", + "padding": "10px 14px", "border": "1px solid #e2e8f0", + "borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff", + "cursor": "pointer"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}], + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "#%d" % rank, "cfontsize": 0.85, + "color": "#94a3b8"}}, + {"widgettype": "VBox", "options": {"gap": "2px"}, "subwidgets": [ + {"widgettype": "Text", "options": {"text": name + by_txt, "cfontsize": 1.0, + "color": "#0f172a", "halign": "left"}}, + {"widgettype": "Text", "options": {"text": ev_txt, "cfontsize": 0.72, + "color": "#94a3b8", "halign": "left", + "wrap": True}}, + ]}, + {"widgettype": "Filler"}, + {"widgettype": "Text", "options": {"text": "%d 条 | %.1f%%" % (cnt, share), + "cfontsize": 0.85, "color": "#64748b"}}, + {"widgettype": "Text", "options": {"text": "›", "cfontsize": 1.1, "color": "#94a3b8"}}, + ]}) + +return { + "id": "opp_mine_clusters_pw", + "widgettype": "PopupWindow", + "options": {"title": "类别排名(批次 %s)" % batch_id[:8], "width": "640px", + "height": "70%", "modal": True}, + "subwidgets": [{ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "12px 16px", + "gap": "4px", "overflow": "auto"}, + "subwidgets": [ + {"widgettype": "Text", "options": { + "text": "点击任一类别,查看类内需求明细(每条带来源链接可查证)。", + "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}, + ] + rows, + }], +} diff --git a/wwwroot/api/opp_mining_items.dspy b/wwwroot/api/opp_mining_items.dspy new file mode 100644 index 0000000..407bef6 --- /dev/null +++ b/wwwroot/api/opp_mining_items.dspy @@ -0,0 +1,120 @@ +# opp_mining_items.dspy - 挖掘类别的类内需求明细(三级下钻第三级) +# 入参:cluster_id。可见性经 opp_mining_flow.cluster_detail(含批次项目/org 校验)。 +# 每条带来源(source_text 说明文字),整行点击 → opp_item_detail.dspy(爬虫平台详情弹窗, +# 快照 src_id 即爬虫平台 tenders.id,直接复用既有详情通道)。 + +import json as _json + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +cluster_id = ((params_kw or {}).get("cluster_id") or "").strip() +if not cluster_id: + return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 cluster_id"}} +try: + limit = int((params_kw or {}).get("limit") or 50) +except (TypeError, ValueError): + limit = 50 + +dbname = get_module_dbname('pipeline-opportunity') + +session_id = (params_kw or {}).get('session_id', '') or '' +pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general' +_cur_pid = '' +_uorg = '' +try: + from pipeline_service.workspace import get_session_project_id + async with DBPools().sqlorContext(dbname) as sor: + _cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or '' + _urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid}) + await sor.sqlExe("COMMIT", {}) + if _urecs: + _uorg = str(getattr(_urecs[0], "orgid", "") or "") +except Exception: + pass + +_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg, + "pipeline_id": pipeline_id, "session_id": session_id} + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_mining_flow import cluster_detail + from pipeline_opportunity.opp_common import source_text + _ok, _res = await cluster_detail(sor, _ctx, cluster_id, limit=limit) + await sor.sqlExe("COMMIT", {}) + +detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy") + +rows = [] +if not _ok: + rows.append({"widgettype": "Text", "options": { + "text": str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}}) + cl_name, cl_cnt = "", 0 +else: + cl = _res.get("cluster") or {} + cl_name = str(cl.get("name") or "") + cl_cnt = int(cl.get("doc_count") or 0) + items = _res.get("samples") or [] + if not items: + rows.append({"widgettype": "Text", "options": { + "text": "该类别暂无明细", "color": "#64748b", "padding": "12px", "halign": "left"}}) + for it in items: + src_id = str(it.get("src_id") or "") + title = str(it.get("title") or "") + budget = it.get("budget_wan") or 0 + url = str(it.get("url") or "") + src_name = source_text(str(it.get("source") or ""), "") + meta = "%s | %s | 来源:%s" % ( + str(it.get("publish_time") or "")[:10], + (("预算 %.2f 万" % float(budget)) if budget else "预算面议"), + src_name) + row_subs = [ + {"widgettype": "Text", "options": {"text": title, "cfontsize": 0.95, + "color": "#0f172a", "halign": "left", "wrap": True}}, + {"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "alignItems": "center"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": meta, "cfontsize": 0.75, + "color": "#94a3b8", "halign": "left", "wrap": True}}, + {"widgettype": "Filler"}, + {"widgettype": "Text", "options": {"text": "›", "cfontsize": 1.0, "color": "#94a3b8"}}, + ]}, + ] + row_opts = {"width": "100%", "padding": "8px 12px", "gap": "4px", + "border": "1px solid #e2e8f0", "borderRadius": "8px", + "margin": "0 0 6px 0", "bgcolor": "#ffffff"} + if src_id: + row_opts["cursor"] = "pointer" + script = ("var old=bricks.getWidgetById('opp_item_detail_pw',bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var rp=await fetch(" + _json.dumps(detail_url) + "+'?id='+encodeURIComponent(" + _json.dumps(src_id) + "));" + "var d=await rp.json();if(d){bricks.widgetBuild(d,bricks.app);}") + rows.append({"widgettype": "VBox", "options": row_opts, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}], + "subwidgets": row_subs}) + elif url: + row_opts["cursor"] = "pointer" + script = "window.open(" + _json.dumps(url) + ",'_blank');" + rows.append({"widgettype": "VBox", "options": row_opts, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}], + "subwidgets": row_subs}) + else: + rows.append({"widgettype": "VBox", "options": row_opts, "subwidgets": row_subs}) + +return { + "widgettype": "PopupWindow", + "id": "opp_mine_items_pw", + "options": {"title": "类内需求明细:%s(%d 条,显示前 %d)" % (cl_name, cl_cnt, limit), + "width": "76%", "height": "80%", "auto_open": True, "resizable": True}, + "subwidgets": [{ + "widgettype": "VBox", + "options": {"css": "filler", "width": "100%", "height": "100%", + "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, + "subwidgets": [ + {"widgettype": "Text", "options": { + "text": "点击任一条目弹出需求详情(数据来自数据爬取平台快照,每条标注来源平台)。", + "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}, + ] + rows, + }], +} diff --git a/wwwroot/api/opp_mining_popup.dspy b/wwwroot/api/opp_mining_popup.dspy new file mode 100644 index 0000000..d4fdcec --- /dev/null +++ b/wwwroot/api/opp_mining_popup.dspy @@ -0,0 +1,181 @@ +# opp_mining_popup.dspy - 需求挖掘批次列表(「⛏ 需求挖掘」菜单入口,P2 三级下钻第一级) +# 交互范式对齐 opp_reports_list.dspy:会话当前项目过滤(本项目批次 + 平台级批次), +# 整行点击 → opp_mining_clusters.dspy 弹出类别排名(第二级)。 +# 顶部两个启动按钮:全量挖掘(scope=top)/ 指定类型挖掘(scope=targeted,prompt 输入关键词)。 +# 可见性规则源 = opp_mining_flow.mining_status(与工具通道同一实现,语义不漂移)。 + +import json as _json + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +dbname = get_module_dbname('pipeline-opportunity') + +session_id = (params_kw or {}).get('session_id', '') or '' +pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general' + +# 会话当前项目(统一解析:session→全局兜底+产线隔离+悬空自愈) +_cur_pid = '' +try: + from pipeline_service.workspace import get_session_project_id + async with DBPools().sqlorContext(dbname) as sor: + _cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or '' +except Exception: + _cur_pid = '' + +# 用户机构(org 隔离) +_uorg = '' +async with DBPools().sqlorContext(dbname) as sor: + _urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid}) + await sor.sqlExe("COMMIT", {}) + if _urecs: + _uorg = str(getattr(_urecs[0], "orgid", "") or "") + +_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg, + "pipeline_id": pipeline_id, "session_id": session_id} + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_mining_flow import mining_status + _ok, _res = await mining_status(sor, _ctx, batch_id="") + await sor.sqlExe("COMMIT", {}) + +clusters_url = entire_url("/pipeline-opportunity/api/opp_mining_clusters.dspy") +start_url = entire_url("/pipeline-opportunity/api/opp_mining_start.dspy") +self_url = entire_url("/pipeline-opportunity/api/opp_mining_popup.dspy") +_q = [] +if session_id: + _q.append("session_id=" + session_id) +if pipeline_id: + _q.append("pipeline_id=" + pipeline_id) +_qs = ("?" + "&".join(_q)) if _q else "" + +_STATUS_STYLE = { + "done": ("已完成", "#16a34a", "#f0fdf4"), + "failed": ("失败", "#dc2626", "#fef2f2"), + "pulling": ("拉取中", "#d97706", "#fffbeb"), + "embedding": ("向量化中", "#d97706", "#fffbeb"), + "clustering": ("聚类中", "#d97706", "#fffbeb"), + "naming": ("命名中", "#d97706", "#fffbeb"), +} + +def _refresh_script(): + return ("var r=await fetch(" + _json.dumps(self_url + _qs) + ");" + "var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}") + +def _start_script(scope): + # targeted 先 prompt 关键词;启动后轮询刷新列表 + kw_js = ("var kw=prompt('输入要挖掘的需求类型关键词(如:合同管理)');if(!kw){return;}kw='&keyword='+encodeURIComponent(kw);") if scope == "targeted" else "var kw='';" + base = start_url + _qs + ("&" if _q else "?") + "scope=" + scope + return (kw_js + + "var r=await fetch(" + _json.dumps(base) + "+kw);" + "var d=await r.json();" + "if(!d||d.status!=='ok'){alert((d&&d.message)||'启动失败');return;}" + "alert('挖掘批次已启动:'+d.batch_id+'(后台执行,列表将自动刷新)');" + + _refresh_script()) + +rows = [] +# ── 顶部:启动按钮 + 说明 ── +rows.append({"widgettype": "Text", "options": { + "text": "需求挖掘:把众包平台(猪八戒/一品威客/Freelancer/PPH)的软件需求做语义聚类," + "发现市场热门软件类别(TopX)或分析你指定的需求类型。批次后台执行,完成后点击行查看类别排名。", + "cfontsize": 0.8, "color": "#64748b", "halign": "left", "wrap": True, "margin": "0 0 8px 0"}}) +rows.append({"widgettype": "HBox", "options": {"width": "100%", "gap": "10px", "margin": "0 0 12px 0"}, + "subwidgets": [ + {"widgettype": "Button", "options": {"label": "⛏ 启动全量挖掘(TopX 热门类别)", "css": "primary"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": _start_script("top")}]}, + {"widgettype": "Button", "options": {"label": "🎯 指定类型挖掘", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": _start_script("targeted")}]}, + {"widgettype": "Filler"}, + {"widgettype": "Button", "options": {"label": "🔄 刷新", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": _refresh_script()}]}, + ]}) + +if not _ok: + rows.append({"widgettype": "Text", "options": { + "text": "查询失败:" + str(_res)[:200], "color": "#dc2626", "padding": "12px", "halign": "left"}}) +else: + batches = _res if isinstance(_res, list) else [] + if not batches: + rows.append({"widgettype": "Text", "options": { + "text": "还没有挖掘批次。点击上方「⛏ 启动全量挖掘」开始(首次全量约3-5分钟;" + "embedding 有全机构共享缓存,二次批次显著更快)。", + "color": "#64748b", "padding": "12px", "halign": "left", "wrap": True}}) + for b in batches: + bid = str(b.get("id") or "") + status = str(b.get("status") or "") + s_label, s_color, s_bg = _STATUS_STYLE.get(status, (status, "#64748b", "#f8fafc")) + scope = str(b.get("scope") or "") + scope_txt = "全量 TopX" if scope == "top" else "指定类型" + try: + _params = _json.loads(b.get("params_json") or "{}") + except Exception: + _params = {} + if scope == "targeted": + kw = _params.get("keyword") or _params.get("category") or "" + if kw: + scope_txt += ":%s" % kw + try: + _stats = _json.loads(b.get("stats_json") or "{}") + except Exception: + _stats = {} + meta_bits = [] + if _stats.get("demands"): + meta_bits.append("需求 %s 条" % _stats["demands"]) + if _stats.get("clusters"): + meta_bits.append("类别 %s 个" % _stats["clusters"]) + if _stats.get("elapsed_s"): + meta_bits.append("耗时 %ss" % _stats["elapsed_s"]) + if not str(b.get("project_id") or ""): + meta_bits.append("平台级") + if status == "failed" and b.get("error_msg"): + meta_bits.append("原因:" + str(b.get("error_msg"))[:80]) + meta = " | ".join(meta_bits) if meta_bits else str(b.get("created_at") or "")[:16] + + # done 批次整行可点下钻;进行中/失败批次不可点(无结果可看) + if status == "done": + qstr = ("&" + "&".join(_q)) if _q else "" + script = ("var old=bricks.getWidgetById('opp_mine_clusters_pw',bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var r=await fetch(" + _json.dumps(clusters_url) + "+'?batch_id='+encodeURIComponent(" + _json.dumps(bid) + ")+" + _json.dumps(qstr) + ");" + "var d=await r.json();if(d){bricks.widgetBuild(d,bricks.app);}") + row_opts = {"width": "100%", "gap": "10px", "alignItems": "center", + "padding": "10px 14px", "border": "1px solid #e2e8f0", + "borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff", + "cursor": "pointer"} + row_binds = [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", "script": script}] + arrow = {"widgettype": "Text", "options": {"text": "›", "cfontsize": 1.1, "color": "#94a3b8"}} + else: + row_opts = {"width": "100%", "gap": "10px", "alignItems": "center", + "padding": "10px 14px", "border": "1px solid #e2e8f0", + "borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#f8fafc"} + row_binds = [] + arrow = None + + subs = [ + {"widgettype": "Text", "options": {"text": scope_txt, "cfontsize": 1.0, + "color": "#0f172a", "halign": "left"}}, + {"widgettype": "Text", "options": {"text": s_label, "cfontsize": 0.75, + "color": s_color, "bgcolor": s_bg, + "padding": "2px 8px", "borderRadius": "10px"}}, + {"widgettype": "Filler"}, + {"widgettype": "Text", "options": {"text": meta, "cfontsize": 0.75, + "color": "#94a3b8", "halign": "right", "wrap": True}}, + ] + if arrow: + subs.append(arrow) + row = {"widgettype": "HBox", "options": row_opts, "subwidgets": subs} + if row_binds: + row["binds"] = row_binds + rows.append(row) + +return { + "widgettype": "VBox", + "options": {"css": "filler", "width": "100%", "height": "100%", "padding": "12px 16px", + "gap": "6px", "overflow": "auto"}, + "subwidgets": rows, +} diff --git a/wwwroot/api/opp_mining_start.dspy b/wwwroot/api/opp_mining_start.dspy new file mode 100644 index 0000000..48805e2 --- /dev/null +++ b/wwwroot/api/opp_mining_start.dspy @@ -0,0 +1,48 @@ +# opp_mining_start.dspy - 启动需求挖掘批次(opp_mining_popup 的按钮端点) +# GET/POST: scope=top|targeted, keyword(targeted必填), category?, days?, sources? +# 复用 opp_mining_flow.start_mining(与工具通道同一实现),返回 {status,batch_id}。 +# 权限:logined;项目归属从会话当前项目解析(与列表页同一口径)。 + +uid = await get_user() +if not uid: + return {"status": "error", "message": "未登录"} + +dbname = get_module_dbname('pipeline-opportunity') + +scope = ((params_kw or {}).get("scope") or "top").strip() +keyword = ((params_kw or {}).get("keyword") or "").strip() +category = ((params_kw or {}).get("category") or "").strip() +try: + days = int((params_kw or {}).get("days") or 365) +except (TypeError, ValueError): + days = 365 +sources = ((params_kw or {}).get("sources") or "").strip() + +session_id = (params_kw or {}).get('session_id', '') or '' +pipeline_id = (params_kw or {}).get('pipeline_id', '') or 'opportunity_general' + +_cur_pid = '' +_uorg = '' +try: + from pipeline_service.workspace import get_session_project_id + async with DBPools().sqlorContext(dbname) as sor: + _cur_pid = await get_session_project_id(sor, uid, session_id, pipeline_id) or '' + _urecs = await sor.sqlExe("SELECT orgid FROM users WHERE id=${u}$", {"u": uid}) + await sor.sqlExe("COMMIT", {}) + if _urecs: + _uorg = str(getattr(_urecs[0], "orgid", "") or "") +except Exception: + pass + +_ctx = {"project_id": _cur_pid, "user_id": uid, "org_id": _uorg, + "pipeline_id": pipeline_id, "session_id": session_id} + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_mining_flow import start_mining + bid, err = await start_mining(sor, _ctx, scope=scope, days=days, sources=sources, + keyword=keyword, category=category) + await sor.sqlExe("COMMIT", {}) + +if err: + return {"status": "error", "message": err} +return {"status": "ok", "batch_id": bid}