feat(公开内容): 添加临时留言功能,留言保留10分钟,显示用户名、内容、时间和来源IP

This commit is contained in:
xiaji
2026-05-25 22:04:57 +08:00
parent df595c706c
commit b1cf94cd23
8 changed files with 449 additions and 5 deletions

View File

@@ -9,7 +9,8 @@ from .models import (
TodayPlan,
SystemConfig,
FamilyMember,
PublicContent
PublicContent,
TempMessage
)
class ReadingRecordForm(forms.ModelForm):
@@ -142,4 +143,21 @@ class TempUploadForm(forms.ModelForm):
class Meta:
model = PublicContent
fields = ['title', 'file']
fields = ['title', 'file']
class TempMessageForm(forms.ModelForm):
"""临时留言表单"""
class Meta:
model = TempMessage
fields = ['username', 'content']
widgets = {
'username': forms.TextInput(attrs={'class': 'form-control', 'maxlength': '20', 'placeholder': '用户名可选最多20字节'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'maxlength': '1000', 'placeholder': '说点什么...最多1000字节'}),
}
def clean_content(self):
content = self.cleaned_data.get('content', '')
if len(content.encode('utf-8')) > 1000:
raise forms.ValidationError("内容不能超过1000字节")
return content

View File

@@ -262,3 +262,20 @@ class PublicContent(models.Model):
def __str__(self):
return f"{self.type.name} - {self.title}"
class TempMessage(models.Model):
"""临时留言"""
username = models.CharField(max_length=20, blank=True, null=True, verbose_name="用户名")
content = models.CharField(max_length=1000, verbose_name="内容")
ip_address = models.GenericIPAddressField(blank=True, null=True, verbose_name="来源IP")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
expire_at = models.DateTimeField(blank=True, null=True, verbose_name="过期时间")
class Meta:
verbose_name = "临时留言"
verbose_name_plural = "临时留言"
ordering = ['-created_at']
def __str__(self):
return f"{self.username or '匿名'} - {self.content[:20]}..."

View File

@@ -621,3 +621,49 @@ def cleanup_expired_temp_files(self):
'error': error_msg,
'timestamp': timezone.now().isoformat()
}
@shared_task(bind=True)
def cleanup_expired_messages(self):
"""
清理过期的临时留言
每5分钟执行一次删除已过期的留言
"""
task_id = self.request.id if hasattr(self, 'request') else 'unknown'
logger.info(f"[任务 {task_id}] 开始执行清理过期留言任务")
try:
from core.models import TempMessage
expired_messages = TempMessage.objects.filter(expire_at__lte=timezone.now())
deleted_count = 0
for message in expired_messages:
try:
msg_content = message.content[:20]
message.delete()
deleted_count += 1
logger.info(f"[任务 {task_id}] 已删除过期留言: {msg_content}...")
except Exception as e:
logger.error(f"[任务 {task_id}] 删除留言失败: {str(e)}")
logger.success(f"[任务 {task_id}] 清理完成,共删除 {deleted_count} 条过期留言")
return {
'status': 'success',
'task_id': task_id,
'deleted_count': deleted_count,
'timestamp': timezone.now().isoformat()
}
except Exception as e:
error_msg = str(e)
error_traceback = traceback.format_exc()
logger.error(f"[任务 {task_id}] 清理过期留言失败: {error_msg}")
logger.error(f"[任务 {task_id}] 错误详情:\n{error_traceback}")
return {
'status': 'failed',
'task_id': task_id,
'error': error_msg,
'timestamp': timezone.now().isoformat()
}

View File

