feat: references可查证界面+报告确认门禁全文展示+PPT存储下载+数据参考菜单

This commit is contained in:
yumoqing 2026-08-29 13:08:16 +08:00
parent 2c8d723142
commit f192128f5e
14 changed files with 505 additions and 3 deletions

View File

@ -17,7 +17,8 @@
"created_at",
"updated_at",
"confirm_task_id",
"confirmed_by"
"confirmed_by",
"ppt_path"
],
"editable": {
"new_data_url": "{{entire_url('add_opp_reports.dspy')}}",

View File

@ -56,6 +56,12 @@
"type": "str",
"length": 32
},
{
"name": "ppt_path",
"title": "PPT文件路径",
"type": "str",
"length": 500
},
{
"name": "confirmed_by",
"title": "确认人",

View File

@ -36,6 +36,9 @@ OPP_TOOLS = [
parameters={"tender_id": "招标信息ID"}, category="data"),
ToolDefinition(name="opp_crawler_stats", description="查看数据爬取平台采集健康度(总量/24h新增/最近运行)",
parameters={}, category="data"),
ToolDefinition(name="opp_category_references", description="查某软件主题的全部分项明细references 可查证每条带公告原文URL+采集来源)。回答热点/排名类问题时用本工具取分项并附来源链接",
parameters={"category": "软件主题名(须与热点排名中的category一致)",
"days": "统计窗口天数(默认30)", "limit": "条数(默认200)"}, category="data"),
# ── 报告与审批(商机闭环)──
ToolDefinition(name="opp_create_report", description="创建软件研发报告草稿。从热门软件中选定的方向写入报告,数据基础章节自动从爬取平台拉取",
@ -76,11 +79,16 @@ OPP_PROMPT = """你是「商机产线」的驾驶舱 agent负责把招标市
## 硬规则
- 报告里的招标数量预算项目案例必须来自工具返回的真实数据**禁止编造**
查不到就如实写"数据未覆盖"
- **所有分析必须带 references可查证**给排名/统计/推荐结论时
opp_category_references 取该主题的分项明细回答末尾附 References 章节
列出自带的📋 数据参考菜单用户可逐条点开查公告原文
并在正文中给出 3-5 条代表性分项的标题+公告链接markdown 链接外链会自动新窗口打开
- 每条引用的招标信息带来源链接爬虫平台已提供 url / source_url
- 报告没被人工确认不许当作可发起审批审批没过不许当作可立项
## 典型场景
- 用户问最近什么软件最火/最值得做 opp_hot_software(days=30) 后给结论
- 用户问最近什么软件最火/最值得做 opp_hot_software(days=30) 后给结论
并对推荐的主题调 opp_category_references references
- 用户问今天有什么AI招标 opp_daily_ai_tenders()
- 用户说给XX写个研发报告 opp_create_report 拉数据补写 opp_submit_report
- 用户问进展 opp_diagnose报告/审批分布 + 待办人工任务"""
@ -174,6 +182,25 @@ async def _h_crawler_stats(sor, p, ctx):
return json.dumps(res, ensure_ascii=False, default=str)
async def _h_category_references(sor, p, ctx):
from .opp_data_capability import category_references
category = (p.get("category") or "").strip()
if not category:
return _fmt(False, "缺少 category软件主题名")
ok, res = await category_references(
sor, category, days=int(p.get("days") or 30),
limit=min(int(p.get("limit") or 200), 1000))
if not ok:
return _fmt(False, res)
return json.dumps({
"category": res.get("category"),
"窗口": "%d" % res.get("window_days"),
"分项总数": res.get("count"),
"分项": res.get("items"),
"说明": "每条分项的 url 为公告原文、source_url 为采集来源,均可点击查证",
}, ensure_ascii=False, default=str)
async def _h_create_report(sor, p, ctx):
from .opp_report_capability import create_report
ok, rid = await create_report(
@ -248,6 +275,7 @@ OPP_HANDLERS = {
"opp_search_tenders": _h_search,
"opp_tender_detail": _h_detail,
"opp_crawler_stats": _h_crawler_stats,
"opp_category_references": _h_category_references,
"opp_create_report": _h_create_report,
"opp_update_report": _h_update_report,
"opp_list_reports": _h_list_reports,
@ -287,6 +315,8 @@ def register_opp_ability():
"type": "popup", "width": "88%", "height": "82%"},
{"label": "✅ 研发审批", "icon": "", "url": "/pipeline-opportunity/opp_approvals/index.ui",
"type": "popup", "width": "88%", "height": "82%"},
{"label": "📋 数据参考", "icon": "", "url": "/pipeline-opportunity/api/opp_references_popup.dspy",
"type": "popup", "width": "70%", "height": "76%"},
],
)
register_ability(ability)

View File

@ -37,6 +37,15 @@ async def hot_software(sor, days=30, top=10):
{"days": days, "top": top})
async def category_references(sor, category, days=30, limit=500):
"""某软件主题的全部分项references 可查证)。
每条带公告原文 url + 采集来源 source_url供界面逐条查证
"""
return await crawler_get(sor, "/api/category_references",
{"category": category, "days": days, "limit": limit})
async def crawler_stats(sor):
"""爬虫平台采集健康度。"""
return await crawler_get(sor, "/api/stats")

