fix(file-summary): 调整工作流批次轮播展示
This commit is contained in:
@@ -941,6 +941,69 @@ input:focus {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-batch-carousel {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-carousel .workflow-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-carousel .workflow-card.active {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-btn {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-dots {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-batch-dot.active {
|
||||||
|
width: 18px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.node-status {
|
.node-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
107
static/js/app.js
107
static/js/app.js
@@ -554,9 +554,86 @@
|
|||||||
escapeHtml(batch.batch_no || "文件汇总") +
|
escapeHtml(batch.batch_no || "文件汇总") +
|
||||||
'</strong><span class="workflow-status status-running">running</span></header><ol></ol>';
|
'</strong><span class="workflow-status status-running">running</span></header><ol></ol>';
|
||||||
workflowCardList.prepend(card);
|
workflowCardList.prepend(card);
|
||||||
|
refreshWorkflowBatchCarousel(0);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowCards() {
|
||||||
|
if (!workflowCardList) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.prototype.slice.call(workflowCardList.querySelectorAll(".workflow-card"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWorkflowBatchControls() {
|
||||||
|
if (!workflowCardList || workflowCardList.querySelector(".workflow-batch-controls")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var controls = document.createElement("div");
|
||||||
|
controls.className = "workflow-batch-controls";
|
||||||
|
controls.innerHTML =
|
||||||
|
'<button type="button" class="workflow-batch-btn" data-workflow-action="prev" aria-label="上一个工作流">‹</button>' +
|
||||||
|
'<div class="workflow-batch-dots" aria-label="工作流批次"></div>' +
|
||||||
|
'<button type="button" class="workflow-batch-btn" data-workflow-action="next" aria-label="下一个工作流">›</button>';
|
||||||
|
workflowCardList.appendChild(controls);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWorkflowBatchIndex(index) {
|
||||||
|
var cards = workflowCards();
|
||||||
|
if (!workflowCardList || !cards.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var safeIndex = Math.max(0, Math.min(index, cards.length - 1));
|
||||||
|
workflowCardList.setAttribute("data-active-index", safeIndex);
|
||||||
|
cards.forEach(function (card, cardIndex) {
|
||||||
|
var isActive = cardIndex === safeIndex;
|
||||||
|
card.classList.toggle("active", isActive);
|
||||||
|
card.setAttribute("data-workflow-index", cardIndex);
|
||||||
|
card.setAttribute("aria-hidden", isActive ? "false" : "true");
|
||||||
|
});
|
||||||
|
var dots = workflowCardList.querySelector(".workflow-batch-dots");
|
||||||
|
if (!dots) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dots.querySelectorAll("[data-workflow-index-dot]").forEach(function (dot) {
|
||||||
|
var dotIndex = parseInt(dot.getAttribute("data-workflow-index-dot"), 10);
|
||||||
|
var isActive = dotIndex === safeIndex;
|
||||||
|
dot.classList.toggle("active", isActive);
|
||||||
|
dot.setAttribute("aria-current", isActive ? "true" : "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshWorkflowBatchCarousel(preferredIndex) {
|
||||||
|
var cards = workflowCards();
|
||||||
|
if (!workflowCardList || !cards.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workflowCardList.classList.add("workflow-batch-carousel");
|
||||||
|
ensureWorkflowBatchControls();
|
||||||
|
var dots = workflowCardList.querySelector(".workflow-batch-dots");
|
||||||
|
if (dots) {
|
||||||
|
dots.innerHTML = "";
|
||||||
|
cards.forEach(function (card, index) {
|
||||||
|
card.setAttribute("data-workflow-index", index);
|
||||||
|
var title = card.querySelector("strong");
|
||||||
|
var dot = document.createElement("button");
|
||||||
|
dot.type = "button";
|
||||||
|
dot.className = "workflow-batch-dot";
|
||||||
|
dot.setAttribute("data-workflow-index-dot", index);
|
||||||
|
dot.setAttribute("aria-label", "查看" + (title ? title.textContent.trim() : "工作流") + "状态");
|
||||||
|
dots.appendChild(dot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var activeIndex =
|
||||||
|
typeof preferredIndex === "number"
|
||||||
|
? preferredIndex
|
||||||
|
: parseInt(workflowCardList.getAttribute("data-active-index") || "0", 10);
|
||||||
|
if (Number.isNaN(activeIndex)) {
|
||||||
|
activeIndex = 0;
|
||||||
|
}
|
||||||
|
selectWorkflowBatchIndex(activeIndex);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshWorkflowCard(batchId) {
|
async function refreshWorkflowCard(batchId) {
|
||||||
if (!summaryPanel || !batchId) {
|
if (!summaryPanel || !batchId) {
|
||||||
return "";
|
return "";
|
||||||
@@ -612,9 +689,37 @@
|
|||||||
"%</em>";
|
"%</em>";
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
|
refreshWorkflowBatchCarousel();
|
||||||
return payload.batch.status || "";
|
return payload.batch.status || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindWorkflowBatchCarouselControls() {
|
||||||
|
if (!workflowCardList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workflowCardList.addEventListener("click", function (event) {
|
||||||
|
var cards = workflowCards();
|
||||||
|
if (!cards.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var actionButton = event.target.closest("[data-workflow-action]");
|
||||||
|
var dotButton = event.target.closest("[data-workflow-index-dot]");
|
||||||
|
var currentIndex = parseInt(workflowCardList.getAttribute("data-active-index") || "0", 10);
|
||||||
|
if (Number.isNaN(currentIndex)) {
|
||||||
|
currentIndex = 0;
|
||||||
|
}
|
||||||
|
if (actionButton) {
|
||||||
|
var nextIndex =
|
||||||
|
actionButton.getAttribute("data-workflow-action") === "next"
|
||||||
|
? (currentIndex + 1) % cards.length
|
||||||
|
: (currentIndex - 1 + cards.length) % cards.length;
|
||||||
|
selectWorkflowBatchIndex(nextIndex);
|
||||||
|
} else if (dotButton) {
|
||||||
|
selectWorkflowBatchIndex(parseInt(dotButton.getAttribute("data-workflow-index-dot"), 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isWorkflowTerminalStatus(status) {
|
function isWorkflowTerminalStatus(status) {
|
||||||
return status === "success" || status === "failed";
|
return status === "success" || status === "failed";
|
||||||
}
|
}
|
||||||
@@ -817,6 +922,8 @@
|
|||||||
syncLatestMessageIdFromDom();
|
syncLatestMessageIdFromDom();
|
||||||
bindNodeAnchorClicks();
|
bindNodeAnchorClicks();
|
||||||
renderExistingAssistantMessages();
|
renderExistingAssistantMessages();
|
||||||
|
refreshWorkflowBatchCarousel(0);
|
||||||
|
bindWorkflowBatchCarouselControls();
|
||||||
refreshRunningWorkflowCards();
|
refreshRunningWorkflowCards();
|
||||||
|
|
||||||
if (chatScroll) {
|
if (chatScroll) {
|
||||||
|
|||||||
@@ -215,9 +215,14 @@
|
|||||||
<div class="summary-subheading">
|
<div class="summary-subheading">
|
||||||
<h3>工作流</h3>
|
<h3>工作流</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="workflow-card-list" id="workflowCardList">
|
<div class="workflow-card-list workflow-batch-carousel" id="workflowCardList" data-active-index="0">
|
||||||
{% for batch in summary_batches %}
|
{% for batch in summary_batches %}
|
||||||
<article class="workflow-card" data-batch-id="{{ batch.pk }}">
|
<article
|
||||||
|
class="workflow-card{% if forloop.first %} active{% endif %}"
|
||||||
|
data-batch-id="{{ batch.pk }}"
|
||||||
|
data-workflow-index="{{ forloop.counter0 }}"
|
||||||
|
aria-hidden="{% if forloop.first %}false{% else %}true{% endif %}"
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<strong>{{ batch.batch_no }}</strong>
|
<strong>{{ batch.batch_no }}</strong>
|
||||||
<span class="workflow-status status-{{ batch.status }}">{{ batch.status }}</span>
|
<span class="workflow-status status-{{ batch.status }}">{{ batch.status }}</span>
|
||||||
@@ -240,6 +245,23 @@
|
|||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="panel-empty">暂无工作流</div>
|
<div class="panel-empty">暂无工作流</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if summary_batches %}
|
||||||
|
<div class="workflow-batch-controls">
|
||||||
|
<button type="button" class="workflow-batch-btn" data-workflow-action="prev" aria-label="上一个工作流">‹</button>
|
||||||
|
<div class="workflow-batch-dots" aria-label="工作流批次">
|
||||||
|
{% for batch in summary_batches %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="workflow-batch-dot{% if forloop.first %} active{% endif %}"
|
||||||
|
data-workflow-index-dot="{{ forloop.counter0 }}"
|
||||||
|
aria-label="查看{{ batch.batch_no }}状态"
|
||||||
|
aria-current="{% if forloop.first %}true{% else %}false{% endif %}"
|
||||||
|
></button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="workflow-batch-btn" data-workflow-action="next" aria-label="下一个工作流">›</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from review_agent.models import Conversation, Message
|
from review_agent.models import Conversation, FileSummaryBatch, Message, WorkflowNodeRun
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -32,6 +32,53 @@ def test_workspace_renders_summary_panel(client, django_user_model):
|
|||||||
assert "自动汇总文件目录与页数" in content
|
assert "自动汇总文件目录与页数" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_renders_workflow_history_as_batch_carousel(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
older = FileSummaryBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
batch_no="FS-OLDER",
|
||||||
|
status=FileSummaryBatch.Status.SUCCESS,
|
||||||
|
)
|
||||||
|
latest = FileSummaryBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
batch_no="FS-LATEST",
|
||||||
|
status=FileSummaryBatch.Status.FAILED,
|
||||||
|
error_message="解压失败",
|
||||||
|
)
|
||||||
|
WorkflowNodeRun.objects.create(
|
||||||
|
batch=older,
|
||||||
|
node_code="upload",
|
||||||
|
node_name="附件固化",
|
||||||
|
status=WorkflowNodeRun.Status.SUCCESS,
|
||||||
|
progress=100,
|
||||||
|
message="附件固化完成",
|
||||||
|
)
|
||||||
|
WorkflowNodeRun.objects.create(
|
||||||
|
batch=latest,
|
||||||
|
node_code="extract",
|
||||||
|
node_name="压缩包解压",
|
||||||
|
status=WorkflowNodeRun.Status.FAILED,
|
||||||
|
progress=10,
|
||||||
|
message="压缩包损坏",
|
||||||
|
)
|
||||||
|
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 "workflow-batch-carousel" in content
|
||||||
|
assert 'class="workflow-card active"' in content
|
||||||
|
assert 'data-workflow-index="0"' in content
|
||||||
|
assert 'data-workflow-action="prev"' in content
|
||||||
|
assert 'data-workflow-action="next"' in content
|
||||||
|
assert content.index("FS-LATEST") < content.index("FS-OLDER")
|
||||||
|
assert "压缩包损坏" in content
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_prevents_long_message_overflow():
|
def test_frontend_prevents_long_message_overflow():
|
||||||
css = open("static/css/login.css", encoding="utf-8").read()
|
css = open("static/css/login.css", encoding="utf-8").read()
|
||||||
|
|
||||||
@@ -88,3 +135,15 @@ def test_frontend_renders_workflow_error_messages():
|
|||||||
assert "workflow-error" in script
|
assert "workflow-error" in script
|
||||||
assert "node.message" in script
|
assert "node.message" in script
|
||||||
assert ".workflow-error" in css
|
assert ".workflow-error" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_renders_workflow_batches_as_carousel():
|
||||||
|
script = open("static/js/app.js", encoding="utf-8").read()
|
||||||
|
css = open("static/css/login.css", encoding="utf-8").read()
|
||||||
|
|
||||||
|
assert "selectWorkflowBatchIndex" in script
|
||||||
|
assert "refreshWorkflowBatchCarousel" in script
|
||||||
|
assert "data-workflow-action" in script
|
||||||
|
assert "workflow-batch-carousel" in script
|
||||||
|
assert ".workflow-batch-controls" in css
|
||||||
|
assert ".workflow-card.active" in css
|
||||||
|
|||||||
Reference in New Issue
Block a user