pipeline-opportunity/wwwroot/api/opp_report_detail.dspy
yumoqing 8627b855c0 feat(opp): 研发报告菜单改造——owner过滤列表+四节详情+成果文件下载
用户需求(2026-09-09):研发报告按钮点击按项目owner过滤列表;每项点击看
研发场景/输入/输出/成果;成果文件多个列出、逐个点击下载。

1. 列表页 api/opp_reports_list.dspy(替换CRUD裸表页作菜单入口):
   - list_visible_reports 单一事实源:挂项目的报告仅项目owner可见
     (sd_projects.created_by==uid;agent建的项目降级同机构,对齐
     check_project_owner既有语义);未挂项目的平台级调研登录可见;
     孤儿报告(项目已删)不可见(LIKE 'agent.%' 写法有 rag_client.py 生产先例)
   - 整行点击下钻详情,交互范式照抄 opp_references_items.dspy
2. 详情弹窗 api/opp_report_detail.dspy:
   - parse_report_sections 四节解析(## 研发场景/输入/输出/成果),
     MdWidget逐节渲染;兼容序号/冒号/加粗标题变体;三级标题不误判;
     旧报告无节时全文归输出节;空节如实标注不编造
   - check_report_visible 与列表同一可见性规则(语义不漂移)
3. 文件下载 api/opp_report_file_dl.dspy + list_report_files:
   - 范围=项目deliverables/全部文件+报告PPT(realpath去重)
   - 安全模型对齐 opp_attachment_dl:前端只传report_id+name,
     服务端回查清单命中才给文件,文件名不参与路径拼接(防穿越)
4. agent侧四节模板硬要求:OPP_PROMPT新增节+create/update_report工具描述
5. RBAC load_path 补3端点;i18n zh/en 补9词条;README 补改造说明

离线实测11项断言全过:标准四节/标题变体/旧报告归输出/缺节/三级标题不误判/
重复节拼接/空正文/deliverables扫描+PPT去重/项目外PPT/无项目不崩/防穿越
2026-09-09 15:04:20 +08:00

123 lines
5.3 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
}]
}