View File

@ -0,0 +1,161 @@
"""研发报告 PPT 生成markdown 报告正文 → .pptx 文件(可下载查证)。
触发时机opp_submit_report 提交人工确认时生成/更新存到
项目工作空间 deliverables/ 目录与报告同源人工确认时可直接下载审阅
设计取舍
- python-pptx 纯后端生成无外部模板依赖模板缺失时降级纯文本版式
- markdown # / ## 切页;列表/表格转要点来源链接保留为文本PPT 超链接
兼容性差查证走界面按钮
"""
import logging
import os
import re
logger = logging.getLogger("pipeline.opportunity.ppt")
def _md_to_sections(content):
"""markdown 正文切分为 [(标题, [要点行], 原文块)] 段。
# 一级标题视为章节页;无标题的连续文本归入"正文"页。
"""
sections = []
cur_title = ""
cur_lines = []
for raw in (content or "").split("\n"):
line = raw.rstrip()
m = re.match(r"^(#{1,3})\s+(.+)$", line)
if m and len(m.group(1)) <= 2:
if cur_title or cur_lines:
sections.append((cur_title, cur_lines))
cur_title = m.group(2).strip()
cur_lines = []
continue
if line.strip():
cur_lines.append(line)
if cur_title or cur_lines:
sections.append((cur_title, cur_lines))
return sections
def _line_to_bullet(line):
"""markdown 行 → PPT 要点文本(去标记、保留链接为 文字(链接))。"""
s = line.strip()
s = re.sub(r"^\s*[-*+]\s+", "", s) # 列表符
s = re.sub(r"^\s*\d+[.、)]\s*", "", s) # 有序列表
s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) # 粗体
s = re.sub(r"\*(.+?)\*", r"\1", s) # 斜体
s = re.sub(r"^\|.*\|$", lambda m: " | ".join(
c.strip() for c in m.group(0).strip("|").split("|") if c.strip()), s) # 表格行
if re.match(r"^[-: =]+$", s):
return "" # 表格分隔线
s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1\2", s) # 链接 → 文字(链接)
return s.strip()
def build_report_ppt(title, content, software="", out_path=""):
"""生成 PPT 并保存。返回 (True, 文件路径) 或 (False, 错误)。"""
try:
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
except ImportError:
return False, "缺少 python-pptxpip install python-pptx"
if not out_path:
return False, "缺少输出路径"
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank_layout = prs.slide_layouts[6]
def _add_title_bar(slide, text, color=(0x1E, 0x40, 0xAF)):
box = slide.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.8))
tf = box.text_frame
p = tf.paragraphs[0]
run = p.add_run()
run.text = text
run.font.size = Pt(26)
run.font.bold = True
run.font.color.rgb = RGBColor(*color)
# ── 封面页 ──
slide = prs.slides.add_slide(blank_layout)
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.6), Inches(11.7), Inches(2))
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
run = p.add_run()
run.text = title or "研发报告"
run.font.size = Pt(36)
run.font.bold = True
run.font.color.rgb = RGBColor(0x0F, 0x17, 0x2A)
sub = software or ""
if sub:
p2 = tf.add_paragraph()
r2 = p2.add_run()
r2.text = "方向:%s" % sub
r2.font.size = Pt(18)
r2.font.color.rgb = RGBColor(0x64, 0x74, 0x8B)
p3 = tf.add_paragraph()
r3 = p3.add_run()
r3.text = "商机产线 · 数据来自数据爬取平台(可查证)"
r3.font.size = Pt(14)
r3.font.color.rgb = RGBColor(0x94, 0xA3, 0xB8)
# ── 内容页 ──
sections = _md_to_sections(content)
for sec_title, lines in sections:
bullets = [b for b in (_line_to_bullet(l) for l in lines) if b]
if not sec_title and not bullets:
continue
# 要点过多 → 分页(每页 ≤ 8 条,防溢出)
chunks = [bullets[i:i + 8] for i in range(0, max(len(bullets), 1), 8)] or [[]]
for ci, chunk in enumerate(chunks):
slide = prs.slides.add_slide(blank_layout)
t = sec_title or "正文"
if len(chunks) > 1:
t += "%d/%d" % (ci + 1, len(chunks))
_add_title_bar(slide, t)
box = slide.shapes.add_textbox(Inches(0.7), Inches(1.3), Inches(12), Inches(5.8))
tf = box.text_frame
tf.word_wrap = True
first = True
for b in chunk:
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
run = p.add_run()
run.text = "" + b
run.font.size = Pt(15)
run.font.color.rgb = RGBColor(0x33, 0x41, 0x55)
p.space_after = Pt(8)
# ── 尾页 ──
slide = prs.slides.add_slide(blank_layout)
_add_title_bar(slide, "说明", color=(0x64, 0x74, 0x8B))
box = slide.shapes.add_textbox(Inches(0.7), Inches(1.4), Inches(12), Inches(5))
tf = box.text_frame
tf.word_wrap = True
for i, note in enumerate([
"本报告全部数据来自数据爬取平台(内网采集、已去重)。",
"每条引用均带公告原文/采集来源,可在产线平台「数据参考」逐条查证。",
"报告状态以平台门禁为准(人工确认 → 研发审批),未经审批不得视为立项依据。",
]):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
run = p.add_run()
run.text = note
run.font.size = Pt(15)
run.font.color.rgb = RGBColor(0x47, 0x55, 0x69)
p.space_after = Pt(10)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
prs.save(out_path)
try:
npages = len(prs.slides._sldIdLst)
except Exception:
npages = -1
logger.info("报告 PPT 已生成: %s (%d 页)", out_path, npages)
return True, out_path

