feat(documents): 增强上传反馈与状态展示

This commit is contained in:
2026-05-30 00:29:03 +08:00
parent 905067277a
commit c2b3a3b4f7
7 changed files with 91 additions and 9 deletions

View File

@@ -3,14 +3,24 @@ from pathlib import Path
from django import forms from django import forms
from apps.scenarios.services import ScenarioNotFound, get_scenario from apps.scenarios.services import ScenarioNotFound, get_scenario
from apps.scenarios.services import list_scenarios
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"} SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
class DocumentUploadForm(forms.Form): class DocumentUploadForm(forms.Form):
scenario_id = forms.CharField(label="场景") # 使用 ChoiceField 让表单自己维护场景选项,
# 这样模板、校验和后续扩展都能围绕一个入口完成。
scenario_id = forms.ChoiceField(label="场景", choices=())
file = forms.FileField(label="文件") file = forms.FileField(label="文件")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["scenario_id"].choices = [
(scenario["id"], scenario["name"])
for scenario in list_scenarios()
]
def clean_scenario_id(self): def clean_scenario_id(self):
scenario_id = self.cleaned_data["scenario_id"] scenario_id = self.cleaned_data["scenario_id"]
try: try:

View File

@@ -2,6 +2,7 @@ from django.db import models
class UploadedDocument(models.Model): class UploadedDocument(models.Model):
# 文档状态用于驱动前端提示和后续可操作项。
STATUS_UPLOADED = "uploaded" STATUS_UPLOADED = "uploaded"
STATUS_INDEXED = "indexed" STATUS_INDEXED = "indexed"
STATUS_FAILED = "failed" STATUS_FAILED = "failed"
@@ -21,3 +22,11 @@ class UploadedDocument(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return self.original_name return self.original_name
def get_status_display_text(self) -> str:
"""为模板提供更适合演示的中文状态文案。"""
return {
self.STATUS_UPLOADED: "已上传,待入库",
self.STATUS_INDEXED: "已入库,可检索",
self.STATUS_FAILED: "入库失败",
}.get(self.status, self.status)

View File

@@ -1,3 +1,4 @@
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@@ -9,15 +10,18 @@ from .services import create_uploaded_document, index_document
def document_list(request): def document_list(request):
# 列表页只负责展示文档元数据和可执行操作,不处理入库细节。
documents = UploadedDocument.objects.all() documents = UploadedDocument.objects.all()
return render(request, "documents/document_list.html", {"documents": documents}) return render(request, "documents/document_list.html", {"documents": documents})
def upload(request): def upload(request):
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发。
if request.method == "POST": if request.method == "POST":
form = DocumentUploadForm(request.POST, request.FILES) form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"]) create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"])
messages.success(request, "文件已上传,可继续执行入库。")
return redirect("documents:list") return redirect("documents:list")
else: else:
form = DocumentUploadForm() form = DocumentUploadForm()
@@ -31,5 +35,9 @@ def upload(request):
@require_POST @require_POST
def index(request, document_id: int): def index(request, document_id: int):
document = get_object_or_404(UploadedDocument, pk=document_id) document = get_object_or_404(UploadedDocument, pk=document_id)
index_document(document) document = index_document(document)
if document.status == UploadedDocument.STATUS_INDEXED:
messages.success(request, "文档入库成功,当前文档已可参与检索。")
else:
messages.error(request, "文档入库失败,请检查错误原因后重试。")
return redirect("documents:list") return redirect("documents:list")

View File

@@ -338,6 +338,13 @@
<a class="nav-link" href="{% url 'audit:list' %}">审计日志</a> <a class="nav-link" href="{% url 'audit:list' %}">审计日志</a>
</nav> </nav>
</header> </header>
{% if messages %}
<section class="stack" style="margin-bottom: 18px;">
{% for message in messages %}
<div class="notice{% if message.tags == 'error' %} notice-error{% endif %}">{{ message }}</div>
{% endfor %}
</section>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</body> </body>

View File

