feat(attachments): 新增附件管理页面

This commit is contained in:
2026-06-06 22:45:48 +08:00
parent 0fca20756b
commit df3f393dd2
7 changed files with 660 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -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 || "附件操作失败。");
}
});
}
})();

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% load static %}
{% block title %}附件管理 - DEMO-AGENT V2{% endblock %}
{% block body_class %}app-body{% endblock %}
{% block content %}
<main class="app-shell">
<header class="topbar">
<div class="topbar-left">
<div class="tabbar" role="tablist" aria-label="页面切换">
<a class="tab" href="/" role="tab" aria-selected="false">首页</a>
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button>
<a class="tab" href="/" role="tab" aria-selected="false">审核智能体</a>
<a class="tab active" href="{% url 'attachment_manager' %}" role="tab" aria-selected="true">附件管理</a>
</div>
</div>
<div class="topbar-right">
<div class="user-menu">
<button class="user-menu-trigger" type="button">
<span class="avatar large">{{ request.user.username|slice:":1"|upper }}</span>
<div class="user-copy">
<strong>{{ request.user.username }}</strong>
<span>当前登录用户</span>
</div>
</button>
</div>
</div>
</header>
<section
class="attachment-manager-page"
data-selected-conversation="{% if selected_conversation %}{{ selected_conversation.pk }}{% endif %}"
>
<header class="attachment-manager-hero attachment-manager-toolbar">
<div>
<p class="eyebrow">附件管理</p>
<h1>附件管理</h1>
<p>管理各对话下上传的审核资料、版本、状态和下载。</p>
</div>
<div class="attachment-manager-selectbar">
<label for="attachmentConversationSelect">对话</label>
<select class="attachment-manager-select-control" id="attachmentConversationSelect">
<option value="">请选择对话</option>
{% for conversation in conversations %}
<option
value="{{ conversation.pk }}"
{% if selected_conversation and selected_conversation.pk == conversation.pk %}selected{% endif %}
>
{{ conversation.title|default:"新对话" }} · {{ conversation.updated_at|date:"m月d日 H:i" }} · {{ conversation.attachment_count }} 个附件
</option>
{% endfor %}
</select>
{% if selected_conversation %}
<a class="return-chat-link" href="{% url 'home' %}?conversation={{ selected_conversation.pk }}">返回对话</a>
{% endif %}
</div>
</header>
{% if selected_conversation %}
<div class="attachment-manager-content attachment-manager-split">
<section class="attachment-manager-panel upload-manager-panel">
<div class="summary-subheading">
<h3>上传附件</h3>
<span>{{ selected_conversation.title|default:"新对话" }}</span>
</div>
<div
class="upload-dropzone manager-upload-dropzone"
id="managerUploadDropzone"
data-upload-url="{% url 'file_summary_attachment_upload' selected_conversation.pk %}"
tabindex="0"
role="button"
>
<input id="managerAttachmentInput" type="file" multiple hidden>
<strong>拖拽文件到这里</strong>
<span>支持 doc、docx、xls、xlsx、ppt、pptx、pdf、zip、7z、rar</span>
</div>
<p class="upload-status" id="managerUploadStatus">上传后会归属到当前选择的对话。</p>
</section>
<section class="attachment-manager-panel">
<div class="summary-subheading">
<h3>附件列表</h3>
<input class="attachment-search" id="attachmentSearch" type="search" placeholder="搜索文件名">
</div>
<div class="attachment-table-wrap">
<table class="attachment-table" id="attachmentManagerTable">
<thead>
<tr>
<th>状态</th>
<th>文件名</th>
<th>版本</th>
<th>大小</th>
<th>上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for attachment in attachments %}
<tr
data-attachment-id="{{ attachment.pk }}"
data-update-url="{% url 'file_summary_attachment_detail' selected_conversation.pk attachment.pk %}"
data-download-url="{% url 'file_summary_attachment_download' selected_conversation.pk attachment.pk %}"
>
<td>{% if attachment.is_active %}启用{% else %}禁用{% endif %}</td>
<td class="attachment-name">{{ attachment.original_name }}</td>
<td>v{{ attachment.version_no }}</td>
<td>{{ attachment.file_size }} bytes</td>
<td>{{ attachment.created_at|date:"Y-m-d H:i" }}</td>
<td class="attachment-actions">
<a href="{% url 'file_summary_attachment_download' selected_conversation.pk attachment.pk %}">下载</a>
<button type="button" data-attachment-action="edit">编辑</button>
<button type="button" data-attachment-action="toggle">{% if attachment.is_active %}禁用{% else %}启用{% endif %}</button>
<button type="button" data-attachment-action="delete">删除</button>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="table-empty">当前对话暂无附件</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% else %}
<section class="attachment-manager-panel attachment-manager-empty attachment-manager-content">
<h2>请选择一个对话查看附件</h2>
<p>通过上方下拉框选择对话后,可上传、下载、编辑、启用禁用或删除附件。</p>
</section>
{% endif %}
</section>
</main>
{% endblock %}
{% block scripts %}
<script src="{% static 'js/attachment_manager.js' %}"></script>
{% endblock %}

View File

@@ -9,10 +9,10 @@
<header class="topbar">
<div class="topbar-left">
<div class="tabbar" role="tablist" aria-label="页面切换">
<button class="tab" type="button" role="tab" aria-selected="false">首页</button>
<a class="tab" href="/" role="tab" aria-selected="false">首页</a>
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button>
<button class="tab active" type="button" role="tab" aria-selected="true">审核智能体</button>
<button class="tab" type="button" role="tab" aria-selected="false">视频实时监测</button>
<a class="tab active" href="/" role="tab" aria-selected="true">审核智能体</a>
<a class="tab" href="{% url 'attachment_manager' %}" role="tab" aria-selected="false">附件管理</a>
</div>
</div>
@@ -195,6 +195,11 @@
<section class="summary-section attachment-section">
<div class="summary-subheading">
<h3>附件</h3>
<a
class="attachment-manager-link"
href="{% url 'attachment_manager' %}{% if current_conversation %}?conversation={{ current_conversation.pk }}{% endif %}"
aria-label="打开附件管理页面"
></a>
</div>
<div class="attachment-list" id="attachmentList">
{% for attachment in attachments %}

View File

@@ -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="会话")