View File

@ -11,6 +11,8 @@
"""
import logging
import os
import re
from ahserver.serverenv import ServerEnv
@ -87,10 +89,42 @@ async def get_report_full(report_id):
return rep
async def _build_report_ppt(sor, rep):
"""为报告生成 PPT存到项目工作空间 deliverables/ 目录。返回文件路径(失败 '')。"""
from .opp_ppt import build_report_ppt
# 解析项目目录({workspace}/{org}/{space}/projects/{项目}/
project_id = rep.get("project_id", "") or ""
project_dir = ""
if project_id:
try:
recs = await sor.sqlExe(
"SELECT workspace_dir FROM sd_projects WHERE id=${p}$",
{"p": project_id})
if recs:
project_dir = getattr(recs[0], "workspace_dir", "") or ""
except Exception:
project_dir = ""
if not project_dir:
# 无项目/无目录:兜底放平台 files 目录
project_dir = os.path.join(os.getcwd(), "files", "opp_reports")
rid = rep.get("id", "")
software = re.sub(r"[^\w\u4e00-\u9fff-]", "_", rep.get("software", "") or "report")[:40]
fname = "research_report_%s_%s.pptx" % (software, rid[:8])
out_path = os.path.join(project_dir, "deliverables", fname)
ok, res = build_report_ppt(
rep.get("title", ""), rep.get("content", ""),
software=rep.get("software", ""), out_path=out_path)
if not ok:
logger.warning("build_report_ppt 失败: %s", str(res)[:200])
return ""
return out_path
async def submit_for_confirmation(report_id, note=""):
"""agent 完成报告后提交待人工确认:发人工确认任务(门禁)。
报告保持 draft创建/复用 pending 人工任务人工确认后才进 confirmed
同时生成报告 PPT存项目工作空间 deliverables/供确认人下载审阅
"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
@ -99,6 +133,14 @@ async def submit_for_confirmation(report_id, note=""):
return False, "报告不存在: %s" % report_id
if rep.get("status") != RP_DRAFT:
return False, "状态 %s 不可提交确认" % rep.get("status")
# 生成/更新报告 PPT幂等重复提交覆盖旧文件
ppt_path = ""
try:
ppt_path = await _build_report_ppt(sor, rep)
except Exception as e:
logger.warning("报告PPT生成失败(不阻塞提交): %s", str(e)[:200])
if ppt_path:
await sor.U("opp_reports", {"id": report_id, "ppt_path": ppt_path})
exist = await find_human_task(sor, rep.get("project_id", ""),
HT_REPORT_CONFIRM, status="pending")
if exist:

