diff --git a/docs/5.开发计划/1.自动汇总-前端线框图.md b/docs/5.开发计划/1.自动汇总-前端线框图.md
new file mode 100644
index 0000000..3bb0ed1
--- /dev/null
+++ b/docs/5.开发计划/1.自动汇总-前端线框图.md
@@ -0,0 +1,74 @@
+# 自动汇总前端线框图
+
+## 评审目标
+
+在实现三栏页面前,先确认审核智能体工作台的信息架构、右侧文件汇总面板、工作流状态展示和移动端降级方式。
+
+## 桌面端布局
+
+```mermaid
+flowchart LR
+ A["左栏:会话列表
新对话 / 搜索 / 历史会话"] --> B["中栏:聊天区
顶部导航 / 消息流 / 输入框"]
+ B --> C["右栏:文件汇总面板"]
+ C --> C1["上半区:上传区
拖拽上传 / 选择文件 / 上传状态"]
+ C --> C2["中段:当前对话附件
文件名 / 版本 / 大小 / 状态 / 删除"]
+ C --> C3["下半区:工作流卡片
批次号 / 节点进度 / 下载入口"]
+```
+
+## 右侧面板结构
+
+```mermaid
+flowchart TB
+ P["文件汇总面板"] --> U["上传拖拽区"]
+ U --> U0["无附件:提示上传文件或压缩包"]
+ U --> U1["上传中:显示文件名和处理中状态"]
+ U --> U2["上传失败:展示错误并允许重试"]
+ P --> L["附件列表"]
+ L --> L1["active 版本优先展示"]
+ L --> L2["历史版本保留展示"]
+ L --> L3["逻辑删除后从默认候选移除"]
+ P --> W["工作流卡片列表"]
+ W --> W1["运行中:节点逐项更新"]
+ W --> W2["成功:展示 Markdown/Excel 下载"]
+ W --> W3["失败:展示失败节点和错误说明"]
+```
+
+## 工作流状态流转
+
+```mermaid
+stateDiagram-v2
+ [*] --> Pending: 用户上传附件
+ Pending --> Running: 发送自动汇总提示词
+ Running --> Extracting: 固化附件
+ Extracting --> Scanning: 解压完成或跳过
+ Scanning --> Counting: 生成文件清单
+ Counting --> Detecting: 页数统计完成
+ Detecting --> Reporting: 产品名识别完成
+ Reporting --> Success: 生成报告与下载
+ Running --> Failed: 批次级异常
+ Extracting --> Failed: 解压安全检查失败
+ Reporting --> Failed: 报告生成失败
+ Success --> Restored: 刷新页面后状态恢复
+ Failed --> Restored: 刷新页面后状态恢复
+```
+
+## 移动端布局
+
+```mermaid
+flowchart TB
+ M["移动端工作台"] --> T["顶部:侧栏按钮 / 当前页面 / 用户菜单"]
+ T --> Chat["聊天区优先展示"]
+ Chat --> Composer["底部输入框"]
+ T --> Drawer["会话侧栏抽屉"]
+ Chat --> Panel["文件汇总面板下移或折叠"]
+ Panel --> Upload["上传区"]
+ Panel --> Workflow["工作流卡片"]
+```
+
+## 关键评审点
+
+- 桌面端保持左侧会话、中间聊天、右侧文件汇总三栏,不改变现有聊天主路径。
+- 右侧面板上半部分用于上传和附件列表,下半部分用于批次工作流卡片。
+- 工作流卡片节点顺序固定为:附件固化、压缩包解压、文件扫描、页数统计、产品识别、报告输出、完成。
+- 助手消息中的文件汇总结果使用安全 Markdown 渲染,用户消息仍按纯文本转义。
+- 移动端优先保证聊天可用,文件汇总面板折叠或下移,不能遮挡输入框。
diff --git a/review_agent/views.py b/review_agent/views.py
index e384834..a2aa67e 100644
--- a/review_agent/views.py
+++ b/review_agent/views.py
@@ -10,6 +10,7 @@ from .services import (
send_message,
stream_message,
)
+from .models import FileAttachment, FileSummaryBatch
@login_required
@@ -49,6 +50,8 @@ def workspace(request: HttpRequest) -> HttpResponse:
"conversations": conversations,
"current_conversation": current,
"messages": current.messages.all() if current else [],
+ "attachments": FileAttachment.objects.filter(conversation=current).order_by("original_name", "-version_no") if current else [],
+ "summary_batches": FileSummaryBatch.objects.filter(conversation=current).prefetch_related("node_runs").order_by("-created_at")[:5] if current else [],
},
)
diff --git a/static/css/login.css b/static/css/login.css
index 7f4f93f..3162919 100644
--- a/static/css/login.css
+++ b/static/css/login.css
@@ -127,7 +127,7 @@ input:focus {
.workspace {
display: grid;
- grid-template-columns: 296px minmax(0, 1fr);
+ grid-template-columns: 296px minmax(0, 1fr) 340px;
min-height: 100vh;
}
@@ -760,9 +760,176 @@ input:focus {
padding-right: 12px;
}
+.summary-panel {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 14px;
+ min-width: 0;
+ max-height: 100vh;
+ padding: 16px;
+ overflow: auto;
+ border-left: 1px solid var(--line);
+ background: #ffffff;
+}
+
+.summary-section {
+ display: grid;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel-soft);
+}
+
+.summary-heading,
+.summary-subheading,
+.workflow-card header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.summary-heading h2,
+.summary-subheading h3 {
+ margin: 0;
+ font-size: 16px;
+}
+
+.summary-heading span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.upload-dropzone {
+ display: grid;
+ place-items: center;
+ gap: 6px;
+ min-height: 112px;
+ padding: 18px;
+ border: 1px dashed var(--accent);
+ border-radius: 8px;
+ background: #f5f9ff;
+ color: var(--text);
+ cursor: pointer;
+ text-align: center;
+}
+
+.upload-dropzone.dragging {
+ border-color: var(--accent-dark);
+ background: #eaf2ff;
+}
+
+.upload-dropzone span,
+.upload-status,
+.attachment-item span,
+.workflow-card em {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.upload-status {
+ margin: 0;
+ line-height: 1.5;
+}
+
+.attachment-list,
+.workflow-card-list {
+ display: grid;
+ gap: 10px;
+}
+
+.attachment-item,
+.workflow-card {
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #ffffff;
+}
+
+.attachment-item {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+}
+
+.attachment-item strong,
+.workflow-card strong {
+ display: block;
+ overflow-wrap: anywhere;
+ font-size: 13px;
+}
+
+.attachment-item em,
+.workflow-status {
+ padding: 3px 8px;
+ border-radius: 999px;
+ background: #eaf2ff;
+ color: var(--accent);
+ font-size: 11px;
+ font-style: normal;
+ font-weight: 700;
+}
+
+.workflow-card ol {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.node-status {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 0;
+ border-top: 1px solid var(--line);
+ font-size: 13px;
+}
+
+.status-running,
+.status-retrying {
+ color: var(--accent);
+}
+
+.status-success {
+ color: #047857;
+}
+
+.status-failed {
+ color: var(--danger-text);
+}
+
+.panel-empty {
+ padding: 14px;
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.message-bubble table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.message-bubble th,
+.message-bubble td {
+ padding: 8px;
+ border: 1px solid var(--line);
+ text-align: left;
+ vertical-align: top;
+}
+
@media (max-width: 980px) {
.workspace {
grid-template-columns: minmax(0, 1fr);
+ min-height: 100vh;
+ overflow: auto;
}
.sidebar {
@@ -815,7 +982,14 @@ input:focus {
}
.chat-stage {
- height: calc(100vh - 88px);
+ min-height: calc(100vh - 88px);
+ height: auto;
+ }
+
+ .summary-panel {
+ max-height: none;
+ border-left: 0;
+ border-top: 1px solid var(--line);
}
.chat-scroll {
@@ -889,7 +1063,7 @@ input:focus {
width: 20px;
}
-.node-dot {
+ .node-dot {
width: 10px;
height: 10px;
}
diff --git a/static/js/app.js b/static/js/app.js
index 1c3ee89..e8d2155 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -11,6 +11,12 @@
var sendButton = document.getElementById("sendButton");
var conversationIdInput = document.getElementById("conversationIdInput");
var chatStage = document.querySelector(".chat-stage");
+ var summaryPanel = document.getElementById("summaryPanel");
+ var uploadDropzone = document.getElementById("uploadDropzone");
+ var attachmentInput = document.getElementById("attachmentInput");
+ var attachmentList = document.getElementById("attachmentList");
+ var uploadStatus = document.getElementById("uploadStatus");
+ var workflowCardList = document.getElementById("workflowCardList");
var nodeAnchors = [];
if (!workspace) {
@@ -32,7 +38,7 @@
function syncSidebarState() {
if (isMobile()) {
- if (workspace.getAttribute("data-sidebar-state") === "collapsed") {
+ if (workspace.getAttribute("data-sidebar-state") !== "closed") {
workspace.setAttribute("data-sidebar-state", "closed");
}
} else if (workspace.getAttribute("data-sidebar-state") === "closed") {
@@ -147,6 +153,13 @@
return escapeHtml(text).replace(/\n/g, "
");
}
+ function renderAssistantContent(text) {
+ if (window.marked && window.DOMPurify) {
+ return window.DOMPurify.sanitize(window.marked.parse(text || ""));
+ }
+ return nl2br(text || "");
+ }
+
function scrollChatToBottom() {
if (chatScroll) {
chatScroll.scrollTop = chatScroll.scrollHeight;
@@ -169,7 +182,7 @@
bubble.className = "message-bubble";
var text = document.createElement("p");
- text.innerHTML = nl2br(content);
+ text.innerHTML = role === "assistant" ? renderAssistantContent(content) : nl2br(content);
bubble.appendChild(text);
article.appendChild(avatar);
@@ -271,6 +284,149 @@
}
}
+ function currentConversationId() {
+ return conversationIdInput ? conversationIdInput.value : "";
+ }
+
+ function templateUrl(attributeName, token, value) {
+ if (!summaryPanel) {
+ return "";
+ }
+ return summaryPanel.getAttribute(attributeName).replace(token, value);
+ }
+
+ function renderAttachments(attachments) {
+ if (!attachmentList) {
+ return;
+ }
+ attachmentList.innerHTML = "";
+ if (!attachments.length) {
+ attachmentList.innerHTML = '