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:
parent
a066cb0f91
commit
8627b855c0
26
README.md
26
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` 表(兜底默认值内网地址):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -28,3 +28,12 @@
|
||||
创建人=创建人
|
||||
创建时间=创建时间
|
||||
更新时间=更新时间
|
||||
研发场景=研发场景
|
||||
输入=输入
|
||||
输出=输出
|
||||
成果=成果
|
||||
成果文件=成果文件
|
||||
研发报告详情=研发报告详情
|
||||
下载=下载
|
||||
无权查看=无权查看
|
||||
暂无成果文件=暂无成果文件
|
||||
|
||||
@ -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%"},
|
||||
|
||||
@ -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:
|
||||
|
||||
Binary file not shown.
@ -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 += [
|
||||
|
||||
122
wwwroot/api/opp_report_detail.dspy
Normal file
122
wwwroot/api/opp_report_detail.dspy
Normal 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
|
||||
}]
|
||||
}
|
||||
52
wwwroot/api/opp_report_file_dl.dspy
Normal file
52
wwwroot/api/opp_report_file_dl.dspy
Normal 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)
|
||||
82
wwwroot/api/opp_reports_list.dspy
Normal file
82
wwwroot/api/opp_reports_list.dspy
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user