View File

@ -24,6 +24,10 @@ PATHS_LOGINED = [
"/%s/index.ui" % MOD,
"/%s/agent" % MOD,
"/%s/agent/index.ui" % MOD,
"/%s/api/opp_references_popup.dspy" % MOD,
"/%s/api/opp_references_items.dspy" % MOD,
"/%s/api/opp_confirm_report.dspy" % MOD,
"/%s/api/opp_report_ppt.dspy" % MOD,
]
for t in TABLES:
PATHS_LOGINED += [

View File

@ -0,0 +1,3 @@
-- pipeline-opportunity 迁移:报告 PPT 路径字段2026-08-29
-- 幂等:列已存在时报错可忽略
ALTER TABLE opp_reports ADD COLUMN ppt_path VARCHAR(500) DEFAULT '';

View File

@ -0,0 +1,42 @@
# opp_confirm_report.dspy - 研发报告人工确认(门禁)
# 入参human_task_id=<确认任务id>, decision=approve|reject, comment=意见(可选)
import json as _json
uid = await get_user()
if not uid:
return _json.dumps({"success": False, "error": "未登录"}, ensure_ascii=False)
human_task_id = ((params_kw or {}).get('human_task_id') or '').strip()
decision = ((params_kw or {}).get('decision') or '').strip()
comment = ((params_kw or {}).get('comment') or '').strip()
if not human_task_id:
return _json.dumps({"success": False, "error": "缺少 human_task_id"}, ensure_ascii=False)
if decision not in ('approve', 'reject'):
return _json.dumps({"success": False, "error": "decision 必须是 approve/reject"}, ensure_ascii=False)
dbname = get_module_dbname('pipeline-opportunity')
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_report_capability import confirm_report
# 确认任务 → 报告opp_reports.confirm_task_id
recs = await sor.sqlExe(
"SELECT id, title, status FROM opp_reports WHERE confirm_task_id=${t}$",
{"t": human_task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return _json.dumps({"success": False, "error": "未找到该确认任务对应的报告"}, ensure_ascii=False)
report_id = getattr(recs[0], 'id', '')
ok, msg = await confirm_report(report_id, decision == 'approve', operator=uid)
if not ok:
return _json.dumps({"success": False, "error": msg}, ensure_ascii=False)
if comment:
try:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET result_data=${r}$ WHERE id=${i}$",
{"r": _json.dumps({"comment": comment}, ensure_ascii=False), "i": human_task_id})
await sor.sqlExe("COMMIT", {})
except Exception:
pass
return _json.dumps({"success": True,
"message": ("报告已确认,可发起研发审批" if decision == 'approve' else "报告已退回草稿待修改")},
ensure_ascii=False)

View File

@ -0,0 +1,86 @@
# opp_references_items.dspy - 某软件主题的分项列表references 可查证)
# 入参category=<主题名>。每条分项:标题+时间+预算+地区 + 「公告原文」「来源页」按钮(新窗口)
import json as _json
uid = await get_user()
if not uid:
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录"}}
category = ((params_kw or {}).get("category") or "").strip()
days = int((params_kw or {}).get("days") or 30)
if not category:
return {"widgettype": "Message", "options": {"title": "缺少参数", "message": "缺少 category"}}
dbname = get_module_dbname('pipeline-opportunity')
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import category_references
ok, res = await category_references(sor, category, days=days, limit=500)
await sor.sqlExe("COMMIT", {})
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
"text": "查询失败:" + str(res)[:200],
"color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
items = res.get("items") or []
if not items:
rows.append({"widgettype": "Text", "options": {
"text": "该主题近 %d 天无分项" % days, "color": "#64748b", "padding": "12px", "halign": "left"}})
for it in items:
url = str(it.get("url") or "")
src = str(it.get("source_url") or "")
sub_btns = []
if url:
sub_btns.append({"widgettype": "Button", "options": {"label": "公告原文", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(url) + ",'_blank','noopener');"}]})
if src and src != url:
sub_btns.append({"widgettype": "Button", "options": {"label": "来源页", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self",
"script": "window.open(" + _json.dumps(src) + ",'_blank','noopener');"}]})
budget = it.get("budget_wan") or 0
meta = "%s %s %s" % (
str(it.get("publish_time") or "")[:10],
str(it.get("region") or "—"),
(("预算 %.0f 万" % budget) if budget else "预算未公开"))
rows.append({
"widgettype": "VBox",
"options": {"width": "100%", "padding": "8px 12px", "gap": "4px",
"border": "1px solid #e2e8f0", "borderRadius": "8px",
"margin": "0 0 6px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": str(it.get("title") 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": "#94a3b8", "halign": "left"}},
{"widgettype": "Filler"}
] + sub_btns}
]
})
total = res.get("count", len(rows)) if ok else 0
return {
"widgettype": "PopupWindow",
"id": "opp_refs_items_pw",
"options": {"title": "分项明细:%s%d 条)" % (category, total),
"width": "80%", "height": "82%", "auto_open": True, "resizable": True},
"subwidgets": [{
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%",
"padding": "12px 16px", "gap": "0px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "每条「公告原文」为招标公告原链接、「来源页」为采集来源页,均在新窗口打开,可逐条查证。",
"cfontsize": 0.78, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}]
}

