diff --git a/json/opp_reports.json b/json/opp_reports.json index bb7f442..652c513 100644 --- a/json/opp_reports.json +++ b/json/opp_reports.json @@ -17,7 +17,8 @@ "created_at", "updated_at", "confirm_task_id", - "confirmed_by" + "confirmed_by", + "ppt_path" ], "editable": { "new_data_url": "{{entire_url('add_opp_reports.dspy')}}", diff --git a/models/opp_reports.json b/models/opp_reports.json index c9a77a3..faa5c29 100644 --- a/models/opp_reports.json +++ b/models/opp_reports.json @@ -56,6 +56,12 @@ "type": "str", "length": 32 }, + { + "name": "ppt_path", + "title": "PPT文件路径", + "type": "str", + "length": 500 + }, { "name": "confirmed_by", "title": "确认人", diff --git a/pipeline_opportunity/opp_ability.py b/pipeline_opportunity/opp_ability.py index 9fe6207..bb3a8ab 100644 --- a/pipeline_opportunity/opp_ability.py +++ b/pipeline_opportunity/opp_ability.py @@ -36,6 +36,9 @@ OPP_TOOLS = [ parameters={"tender_id": "招标信息ID"}, category="data"), ToolDefinition(name="opp_crawler_stats", description="查看数据爬取平台采集健康度(总量/24h新增/最近运行)", parameters={}, category="data"), + ToolDefinition(name="opp_category_references", description="查某软件主题的全部分项明细(references 可查证:每条带公告原文URL+采集来源)。回答热点/排名类问题时用本工具取分项并附来源链接", + parameters={"category": "软件主题名(须与热点排名中的category一致)", + "days": "统计窗口天数(默认30)", "limit": "条数(默认200)"}, category="data"), # ── 报告与审批(商机闭环)── ToolDefinition(name="opp_create_report", description="创建软件研发报告草稿。从热门软件中选定的方向写入报告,数据基础章节自动从爬取平台拉取", @@ -76,11 +79,16 @@ OPP_PROMPT = """你是「商机产线」的驾驶舱 agent,负责把招标市 ## 硬规则 - 报告里的招标数量、预算、项目案例必须来自工具返回的真实数据,**禁止编造**; 查不到就如实写"数据未覆盖"。 +- **所有分析必须带 references(可查证)**:给排名/统计/推荐结论时,调 + opp_category_references 取该主题的分项明细,回答末尾附 References 章节—— + 列出自带的「📋 数据参考」菜单(用户可逐条点开查公告原文), + 并在正文中给出 3-5 条代表性分项的标题+公告链接(markdown 链接,外链会自动新窗口打开)。 - 每条引用的招标信息带来源链接(爬虫平台已提供 url / source_url)。 - 报告没被人工确认,不许当作可发起审批;审批没过,不许当作可立项。 ## 典型场景 -- 用户问「最近什么软件最火/最值得做」→ opp_hot_software(days=30) 后给结论。 +- 用户问「最近什么软件最火/最值得做」→ opp_hot_software(days=30) 后给结论, + 并对推荐的主题调 opp_category_references 附 references。 - 用户问「今天有什么AI招标」→ opp_daily_ai_tenders()。 - 用户说「给XX写个研发报告」→ opp_create_report → 拉数据补写 → opp_submit_report。 - 用户问进展 → opp_diagnose(报告/审批分布 + 待办人工任务)。""" @@ -174,6 +182,25 @@ async def _h_crawler_stats(sor, p, ctx): return json.dumps(res, ensure_ascii=False, default=str) +async def _h_category_references(sor, p, ctx): + from .opp_data_capability import category_references + category = (p.get("category") or "").strip() + if not category: + return _fmt(False, "缺少 category(软件主题名)") + ok, res = await category_references( + sor, category, days=int(p.get("days") or 30), + limit=min(int(p.get("limit") or 200), 1000)) + if not ok: + return _fmt(False, res) + return json.dumps({ + "category": res.get("category"), + "窗口": "%d天" % res.get("window_days"), + "分项总数": res.get("count"), + "分项": res.get("items"), + "说明": "每条分项的 url 为公告原文、source_url 为采集来源,均可点击查证", + }, ensure_ascii=False, default=str) + + async def _h_create_report(sor, p, ctx): from .opp_report_capability import create_report ok, rid = await create_report( @@ -248,6 +275,7 @@ OPP_HANDLERS = { "opp_search_tenders": _h_search, "opp_tender_detail": _h_detail, "opp_crawler_stats": _h_crawler_stats, + "opp_category_references": _h_category_references, "opp_create_report": _h_create_report, "opp_update_report": _h_update_report, "opp_list_reports": _h_list_reports, @@ -287,6 +315,8 @@ def register_opp_ability(): "type": "popup", "width": "88%", "height": "82%"}, {"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui", "type": "popup", "width": "88%", "height": "82%"}, + {"label": "📋 数据参考", "icon": "", "url": "/pipeline-opportunity/api/opp_references_popup.dspy", + "type": "popup", "width": "70%", "height": "76%"}, ], ) register_ability(ability) diff --git a/pipeline_opportunity/opp_data_capability.py b/pipeline_opportunity/opp_data_capability.py index c2b6786..f16ffa2 100644 --- a/pipeline_opportunity/opp_data_capability.py +++ b/pipeline_opportunity/opp_data_capability.py @@ -37,6 +37,15 @@ async def hot_software(sor, days=30, top=10): {"days": days, "top": top}) +async def category_references(sor, category, days=30, limit=500): + """某软件主题的全部分项(references 可查证)。 + + 每条带公告原文 url + 采集来源 source_url,供界面逐条查证。 + """ + return await crawler_get(sor, "/api/category_references", + {"category": category, "days": days, "limit": limit}) + + async def crawler_stats(sor): """爬虫平台采集健康度。""" return await crawler_get(sor, "/api/stats") diff --git a/pipeline_opportunity/opp_ppt.py b/pipeline_opportunity/opp_ppt.py new file mode 100644 index 0000000..2edc6e3 --- /dev/null +++ b/pipeline_opportunity/opp_ppt.py @@ -0,0 +1,161 @@ +"""研发报告 PPT 生成:markdown 报告正文 → .pptx 文件(可下载查证)。 + +触发时机:opp_submit_report 提交人工确认时生成/更新,存到 +项目工作空间 deliverables/ 目录(与报告同源,人工确认时可直接下载审阅)。 + +设计取舍: +- python-pptx 纯后端生成,无外部模板依赖(模板缺失时降级纯文本版式)。 +- markdown 按 # / ## 切页;列表/表格转要点;来源链接保留为文本(PPT 超链接 + 兼容性差,查证走界面按钮)。 +""" + +import logging +import os +import re + +logger = logging.getLogger("pipeline.opportunity.ppt") + + +def _md_to_sections(content): + """markdown 正文切分为 [(标题, [要点行], 原文块)] 段。 + + # 一级标题视为章节页;无标题的连续文本归入"正文"页。 + """ + sections = [] + cur_title = "" + cur_lines = [] + for raw in (content or "").split("\n"): + line = raw.rstrip() + m = re.match(r"^(#{1,3})\s+(.+)$", line) + if m and len(m.group(1)) <= 2: + if cur_title or cur_lines: + sections.append((cur_title, cur_lines)) + cur_title = m.group(2).strip() + cur_lines = [] + continue + if line.strip(): + cur_lines.append(line) + if cur_title or cur_lines: + sections.append((cur_title, cur_lines)) + return sections + + +def _line_to_bullet(line): + """markdown 行 → PPT 要点文本(去标记、保留链接为 文字(链接))。""" + s = line.strip() + s = re.sub(r"^\s*[-*+]\s+", "", s) # 列表符 + s = re.sub(r"^\s*\d+[.、)]\s*", "", s) # 有序列表 + s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) # 粗体 + s = re.sub(r"\*(.+?)\*", r"\1", s) # 斜体 + s = re.sub(r"^\|.*\|$", lambda m: " | ".join( + c.strip() for c in m.group(0).strip("|").split("|") if c.strip()), s) # 表格行 + if re.match(r"^[-: =]+$", s): + return "" # 表格分隔线 + s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1(\2)", s) # 链接 → 文字(链接) + return s.strip() + + +def build_report_ppt(title, content, software="", out_path=""): + """生成 PPT 并保存。返回 (True, 文件路径) 或 (False, 错误)。""" + try: + from pptx import Presentation + from pptx.util import Inches, Pt + from pptx.dml.color import RGBColor + except ImportError: + return False, "缺少 python-pptx(pip install python-pptx)" + + if not out_path: + return False, "缺少输出路径" + + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + blank_layout = prs.slide_layouts[6] + + def _add_title_bar(slide, text, color=(0x1E, 0x40, 0xAF)): + box = slide.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.8)) + tf = box.text_frame + p = tf.paragraphs[0] + run = p.add_run() + run.text = text + run.font.size = Pt(26) + run.font.bold = True + run.font.color.rgb = RGBColor(*color) + + # ── 封面页 ── + slide = prs.slides.add_slide(blank_layout) + box = slide.shapes.add_textbox(Inches(0.8), Inches(2.6), Inches(11.7), Inches(2)) + tf = box.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + run = p.add_run() + run.text = title or "研发报告" + run.font.size = Pt(36) + run.font.bold = True + run.font.color.rgb = RGBColor(0x0F, 0x17, 0x2A) + sub = software or "" + if sub: + p2 = tf.add_paragraph() + r2 = p2.add_run() + r2.text = "方向:%s" % sub + r2.font.size = Pt(18) + r2.font.color.rgb = RGBColor(0x64, 0x74, 0x8B) + p3 = tf.add_paragraph() + r3 = p3.add_run() + r3.text = "商机产线 · 数据来自数据爬取平台(可查证)" + r3.font.size = Pt(14) + r3.font.color.rgb = RGBColor(0x94, 0xA3, 0xB8) + + # ── 内容页 ── + sections = _md_to_sections(content) + for sec_title, lines in sections: + bullets = [b for b in (_line_to_bullet(l) for l in lines) if b] + if not sec_title and not bullets: + continue + # 要点过多 → 分页(每页 ≤ 8 条,防溢出) + chunks = [bullets[i:i + 8] for i in range(0, max(len(bullets), 1), 8)] or [[]] + for ci, chunk in enumerate(chunks): + slide = prs.slides.add_slide(blank_layout) + t = sec_title or "正文" + if len(chunks) > 1: + t += "(%d/%d)" % (ci + 1, len(chunks)) + _add_title_bar(slide, t) + box = slide.shapes.add_textbox(Inches(0.7), Inches(1.3), Inches(12), Inches(5.8)) + tf = box.text_frame + tf.word_wrap = True + first = True + for b in chunk: + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + run = p.add_run() + run.text = "• " + b + run.font.size = Pt(15) + run.font.color.rgb = RGBColor(0x33, 0x41, 0x55) + p.space_after = Pt(8) + + # ── 尾页 ── + slide = prs.slides.add_slide(blank_layout) + _add_title_bar(slide, "说明", color=(0x64, 0x74, 0x8B)) + box = slide.shapes.add_textbox(Inches(0.7), Inches(1.4), Inches(12), Inches(5)) + tf = box.text_frame + tf.word_wrap = True + for i, note in enumerate([ + "本报告全部数据来自数据爬取平台(内网采集、已去重)。", + "每条引用均带公告原文/采集来源,可在产线平台「数据参考」逐条查证。", + "报告状态以平台门禁为准(人工确认 → 研发审批),未经审批不得视为立项依据。", + ]): + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + run = p.add_run() + run.text = note + run.font.size = Pt(15) + run.font.color.rgb = RGBColor(0x47, 0x55, 0x69) + p.space_after = Pt(10) + + os.makedirs(os.path.dirname(out_path), exist_ok=True) + prs.save(out_path) + try: + npages = len(prs.slides._sldIdLst) + except Exception: + npages = -1 + logger.info("报告 PPT 已生成: %s (%d 页)", out_path, npages) + return True, out_path diff --git a/pipeline_opportunity/opp_report_capability.py b/pipeline_opportunity/opp_report_capability.py index 06b7932..ff444ea 100644 --- a/pipeline_opportunity/opp_report_capability.py +++ b/pipeline_opportunity/opp_report_capability.py @@ -11,6 +11,8 @@ """ import logging +import os +import re from ahserver.serverenv import ServerEnv @@ -87,10 +89,42 @@ async def get_report_full(report_id): return rep +async def _build_report_ppt(sor, rep): + """为报告生成 PPT,存到项目工作空间 deliverables/ 目录。返回文件路径(失败 '')。""" + from .opp_ppt import build_report_ppt + # 解析项目目录({workspace}/{org}/{space}/projects/{项目}/) + project_id = rep.get("project_id", "") or "" + project_dir = "" + if project_id: + try: + recs = await sor.sqlExe( + "SELECT workspace_dir FROM sd_projects WHERE id=${p}$", + {"p": project_id}) + if recs: + project_dir = getattr(recs[0], "workspace_dir", "") or "" + except Exception: + project_dir = "" + if not project_dir: + # 无项目/无目录:兜底放平台 files 目录 + project_dir = os.path.join(os.getcwd(), "files", "opp_reports") + rid = rep.get("id", "") + software = re.sub(r"[^\w\u4e00-\u9fff-]", "_", rep.get("software", "") or "report")[:40] + fname = "research_report_%s_%s.pptx" % (software, rid[:8]) + out_path = os.path.join(project_dir, "deliverables", fname) + ok, res = build_report_ppt( + rep.get("title", ""), rep.get("content", ""), + software=rep.get("software", ""), out_path=out_path) + if not ok: + logger.warning("build_report_ppt 失败: %s", str(res)[:200]) + return "" + return out_path + + async def submit_for_confirmation(report_id, note=""): """agent 完成报告后提交待人工确认:发人工确认任务(门禁)。 报告保持 draft,创建/复用 pending 人工任务。人工确认后才进 confirmed。 + 同时生成报告 PPT(存项目工作空间 deliverables/),供确认人下载审阅。 """ db, dbname = get_db() async with db.sqlorContext(dbname) as sor: @@ -99,6 +133,14 @@ async def submit_for_confirmation(report_id, note=""): return False, "报告不存在: %s" % report_id if rep.get("status") != RP_DRAFT: return False, "状态 %s 不可提交确认" % rep.get("status") + # 生成/更新报告 PPT(幂等:重复提交覆盖旧文件) + ppt_path = "" + try: + ppt_path = await _build_report_ppt(sor, rep) + except Exception as e: + logger.warning("报告PPT生成失败(不阻塞提交): %s", str(e)[:200]) + if ppt_path: + await sor.U("opp_reports", {"id": report_id, "ppt_path": ppt_path}) exist = await find_human_task(sor, rep.get("project_id", ""), HT_REPORT_CONFIRM, status="pending") if exist: diff --git a/scripts/load_path.py b/scripts/load_path.py index 5fede5b..aeb23fb 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -24,6 +24,10 @@ PATHS_LOGINED = [ "/%s/index.ui" % MOD, "/%s/agent" % MOD, "/%s/agent/index.ui" % MOD, + "/%s/api/opp_references_popup.dspy" % MOD, + "/%s/api/opp_references_items.dspy" % MOD, + "/%s/api/opp_confirm_report.dspy" % MOD, + "/%s/api/opp_report_ppt.dspy" % MOD, ] for t in TABLES: PATHS_LOGINED += [ diff --git a/scripts/migrate_ppt_path.sql b/scripts/migrate_ppt_path.sql new file mode 100644 index 0000000..77ad962 --- /dev/null +++ b/scripts/migrate_ppt_path.sql @@ -0,0 +1,3 @@ +-- pipeline-opportunity 迁移:报告 PPT 路径字段(2026-08-29) +-- 幂等:列已存在时报错可忽略 +ALTER TABLE opp_reports ADD COLUMN ppt_path VARCHAR(500) DEFAULT ''; diff --git a/wwwroot/api/opp_confirm_report.dspy b/wwwroot/api/opp_confirm_report.dspy new file mode 100644 index 0000000..c09d162 --- /dev/null +++ b/wwwroot/api/opp_confirm_report.dspy @@ -0,0 +1,42 @@ +# opp_confirm_report.dspy - 研发报告人工确认(门禁) +# 入参:human_task_id=<确认任务id>, decision=approve|reject, comment=意见(可选) + +import json as _json + +uid = await get_user() +if not uid: + return _json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False) + +human_task_id = ((params_kw or {}).get('human_task_id') or '').strip() +decision = ((params_kw or {}).get('decision') or '').strip() +comment = ((params_kw or {}).get('comment') or '').strip() +if not human_task_id: + return _json.dumps({"success": False, "error": "缺少 human_task_id"}, ensure_ascii=False) +if decision not in ('approve', 'reject'): + return _json.dumps({"success": False, "error": "decision 必须是 approve/reject"}, ensure_ascii=False) + +dbname = get_module_dbname('pipeline-opportunity') +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_report_capability import confirm_report + # 确认任务 → 报告(opp_reports.confirm_task_id) + recs = await sor.sqlExe( + "SELECT id, title, status FROM opp_reports WHERE confirm_task_id=${t}$", + {"t": human_task_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return _json.dumps({"success": False, "error": "未找到该确认任务对应的报告"}, ensure_ascii=False) + report_id = getattr(recs[0], 'id', '') + ok, msg = await confirm_report(report_id, decision == 'approve', operator=uid) + if not ok: + return _json.dumps({"success": False, "error": msg}, ensure_ascii=False) + if comment: + try: + await sor.sqlExe( + "UPDATE pipeline_human_tasks SET result_data=${r}$ WHERE id=${i}$", + {"r": _json.dumps({"comment": comment}, ensure_ascii=False), "i": human_task_id}) + await sor.sqlExe("COMMIT", {}) + except Exception: + pass +return _json.dumps({"success": True, + "message": ("报告已确认,可发起研发审批" if decision == 'approve' else "报告已退回草稿待修改")}, + ensure_ascii=False) diff --git a/wwwroot/api/opp_references_items.dspy b/wwwroot/api/opp_references_items.dspy new file mode 100644 index 0000000..915f5bd --- /dev/null +++ b/wwwroot/api/opp_references_items.dspy @@ -0,0 +1,86 @@ +# opp_references_items.dspy - 某软件主题的分项列表(references 可查证) +# 入参:category=<主题名>。每条分项:标题+时间+预算+地区 + 「公告原文」「来源页」按钮(新窗口) + +import json as _json + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +category = ((params_kw or {}).get("category") or "").strip() +days = int((params_kw or {}).get("days") or 30) +if not category: + return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}} + +dbname = get_module_dbname('pipeline-opportunity') + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_data_capability import category_references + ok, res = await category_references(sor, category, days=days, limit=500) + await sor.sqlExe("COMMIT", {}) + +rows = [] +if not ok: + rows.append({"widgettype": "Text", "options": { + "text": "查询失败:" + str(res)[:200], + "color": "#dc2626", "padding": "12px", "halign": "left"}}) +else: + items = res.get("items") or [] + if not items: + 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');"}]}) + if src and src != url: + sub_btns.append({"widgettype": "Button", "options": {"label": "来源页", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "target": "self", + "script": "window.open(" + _json.dumps(src) + ",'_blank','noopener');"}]}) + budget = it.get("budget_wan") or 0 + meta = "%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} + ] + }) + +total = res.get("count", len(rows)) if ok else 0 +return { + "widgettype": "PopupWindow", + "id": "opp_refs_items_pw", + "options": {"title": "分项明细:%s(%d 条)" % (category, total), + "width": "80%", "height": "82%", "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_references_popup.dspy b/wwwroot/api/opp_references_popup.dspy new file mode 100644 index 0000000..26d4d1f --- /dev/null +++ b/wwwroot/api/opp_references_popup.dspy @@ -0,0 +1,66 @@ +# opp_references_popup.dspy - 数据参考(references):主题列表(agent_menu_open 弹窗内嵌内容) +# 点主题「查看分项」→ opp_references_items.dspy 列分项(每条带来源,点击新窗口查证) + +import json as _json + +uid = await get_user() +if not uid: + return {"widgettype": "Text", "options": {"text": "请先登录", "color": "#dc2626", "padding": "12px"}} + +dbname = get_module_dbname('pipeline-opportunity') + +items_url = entire_url("/pipeline-opportunity/api/opp_references_items.dspy") + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_data_capability import hot_software + ok, res = await hot_software(sor, days=30, top=10) + await sor.sqlExe("COMMIT", {}) + +rows = [] +if not ok: + rows.append({"widgettype": "Text", "options": { + "text": "数据爬取平台暂不可用:" + str(res)[:200], + "color": "#dc2626", "padding": "12px", "halign": "left"}}) +else: + ranking = res.get("ranking") or [] + 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}]} + ] + }) + +return { + "widgettype": "VBox", + "options": {"css": "filler", "width": "100%", "height": "100%", "padding": "12px 16px", + "gap": "6px", "overflow": "auto"}, + "subwidgets": [ + {"widgettype": "Text", "options": { + "text": "点击「查看分项」列出该主题的全部招标分项;每条分项带公告原文链接,可逐条查证。", + "cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} + ] + rows +} diff --git a/wwwroot/api/opp_report_ppt.dspy b/wwwroot/api/opp_report_ppt.dspy new file mode 100644 index 0000000..5bf7f22 --- /dev/null +++ b/wwwroot/api/opp_report_ppt.dspy @@ -0,0 +1,35 @@ +# opp_report_ppt.dspy - 下载报告 PPT(附件) +# 入参:report_id。路径只从库读(不接受用户传路径),防路径穿越。 + +import os +from urllib.parse import quote +from aiohttp.web_fileresponse import FileResponse + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +report_id = ((params_kw or {}).get('report_id') or '').strip() +if not report_id: + return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 report_id"}} + +dbname = get_module_dbname('pipeline-opportunity') +async with DBPools().sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT title, ppt_path FROM opp_reports WHERE id=${i}$", {"i": report_id}) + await sor.sqlExe("COMMIT", {}) + +if not recs: + return {"widgettype": "Message", "options": {"title": "不存在", "message": "报告不存在"}} + +ppt_path = (getattr(recs[0], 'ppt_path', '') or '').strip() +title = getattr(recs[0], 'title', '') or '研发报告' +if not ppt_path or not os.path.isfile(ppt_path): + return {"widgettype": "Message", "options": { + "title": "无 PPT", "message": "该报告尚未生成 PPT(提交人工确认时自动生成)"}} + +filename = os.path.basename(ppt_path) +safe_name = quote(filename) +headers = {'Content-Disposition': + 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (filename, safe_name)} +return FileResponse(ppt_path, headers=headers) diff --git a/wwwroot/opp_reports/get_opp_reports.dspy b/wwwroot/opp_reports/get_opp_reports.dspy index 79dbb8c..d3aa216 100644 --- a/wwwroot/opp_reports/get_opp_reports.dspy +++ b/wwwroot/opp_reports/get_opp_reports.dspy @@ -67,6 +67,12 @@ fields_str=r'''[ "type": "str", "length": 32 }, + { + "name": "ppt_path", + "title": "PPT文件路径", + "type": "str", + "length": 500 + }, { "name": "confirmed_by", "title": "确认人", diff --git a/wwwroot/opp_reports/index.ui b/wwwroot/opp_reports/index.ui index 2678852..81a30c4 100644 --- a/wwwroot/opp_reports/index.ui +++ b/wwwroot/opp_reports/index.ui @@ -52,7 +52,8 @@ "created_at", "updated_at", "confirm_task_id", - "confirmed_by" + "confirmed_by", + "ppt_path" ], "fields":[ @@ -130,6 +131,16 @@ "datatype": "str", "label": "确认任务ID" }, + { + "name": "ppt_path", + "title": "PPT文件路径", + "type": "str", + "length": 500, + "cwidth": 18, + "uitype": "str", + "datatype": "str", + "label": "PPT文件路径" + }, { "name": "confirmed_by", "title": "确认人",