用户需求(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/无项目不崩/防穿越
123 lines
5.3 KiB
Plaintext
123 lines
5.3 KiB
Plaintext
# 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
|
||
}]
|
||
}
|