View File

@ -0,0 +1,66 @@
# opp_references_popup.dspy - 数据参考references主题列表agent_menu_open 弹窗内嵌内容)
# 点主题「查看分项」→ opp_references_items.dspy 列分项(每条带来源,点击新窗口查证)
import json as _json
uid = await get_user()
if not uid:
return {"widgettype": "Text", "options": {"text": "请先登录", "color": "#dc2626", "padding": "12px"}}
dbname = get_module_dbname('pipeline-opportunity')
items_url = entire_url("/pipeline-opportunity/api/opp_references_items.dspy")
async with DBPools().sqlorContext(dbname) as sor:
from pipeline_opportunity.opp_data_capability import hot_software
ok, res = await hot_software(sor, days=30, top=10)
await sor.sqlExe("COMMIT", {})
rows = []
if not ok:
rows.append({"widgettype": "Text", "options": {
"text": "数据爬取平台暂不可用:" + str(res)[:200],
"color": "#dc2626", "padding": "12px", "halign": "left"}})
else:
ranking = res.get("ranking") or []
if not ranking:
rows.append({"widgettype": "Text", "options": {
"text": "近30天暂无招标数据", "color": "#64748b", "padding": "12px", "halign": "left"}})
for i, r in enumerate(ranking):
cat = str(r.get("category", ""))
cnt = r.get("tender_count", 0)
budget = r.get("total_budget_wan", 0)
script = ("var old=bricks.getWidgetById('opp_refs_items_pw',bricks.app);"
"if(old&&old.destroy){old.destroy();}"
"var rp=await fetch(" + _json.dumps(items_url) + "+'?category='+encodeURIComponent(" + _json.dumps(cat) + "));"
"var d=await rp.json();"
"if(d){bricks.widgetBuild(d,bricks.app);}")
rows.append({
"widgettype": "HBox",
"options": {"width": "100%", "gap": "10px", "alignItems": "center",
"padding": "8px 14px", "border": "1px solid #e2e8f0",
"borderRadius": "8px", "margin": "0 0 8px 0", "bgcolor": "#ffffff"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": cat, "cfontsize": 1.0,
"color": "#0f172a", "halign": "left"}},
{"widgettype": "Filler"},
{"widgettype": "Text", "options": {"text": "%d 条" % cnt,
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Text", "options": {"text": "预算 %s 万" % ("{:,.0f}".format(budget) if budget else "—"),
"cfontsize": 0.85, "color": "#64748b"}},
{"widgettype": "Button", "options": {"label": "查看分项", "css": "small"},
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
"target": "self", "script": script}]}
]
})
return {
"widgettype": "VBox",
"options": {"css": "filler", "width": "100%", "height": "100%", "padding": "12px 16px",
"gap": "6px", "overflow": "auto"},
"subwidgets": [
{"widgettype": "Text", "options": {
"text": "点击「查看分项」列出该主题的全部招标分项;每条分项带公告原文链接,可逐条查证。",
"cfontsize": 0.8, "color": "#64748b", "halign": "left", "margin": "0 0 8px 0"}}
] + rows
}

