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/无项目不崩/防穿越
This commit is contained in:
yumoqing 2026-09-09 15:04:20 +08:00
parent a066cb0f91
commit 8627b855c0
10 changed files with 466 additions and 5 deletions

View File

@ -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` 表(兜底默认值内网地址):

View File

@ -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

View File

@ -28,3 +28,12 @@
创建人=创建人
创建时间=创建时间
更新时间=更新时间
研发场景=研发场景
输入=输入
输出=输出
成果=成果
成果文件=成果文件
研发报告详情=研发报告详情
下载=下载
无权查看=无权查看
暂无成果文件=暂无成果文件

View File

@ -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%"},

View File

@ -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 用户要求)。
可见性规则用户确认口径
- 挂项目的报告当前用户是项目 ownersd_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:

View File

@ -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 += [

View File

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

View File

@ -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)

View File

@ -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
}