@@ -1,6 +1,140 @@
{% extends 'core/base.html' %}
{% load humanize %}
{% block content %}
<style>
.api-float-panel {
position: fixed;
top: 80px;
right: 16px;
width: 280px;
z-index: 1050;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(8px);
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
transition: transform 0.3s ease, opacity 0.3s ease;
}
.api-float-panel.collapsed {
transform: translateX(260px);
}
.api-float-panel .panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: linear-gradient(135deg, #0d6efd, #6610f2);
color: #fff;
border-radius: 8px 8px 0 0;
font-size: 13px;
font-weight: 600;
cursor: default;
}
.api-float-panel .panel-body {
padding: 10px 12px;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
}
.api-float-panel .panel-body code {
font-size: 11px;
background: #f8f9fa;
padding: 1px 4px;
border-radius: 3px;
color: #d63384;
}
.api-float-panel .panel-body pre {
font-size: 11px;
margin: 4px 0 0;
padding: 6px 8px;
border-radius: 4px;
line-height: 1.4;
}
.api-float-panel .btn-toggle {
background: none;
border: none;
color: #fff;
font-size: 16px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
opacity: 0.8;
transition: opacity 0.2s;
}
.api-float-panel .btn-toggle:hover {
opacity: 1;
}
.api-float-panel .panel-tab {
display: none;
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
background: linear-gradient(135deg, #0d6efd, #6610f2);
color: #fff;
padding: 10px 6px;
border-radius: 6px 0 0 6px;
font-size: 12px;
cursor: pointer;
writing-mode: vertical-rl;
letter-spacing: 2px;
z-index: 1049;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
transition: opacity 0.3s;
}
.api-float-panel.collapsed .panel-tab {
display: block;
}
.api-float-panel.collapsed .panel-header,
.api-float-panel.collapsed .panel-body {
display: none;
}
@media (max-width: 768px) {
.api-float-panel {
width: 240px;
right: 4px;
top: 70px;
}
.api-float-panel.collapsed {
transform: translateX(225px);
}
}
</style>
<!-- 浮动API说明面板 -->
<div class="api-float-panel" id="apiFloatPanel">
<div class="panel-tab" onclick="toggleApiPanel()" title="展开API说明">API 说明</div>
<div class="panel-header">
<span><i class="bi bi-terminal me-1"></i>API 说明</span>
<button class="btn-toggle" onclick="toggleApiPanel()" title="最小化"></button>
</div>
<div class="panel-body">
<p class="mb-1"><strong>端点:</strong></p>
<code class="d-block mb-2">POST /api/v1/temp-upload/</code>
<p class="mb-1"><strong>参数:</strong></p>
<ul class="small mb-2 ps-3">
<li><code>title</code> 文件标题</li>
<li><code>file</code> 文件(≤500MB)</li>
<li><code>expire_type</code> expire_1h / 1d / 7d</li>
</ul>
<p class="mb-1"><strong>cURL:</strong></p>
<pre class="bg-dark text-white"><code>curl -X POST \
-F "file=@f.pdf" \
-F "title=文件" \
-F "expire_type=expire_1d" \
/api/v1/temp-upload/</code></pre>
</div>
</div>
<script>
function toggleApiPanel() {
var panel = document.getElementById('apiFloatPanel');
panel.classList.toggle('collapsed');
}
</script>
<!-- 页面标题 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
@@ -35,6 +169,64 @@
</div>
</div>
<!-- 临时发言区域 -->
<div class="card mb-4">
<div class="card-header bg-secondary text-white">
<h5 class="card-title mb-0">
<i class="bi bi-chat-left-text me-2"></i>临时发言
<span class="badge bg-light text-dark ms-2">留言仅保留10分钟</span>
</h5>
</div>
<div class="card-body">
<form method="post" action="{% url 'public_content' %}" class="mb-3">
{% csrf_token %}
<div class="row g-2">
<div class="col-md-3">
<input type="text" name="username" class="form-control" maxlength="20" placeholder="用户名(可选)">
</div>
<div class="col-md-7">
<input type="text" name="content" class="form-control" maxlength="1000" placeholder="说点什么...最多1000字节" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-send me-1"></i>发送
</button>
</div>
</div>
</form>
{% if temp_messages %}
<div class="list-group mt-3">
{% for message in temp_messages %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<i class="bi bi-person me-1"></i>{{ message.username|default:"匿名" }}
<small class="text-muted ms-2">
<i class="bi bi-clock me-1"></i>{{ message.created_at|naturaltime }}
</small>
</h6>
<p class="mb-1">{{ message.content }}</p>
<small class="text-muted">
<i class="bi bi-geo-alt me-1"></i>IP: {{ message.ip_address|default:"未知" }}
</small>
</div>
<span class="badge bg-secondary">
{{ message.created_at|naturaltime }}
</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center mb-0">
<i class="bi bi-chat-left me-1"></i>暂无留言
</p>
{% endif %}
</div>
</div>
{% if content_by_type %}
{% for type_name, contents in content_by_type.items %}
<div class="card mb-4">
@@ -71,7 +263,6 @@
</a>
{% endif %}
{% if content.is_temp_file and content.expire_at %}
{% load humanize %}
<span class="badge bg-secondary">
<i class="bi bi-clock me-1"></i>{{ content.expire_at|naturaltime }}过期
</span>

View File

@@ -50,7 +50,8 @@ from .models import (
FamilyMember,
SummaryCategory,
PublicContentType,
PublicContent
PublicContent,
TempMessage
)
from .forms import (
ReadingRecordForm,
@@ -60,7 +61,8 @@ from .forms import (
TodayPlanForm,
SystemConfigForm,
PublicContentForm,
TempUploadForm
TempUploadForm,
TempMessageForm
)
# 首页视图
@@ -1044,6 +1046,24 @@ def user_logout(request):
def public_content(request):
"""公开内容页面 - 无需登录"""
logger.info("用户访问公开内容页面")
if request.method == 'POST':
form = TempMessageForm(request.POST)
if form.is_valid():
message = form.save(commit=False)
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
message.ip_address = x_forwarded_for.split(',')[0].strip()
else:
message.ip_address = request.META.get('REMOTE_ADDR')
from datetime import timedelta
message.expire_at = timezone.now() + timedelta(minutes=10)
message.save()
logger.info(f"临时留言: {message.username or '匿名'} - {message.content[:20]}...")
return redirect('public_content')
else:
form = TempMessageForm()
public_contents = PublicContent.objects.filter(is_published=True)
content_by_type = {}
@@ -1054,10 +1074,13 @@ def public_content(request):
content_by_type[type_name].append(content)
temp_upload_form = TempUploadForm()
temp_messages = TempMessage.objects.filter(expire_at__gt=timezone.now())
context = {
'content_by_type': content_by_type,
'temp_upload_form': temp_upload_form,
'temp_messages': temp_messages,
'temp_message_form': form,
}
return render(request, 'core/public_content.html', context)