View File

@ -0,0 +1,35 @@
# opp_report_ppt.dspy - 下载报告 PPT附件
# 入参report_id。路径只从库读不接受用户传路径防路径穿越。
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()
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:
recs = await sor.sqlExe(
"SELECT title, ppt_path FROM opp_reports WHERE id=${i}$", {"i": report_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return {"widgettype": "Message", "options": {"title": "不存在", "message": "报告不存在"}}
ppt_path = (getattr(recs[0], 'ppt_path', '') or '').strip()
title = getattr(recs[0], 'title', '') or '研发报告'
if not ppt_path or not os.path.isfile(ppt_path):
return {"widgettype": "Message", "options": {
"title": "无 PPT", "message": "该报告尚未生成 PPT提交人工确认时自动生成"}}
filename = os.path.basename(ppt_path)
safe_name = quote(filename)
headers = {'Content-Disposition':
'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (filename, safe_name)}
return FileResponse(ppt_path, headers=headers)

View File

@ -67,6 +67,12 @@ fields_str=r'''[
"type": "str",
"length": 32
},
{
"name": "ppt_path",
"title": "PPT文件路径",
"type": "str",
"length": 500
},
{
"name": "confirmed_by",
"title": "确认人",

View File

@ -52,7 +52,8 @@
"created_at",
"updated_at",
"confirm_task_id",
"confirmed_by"
"confirmed_by",
"ppt_path"
],
"fields":[
@ -130,6 +131,16 @@
"datatype": "str",
"label": "确认任务ID"
},
{
"name": "ppt_path",
"title": "PPT文件路径",
"type": "str",
"length": 500,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "PPT文件路径"
},
{
"name": "confirmed_by",
"title": "确认人",