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 apps.scenarios.services import ScenarioNotFound, get_scenario
from apps.scenarios.services import list_scenarios
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
class DocumentUploadForm(forms.Form):
scenario_id = forms.CharField(label="场景")
# 使用 ChoiceField 让表单自己维护场景选项,
# 这样模板、校验和后续扩展都能围绕一个入口完成。
scenario_id = forms.ChoiceField(label="场景", choices=())
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):
scenario_id = self.cleaned_data["scenario_id"]
try:

View File

@@ -2,6 +2,7 @@ from django.db import models
class UploadedDocument(models.Model):
# 文档状态用于驱动前端提示和后续可操作项。
STATUS_UPLOADED = "uploaded"
STATUS_INDEXED = "indexed"
STATUS_FAILED = "failed"
@@ -21,3 +22,11 @@ class UploadedDocument(models.Model):
def __str__(self) -> str:
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.views.decorators.http import require_POST
@@ -9,15 +10,18 @@ from .services import create_uploaded_document, index_document
def document_list(request):
# 列表页只负责展示文档元数据和可执行操作,不处理入库细节。
documents = UploadedDocument.objects.all()
return render(request, "documents/document_list.html", {"documents": documents})
def upload(request):
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发。
if request.method == "POST":
form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid():
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"])
messages.success(request, "文件已上传,可继续执行入库。")
return redirect("documents:list")
else:
form = DocumentUploadForm()
@@ -31,5 +35,9 @@ def upload(request):
@require_POST
def index(request, document_id: int):
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")

View File

@@ -338,6 +338,13 @@
<a class="nav-link" href="{% url 'audit:list' %}">审计日志</a>
</nav>
</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 %}
</div>
</body>

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from apps.documents.forms import DocumentUploadForm
from apps.documents.models import UploadedDocument
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"
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):
for filename, payload in [
("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 "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