From 45cec8987ec8083deb5f67e8331486c420e9a824 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 8 Sep 2026 23:39:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(opp):=20=E6=95=B0=E6=8D=AE=E5=8F=82?= =?UTF-8?q?=E8=80=83=E4=B8=89=E7=BA=A7=E6=95=B4=E8=A1=8C=E4=B8=8B=E9=92=BB?= =?UTF-8?q?+=E6=98=8E=E7=BB=86=E8=AF=A6=E6=83=85=E5=BC=B9=E7=AA=97+?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E4=B8=8B=E8=BD=BD=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 汇总主题/分项条目整行点击(cursor:pointer)弹下级弹窗,撤「查看分项」按钮 - 明细每条来源写进说明文字(source_text),删除 source_button_widget 来源按钮 - 新 opp_item_detail.dspy: 爬虫平台条目全量字段+公告全文+附件列表 - 新 opp_attachment_dl.dspy: 服务端附件下载代理,归属校验防SSRF, 降级链 download_url→/api/attachments/{id}/download→原始url,失败如实报错 - opp_data_capability: tender_attachments/normalize_attachment 容错对接 爬虫平台附件子系统(/api/tenders/{id}/attachments,另一会话开发中,未就绪时降级提示) - opp_common: crawler_get_raw 原始字节下载辅助; load_path 注册两个新端点 - README/prompt 同步新交互描述 --- README.md | 19 ++ pipeline_opportunity/opp_ability.py | 3 +- pipeline_opportunity/opp_common.py | 77 ++++++-- pipeline_opportunity/opp_data_capability.py | 42 +++++ scripts/load_path.py | 2 + wwwroot/api/opp_attachment_dl.dspy | 75 ++++++++ wwwroot/api/opp_demand_items.dspy | 88 +++++---- wwwroot/api/opp_item_detail.dspy | 193 ++++++++++++++++++++ wwwroot/api/opp_overseas_items.dspy | 85 +++++---- wwwroot/api/opp_references_items.dspy | 86 +++++---- wwwroot/api/opp_references_popup.dspy | 127 +++++-------- 11 files changed, 584 insertions(+), 213 deletions(-) create mode 100644 wwwroot/api/opp_attachment_dl.dspy create mode 100644 wwwroot/api/opp_item_detail.dspy diff --git a/README.md b/README.md index fa903a4..1f08af5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,25 @@ agent 禁止自批自审:确认与审批结论只能由人工任务回流驱 角色:`agent.opp_analyst`(商机分析师)、`agent.opp_writer`(研发报告撰写工程师)。 slash 命令:`/hot` `/ai` `/reports` `/oppdiag`。 +## 数据参考弹窗(三级下钻,2026-09-08 交互定稿) + +菜单「📋 数据参考」→ `opp_references_popup.dspy`,三级结构全部**整行点击**(cursor:pointer, +无「查看分项」按钮): + +1. **汇总层**(主题排名:招标/国内众包/海外三板块):点主题条目 → 弹出该主题分项列表 + (`opp_references_items` / `opp_demand_items` / `opp_overseas_items.dspy`)。 +2. **分项层**(明细条目):每条说明文字标注「来源:xxx」(`source_text()` 解析, + 来源按钮已废弃删除);点条目 → 弹出具体内容(`opp_item_detail.dspy`: + 爬虫平台 `/api/tenders/{id}` 全量字段 + 公告/需求全文 + 原文页/采集来源页按钮 + 附件列表)。 +3. **附件层**:详情弹窗列出条目附件(爬虫平台 `/api/tenders/{id}/attachments`, + 字段容错归一 `normalize_attachment`),「下载」按钮走服务端代理 + `opp_attachment_dl.dspy`——先回查条目附件清单做归属校验(防 SSRF,前端传任意 + att_url 不在清单内即拒绝),再按降级链取字节:记录 download_url → + `/api/attachments/{id}/download` → 附件原始 url;代理失败如实报错不静默。 + +附件端点未就绪(爬虫平台附件子系统部署中)时:分项列表不显示 📎 角标、详情弹窗 +显示「附件服务暂不可用」,其余功能不受影响。 + ## 爬虫平台接入配置 系统级配置禁硬编码,走 appbase `params` 表(兜底默认值内网地址): diff --git a/pipeline_opportunity/opp_ability.py b/pipeline_opportunity/opp_ability.py index 6881623..26af2bf 100644 --- a/pipeline_opportunity/opp_ability.py +++ b/pipeline_opportunity/opp_ability.py @@ -124,7 +124,8 @@ opp_query_tenders / opp_tender_detail),**不要**自己去爬网站, - **所有分析必须带 references(可查证)**:给排名/统计/推荐结论时,调 opp_category_references / opp_query_tenders / opp_demands / opp_overseas_references 取分项明细,回答末尾附 References 章节—— - 列出自带的「📋 数据参考」菜单(招标/国内众包需求/海外需求三类主题都可逐条点开查原文), + 列出自带的「📋 数据参考」菜单(招标/国内众包需求/海外需求三类主题:点主题条目弹出 + 全部分项,点分项条目弹出具体内容——公告/需求全文与附件下载,数据均来自爬取平台), 并在正文中给出 3-5 条代表性分项的标题+来源链接(markdown 链接,外链会自动新窗口打开)。 - **凡是回复中出现单个数据项的列表(招标/需求/样例),每一项都必须带来源 URL**: 用 markdown 链接把标题挂到该条的 url(公告原文/需求详情页)上,格式如 diff --git a/pipeline_opportunity/opp_common.py b/pipeline_opportunity/opp_common.py index a4f5689..602215e 100644 --- a/pipeline_opportunity/opp_common.py +++ b/pipeline_opportunity/opp_common.py @@ -9,6 +9,7 @@ import json import logging +import re import urllib.parse import urllib.request @@ -76,30 +77,17 @@ def infer_source_code(source_url): return "" -def source_button_widget(code, source_url=""): - """来源名称按钮:显示来源名称,点击新窗口直跳来源 url。 +def source_text(code, source_url=""): + """来源 code/url → 说明文字用的来源名称(2026-09-08 用户定夺: + 明细列表每项的来源写进说明文字,不再渲染来源按钮)。 - 名称解析顺序:记录 source code → source_url 域名反推 → 空则「采集来源页」。 - 跳转目标优先记录级 source_url(采集来源页,最精确),缺失回退来源主页; - 两者皆无返回 None(不渲染按钮)。 + 解析顺序:记录 source code → source_url 域名反推;解析不出返回「未知来源」。 """ code = (code or "").strip() if not code: code = infer_source_code(source_url) - name, home = source_display(code) - target = (source_url or "").strip() or home - if not target: - return None - if not name: - name = "采集来源页" - return { - "widgettype": "Button", - "options": {"label": name, "css": "small"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "script", - "target": "self", - "script": "window.open(%s,'_blank','noopener');" - % json.dumps(target)}], - } + name, _home = source_display(code) + return name or "未知来源" # ── 报告状态 ── RP_DRAFT = "draft" # 草稿(agent 编写中/待审阅) @@ -224,6 +212,57 @@ async def crawler_get(sor, path, params=None, timeout=20): return False, "爬虫平台调用失败: %s" % str(e)[:200] +async def crawler_get_raw(sor, url, timeout=60): + """下载爬虫平台上的原始字节(附件代理下载用)。 + + url 为绝对地址(含平台下载端点)。返回 (ok, bytes, content_type, 文件名或错误)。 + 带 X-API-Token;文件名优先从 Content-Disposition 解析。 + """ + base, token = await get_crawler_config(sor) + # 相对下载端点补全为绝对地址(download_url 可能是 /api/... 形态) + if url.startswith("/"): + url = base + url + headers = {"X-API-Token": token} if token else {} + try: + import httpx + except ImportError: + httpx = None + + def _parse_disp(cd): + if not cd: + return "" + m = re.search(r"filename\*=UTF-8''([^;]+)", cd, re.I) + if m: + return urllib.parse.unquote(m.group(1)).strip() + m = re.search(r'filename="?([^";]+)"?', cd, re.I) + if m: + return m.group(1).strip() + return "" + + try: + if httpx is not None: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as cli: + r = await cli.get(url, headers=headers) + if r.status_code != 200: + return False, b"", "", "爬虫平台返回 %d" % r.status_code + ct = r.headers.get("Content-Type", "") + fn = _parse_disp(r.headers.get("Content-Disposition", "")) + return True, r.content, ct, fn + import asyncio + + def _blocking(): + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return (resp.read(), resp.headers.get("Content-Type", ""), + _parse_disp(resp.headers.get("Content-Disposition", ""))) + + data, ct, fn = await asyncio.get_event_loop().run_in_executor(None, _blocking) + return True, data, ct, fn + except Exception as e: + logger.warning("crawler_get_raw failed: %s", str(e)[:200]) + return False, b"", "", "附件下载失败: %s" % str(e)[:200] + + async def find_human_task(sor, project_id, task_type, status=None): """查项目下指定类型的人工任务(供门禁判断与幂等创建)。""" sql = ("SELECT id, title, status, task_type FROM pipeline_human_tasks " diff --git a/pipeline_opportunity/opp_data_capability.py b/pipeline_opportunity/opp_data_capability.py index bc3a6a7..61dbd4e 100644 --- a/pipeline_opportunity/opp_data_capability.py +++ b/pipeline_opportunity/opp_data_capability.py @@ -106,3 +106,45 @@ async def query_tenders(sor, keyword="", region="", buyer="", notice_type="", params[k] = v params["only_it"] = "1" if only_it else "0" return await crawler_get(sor, "/api/tenders_query", params) + + +# ── 条目附件(爬虫平台附件子系统对接,2026-09-08)── +# 契约:GET /api/tenders/{id}/attachments(爬虫平台侧由附件子系统提供)。 +# 字段做容错归一:名称 filename|name|title、原始地址 url|origin_url、 +# 平台副本标记 local_path|file_path|downloaded、平台下载入口 +# download_url(有则优先),大小 size|file_size。 +def normalize_attachment(a): + """单个附件 dict → 统一字段 {name,url,downloaded,download_url,size}。""" + if not isinstance(a, dict): + return None + name = (a.get("filename") or a.get("name") or a.get("title") + or a.get("local_path") or "").strip() + url = (a.get("url") or a.get("origin_url") or "").strip() + dl = (a.get("download_url") or "").strip() + downloaded = bool(a.get("local_path") or a.get("file_path") + or a.get("downloaded") or dl) + size = a.get("size") or a.get("file_size") or 0 + try: + size = int(size) + except (TypeError, ValueError): + size = 0 + if not name and not url and not dl: + return None + return {"id": a.get("id") or a.get("attachment_id") or "", + "name": name or url.rsplit("/", 1)[-1] or "附件", + "url": url, "download_url": dl, + "downloaded": downloaded, "size": size} + + +async def tender_attachments(sor, tender_id): + """条目附件清单 → (ok, [归一化附件])。端点未就绪/失败时返回 (False, 原因)。""" + ok, res = await crawler_get(sor, "/api/tenders/%s/attachments" % tender_id) + if not ok or not isinstance(res, dict): + return False, res + items = res.get("items") or res.get("attachments") or [] + out = [] + for a in items: + n = normalize_attachment(a) + if n: + out.append(n) + return True, out diff --git a/scripts/load_path.py b/scripts/load_path.py index f093d5d..7a6a37b 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -28,6 +28,8 @@ PATHS_LOGINED = [ "/%s/api/opp_references_items.dspy" % MOD, "/%s/api/opp_demand_items.dspy" % MOD, "/%s/api/opp_overseas_items.dspy" % MOD, + "/%s/api/opp_item_detail.dspy" % MOD, + "/%s/api/opp_attachment_dl.dspy" % MOD, "/%s/api/opp_confirm_report.dspy" % MOD, "/%s/api/opp_report_ppt.dspy" % MOD, ] diff --git a/wwwroot/api/opp_attachment_dl.dspy b/wwwroot/api/opp_attachment_dl.dspy new file mode 100644 index 0000000..2a83c71 --- /dev/null +++ b/wwwroot/api/opp_attachment_dl.dspy @@ -0,0 +1,75 @@ +# opp_attachment_dl.dspy - 附件下载代理(服务端带 token 取爬虫平台附件字节回传浏览器) +# 入参:tender_id=<条目id> + (att_id=<附件id> 或 att_url=<附件原始url>二选一) +# 安全:att_url 必须回查该条目的附件清单验证归属,前端不可传任意地址(防 SSRF); +# att_id 同样只用于在下载候选里定位,不作为取文件的路径拼接输入。 +# 下载源降级链:记录自带 download_url → 约定端点 /api/attachments/{id}/download → 附件原始 url。 + +from urllib.parse import quote +from aiohttp import web + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +tender_id = str((params_kw or {}).get("tender_id") or "").strip() +att_id = str((params_kw or {}).get("att_id") or "").strip() +att_url = str((params_kw or {}).get("att_url") or "").strip() +if not tender_id or not (att_id or att_url): + return {"widgettype": "Message", "options": { + "title": "缺少参数", "message": "需要 tender_id + att_id/att_url"}} + +dbname = get_module_dbname('pipeline-opportunity') + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_data_capability import tender_attachments + from pipeline_opportunity.opp_common import crawler_get_raw, get_crawler_config + ok, atts = await tender_attachments(sor, tender_id) + await sor.sqlExe("COMMIT", {}) + base, _token = await get_crawler_config(sor) + await sor.sqlExe("COMMIT", {}) + +if not ok: + return {"widgettype": "Message", "options": { + "title": "附件服务不可用", "message": str(atts)[:200]}} + +# ── 定位附件(必须在清单内,归属校验)── +target = None +for a in atts: + if att_id and str(a.get("id") or "") == att_id: + target = a + break + if att_url and a.get("url") == att_url: + target = a + break +if target is None: + return {"widgettype": "Message", "options": { + "title": "附件不存在", "message": "该附件不在条目 %s 的附件清单中" % tender_id}} + +# ── 下载候选降级链 ── +candidates = [] +du = str(target.get("download_url") or "") +if du: + candidates.append(du if du.startswith("http") else base + du) +if att_id or str(target.get("id") or ""): + candidates.append(base + "/api/attachments/%s/download" % (att_id or target.get("id"))) +if target.get("url"): + candidates.append(target["url"]) +if not candidates: + return {"widgettype": "Message", "options": { + "title": "无可下载地址", "message": "该附件记录缺少可用地址"}} + +err_msgs = [] +for cand in candidates: + ok2, data, ctype, disp_name = await crawler_get_raw(sor, cand, timeout=120) + if ok2 and data: + name = disp_name or str(target.get("name") or "attachment") + headers = {"Content-Disposition": + 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (quote(name, safe=""), quote(name))} + return web.Response(body=data, + content_type=(ctype or "application/octet-stream").split(";")[0].strip() or None, + headers=headers) + err_msgs.append(str(cand)[:80] + " → " + str(disp_name)[:80]) + +return {"widgettype": "Message", "options": { + "title": "附件下载失败", + "message": "已尝试 %d 个下载地址均失败:%s" % (len(candidates), ";".join(err_msgs)[:300])}} diff --git a/wwwroot/api/opp_demand_items.dspy b/wwwroot/api/opp_demand_items.dspy index 12984cc..5936204 100644 --- a/wwwroot/api/opp_demand_items.dspy +++ b/wwwroot/api/opp_demand_items.dspy @@ -1,5 +1,7 @@ -# opp_demand_items.dspy - 某软件主题的众包需求分项列表(国内猪八戒/一品威客,references 可查证) -# 入参:category=<主题名>。每条分项:标题+时间+预算+来源平台 + 「需求详情」「来源页」按钮(新窗口) +# opp_demand_items.dspy - 某软件主题的众包需求分项列表(国内猪八戒/一品威客) +# 入参:category=<主题名>。交互(2026-09-08 用户定夺): +# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载); +# 来源写进每条说明文字(来源:猪八戒/一品威客),不再渲染来源按钮。 import json as _json @@ -14,14 +16,14 @@ if not category: dbname = get_module_dbname('pipeline-opportunity') +detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy") + async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_data_capability import category_references - from pipeline_opportunity.opp_common import source_button_widget + from pipeline_opportunity.opp_common import source_text ok, res = await category_references(sor, category, days=days, limit=500, record_type="demand") await sor.sqlExe("COMMIT", {}) -SRC_NAME = {"zbj": "猪八戒", "epwk": "一品威客"} - rows = [] if not ok: rows.append({"widgettype": "Text", "options": { @@ -33,42 +35,48 @@ else: rows.append({"widgettype": "Text", "options": { "text": "该主题近 %d 天无众包需求分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}}) for it in items: - url = str(it.get("url") or "") - src = str(it.get("source_url") or "") - sub_btns = [] - if url: - sub_btns.append({"widgettype": "Button", "options": {"label": "需求详情", "css": "small"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "script", - "target": "self", - "script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]}) - # 来源:显示来源名称,点击直跳来源 url(2026-09-08 用户定夺) - _sb = source_button_widget(str(it.get("source") or ""), src) - if _sb: - sub_btns.append(_sb) + item_id = str(it.get("id") or "") budget = it.get("budget_wan") or 0 - plat = SRC_NAME.get(str(it.get("source") or ""), str(it.get("source") or "众包平台")) - meta = "%s | %s | %s" % ( + # 来源写进说明文字(不再做来源按钮) + src_name = source_text(str(it.get("source") or ""), str(it.get("source_url") or "")) + meta = "%s | %s | 来源:%s" % ( str(it.get("publish_time") or "")[:10], - plat, - (("预算 %.0f 万" % budget) if budget else "预算未公开")) - rows.append({ - "widgettype": "VBox", - "options": {"width": "100%", "padding": "8px 12px", "gap": "4px", - "border": "1px solid #e2e8f0", "borderRadius": "8px", - "margin": "0 0 6px 0", "bgcolor": "#ffffff"}, - "subwidgets": [ - {"widgettype": "Text", "options": { - "text": str(it.get("title") or ""), "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"}}, - {"widgettype": "Filler"} - ] + sub_btns} - ] - }) + (("赏金 %.2f 万" % budget) if budget else "赏金面议"), + src_name) + row_subs = [ + {"widgettype": "Text", "options": { + "text": str(it.get("title") or ""), "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 item_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(item_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}) + else: + rows.append({"widgettype": "VBox", "options": row_opts, + "subwidgets": row_subs}) total = res.get("count", len(rows)) if ok else 0 return { @@ -82,7 +90,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "每条「需求详情」为众包平台原始需求页;来源按钮显示平台名称、点击直达该条采集来源页,均在新窗口打开,可逐条查证。", + "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows }] diff --git a/wwwroot/api/opp_item_detail.dspy b/wwwroot/api/opp_item_detail.dspy new file mode 100644 index 0000000..8c85d07 --- /dev/null +++ b/wwwroot/api/opp_item_detail.dspy @@ -0,0 +1,193 @@ +# opp_item_detail.dspy - 单条数据项的具体内容弹窗(数据来自数据爬取平台) +# 入参:id=<爬虫平台条目id>。展示公告/需求全量字段 + 公告全文 + 来源说明 + 附件列表。 +# 附件点击下载:走 opp_attachment_dl.dspy 代理(平台副本优先,缺失回退原始地址)。 + +import json as _json + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +item_id = str((params_kw or {}).get("id") or "").strip() +if not item_id: + return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 id"}} + +dbname = get_module_dbname('pipeline-opportunity') + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_data_capability import tender_detail, tender_attachments + from pipeline_opportunity.opp_common import source_text + ok, det = await tender_detail(sor, item_id) + await sor.sqlExe("COMMIT", {}) + att_ok, atts = await tender_attachments(sor, item_id) + await sor.sqlExe("COMMIT", {}) + +dl_base = entire_url("/pipeline-opportunity/api/opp_attachment_dl.dspy") + +body = [] +if not ok or not isinstance(det, dict): + body.append({"widgettype": "Text", "options": { + "text": "获取详情失败:" + str(det)[:200], + "color": "#dc2626", "padding": "12px", "halign": "left"}}) +else: + # ── 标题 ── + body.append({"widgettype": "Text", "options": { + "text": str(det.get("title") or ""), "cfontsize": 1.15, + "color": "#0f172a", "halign": "left", "wrap": True, + "margin": "0 0 8px 0"}}) + + # ── 字段区 ── + rt = str(det.get("record_type") or "tender") + budget = det.get("budget_wan") or 0 + fields = [] + fields.append(("发布时间", str(det.get("publish_time") or "—"))) + if rt == "tender": + fields.append(("公告类型", str(det.get("notice_type") or "—"))) + fields.append(("地区", str(det.get("region") or "—"))) + fields.append(("采购人", str(det.get("buyer") or "—"))) + fields.append(("预算", ("%.2f 万元" % budget) if budget else "未公开")) + if det.get("item_category"): + fields.append(("品目", str(det.get("item_category")))) + if det.get("project_code"): + fields.append(("项目编号", str(det.get("project_code")))) + if det.get("agency"): + fields.append(("代理机构", str(det.get("agency")))) + if det.get("contact") or det.get("contact_phone"): + fields.append(("联系人", ("%s %s" % (str(det.get("contact") or ""), + str(det.get("contact_phone") or ""))).strip())) + else: + fields.append(("需求类型", str(det.get("notice_type") or "—"))) + if det.get("item_category"): + fields.append(("站内类目", str(det.get("item_category")))) + fields.append(("赏金/预算", ("%.2f 万元" % budget) if budget else "未公开")) + if det.get("ai_score"): + fields.append(("AI相关度", str(det.get("ai_score")))) + field_rows = [] + for k, v in fields: + field_rows.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "8px", "padding": "2px 0"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": k, "cfontsize": 0.85, + "color": "#64748b", "halign": "left", "width": "90px"}}, + {"widgettype": "Text", "options": {"text": v, "cfontsize": 0.9, + "color": "#0f172a", "halign": "left", "wrap": True}} + ] + }) + body.append({"widgettype": "VBox", "options": { + "width": "100%", "padding": "8px 12px", "gap": "2px", + "border": "1px solid #e2e8f0", "borderRadius": "8px", + "margin": "0 0 10px 0", "bgcolor": "#f8fafc"}, + "subwidgets": field_rows}) + + # ── 来源说明(来源写进说明,不做来源按钮;2026-09-08 用户定夺)── + src_name = source_text(str(det.get("source") or ""), str(det.get("source_url") or "")) + src_lines = ["来源:" + src_name + "(数据爬取平台采集)"] + url = str(det.get("url") or "") + src_url = str(det.get("source_url") or "") + src_note = "|".join(src_lines) + link_btns = [] + if url: + link_btns.append({"widgettype": "Button", "options": {"label": "打开原文页", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", + "script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]}) + if src_url: + link_btns.append({"widgettype": "Button", "options": {"label": "打开采集来源页", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", + "script": "window.open(" + _json.dumps(src_url) + ",'_blank','noopener');"}]}) + body.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "10px", "alignItems": "center", + "margin": "0 0 10px 0"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": src_note, + "cfontsize": 0.8, "color": "#64748b", "halign": "left", "wrap": True}}, + {"widgettype": "Filler"} + ] + link_btns + }) + + # ── 公告/需求全文(爬取平台保存的正文)── + detail_text = str(det.get("detail_text") or "").strip() + body.append({"widgettype": "Text", "options": { + "text": "全文内容", "cfontsize": 0.95, "color": "#0f172a", + "halign": "left", "margin": "0 0 4px 0"}}) + if detail_text: + shown = detail_text[:8000] + if len(detail_text) > 8000: + shown += "\n……(全文 %d 字,已截断显示,完整内容见原文页)" % len(detail_text) + body.append({"widgettype": "Text", "options": { + "text": shown, "cfontsize": 0.85, "color": "#334155", + "halign": "left", "wrap": True, "whiteSpace": "pre-wrap", + "padding": "8px 12px", "border": "1px solid #e2e8f0", + "borderRadius": "8px", "margin": "0 0 10px 0", "bgcolor": "#ffffff"}}) + else: + body.append({"widgettype": "Text", "options": { + "text": "爬取平台未保存该条全文,可点「打开原文页」查看。", + "cfontsize": 0.82, "color": "#94a3b8", "halign": "left", + "margin": "0 0 10px 0"}}) + + # ── 附件区(有附件则逐个可点击下载)── + body.append({"widgettype": "Text", "options": { + "text": "附件(%d)" % (len(atts) if att_ok else 0), + "cfontsize": 0.95, "color": "#0f172a", "halign": "left", + "margin": "0 0 4px 0"}}) + if not att_ok: + # 附件端点未就绪(爬虫平台附件子系统部署中)——如实提示,不静默 + body.append({"widgettype": "Text", "options": { + "text": "附件服务暂不可用:" + str(atts)[:120], + "cfontsize": 0.8, "color": "#94a3b8", "halign": "left", + "margin": "0 0 10px 0"}}) + elif not atts: + body.append({"widgettype": "Text", "options": { + "text": "该条暂无附件", "cfontsize": 0.8, "color": "#94a3b8", + "halign": "left", "margin": "0 0 10px 0"}}) + else: + for a in atts: + a_name = str(a.get("name") or "附件") + a_url = str(a.get("url") or "") + a_id = str(a.get("id") or "") + a_size = int(a.get("size") or 0) + size_txt = "" + if a_size >= 1048576: + size_txt = "%.1f MB" % (a_size / 1048576.0) + elif a_size > 0: + size_txt = "%.0f KB" % (a_size / 1024.0) + dl_query = "?tender_id=" + item_id + if a_id: + dl_query += "&att_id=" + a_id + elif a_url: + dl_query += "&att_url=" + a_url + dl_href = dl_base + dl_query + body.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "10px", "alignItems": "center", + "padding": "6px 12px", "border": "1px solid #e2e8f0", + "borderRadius": "8px", "margin": "0 0 6px 0", "bgcolor": "#ffffff"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "📎 " + a_name, + "cfontsize": 0.88, "color": "#0f172a", "halign": "left", + "wrap": True}}, + {"widgettype": "Text", "options": {"text": size_txt, + "cfontsize": 0.75, "color": "#94a3b8"}}, + {"widgettype": "Filler"}, + {"widgettype": "Button", "options": {"label": "下载", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", + "script": "window.location.href=" + _json.dumps(dl_href) + ";"}]} + ] + }) + +return { + "widgettype": "PopupWindow", + "id": "opp_item_detail_pw", + "options": {"title": "数据项详情", "width": "70%", "height": "85%", + "auto_open": True, "resizable": True}, + "subwidgets": [{ + "widgettype": "VBox", + "options": {"css": "filler", "width": "100%", "height": "100%", + "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, + "subwidgets": body + }] +} diff --git a/wwwroot/api/opp_overseas_items.dspy b/wwwroot/api/opp_overseas_items.dspy index 0096959..361e683 100644 --- a/wwwroot/api/opp_overseas_items.dspy +++ b/wwwroot/api/opp_overseas_items.dspy @@ -1,5 +1,7 @@ # opp_overseas_items.dspy - 某海外需求主题的分项列表(references 可查证) -# 入参:category=<海外主题名>。每条分项:标题+时间+来源平台 + 「需求详情」按钮(新窗口) +# 入参:category=<海外主题名>。交互(2026-09-08 用户定夺): +# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台数据+附件下载); +# 来源写进每条说明文字(来源:Freelancer.com / PeoplePerHour),不再渲染来源按钮。 import json as _json @@ -14,14 +16,14 @@ if not category: dbname = get_module_dbname('pipeline-opportunity') +detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy") + async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_data_capability import overseas_references - from pipeline_opportunity.opp_common import source_button_widget + from pipeline_opportunity.opp_common import source_text ok, res = await overseas_references(sor, category, days=days, limit=500) await sor.sqlExe("COMMIT", {}) -SRC_NAME = {"freelancer": "Freelancer.com", "pph": "PeoplePerHour"} - rows = [] if not ok: rows.append({"widgettype": "Text", "options": { @@ -33,41 +35,48 @@ else: rows.append({"widgettype": "Text", "options": { "text": "该主题近 %d 天无海外分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}}) for it in items: - url = str(it.get("url") or "") - src = SRC_NAME.get(str(it.get("source") or ""), str(it.get("source") or "海外")) - sub_btns = [] - if url: - sub_btns.append({"widgettype": "Button", "options": {"label": "需求详情", "css": "small"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "script", - "target": "self", - "script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]}) - # 来源:显示来源名称,点击直跳来源 url(2026-09-08 用户定夺) - _sb = source_button_widget(str(it.get("source") or ""), str(it.get("source_url") or "")) - if _sb: - sub_btns.append(_sb) + item_id = str(it.get("id") or "") budget = it.get("budget_wan") or 0 - meta = "%s | %s | %s" % ( + # 来源写进说明文字(不再做来源按钮) + src_name = source_text(str(it.get("source") or ""), str(it.get("source_url") or "")) + meta = "%s | %s | 来源:%s" % ( str(it.get("publish_time") or "")[:10], - src, - (("预算 %.2f 万(折人民币)" % budget) if budget else "预算未公开")) - rows.append({ - "widgettype": "VBox", - "options": {"width": "100%", "padding": "8px 12px", "gap": "4px", - "border": "1px solid #e2e8f0", "borderRadius": "8px", - "margin": "0 0 6px 0", "bgcolor": "#ffffff"}, - "subwidgets": [ - {"widgettype": "Text", "options": { - "text": str(it.get("title") or ""), "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"}}, - {"widgettype": "Filler"} - ] + sub_btns} - ] - }) + (("预算 %.2f 万(折人民币)" % budget) if budget else "预算未公开"), + src_name) + row_subs = [ + {"widgettype": "Text", "options": { + "text": str(it.get("title") or ""), "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 item_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(item_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}) + else: + rows.append({"widgettype": "VBox", "options": row_opts, + "subwidgets": row_subs}) total = res.get("matched", len(rows)) if ok else 0 return { @@ -81,7 +90,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "每条「需求详情」为海外众包平台原始需求页;来源按钮显示平台名称、点击直达平台主页,在新窗口打开,可逐条查证。", + "text": "点击任一条目弹出具体内容(数据来自数据爬取平台);每条说明中已标注来源平台。", "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows }] diff --git a/wwwroot/api/opp_references_items.dspy b/wwwroot/api/opp_references_items.dspy index 0e8b21a..490ad7a 100644 --- a/wwwroot/api/opp_references_items.dspy +++ b/wwwroot/api/opp_references_items.dspy @@ -1,5 +1,7 @@ # opp_references_items.dspy - 某软件主题的分项列表(references 可查证) -# 入参:category=<主题名>。每条分项:标题+时间+预算+地区 + 「公告原文」「来源页」按钮(新窗口) +# 入参:category=<主题名>。交互(2026-09-08 用户定夺): +# 每条分项整行点击 → opp_item_detail.dspy 弹出具体内容(爬取平台全文+附件下载); +# 来源写进每条说明文字(来源:xxx),不再渲染来源按钮。 import json as _json @@ -14,9 +16,11 @@ if not category: dbname = get_module_dbname('pipeline-opportunity') +detail_url = entire_url("/pipeline-opportunity/api/opp_item_detail.dspy") + async with DBPools().sqlorContext(dbname) as sor: from pipeline_opportunity.opp_data_capability import category_references - from pipeline_opportunity.opp_common import source_button_widget + from pipeline_opportunity.opp_common import source_text ok, res = await category_references(sor, category, days=days, limit=500) await sor.sqlExe("COMMIT", {}) @@ -31,41 +35,53 @@ else: rows.append({"widgettype": "Text", "options": { "text": "该主题近 %d 天无分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}}) for it in items: - url = str(it.get("url") or "") - src = str(it.get("source_url") or "") - sub_btns = [] - if url: - sub_btns.append({"widgettype": "Button", "options": {"label": "公告原文", "css": "small"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "script", - "target": "self", - "script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]}) - # 来源:显示来源名称,点击直跳来源 url(2026-09-08 用户定夺) - _sb = source_button_widget(str(it.get("source") or ""), src) - if _sb: - sub_btns.append(_sb) + item_id = str(it.get("id") or "") budget = it.get("budget_wan") or 0 - meta = "%s | %s | %s" % ( + # 来源写进说明文字(不再做来源按钮) + src_name = source_text(str(it.get("source") or ""), str(it.get("source_url") or "")) + meta = "%s | %s | %s | 来源:%s" % ( str(it.get("publish_time") or "")[:10], str(it.get("region") or "—"), - (("预算 %.0f 万" % budget) if budget else "预算未公开")) - rows.append({ - "widgettype": "VBox", - "options": {"width": "100%", "padding": "8px 12px", "gap": "4px", - "border": "1px solid #e2e8f0", "borderRadius": "8px", - "margin": "0 0 6px 0", "bgcolor": "#ffffff"}, - "subwidgets": [ - {"widgettype": "Text", "options": { - "text": str(it.get("title") or ""), "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"}}, - {"widgettype": "Filler"} - ] + sub_btns} - ] - }) + (("预算 %.0f 万" % budget) if budget else "预算未公开"), + src_name) + att_cnt = int(it.get("attachment_count") or 0) + row_subs = [ + {"widgettype": "Text", "options": { + "text": str(it.get("title") or ""), "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": "📎%d" % att_cnt, "cfontsize": 0.75, "color": "#0ea5e9"}}] + if att_cnt else []) + [ + {"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 item_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(item_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}) + else: + # 无 id 的记录(异常数据)不可点,如实降级为静态行 + rows.append({"widgettype": "VBox", "options": row_opts, + "subwidgets": row_subs}) total = res.get("count", len(rows)) if ok else 0 return { @@ -79,7 +95,7 @@ return { "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "每条「公告原文」为招标公告原链接;来源按钮显示数据来源名称、点击直达该条采集来源页,均在新窗口打开,可逐条查证。", + "text": "点击任一条目弹出具体内容(公告全文来自数据爬取平台,有附件的可直接下载);每条说明中已标注数据来源。", "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows }] diff --git a/wwwroot/api/opp_references_popup.dspy b/wwwroot/api/opp_references_popup.dspy index 0d3226f..6a08691 100644 --- a/wwwroot/api/opp_references_popup.dspy +++ b/wwwroot/api/opp_references_popup.dspy @@ -1,7 +1,8 @@ # opp_references_popup.dspy - 数据参考(references):主题列表(agent_menu_open 弹窗内嵌内容) -# 点主题「查看分项」→ 对应分项列表(每条带来源,点击新窗口查证): +# 交互(2026-09-08 用户定夺):每个汇总主题条目整行点击 → 弹窗显示分项/明细项: # 招标主题 → opp_references_items.dspy;国内众包需求主题 → opp_demand_items.dspy; # 海外众包需求主题 → opp_overseas_items.dspy +# (原「查看分项」按钮已撤,整行可点,cursor:pointer 提示) import json as _json @@ -24,6 +25,42 @@ async with DBPools().sqlorContext(dbname) as sor: ok_ov, res_ov = await hot_overseas(sor, days=30, top=10) await sor.sqlExe("COMMIT", {}) + +def _topic_row(cat, cnt, budget, target_url, popup_id, extra_txt=""): + """汇总主题行:整行点击 → 弹出该主题的分项明细列表。""" + script = ("var old=bricks.getWidgetById(" + _json.dumps(popup_id) + ",bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var rp=await fetch(" + _json.dumps(target_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));" + "var d=await rp.json();" + "if(d){bricks.widgetBuild(d,bricks.app);}") + subs = [ + {"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0, + "color": "#0f172a", "halign": "left"}}, + {"widgettype": "Filler"}, + {"widgettype": "Text", "options": {"text": "%d 条" % cnt, + "cfontsize": 0.85, "color": "#64748b"}}, + ] + if extra_txt: + subs.append({"widgettype": "Text", "options": {"text": extra_txt, + "cfontsize": 0.75, "color": "#94a3b8"}}) + else: + subs.append({"widgettype": "Text", "options": { + "text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"), + "cfontsize": 0.85, "color": "#64748b"}}) + subs.append({"widgettype": "Text", "options": {"text": "›", + "cfontsize": 1.1, "color": "#94a3b8"}}) + return { + "widgettype": "HBox", + "options": {"width": "100%", "gap": "10px", "alignItems": "center", + "padding": "8px 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": subs + } + + rows = [] if not ok: rows.append({"widgettype": "Text", "options": { @@ -34,33 +71,9 @@ else: if not ranking: rows.append({"widgettype": "Text", "options": { "text": "近30天暂无招标数据", "color": "#64748b", "padding": "12px", "halign": "left"}}) - for i, r in enumerate(ranking): - cat = str(r.get("category", "")) - cnt = r.get("tender_count", 0) - budget = r.get("total_budget_wan", 0) - script = ("var old=bricks.getWidgetById('opp_refs_items_pw',bricks.app);" - "if(old&&old.destroy){old.destroy();}" - "var rp=await fetch(" + _json.dumps(items_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));" - "var d=await rp.json();" - "if(d){bricks.widgetBuild(d,bricks.app);}") - rows.append({ - "widgettype": "HBox", - "options": {"width": "100%", "gap": "10px", "alignItems": "center", - "padding": "8px 14px", "border": "1px solid #e2e8f0", - "borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0, - "color": "#0f172a", "halign": "left"}}, - {"widgettype": "Filler"}, - {"widgettype": "Text", "options": {"text": "%d 条" % cnt, - "cfontsize": 0.85, "color": "#64748b"}}, - {"widgettype": "Text", "options": {"text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"), - "cfontsize": 0.85, "color": "#64748b"}}, - {"widgettype": "Button", "options": {"label": "查看分项", "css": "small"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "script", - "target": "self", "script": script}]} - ] - }) + for r in ranking: + rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0), + r.get("total_budget_wan", 0), items_url, "opp_refs_items_pw")) # ── 国内众包需求板块(猪八戒/一品威客)── dm_rows = [] @@ -73,32 +86,8 @@ if ok_dm: "margin": "14px 0 6px 0", "padding": "6px 0 0 0", "border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}}) for r in dm_ranking: - cat = str(r.get("category", "")) - cnt = r.get("tender_count", 0) - budget = r.get("total_budget_wan", 0) - script = ("var old=bricks.getWidgetById('opp_demand_items_pw',bricks.app);" - "if(old&&old.destroy){old.destroy();}" - "var rp=await fetch(" + _json.dumps(demand_items_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));" - "var d=await rp.json();" - "if(d){bricks.widgetBuild(d,bricks.app);}") - dm_rows.append({ - "widgettype": "HBox", - "options": {"width": "100%", "gap": "10px", "alignItems": "center", - "padding": "8px 14px", "border": "1px solid #e2e8f0", - "borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0, - "color": "#0f172a", "halign": "left"}}, - {"widgettype": "Filler"}, - {"widgettype": "Text", "options": {"text": "%d 条" % cnt, - "cfontsize": 0.85, "color": "#64748b"}}, - {"widgettype": "Text", "options": {"text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"), - "cfontsize": 0.85, "color": "#64748b"}}, - {"widgettype": "Button", "options": {"label": "查看分项", "css": "small"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "script", - "target": "self", "script": script}]} - ] - }) + dm_rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0), + r.get("total_budget_wan", 0), demand_items_url, "opp_demand_items_pw")) # ── 海外众包需求板块(Freelancer.com / PeoplePerHour)── ov_rows = [] @@ -111,33 +100,11 @@ if ok_ov: "margin": "14px 0 6px 0", "padding": "6px 0 0 0", "border": "1px solid #f1f5f9", "borderRadius": "6px", "bgcolor": "#f8fafc"}}) for r in ov_ranking: - cat = str(r.get("category", "")) - cnt = r.get("tender_count", 0) by_src = r.get("by_source") or {} src_txt = " / ".join("%s:%d" % (k, v) for k, v in by_src.items()) - script = ("var old=bricks.getWidgetById('opp_overseas_items_pw',bricks.app);" - "if(old&&old.destroy){old.destroy();}" - "var rp=await fetch(" + _json.dumps(overseas_items_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));" - "var d=await rp.json();" - "if(d){bricks.widgetBuild(d,bricks.app);}") - ov_rows.append({ - "widgettype": "HBox", - "options": {"width": "100%", "gap": "10px", "alignItems": "center", - "padding": "8px 14px", "border": "1px solid #e2e8f0", - "borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0, - "color": "#0f172a", "halign": "left"}}, - {"widgettype": "Filler"}, - {"widgettype": "Text", "options": {"text": "%d 条" % cnt, - "cfontsize": 0.85, "color": "#64748b"}}, - {"widgettype": "Text", "options": {"text": src_txt, - "cfontsize": 0.75, "color": "#94a3b8"}}, - {"widgettype": "Button", "options": {"label": "查看分项", "css": "small"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "script", - "target": "self", "script": script}]} - ] - }) + ov_rows.append(_topic_row(str(r.get("category", "")), r.get("tender_count", 0), + r.get("total_budget_wan", 0), overseas_items_url, "opp_overseas_items_pw", + extra_txt=src_txt)) return { "widgettype": "VBox", @@ -145,7 +112,7 @@ return { "gap": "6px", "overflow": "auto"}, "subwidgets": [ {"widgettype": "Text", "options": { - "text": "点击「查看分项」列出该主题的全部数据项;每条数据项带来源链接(公告原文/需求详情/来源页),可逐条点开查证。", + "text": "点击任一主题条目,弹出该主题的全部分项明细;明细中再点击任一条,弹出具体内容(数据来自数据爬取平台,含公告全文与附件下载)。", "cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} ] + rows + dm_rows + ov_rows }