From df3f393dd21c04a56fa60173cc14523114023c6e Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 22:45:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(attachments):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 3 +- review_agent/views.py | 35 +++- static/css/login.css | 253 ++++++++++++++++++++++++++++ static/js/attachment_manager.js | 147 ++++++++++++++++ templates/attachment_manager.html | 139 +++++++++++++++ templates/home.html | 11 +- tests/test_file_summary_frontend.py | 78 ++++++++- 7 files changed, 660 insertions(+), 6 deletions(-) create mode 100644 static/js/attachment_manager.js create mode 100644 templates/attachment_manager.html diff --git a/config/urls.py b/config/urls.py index cd123c8..36df95c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,10 +2,11 @@ from django.contrib import admin from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView from django.urls import include, path -from review_agent.views import stream_chat, workspace +from review_agent.views import attachment_manager, stream_chat, workspace urlpatterns = [ path("", workspace, name="home"), + path("attachments/", attachment_manager, name="attachment_manager"), path("", include("review_agent.urls")), path("chat/stream/", stream_chat, name="chat_stream"), path( diff --git a/review_agent/views.py b/review_agent/views.py index a2aa67e..43dbded 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -1,4 +1,5 @@ from django.contrib.auth.decorators import login_required +from django.db.models import Count, Q from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods @@ -10,7 +11,7 @@ from .services import ( send_message, stream_message, ) -from .models import FileAttachment, FileSummaryBatch +from .models import Conversation, FileAttachment, FileSummaryBatch @login_required @@ -56,6 +57,38 @@ def workspace(request: HttpRequest) -> HttpResponse: ) +@login_required +@require_http_methods(["GET"]) +def attachment_manager(request: HttpRequest) -> HttpResponse: + conversations = ( + Conversation.objects.filter(user=request.user) + .annotate( + attachment_count=Count( + "file_attachments", + filter=~Q(file_attachments__upload_status=FileAttachment.UploadStatus.DELETED), + ) + ) + .order_by("-updated_at", "-id") + ) + selected = get_conversation_for_user(request.user, request.GET.get("conversation")) + attachments = ( + FileAttachment.objects.filter(conversation=selected) + .order_by("original_name", "-version_no") + if selected + else [] + ) + return render( + request, + "attachment_manager.html", + { + "page_title": "附件管理", + "conversations": conversations, + "selected_conversation": selected, + "attachments": attachments, + }, + ) + + @login_required @require_http_methods(["POST"]) def stream_chat(request: HttpRequest) -> HttpResponse: diff --git a/static/css/login.css b/static/css/login.css index 30cb7b3..212ead0 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -367,6 +367,8 @@ input:focus { } .tab { + display: inline-flex; + align-items: center; height: 60px; padding: 0 20px; border: 0; @@ -376,6 +378,7 @@ input:focus { font: inherit; font-weight: 600; border-bottom: 2px solid transparent; + text-decoration: none; } .tab.active { @@ -889,6 +892,23 @@ input:focus { font-size: 12px; } +.attachment-manager-link { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--accent); + text-decoration: none; + font-weight: 700; +} + +.attachment-manager-link:hover { + border-color: var(--accent); + background: #eaf2ff; +} + .upload-status { margin: 0; line-height: 1.5; @@ -1177,6 +1197,215 @@ input:focus { } } +.attachment-manager-page { + display: grid; + align-content: start; + gap: 12px; + min-height: 0; + height: calc(100vh - 60px); + overflow-y: auto; + padding: 16px 24px 20px; + background: var(--bg); +} + +.attachment-manager-hero, +.attachment-manager-panel, +.attachment-manager-content { + width: min(1440px, 100%); + margin: 0 auto; +} + +.attachment-manager-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0; +} + +.attachment-manager-hero h1 { + margin: 2px 0; + font-size: 22px; +} + +.attachment-manager-hero p { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.attachment-manager-toolbar { + min-height: 66px; +} + +.attachment-manager-selectbar { + display: grid; + grid-template-columns: auto minmax(420px, 680px) auto; + align-items: center; + gap: 10px; + min-width: min(900px, 60vw); +} + +.attachment-manager-selectbar label { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.return-chat-link { + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--accent); + text-decoration: none; + font-weight: 700; +} + +.attachment-manager-panel { + display: grid; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; +} + +.attachment-manager-panel label { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.attachment-manager-panel select, +.attachment-search { + min-height: 34px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.attachment-manager-panel select, +.attachment-manager-select-control { + width: 100%; + height: 38px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--text); + font: inherit; +} + +.attachment-manager-select-control:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(58, 114, 216, 0.14); + outline: none; +} + +.attachment-manager-content { + display: grid; + gap: 12px; +} + +.attachment-manager-split { + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + align-items: start; +} + +.attachment-search { + width: 220px; + padding: 0 10px; +} + +.manager-upload-dropzone { + min-height: 132px; + padding: 14px; +} + +.upload-manager-panel .summary-subheading span { + color: var(--muted); + font-size: 12px; +} + +.attachment-table-wrap { + overflow-x: auto; +} + +.attachment-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.attachment-table th, +.attachment-table td { + padding: 10px 8px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; +} + +.attachment-table th { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.attachment-name { + max-width: 360px; + overflow-wrap: anywhere; + font-weight: 700; +} + +.attachment-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.attachment-actions a, +.attachment-actions button { + min-height: 28px; + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + text-decoration: none; +} + +.attachment-actions a:hover, +.attachment-actions button:hover { + border-color: var(--accent); + background: #eaf2ff; +} + +.table-empty, +.attachment-manager-empty { + color: var(--muted); + text-align: center; +} + +.attachment-manager-empty { + min-height: 150px; + place-content: center; +} + +.attachment-manager-empty h2 { + margin: 0; + font-size: 18px; +} + +.attachment-manager-empty p { + margin: 0; +} + @media (max-width: 640px) { .tabbar { overflow-x: auto; @@ -1244,6 +1473,30 @@ input:focus { width: 10px; height: 10px; } + + .attachment-manager-page { + height: auto; + min-height: calc(100vh - 60px); + padding: 12px; + } + + .attachment-manager-hero { + align-items: stretch; + flex-direction: column; + } + + .attachment-manager-selectbar { + grid-template-columns: 1fr; + min-width: 0; + } + + .attachment-manager-split { + grid-template-columns: 1fr; + } + + .attachment-search { + width: 100%; + } } @keyframes pulse-caret { diff --git a/static/js/attachment_manager.js b/static/js/attachment_manager.js new file mode 100644 index 0000000..0b565c3 --- /dev/null +++ b/static/js/attachment_manager.js @@ -0,0 +1,147 @@ +(function () { + var page = document.querySelector(".attachment-manager-page"); + if (!page) { + return; + } + + var conversationSelect = document.getElementById("attachmentConversationSelect"); + var uploadDropzone = document.getElementById("managerUploadDropzone"); + var attachmentInput = document.getElementById("managerAttachmentInput"); + var uploadStatus = document.getElementById("managerUploadStatus"); + var searchInput = document.getElementById("attachmentSearch"); + var table = document.getElementById("attachmentManagerTable"); + + function csrfToken() { + var cookie = document.cookie.split("; ").find(function (item) { + return item.indexOf("csrftoken=") === 0; + }); + return cookie ? decodeURIComponent(cookie.split("=")[1]) : ""; + } + + function selectedConversationUrl(id) { + return id ? "/attachments/?conversation=" + encodeURIComponent(id) : "/attachments/"; + } + + async function patchAttachment(row, payload) { + var response = await fetch(row.getAttribute("data-update-url"), { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken(), + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error("附件更新失败。"); + } + return response.json(); + } + + async function deleteAttachment(row) { + var response = await fetch(row.getAttribute("data-update-url"), { + method: "DELETE", + headers: { "X-CSRFToken": csrfToken() }, + }); + if (!response.ok) { + throw new Error("附件删除失败。"); + } + } + + async function uploadFiles(files) { + if (!uploadDropzone || !files || !files.length) { + return; + } + var formData = new FormData(); + Array.prototype.forEach.call(files, function (file) { + formData.append("files", file); + }); + if (uploadStatus) { + uploadStatus.textContent = "上传中..."; + } + try { + var response = await fetch(uploadDropzone.getAttribute("data-upload-url"), { + method: "POST", + headers: { "X-CSRFToken": csrfToken() }, + body: formData, + }); + if (!response.ok) { + throw new Error("上传失败。"); + } + window.location.reload(); + } catch (error) { + if (uploadStatus) { + uploadStatus.textContent = "上传失败,请重试。"; + } + } + } + + if (conversationSelect) { + conversationSelect.addEventListener("change", function () { + window.location.href = selectedConversationUrl(conversationSelect.value); + }); + } + + 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 = ""; + }); + } + + if (searchInput && table) { + searchInput.addEventListener("input", function () { + var keyword = searchInput.value.trim().toLowerCase(); + table.querySelectorAll("tbody tr[data-attachment-id]").forEach(function (row) { + var name = (row.querySelector(".attachment-name") || row).textContent.toLowerCase(); + row.hidden = keyword && name.indexOf(keyword) === -1; + }); + }); + } + + if (table) { + table.addEventListener("click", async function (event) { + var actionButton = event.target.closest("[data-attachment-action]"); + if (!actionButton) { + return; + } + var row = actionButton.closest("tr[data-attachment-id]"); + if (!row) { + return; + } + var action = actionButton.getAttribute("data-attachment-action"); + try { + if (action === "edit") { + var nameCell = row.querySelector(".attachment-name"); + var nextName = window.prompt("请输入新的附件展示名", nameCell ? nameCell.textContent.trim() : ""); + if (nextName) { + await patchAttachment(row, { original_name: nextName }); + window.location.reload(); + } + } else if (action === "toggle") { + await patchAttachment(row, { is_active: actionButton.textContent.trim() === "启用" }); + window.location.reload(); + } else if (action === "delete" && window.confirm("确认删除该附件?")) { + await deleteAttachment(row); + window.location.reload(); + } + } catch (error) { + window.alert(error.message || "附件操作失败。"); + } + }); + } +})(); diff --git a/templates/attachment_manager.html b/templates/attachment_manager.html new file mode 100644 index 0000000..72e55dc --- /dev/null +++ b/templates/attachment_manager.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}附件管理 - DEMO-AGENT V2{% endblock %} +{% block body_class %}app-body{% endblock %} + +{% block content %} +
+
+
+
+ 首页 + + 审核智能体 + 附件管理 +
+
+
+
+ +
+
+
+ +
+
+
+

附件管理

+

附件管理

+

管理各对话下上传的审核资料、版本、状态和下载。

+
+
+ + + {% if selected_conversation %} + 返回对话 + {% endif %} +
+
+ + {% if selected_conversation %} +
+
+
+

上传附件

+ {{ selected_conversation.title|default:"新对话" }} +
+
+ + 拖拽文件到这里 + 支持 doc、docx、xls、xlsx、ppt、pptx、pdf、zip、7z、rar +
+

上传后会归属到当前选择的对话。

+
+ +
+
+

附件列表

+ +
+
+ + + + + + + + + + + + + {% for attachment in attachments %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
状态文件名版本大小上传时间操作
{% if attachment.is_active %}启用{% else %}禁用{% endif %}{{ attachment.original_name }}v{{ attachment.version_no }}{{ attachment.file_size }} bytes{{ attachment.created_at|date:"Y-m-d H:i" }} + 下载 + + + +
当前对话暂无附件
+
+
+
+ {% else %} +
+

请选择一个对话查看附件

+

通过上方下拉框选择对话后,可上传、下载、编辑、启用禁用或删除附件。

+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/home.html b/templates/home.html index 90cca20..24196dc 100644 --- a/templates/home.html +++ b/templates/home.html @@ -9,10 +9,10 @@
- + 首页 - - + 审核智能体 + 附件管理
@@ -195,6 +195,11 @@

附件

+
{% for attachment in attachments %} diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index e60bc35..87b3a88 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -1,7 +1,7 @@ import pytest from django.urls import reverse -from review_agent.models import Conversation, FileSummaryBatch, Message, WorkflowNodeRun +from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun pytestmark = pytest.mark.django_db @@ -32,6 +32,82 @@ def test_workspace_renders_summary_panel(client, django_user_model): assert "自动汇总文件目录与页数" in content +def test_workspace_links_to_attachment_manager(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 "附件管理" in content + assert "视频实时监测" not in content + assert f'href="{reverse("attachment_manager")}?conversation={conversation.pk}"' in content + assert 'class="attachment-manager-link"' in content + + +def test_attachment_manager_requires_conversation_selection(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + Conversation.objects.create(user=user, title="待选择会话") + client.force_login(user) + + response = client.get(reverse("attachment_manager")) + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "附件管理" in content + assert "请选择一个对话查看附件" in content + assert "待选择会话" in content + assert 'id="attachmentConversationSelect"' in content + + +def test_attachment_manager_selects_conversation_and_lists_attachments(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="资料会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=128, + is_active=True, + ) + client.force_login(user) + + response = client.get(f"{reverse('attachment_manager')}?conversation={conversation.pk}") + + assert response.status_code == 200 + content = response.content.decode("utf-8") + assert "资料会话" in content + assert "a.docx" in content + assert "下载" in content + assert "编辑" in content + assert "删除" in content + assert "attachment-manager-split" in content + assert reverse("home") + f"?conversation={conversation.pk}" in content + + +def test_attachment_manager_uses_compact_admin_layout(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + Conversation.objects.create(user=user, title="紧凑会话") + client.force_login(user) + + response = client.get(reverse("attachment_manager")) + + assert response.status_code == 200 + content = response.content.decode("utf-8") + css = open("static/css/login.css", encoding="utf-8").read() + assert "attachment-manager-toolbar" in content + assert "attachment-manager-content" in content + assert "attachment-manager-select-control" in content + assert ".attachment-manager-page" in css + assert "align-content: start" in css + assert ".attachment-manager-toolbar" in css + assert ".attachment-manager-select-control" in css + assert ".attachment-manager-split" in css + + 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="会话")