diff --git a/README.md b/README.md index 1f08af5..51bf32b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,32 @@ slash 命令:`/hot` `/ai` `/reports` `/oppdiag`。 附件端点未就绪(爬虫平台附件子系统部署中)时:分项列表不显示 📎 角标、详情弹窗 显示「附件服务暂不可用」,其余功能不受影响。 +## 研发报告列表/详情/成果文件下载(2026-09-09 用户需求改造) + +菜单「📊 研发报告」由原 CRUD 裸表页(`opp_reports/index.ui`,无过滤+可增删改) +改为只读列表页 `api/opp_reports_list.dspy`,三点改造: + +1. **按项目 owner 过滤**(`list_visible_reports` 单一事实源): + - 挂项目的报告:当前用户是项目 owner(`sd_projects.created_by==uid`)才可见; + agent 创建的项目(`created_by LIKE 'agent.%'`)降级为同机构成员可见 + (对齐 `check_project_owner` 既有降级语义,不重复发明) + - 未挂项目的报告(平台级调研,存量数据大多是这类):登录用户可见 + - 项目已删的孤儿报告:owner 无法验证 → 不可见(宁缺勿漏,LEFT JOIN 自然排除) + - 详情/下载端点复用 `check_report_visible`(与列表同一规则,语义不漂移) +2. **详情四节展示**(`opp_report_detail.dspy`,整行点击下钻,范式同 `opp_references_items`): + 报告正文按四节模板解析(`parse_report_sections`)——`## 研发场景` / `## 输入` / + `## 输出` / `## 成果`,逐节 MdWidget 渲染;兼容带序号/冒号/加粗的标题变体, + 三级标题不误判,重复同名节拼接不丢内容。**旧报告(无四节标题)全文归「输出」节**, + 空节如实显示「本报告未填写此节」不编造。agent 写报告的四节模板硬要求已写进 + OPP_PROMPT + opp_create/update_report 工具描述 + opp-workflow 技能。 +3. **成果文件多个列出、逐个下载**(`list_report_files` + `opp_report_file_dl.dspy`): + 文件范围 = 项目工作空间 `deliverables/` 全部文件 + 报告 PPT(`ppt_path`, + 按 realpath 去重)。下载安全模型对齐 `opp_attachment_dl`:前端只传 + `report_id + name`,服务端**回查清单命中才给文件**,文件名绝不参与路径拼接 + (清单只来自目录列举+ppt_path,`../` 穿越名在清单里不可能出现)。 + +CRUD 页(`opp_reports/` `opp_approvals/`)保留供后台运维直接编辑,菜单入口不再暴露。 + ## 爬虫平台接入配置 系统级配置禁硬编码,走 appbase `params` 表(兜底默认值内网地址): diff --git a/i18n/en/msg.txt b/i18n/en/msg.txt index 30cdbec..64e56e4 100644 --- a/i18n/en/msg.txt +++ b/i18n/en/msg.txt @@ -28,3 +28,12 @@ 创建人=Created By 创建时间=Created At 更新时间=Updated At +研发场景=R&D Scenario +输入=Input +输出=Output +成果=Deliverables +成果文件=Output Files +研发报告详情=R&D Report Detail +下载=Download +无权查看=No Access +暂无成果文件=No output files yet diff --git a/i18n/zh/msg.txt b/i18n/zh/msg.txt index f8758c8..1171ee7 100644 --- a/i18n/zh/msg.txt +++ b/i18n/zh/msg.txt @@ -28,3 +28,12 @@ 创建人=创建人 创建时间=创建时间 更新时间=更新时间 +研发场景=研发场景 +输入=输入 +输出=输出 +成果=成果 +成果文件=成果文件 +研发报告详情=研发报告详情 +下载=下载 +无权查看=无权查看 +暂无成果文件=暂无成果文件 diff --git a/pipeline_opportunity/opp_ability.py b/pipeline_opportunity/opp_ability.py index 26af2bf..e5e6f93 100644 --- a/pipeline_opportunity/opp_ability.py +++ b/pipeline_opportunity/opp_ability.py @@ -65,11 +65,11 @@ OPP_TOOLS = [ "days": "统计窗口天数(默认30)", "limit": "条数(默认200)"}, category="data"), # ── 报告与审批(商机闭环)── - ToolDefinition(name="opp_create_report", description="创建软件研发报告草稿。从热门软件中选定的方向写入报告,数据基础章节自动从爬取平台拉取", + ToolDefinition(name="opp_create_report", description="创建软件研发报告草稿。从热门软件中选定的方向写入报告,数据基础章节自动从爬取平台拉取。正文必须按四节模板组织(## 研发场景 / ## 输入 / ## 输出 / ## 成果),商店详情页按节展示", parameters={"software": "软件/主题名", "title": "报告标题(可选)", - "analysis": "市场分析正文(可选,可先建骨架后补写)"}, category="report"), - ToolDefinition(name="opp_update_report", description="更新草稿报告内容(仅草稿状态可改)", - parameters={"report_id": "报告ID", "content": "新的报告全文"}, category="report"), + "analysis": "报告正文(可选,可先建骨架后补写;按四节模板:## 研发场景 / ## 输入 / ## 输出 / ## 成果)"}, category="report"), + ToolDefinition(name="opp_update_report", description="更新草稿报告内容(仅草稿状态可改)。正文按四节模板组织(## 研发场景 / ## 输入 / ## 输出 / ## 成果)", + parameters={"report_id": "报告ID", "content": "新的报告全文(含四节二级标题)"}, category="report"), ToolDefinition(name="opp_list_reports", description="列出研发报告及状态(draft/confirmed/approval_initiated/approved/rejected)", parameters={"status": "状态过滤(可选)"}, category="report"), ToolDefinition(name="opp_get_report", description="查看报告全文", @@ -118,6 +118,16 @@ opp_query_tenders / opp_tender_detail),**不要**自己去爬网站, 4. 人工确认通过后,你才用(或由人工任务驱动)发起研发审批;审批结论由人工回流, **禁止**自己批准自己的审批。 +## 报告正文四节模板(硬要求,商店详情页按此分节展示) +opp_create_report / opp_update_report 的正文必须用以下四个二级标题组织, +「📊 研发报告」详情页逐节渲染(研发场景/输入/输出/成果);缺节会在详情页如实显示 +「本报告未填写此节」,所以四节都要写: +- `## 研发场景`:这个软件方向解决什么业务问题、面向哪些行业/角色、典型使用场景 +- `## 输入`:研发所需的输入(数据来源与招标情报、需求条件、既有资料/接口) +- `## 输出`:分析产出(市场空间/代表项目/竞争格局/研发建议,带 references 来源链接) +- `## 成果`:可交付成果(建议的产品形态、里程碑、预期成果文件如 PPT/方案) +旧格式(无四节标题)的存量报告仍可查看,全文归入「输出」节展示。 + ## 硬规则 - 报告里的招标数量、预算、项目案例必须来自工具返回的真实数据,**禁止编造**; 查不到就如实写"数据未覆盖"。 @@ -447,7 +457,7 @@ def register_opp_ability(): handlers=handlers, roles=OPP_ROLES, menus=[ - {"label": "📊 研发报告", "icon": "", "url": "/pipeline-opportunity/opp_reports/index.ui", + {"label": "📊 研发报告", "icon": "", "url": "/pipeline-opportunity/api/opp_reports_list.dspy", "type": "popup", "width": "88%", "height": "82%"}, {"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui", "type": "popup", "width": "88%", "height": "82%"}, diff --git a/pipeline_opportunity/opp_report_capability.py b/pipeline_opportunity/opp_report_capability.py index 4fd20a6..3c735e6 100644 --- a/pipeline_opportunity/opp_report_capability.py +++ b/pipeline_opportunity/opp_report_capability.py @@ -82,6 +82,154 @@ async def list_reports(project_id="", status=""): return rows_to_dicts(recs) +# ══════════════════ 报告可见性 / 四节模板 / 成果文件(2026-09-09 用户需求)══════════════════ +# 「研发报告」按钮改造:按项目 owner 过滤列表;详情按四节(研发场景/输入/输出/成果) +# 展示;成果文件多个列出、逐个下载。 + +# 报告正文四节模板(二级标题,agent 写报告必须按此结构;旧报告无节时全文归「输出」) +REPORT_SECTIONS = ("研发场景", "输入", "输出", "成果") + + +async def _user_org(sor, user_id): + recs = await sor.sqlExe( + "SELECT orgid FROM users WHERE id=${u}$", {"u": user_id}) + await sor.sqlExe("COMMIT", {}) + return str(getattr(recs[0], "orgid", "") or "") if recs else "" + + +async def list_visible_reports(sor, user_id): + """按项目 owner 过滤的研发报告列表(2026-09-09 用户要求)。 + + 可见性规则(用户确认口径): + - 挂项目的报告:当前用户是项目 owner(sd_projects.created_by==user_id)可见; + agent 创建的项目(created_by LIKE 'agent.%')降级为同机构成员可见 + (对齐 check_project_owner 的既有降级语义,不重复发明) + - 未挂项目的报告(平台级调研,现有数据大多数是这类):登录用户可见 + - 项目已删除的孤儿报告:owner 无法验证 → 不可见(宁缺勿漏,LEFT JOIN 自然排除) + """ + if not user_id: + return [] + uorg = await _user_org(sor, user_id) + sql = ( + "SELECT r.id, r.project_id, r.software, r.title, r.status, r.ppt_path, " + "r.created_by, r.created_at, r.updated_at, " + "p.name AS project_name, p.created_by AS project_owner " + "FROM opp_reports r LEFT JOIN sd_projects p ON r.project_id = p.id " + "WHERE (r.project_id IS NULL OR r.project_id = '') " + " OR p.created_by = ${uid}$ " + + (" OR (p.created_by LIKE 'agent.%' AND p.org_id = ${uorg}$ AND ${uorg}$ <> '') " + if uorg else "") + + "ORDER BY r.created_at DESC LIMIT 200") + recs = await sor.sqlExe(sql, {"uid": user_id, "uorg": uorg}) + await sor.sqlExe("COMMIT", {}) + return rows_to_dicts(recs) + + +async def check_report_visible(sor, report_id, user_id): + """单报告可见性校验(详情/下载端点复用列表同一规则,语义不漂移)。 + 返回 (visible: bool, report: dict|None)。""" + if not report_id or not user_id: + return False, None + uorg = await _user_org(sor, user_id) + sql = ( + "SELECT r.*, p.name AS project_name, p.created_by AS project_owner, " + "p.workspace_dir AS project_dir " + "FROM opp_reports r LEFT JOIN sd_projects p ON r.project_id = p.id " + "WHERE r.id = ${rid}$") + recs = await sor.sqlExe(sql, {"rid": report_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, None + rep = rec_to_dict(recs[0]) + pid = str(rep.get("project_id") or "") + if not pid: + return True, rep # 未挂项目:登录可见 + owner = str(rep.get("project_owner") or "") + if not owner: + return False, rep # 孤儿报告(项目已删):owner 无法验证 + if owner == user_id: + return True, rep + if owner.startswith("agent.") and uorg: + # agent 建的项目降级同机构(对齐 check_project_owner 既有语义) + recs2 = await sor.sqlExe( + "SELECT org_id FROM sd_projects WHERE id=${p}$", {"p": pid}) + await sor.sqlExe("COMMIT", {}) + proj_org = str(getattr(recs2[0], "org_id", "") or "") if recs2 else "" + if proj_org and proj_org == uorg: + return True, rep + return False, rep + + +def parse_report_sections(content): + """按四节模板解析报告正文 → {'研发场景': md, '输入': md, '输出': md, '成果': md}。 + + 模板(agent 写报告约定):二级标题 `## 研发场景` / `## 输入` / `## 输出` / `## 成果`。 + 兼容标题带序号/冒号/加粗(如 `## 一、研发场景:`、`## **输出**`)。 + 旧报告(无任何匹配节,如实测的市场调研报告)→ 全文归「输出」节,其余节为空, + 详情页对空节如实显示「(本报告未填写此节)」,不编造。 + """ + import re as _re + content = content or "" + # 节标题行:## [可选序号/标点] 节名 [可选冒号/加粗残留] + pattern = _re.compile( + r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[**]*\s*" + r"(研发场景|输入|输出|成果)\s*[**]*\s*[::]?\s*$", + _re.MULTILINE) + marks = list(pattern.finditer(content)) + sections = {k: "" for k in REPORT_SECTIONS} + if not marks: + sections["输出"] = content.strip() + return sections + # 首个节标题之前的前言(如报告大标题/数据来源说明)归「输出」节头部展示 + preamble = content[:marks[0].start()].strip() + for i, m in enumerate(marks): + name = m.group(1) + end = marks[i + 1].start() if i + 1 < len(marks) else len(content) + body = content[m.end():end].strip() + # 同名节重复出现时拼接(容错,不丢内容) + sections[name] = (sections[name] + "\n\n" + body).strip() if sections[name] else body + if preamble: + sections["输出"] = (preamble + ("\n\n" + sections["输出"] if sections["输出"] else "")).strip() + return sections + + +async def list_report_files(sor, rep): + """报告的成果文件清单(2026-09-09 用户要求:多个文件列出、点击下载)。 + + 范围(用户确认口径):项目工作空间 deliverables/ 目录全部文件 + 报告 PPT + (ppt_path 可能就在 deliverables/ 里,按 realpath 去重)。 + 未挂项目的报告:只有 PPT(若有)。 + 返回 [{'name','path','size','mtime'}],path 为服务端绝对路径—— + 下载端点以本清单为权威(对齐 opp_attachment_dl 的防穿越模式: + 用户只传文件名,服务端回查清单命中才给文件,绝不拿用户输入拼路径)。 + """ + import os as _os + files = {} + + def _add(p): + try: + rp = _os.path.realpath(p) + if _os.path.isfile(rp) and rp not in files: + st = _os.stat(rp) + files[rp] = {"name": _os.path.basename(rp), "path": rp, + "size": st.st_size, "mtime": st.st_mtime} + except Exception: + pass + + proj_dir = str((rep or {}).get("project_dir") or "") + if proj_dir: + deliv = _os.path.join(proj_dir, "deliverables") + try: + if _os.path.isdir(deliv): + for fn in sorted(_os.listdir(deliv)): + _add(_os.path.join(deliv, fn)) + except Exception: + pass + _add(str((rep or {}).get("ppt_path") or "")) + out = sorted(files.values(), key=lambda f: -f["mtime"]) + return out + + async def get_report_full(report_id): db, dbname = get_db() async with db.sqlorContext(dbname) as sor: diff --git a/scripts/__pycache__/load_path.cpython-310.pyc b/scripts/__pycache__/load_path.cpython-310.pyc index 9404354..4ce05f5 100644 Binary files a/scripts/__pycache__/load_path.cpython-310.pyc and b/scripts/__pycache__/load_path.cpython-310.pyc differ diff --git a/scripts/load_path.py b/scripts/load_path.py index 7a6a37b..ad5fb52 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -32,6 +32,9 @@ PATHS_LOGINED = [ "/%s/api/opp_attachment_dl.dspy" % MOD, "/%s/api/opp_confirm_report.dspy" % MOD, "/%s/api/opp_report_ppt.dspy" % MOD, + "/%s/api/opp_reports_list.dspy" % MOD, + "/%s/api/opp_report_detail.dspy" % MOD, + "/%s/api/opp_report_file_dl.dspy" % MOD, ] for t in TABLES: PATHS_LOGINED += [ diff --git a/wwwroot/api/opp_report_detail.dspy b/wwwroot/api/opp_report_detail.dspy new file mode 100644 index 0000000..68ecf6e --- /dev/null +++ b/wwwroot/api/opp_report_detail.dspy @@ -0,0 +1,122 @@ +# opp_report_detail.dspy - 研发报告详情弹窗(2026-09-09 用户要求) +# 入参:report_id。展示四节(研发场景/输入/输出/成果,MdWidget 渲染)+ 成果文件列表(逐个下载)。 +# 权限:check_report_visible(与列表同一可见性规则——owner 过滤语义不漂移)。 +# 文件下载走 opp_report_file_dl.dspy(只传 report_id+文件名,服务端回查清单命中才给文件,防路径穿越)。 + +import json as _json +from urllib.parse import quote as _quote + +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: + from pipeline_opportunity.opp_report_capability import ( + check_report_visible, parse_report_sections, list_report_files, REPORT_SECTIONS) + visible, rep = await check_report_visible(sor, report_id, uid) + await sor.sqlExe("COMMIT", {}) + if not visible: + return {"widgettype": "Message", "options": { + "title": "无权查看", "message": "报告不存在,或你不是该报告所属项目的 owner"}} + files = await list_report_files(sor, rep) + +dl_url = entire_url("/pipeline-opportunity/api/opp_report_file_dl.dspy") + +STATUS_TXT = { + "draft": "草稿", "confirmed": "已确认", "approval_initiated": "审批中", + "approved": "审批通过", "rejected": "已驳回", +} + +body = [] + +# ── 标题 ── +body.append({"widgettype": "Text", "options": { + "text": str(rep.get("title") or rep.get("software") or "研发报告"), + "cfontsize": 1.15, "color": "#0f172a", "halign": "left", "wrap": True, + "margin": "0 0 6px 0"}}) + +# ── 元信息 ── +st = str(rep.get("status") or "") +proj = str(rep.get("project_name") or "") +meta = "%s | %s | %s | 创建人 %s" % ( + str(rep.get("created_at") or "")[:16], + ("项目:" + proj) if proj else "平台级调研(未挂项目)", + STATUS_TXT.get(st, st or "—"), + str(rep.get("created_by") or "—")) +body.append({"widgettype": "Text", "options": { + "text": meta, "cfontsize": 0.78, "color": "#64748b", "halign": "left", + "wrap": True, "margin": "0 0 10px 0"}}) + +# ── 四节:研发场景 / 输入 / 输出 / 成果(按节解析正文;空节如实标注,不编造)── +sections = parse_report_sections(str(rep.get("content") or "")) +for name in REPORT_SECTIONS: + md = (sections.get(name) or "").strip() + body.append({"widgettype": "Text", "options": { + "text": name, "cfontsize": 0.95, "color": "#0f172a", "halign": "left", + "margin": "0 0 4px 0"}}) + if md: + shown = md[:20000] + if len(md) > 20000: + shown += "\n\n……(本节 %d 字,已截断显示)" % len(md) + body.append({"widgettype": "MdWidget", "options": { + "mdtext": shown, "width": "100%", "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(files), "cfontsize": 0.95, "color": "#0f172a", + "halign": "left", "margin": "0 0 4px 0"}}) +if not files: + body.append({"widgettype": "Text", "options": { + "text": "暂无成果文件(报告提交人工确认时会自动生成 PPT 存入项目 deliverables/)", + "cfontsize": 0.82, "color": "#94a3b8", "halign": "left", "margin": "0 0 10px 0"}}) +for f in files: + size = int(f.get("size") or 0) + if size >= 1048576: + size_txt = "%.1f MB" % (size / 1048576.0) + elif size > 0: + size_txt = "%.0f KB" % (size / 1024.0) + else: + size_txt = "" + # 下载:只传 report_id + 文件名,服务端回查清单命中才返回文件(防路径穿越) + href = dl_url + "?report_id=" + _quote(report_id) + "&name=" + _quote(str(f.get("name") or "")) + 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": "📄 " + str(f.get("name") or "文件"), + "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(href) + ";"}]}] + }) + +return { + "widgettype": "PopupWindow", + "id": "opp_report_detail_pw", + "options": {"title": "研发报告详情", "width": "72%", "height": "86%", + "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_report_file_dl.dspy b/wwwroot/api/opp_report_file_dl.dspy new file mode 100644 index 0000000..dc8f577 --- /dev/null +++ b/wwwroot/api/opp_report_file_dl.dspy @@ -0,0 +1,52 @@ +# opp_report_file_dl.dspy - 报告成果文件下载(2026-09-09 用户要求:文件列表逐个点击下载) +# 入参:report_id + name(文件名)。安全模型对齐 opp_attachment_dl.dspy: +# 1. 登录校验 + check_report_visible(与列表/详情同一 owner 规则) +# 2. 用户传的文件名只用于在【服务端生成的清单】里定位命中项,绝不参与路径拼接—— +# list_report_files 以项目 workspace_dir/deliverables + ppt_path 为权威来源, +# 清单外(../穿越、绝对路径、他项目文件)一律 404 语义拒绝。 + +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() +name = ((params_kw or {}).get("name") or "").strip() +if not report_id or not name: + return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 report_id 或 name"}} + +dbname = get_module_dbname('pipeline-opportunity') + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_report_capability import ( + check_report_visible, list_report_files) + visible, rep = await check_report_visible(sor, report_id, uid) + await sor.sqlExe("COMMIT", {}) + if not visible: + return {"widgettype": "Message", "options": { + "title": "无权下载", "message": "报告不存在,或你不是该报告所属项目的 owner"}} + files = await list_report_files(sor, rep) + +# 文件名回查清单(basename 精确匹配;穿越名在清单里不可能出现) +target = None +for f in files: + if f.get("name") == os.path.basename(name): + target = f + break +if not target: + return {"widgettype": "Message", "options": { + "title": "文件不存在", "message": "该文件不在本报告的成果清单中"}} + +path = target.get("path") or "" +if not path or not os.path.isfile(path): + return {"widgettype": "Message", "options": { + "title": "文件不存在", "message": "文件已从磁盘移除:%s" % os.path.basename(path)}} + +filename = os.path.basename(path) +safe_name = quote(filename) +headers = {'Content-Disposition': + 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (filename, safe_name)} +return FileResponse(path, headers=headers) diff --git a/wwwroot/api/opp_reports_list.dspy b/wwwroot/api/opp_reports_list.dspy new file mode 100644 index 0000000..c3d21c4 --- /dev/null +++ b/wwwroot/api/opp_reports_list.dspy @@ -0,0 +1,82 @@ +# opp_reports_list.dspy - 研发报告列表(「研发报告」菜单入口,2026-09-09 用户要求改造) +# 替换原 CRUD 裸表页:按项目 owner 过滤(list_visible_reports 单一事实源), +# 整行点击 → opp_report_detail.dspy 弹出详情(四节+成果文件下载)。 +# 交互范式对齐 opp_references_items.dspy(整行下钻,fetch+widgetBuild)。 + +import json as _json + +uid = await get_user() +if not uid: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}} + +dbname = get_module_dbname('pipeline-opportunity') + +async with DBPools().sqlorContext(dbname) as sor: + from pipeline_opportunity.opp_report_capability import list_visible_reports + reports = await list_visible_reports(sor, uid) + await sor.sqlExe("COMMIT", {}) + +detail_url = entire_url("/pipeline-opportunity/api/opp_report_detail.dspy") + +STATUS_TXT = { + "draft": ("草稿", "#94a3b8"), + "confirmed": ("已确认", "#2563eb"), + "approval_initiated": ("审批中", "#d97706"), + "approved": ("审批通过", "#16a34a"), + "rejected": ("已驳回", "#dc2626"), +} + +rows = [] +if not reports: + rows.append({"widgettype": "Text", "options": { + "text": "暂无可见的研发报告(列表按项目 owner 过滤:仅显示你 own 的项目及平台级调研报告)", + "cfontsize": 0.85, "color": "#64748b", "padding": "12px", "halign": "left"}}) +for r in reports: + rid = str(r.get("id") or "") + st = str(r.get("status") or "") + st_txt, st_color = STATUS_TXT.get(st, (st or "—", "#64748b")) + proj = str(r.get("project_name") or "") + meta = "%s | %s | %s" % ( + str(r.get("created_at") or "")[:16], + ("项目:" + proj) if proj else "平台级调研(未挂项目)", + st_txt) + row_subs = [ + {"widgettype": "Text", "options": { + "text": str(r.get("title") or r.get("software") 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": st_color, "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 rid: + row_opts["cursor"] = "pointer" + script = ("var old=bricks.getWidgetById('opp_report_detail_pw',bricks.app);" + "if(old&&old.destroy){old.destroy();}" + "var rp=await fetch(" + _json.dumps(detail_url) + "+'?report_id='+encodeURIComponent(" + _json.dumps(rid) + "));" + "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}) + +return { + "widgettype": "VBox", + "options": {"css": "filler", "width": "100%", "height": "100%", + "padding": "12px 16px", "gap": "0px", "overflow": "auto"}, + "subwidgets": [ + {"widgettype": "Text", "options": { + "text": "研发报告(%d 份)|按项目 owner 过滤;点击任一条查看研发场景/输入/输出/成果与成果文件下载" % len(reports), + "cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}} + ] + rows +}