feat(file-summary): 增加前端汇总面板

This commit is contained in:
2026-06-06 10:25:11 +08:00
parent 61bd31790b
commit a917a18ca1
6 changed files with 529 additions and 7 deletions

View File

@@ -0,0 +1,74 @@
# 自动汇总前端线框图
## 评审目标
在实现三栏页面前,先确认审核智能体工作台的信息架构、右侧文件汇总面板、工作流状态展示和移动端降级方式。
## 桌面端布局
```mermaid
flowchart LR
A["左栏:会话列表<br/>新对话 / 搜索 / 历史会话"] --> B["中栏:聊天区<br/>顶部导航 / 消息流 / 输入框"]
B --> C["右栏:文件汇总面板"]
C --> C1["上半区:上传区<br/>拖拽上传 / 选择文件 / 上传状态"]
C --> C2["中段:当前对话附件<br/>文件名 / 版本 / 大小 / 状态 / 删除"]
C --> C3["下半区:工作流卡片<br/>批次号 / 节点进度 / 下载入口"]
```
## 右侧面板结构
```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 渲染,用户消息仍按纯文本转义。
- 移动端优先保证聊天可用,文件汇总面板折叠或下移,不能遮挡输入框。

View File

@@ -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 [],
},
)

View File

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

View File

@@ -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, "<br>");
}
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 = '<div class="panel-empty">暂无附件</div>';
return;
}
attachments.forEach(function (attachment) {
var item = document.createElement("div");
item.className = "attachment-item";
item.setAttribute("data-attachment-id", attachment.id);
item.innerHTML =
"<div><strong>" +
escapeHtml(attachment.original_name) +
"</strong><span>v" +
attachment.version_no +
" · " +
attachment.file_size +
" bytes · " +
escapeHtml(attachment.upload_status) +
"</span></div>" +
(attachment.is_active ? "<em>active</em>" : "");
attachmentList.appendChild(item);
});
}
async function refreshAttachments() {
var conversationId = currentConversationId();
if (!conversationId || !summaryPanel) {
return;
}
var response = await fetch(templateUrl("data-attachment-url-template", "__conversation_id__", conversationId));
if (!response.ok) {
return;
}
var payload = await response.json();
renderAttachments(payload.attachments || []);
}
async function uploadFiles(files) {
var conversationId = currentConversationId();
if (!conversationId || !files.length || !summaryPanel) {
if (uploadStatus) {
uploadStatus.textContent = "请先创建或选择一个对话。";
}
return;
}
var data = new FormData();
Array.prototype.forEach.call(files, function (file) {
data.append("files", file);
});
var csrf = new FormData(composer).get("csrfmiddlewaretoken");
if (uploadStatus) {
uploadStatus.textContent = "正在上传 " + files.length + " 个文件...";
}
try {
var response = await fetch(templateUrl("data-attachment-url-template", "__conversation_id__", conversationId), {
method: "POST",
headers: { "X-CSRFToken": csrf },
body: data,
});
if (!response.ok) {
throw new Error("上传失败。");
}
var payload = await response.json();
renderAttachments(payload.attachments || []);
if (uploadStatus) {
uploadStatus.textContent = "上传完成,可发送自动汇总提示词。";
}
await refreshAttachments();
} catch (error) {
if (uploadStatus) {
uploadStatus.textContent = "上传失败,请重试。";
}
}
}
function ensureWorkflowCard(batch) {
if (!workflowCardList || !batch) {
return null;
}
var empty = workflowCardList.querySelector(".panel-empty");
if (empty) {
empty.remove();
}
var card = workflowCardList.querySelector('[data-batch-id="' + batch.batch_id + '"]');
if (card) {
return card;
}
card = document.createElement("article");
card.className = "workflow-card";
card.setAttribute("data-batch-id", batch.batch_id);
card.innerHTML =
"<header><strong>" +
escapeHtml(batch.batch_no || "文件汇总") +
'</strong><span class="workflow-status status-running">running</span></header><ol></ol>';
workflowCardList.prepend(card);
return card;
}
async function refreshWorkflowCard(batchId) {
if (!summaryPanel || !batchId) {
return;
}
var response = await fetch(templateUrl("data-status-url-template", "__batch_id__", batchId));
if (!response.ok) {
return;
}
var payload = await response.json();
var card = ensureWorkflowCard({
batch_id: payload.batch.id,
batch_no: payload.batch.batch_no,
});
if (!card) {
return;
}
var status = card.querySelector(".workflow-status");
status.textContent = payload.batch.status;
status.className = "workflow-status status-" + payload.batch.status;
var list = card.querySelector("ol");
list.innerHTML = "";
(payload.nodes || []).forEach(function (node) {
var item = document.createElement("li");
item.className = "node-status status-" + node.status;
item.setAttribute("data-node-code", node.node_code);
item.innerHTML = "<span>" + escapeHtml(node.node_name) + "</span><em>" + node.progress + "%</em>";
list.appendChild(item);
});
}
async function streamChat(event) {
event.preventDefault();
if (!composer || !promptInput || !sendButton || !chatStage) {
@@ -356,11 +512,14 @@
}
} else if (eventName === "chunk") {
assistantText += payload.delta || "";
assistantMessage.text.innerHTML = nl2br(assistantText);
assistantMessage.text.innerHTML = renderAssistantContent(assistantText);
scrollChatToBottom();
} else if (eventName === "error") {
assistantText = payload.message || "模型调用失败。";
assistantMessage.text.innerHTML = nl2br(assistantText);
assistantMessage.text.innerHTML = renderAssistantContent(assistantText);
} else if (eventName === "workflow_started") {
ensureWorkflowCard(payload);
refreshWorkflowCard(payload.batch_id);
} else if (eventName === "done") {
if (payload.assistant_message_id) {
assistantMessage.article.id = "message-" + payload.assistant_message_id;
@@ -400,6 +559,28 @@
composer.addEventListener("submit", streamChat);
}
if (uploadDropzone && attachmentInput) {
uploadDropzone.addEventListener("click", function () {
attachmentInput.click();
});
uploadDropzone.addEventListener("dragover", function (event) {
event.preventDefault();
uploadDropzone.classList.add("dragging");
});
uploadDropzone.addEventListener("dragleave", function () {
uploadDropzone.classList.remove("dragging");
});
uploadDropzone.addEventListener("drop", function (event) {
event.preventDefault();
uploadDropzone.classList.remove("dragging");
uploadFiles(event.dataTransfer.files);
});
attachmentInput.addEventListener("change", function () {
uploadFiles(attachmentInput.files);
attachmentInput.value = "";
});
}
window.addEventListener("resize", syncSidebarState);
syncSidebarState();
})();

View File

@@ -164,9 +164,77 @@
</div>
</section>
</section>
<aside
class="summary-panel"
id="summaryPanel"
data-attachment-url-template="/api/review-agent/conversations/__conversation_id__/attachments/"
data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/"
data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/"
>
<section class="summary-section upload-section">
<div class="summary-heading">
<h2>文件汇总</h2>
<span>当前对话</span>
</div>
<div class="upload-dropzone" id="uploadDropzone" tabindex="0" role="button">
<input id="attachmentInput" type="file" multiple hidden>
<strong>拖拽文件到这里</strong>
<span>支持多文件、zip、7z、rar</span>
</div>
<p class="upload-status" id="uploadStatus">上传后发送“自动汇总文件目录与页数”启动工作流。</p>
</section>
<section class="summary-section attachment-section">
<div class="summary-subheading">
<h3>附件</h3>
</div>
<div class="attachment-list" id="attachmentList">
{% for attachment in attachments %}
<div class="attachment-item" data-attachment-id="{{ attachment.pk }}">
<div>
<strong>{{ attachment.original_name }}</strong>
<span>v{{ attachment.version_no }} · {{ attachment.file_size }} bytes · {{ attachment.upload_status }}</span>
</div>
{% if attachment.is_active %}<em>active</em>{% endif %}
</div>
{% empty %}
<div class="panel-empty">暂无附件</div>
{% endfor %}
</div>
</section>
<section class="summary-section workflow-section">
<div class="summary-subheading">
<h3>工作流</h3>
</div>
<div class="workflow-card-list" id="workflowCardList">
{% for batch in summary_batches %}
<article class="workflow-card" data-batch-id="{{ batch.pk }}">
<header>
<strong>{{ batch.batch_no }}</strong>
<span class="workflow-status status-{{ batch.status }}">{{ batch.status }}</span>
</header>
<ol>
{% for node in batch.node_runs.all %}
<li class="node-status status-{{ node.status }}" data-node-code="{{ node.node_code }}">
<span>{{ node.node_name }}</span>
<em>{{ node.progress }}%</em>
</li>
{% endfor %}
</ol>
</article>
{% empty %}
<div class="panel-empty">暂无工作流</div>
{% endfor %}
</div>
</section>
</aside>
</main>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="{% static 'js/app.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,22 @@
import pytest
from django.urls import reverse
from review_agent.models import Conversation
pytestmark = pytest.mark.django_db
def test_workspace_renders_summary_panel(client, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
client.force_login(user)
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
assert response.status_code == 200
content = response.content.decode("utf-8")
assert 'id="summaryPanel"' in content
assert 'id="uploadDropzone"' in content
assert 'id="workflowCardList"' in content
assert "自动汇总文件目录与页数" in content