@@ -29,7 +29,7 @@
<td>{{ document.scenario_id }}</td> <td>{{ document.scenario_id }}</td>
<td>{{ document.file_type }}</td> <td>{{ document.file_type }}</td>
<td>{{ document.size }}</td> <td>{{ document.size }}</td>
<td>{{ document.status }}</td> <td>{{ document.get_status_display_text }}</td>
<td> <td>
{% if document.status != "indexed" %} {% if document.status != "indexed" %}
<form action="{% url 'documents:index' document.id %}" method="post"> <form action="{% url 'documents:index' document.id %}" method="post">
@@ -42,6 +42,7 @@
{% if document.error_message %} {% if document.error_message %}
<pre class="code-block" style="margin-top: 10px;">{{ document.error_message }}</pre> <pre class="code-block" style="margin-top: 10px;">{{ document.error_message }}</pre>
{% endif %} {% endif %}
<p class="muted" style="margin-top: 10px;">上传时间:{{ document.created_at|date:"Y-m-d H:i" }}</p>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@@ -13,12 +13,11 @@
<form method="post" enctype="multipart/form-data" class="stack"> <form method="post" enctype="multipart/form-data" class="stack">
{% csrf_token %} {% csrf_token %}
<div> <div>
<label for="id_scenario_id">关联场景</label> {{ form.scenario_id.label_tag }}
<select name="scenario_id" id="id_scenario_id"> {{ form.scenario_id }}
{% for scenario in scenarios %} {% if form.scenario_id.errors %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option> <p class="notice notice-error">{{ form.scenario_id.errors|join:" " }}</p>
{% endfor %} {% endif %}
</select>
</div> </div>
<div> <div>
{{ form.file.label_tag }} {{ form.file.label_tag }}

View File

@@ -1,6 +1,7 @@
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from apps.documents.forms import DocumentUploadForm
from apps.documents.models import UploadedDocument from apps.documents.models import UploadedDocument
from apps.documents.services import extract_text from apps.documents.services import extract_text
@@ -20,6 +21,19 @@ def test_upload_txt_document_creates_uploaded_record(client, db):
assert document.scenario_id == "knowledge_qa" assert document.scenario_id == "knowledge_qa"
def test_upload_redirect_shows_success_message(client, db):
file = SimpleUploadedFile("notice.txt", "hello".encode("utf-8"), content_type="text/plain")
response = client.post(
reverse("documents:upload"),
{"scenario_id": "knowledge_qa", "file": file},
follow=True,
)
assert response.status_code == 200
assert "文件已上传,可继续执行入库" in response.content.decode("utf-8")
def test_upload_accepts_pdf_and_docx_documents(client, db): def test_upload_accepts_pdf_and_docx_documents(client, db):
for filename, payload in [ for filename, payload in [
("policy.pdf", b"%PDF-1.4\nplain policy text"), ("policy.pdf", b"%PDF-1.4\nplain policy text"),
@@ -80,3 +94,37 @@ def test_extract_text_supports_pdf_and_docx_plain_text_fallback(db):
assert "Safety policy" in extract_text(pdf_document) assert "Safety policy" in extract_text(pdf_document)
assert "Contract clause review" in extract_text(docx_document) assert "Contract clause review" in extract_text(docx_document)
def test_document_upload_form_builds_scenario_choices():
form = DocumentUploadForm()
choice_values = [value for value, _label in form.fields["scenario_id"].choices]
assert "knowledge_qa" in choice_values
assert "quality_analysis" in choice_values
def test_index_failure_message_is_visible_on_document_list(client, db, monkeypatch):
document = UploadedDocument.objects.create(
scenario_id="knowledge_qa",
original_name="broken.md",
file_type="md",
size=5,
status="uploaded",
)
def fake_index_document(target_document):
target_document.status = UploadedDocument.STATUS_FAILED
target_document.error_message = "模拟入库失败"
target_document.save(update_fields=["status", "error_message", "updated_at"])
return target_document
monkeypatch.setattr("apps.documents.views.index_document", fake_index_document)
response = client.post(reverse("documents:index", args=[document.id]), follow=True)
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "文档入库失败,请检查错误原因后重试" in content
assert "模拟入库失败" in content