diff --git a/forum/form.py b/forum/form.py
index d73e9a0..05ff949 100644
--- a/forum/form.py
+++ b/forum/form.py
@@ -1,4 +1,4 @@
-from .models import Post, Comment
+from .models import Post, Comment, Collection
from django import forms
import re
@@ -30,6 +30,32 @@ def save(self, commit=True):
instance.save()
return instance
+class CollectionForm(forms.ModelForm):
+ def __init__(self, *args, user=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user = user
+
+ class Meta:
+ model = Collection
+ fields = ['name', 'description']
+ labels = {
+ 'name': '合集名称',
+ 'description': '描述',
+ }
+ widgets = {
+ 'name': forms.TextInput(attrs={'class': 'form-control'}),
+ 'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
+ }
+
+ def save(self, commit=True):
+ instance = super().save(commit=False)
+ if self.user and not instance.pk:
+ instance.owner = self.user
+ if commit:
+ instance.save()
+ return instance
+
+
# TODO: support @xxx
class MDEditorCommentForm(forms.ModelForm):
def __init__(self, *args, user=None, post=None, **kwargs):
diff --git a/forum/migrations/0005_collection_collectionpost.py b/forum/migrations/0005_collection_collectionpost.py
new file mode 100644
index 0000000..4be0277
--- /dev/null
+++ b/forum/migrations/0005_collection_collectionpost.py
@@ -0,0 +1,42 @@
+# Generated by Django 6.0.2 on 2026-02-24 07:24
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('forum', '0004_post_views'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Collection',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100)),
+ ('description', models.TextField(blank=True, default='')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='CollectionPost',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('order', models.PositiveIntegerField(default=0)),
+ ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_posts', to='forum.collection')),
+ ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_entries', to='forum.post')),
+ ],
+ options={
+ 'ordering': ['order'],
+ 'unique_together': {('collection', 'post')},
+ },
+ ),
+ ]
diff --git a/forum/migrations/0006_collection_description_html.py b/forum/migrations/0006_collection_description_html.py
new file mode 100644
index 0000000..5203a08
--- /dev/null
+++ b/forum/migrations/0006_collection_description_html.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.2 on 2026-02-24 07:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('forum', '0005_collection_collectionpost'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='collection',
+ name='description_html',
+ field=models.TextField(blank=True, editable=False),
+ ),
+ ]
diff --git a/forum/models.py b/forum/models.py
index 53e7cfb..0880d48 100644
--- a/forum/models.py
+++ b/forum/models.py
@@ -3,6 +3,9 @@
from .markdown import MarkdownModel
+import markdown
+import bleach
+
# Create your models here.
class Item(MarkdownModel):
@@ -42,3 +45,43 @@ class Comment(MarkdownModel):
def __str__(self):
return f"{self.author} comment {self.post}"
+
+
+class Collection(models.Model):
+ owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='collections')
+ name = models.CharField(max_length=100)
+ description = models.TextField(blank=True, default='')
+ description_html = models.TextField(editable=False, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return self.name
+
+ def save(self, *args, **kwargs):
+ if self.description:
+ html = markdown.markdown(self.description, extensions=MarkdownModel.MARKDOWN_EXTENSIONS)
+ self.description_html = bleach.clean(
+ html,
+ tags=MarkdownModel.allowed_tags,
+ attributes=MarkdownModel.allowed_attrs,
+ protocols=MarkdownModel.ALLOWED_PROTOCOLS,
+ )
+ else:
+ self.description_html = ''
+ super().save(*args, **kwargs)
+
+
+class CollectionPost(models.Model):
+ collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='collection_posts')
+ post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='collection_entries')
+ order = models.PositiveIntegerField(default=0)
+
+ class Meta:
+ ordering = ['order']
+ unique_together = ('collection', 'post')
+
+ def __str__(self):
+ return f"{self.collection.name} - {self.post.title}"
diff --git a/forum/templates/base.html b/forum/templates/base.html
index dbce856..79f9c83 100644
--- a/forum/templates/base.html
+++ b/forum/templates/base.html
@@ -68,6 +68,9 @@
所有文章
+
+ 合集
+
关于
diff --git a/forum/templates/forum/collection_check_delete.html b/forum/templates/forum/collection_check_delete.html
new file mode 100644
index 0000000..b3b8d37
--- /dev/null
+++ b/forum/templates/forum/collection_check_delete.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+
+{% block title %}确认删除合集{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
删除合集 "{{ object.name }}"
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/forum/templates/forum/collection_detail.html b/forum/templates/forum/collection_detail.html
new file mode 100644
index 0000000..465b31f
--- /dev/null
+++ b/forum/templates/forum/collection_detail.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+
+{% block title %}{{ collection.name }}{% endblock %}
+
+{% block content %}
+{{ collection.name }}
+{% if collection.description_html %}
+{{ collection.description_html|safe }}
+{% endif %}
+
+
+ 创建者:{{ collection.owner.username }} · {{ collection.created_at|date:"Y-m-d" }}
+
+
+{% if user == collection.owner %}
+
+{% endif %}
+
+
+{% for cp in collection_posts %}
+ -
+
+ {{ cp.post.created_at|date:"Y-m-d" }}
+
+{% empty %}
+ - 合集中暂无文章
+{% endfor %}
+
+{% endblock %}
diff --git a/forum/templates/forum/collection_form.html b/forum/templates/forum/collection_form.html
new file mode 100644
index 0000000..7a483bb
--- /dev/null
+++ b/forum/templates/forum/collection_form.html
@@ -0,0 +1,22 @@
+{% extends 'base.html' %}
+
+{% block title %}{{ title }}{% endblock %}
+
+{% block content %}
+{{ title }}
+
+
+{% endblock %}
diff --git a/forum/templates/forum/collection_list.html b/forum/templates/forum/collection_list.html
new file mode 100644
index 0000000..9e9c854
--- /dev/null
+++ b/forum/templates/forum/collection_list.html
@@ -0,0 +1,27 @@
+{% extends 'base.html' %}
+
+{% block title %}合集{% endblock %}
+
+{% block content %}
+合集
+{% if user.is_authenticated %}
+创建合集
+{% endif %}
+
+
+{% for collection in collections %}
+ -
+
+
{{ collection.name }}
+
by {{ collection.owner.username }}
+ {% if collection.description %}
+
{{ collection.description }}
+ {% endif %}
+
+ {{ collection.collection_posts.count }} 篇
+
+{% empty %}
+ - 暂无合集
+{% endfor %}
+
+{% endblock %}
diff --git a/forum/templates/forum/collection_manage.html b/forum/templates/forum/collection_manage.html
new file mode 100644
index 0000000..0759949
--- /dev/null
+++ b/forum/templates/forum/collection_manage.html
@@ -0,0 +1,75 @@
+{% extends 'base.html' %}
+
+{% block title %}管理合集 - {{ collection.name }}{% endblock %}
+
+{% block content %}
+管理合集:{{ collection.name }}
+
+当前文章
+{% if collection_posts %}
+
+ {% for cp in collection_posts %}
+ -
+
+
+ {{ forloop.counter }}
+ {{ cp.post.title }}
+
+
+
+ {% endfor %}
+
+拖拽条目可调整顺序,松手自动保存。
+{% else %}
+合集中暂无文章
+{% endif %}
+
+添加文章
+{% if available_posts %}
+
+{% else %}
+没有可添加的文章
+{% endif %}
+
+返回合集
+
+
+
+{% endblock %}
diff --git a/forum/templates/forum/post_add_to_collection.html b/forum/templates/forum/post_add_to_collection.html
new file mode 100644
index 0000000..b166142
--- /dev/null
+++ b/forum/templates/forum/post_add_to_collection.html
@@ -0,0 +1,28 @@
+{% extends 'base.html' %}
+
+{% block title %}添加到合集{% endblock %}
+
+{% block content %}
+将「{{ post.title }}」添加到合集
+
+{% if collections %}
+
+{% else %}
+你还没有创建任何合集。
+创建合集
+返回
+{% endif %}
+{% endblock %}
diff --git a/forum/templates/forum/post_detail.html b/forum/templates/forum/post_detail.html
index 867388e..38baea5 100644
--- a/forum/templates/forum/post_detail.html
+++ b/forum/templates/forum/post_detail.html
@@ -5,6 +5,28 @@
{% block content %}
+ {% if collection %}
+
+ {% endif %}
+
{{ post.title }}
@@ -21,6 +43,9 @@
{{ post.title }}
+
+
+
@@ -111,6 +136,30 @@
发表评论
{% endif %}
+{% if collection %}
+
+
+
+
+ {% for cp in collection_posts_all %}
+ -
+ {% if cp.post.id == post.id %}
+ {{ cp.post.title }}
+ {% else %}
+ {{ cp.post.title }}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+{% endif %}
+
{% endblock %}
diff --git a/forum/templates/forum/post_list.html b/forum/templates/forum/post_list.html
index e3b624c..8b0d24b 100644
--- a/forum/templates/forum/post_list.html
+++ b/forum/templates/forum/post_list.html
@@ -6,11 +6,20 @@
所有文章
发新帖
-{% for post in object_list %}
+{% for item in page_obj %}
+ {% if item.type == 'collection' %}
-
- {{ post.title }} - 作者:{{ post.author }} {{ post.views }}
- 去查看
+
+ {{ item.obj.name }}
+ — 合集 · {{ item.obj.owner.username }}
+ · {{ item.obj.collection_posts.count }} 篇文章
+ {% else %}
+ -
+ {{ item.obj.title }} - 作者:{{ item.obj.author }} {{ item.obj.views }}
+ 去查看
+
+ {% endif %}
{% empty %}
- 暂时没有帖子
{% endfor %}
diff --git a/forum/urls.py b/forum/urls.py
index 240ec5f..e297155 100644
--- a/forum/urls.py
+++ b/forum/urls.py
@@ -4,13 +4,21 @@
urlpatterns = [
path('', views.index, name='index'),
path('rate//', views.rate_item, name='rate_item'),
- path('posts/', views.PostListView.as_view(), name='post_list'),
+ path('posts/', views.post_list, name='post_list'),
path('posts/create/', views.post_create, name='post_create'),
path('posts//', views.PostDetailView.as_view(), name='post_detail'),
path('posts//delete/', views.PostDeleteView.as_view(), name='post_delete'),
path('posts//edit/', views.post_edit_view, name='post_edit'),
+ path('posts//add-to-collection/', views.post_add_to_collection, name='post_add_to_collection'),
path('comments//delete/', views.comment_delete_view, name='comment_delete'),
path('comments//edit/', views.comment_edit_view, name='comment_edit'),
+ path('collections/', views.collection_list, name='collection_list'),
+ path('collections/create/', views.collection_create, name='collection_create'),
+ path('collections//', views.collection_detail, name='collection_detail'),
+ path('collections//edit/', views.collection_edit, name='collection_edit'),
+ path('collections//delete/', views.CollectionDeleteView.as_view(), name='collection_delete'),
+ path('collections//manage/', views.collection_manage, name='collection_manage'),
+ path('collections//posts//', views.collection_post_detail, name='collection_post_detail'),
path('login/', views.LoginView.as_view(), name='login'),
path('settings/', views.user_settings_view, name='settings'),
path('settings/user_delete/', views.user_delete_view, name='user_delete'),
diff --git a/forum/views.py b/forum/views.py
index 5f49c76..1c08d7b 100644
--- a/forum/views.py
+++ b/forum/views.py
@@ -1,5 +1,7 @@
-import re, random
+import json, re, random
from django.db.models import F
+from django.db import models as db_models
+from django.http import JsonResponse
from django.urls import reverse_lazy
from django.utils import timezone
from django.contrib.auth.models import User
@@ -8,12 +10,12 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login, logout
from django.contrib import messages
-from django.views.generic import ListView, View, DeleteView
+from django.views.generic import View, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from .utils import send_group_notification
-from forum.form import MDEditorCommentForm, MDEditorModelForm
-from forum.models import Comment, Item, Post, Rating
+from forum.form import MDEditorCommentForm, MDEditorModelForm, CollectionForm
+from forum.models import Comment, Item, Post, Rating, Collection, CollectionPost
from forum.bots_manager import manager
# Create your views here.
@@ -23,15 +25,32 @@ def index(request):
posts = Post.objects.all().order_by('-created_at')[:5]
return render(request, 'forum/index.html', {'items': items, 'posts' : posts})
-class PostListView(ListView):
- paginate_by = 20
- model = Post
- ordering = ["-created_at"]
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["now"] = timezone.now()
- return context
+def post_list(request):
+ # IDs of posts that belong to any collection
+ collected_post_ids = CollectionPost.objects.values_list('post_id', flat=True)
+
+ # Standalone posts (not in any collection)
+ standalone_posts = [
+ {'type': 'post', 'obj': p, 'date': p.created_at}
+ for p in Post.objects.exclude(id__in=collected_post_ids)
+ ]
+
+ # Collections as items
+ collection_items = [
+ {'type': 'collection', 'obj': c, 'date': c.created_at}
+ for c in Collection.objects.all()
+ ]
+
+ # Merge and sort by date descending
+ items = sorted(standalone_posts + collection_items, key=lambda x: x['date'], reverse=True)
+
+ paginator = Paginator(items, 20)
+ page_obj = paginator.get_page(request.GET.get('page', 1))
+
+ return render(request, 'forum/post_list.html', {
+ 'page_obj': page_obj,
+ 'now': timezone.now(),
+ })
@login_required
def rate_item(request, item_id):
@@ -218,3 +237,163 @@ def logout_view(request):
def about_view(request):
return render(request, "forum/about.html")
+
+
+# ---- Collection views ----
+
+def collection_list(request):
+ collections = Collection.objects.all()
+ return render(request, 'forum/collection_list.html', {'collections': collections})
+
+
+@login_required
+def collection_create(request):
+ form = CollectionForm(user=request.user)
+ if request.method == 'POST':
+ form = CollectionForm(request.POST, user=request.user)
+ if form.is_valid():
+ form.save()
+ return redirect('collection_list')
+ return render(request, 'forum/collection_form.html', {'form': form, 'title': '创建合集'})
+
+
+def collection_detail(request, collection_id):
+ collection = get_object_or_404(Collection, id=collection_id)
+ posts = collection.collection_posts.select_related('post', 'post__author').all()
+ return render(request, 'forum/collection_detail.html', {
+ 'collection': collection,
+ 'collection_posts': posts,
+ })
+
+
+@login_required
+def collection_edit(request, collection_id):
+ collection = get_object_or_404(Collection, id=collection_id, owner=request.user)
+ form = CollectionForm(instance=collection, user=request.user)
+ if request.method == 'POST':
+ form = CollectionForm(request.POST, instance=collection, user=request.user)
+ if form.is_valid():
+ form.save()
+ return redirect('collection_detail', collection_id=collection.id)
+ return render(request, 'forum/collection_form.html', {'form': form, 'title': '编辑合集'})
+
+
+class CollectionDeleteView(LoginRequiredMixin, DeleteView):
+ login_url = "login"
+ model = Collection
+ template_name = 'forum/collection_check_delete.html'
+ success_url = reverse_lazy("collection_list")
+
+ def get_queryset(self):
+ return super().get_queryset().filter(owner=self.request.user)
+
+
+@login_required
+def collection_manage(request, collection_id):
+ collection = get_object_or_404(Collection, id=collection_id, owner=request.user)
+
+ if request.method == 'POST':
+ action = request.POST.get('action')
+ cp_id = request.POST.get('cp_id')
+
+ if action == 'remove' and cp_id:
+ CollectionPost.objects.filter(id=cp_id, collection=collection).delete()
+
+ elif action == 'move_up' and cp_id:
+ cp = get_object_or_404(CollectionPost, id=cp_id, collection=collection)
+ prev = collection.collection_posts.filter(order__lt=cp.order).last()
+ if prev:
+ prev.order, cp.order = cp.order, prev.order
+ prev.save()
+ cp.save()
+
+ elif action == 'move_down' and cp_id:
+ cp = get_object_or_404(CollectionPost, id=cp_id, collection=collection)
+ nxt = collection.collection_posts.filter(order__gt=cp.order).first()
+ if nxt:
+ nxt.order, cp.order = cp.order, nxt.order
+ nxt.save()
+ cp.save()
+
+ elif action == 'reorder':
+ order_ids = request.POST.get('order', '')
+ if order_ids:
+ for i, cp_id_str in enumerate(order_ids.split(',')):
+ CollectionPost.objects.filter(id=int(cp_id_str), collection=collection).update(order=i)
+ return JsonResponse({'ok': True})
+
+ elif action == 'add':
+ post_ids = request.POST.getlist('post_id')
+ for pid in post_ids:
+ post = get_object_or_404(Post, id=pid, author=request.user)
+ if not CollectionPost.objects.filter(collection=collection, post=post).exists():
+ max_order = collection.collection_posts.aggregate(db_models.Max('order'))['order__max'] or 0
+ CollectionPost.objects.create(collection=collection, post=post, order=max_order + 1)
+
+ return redirect('collection_manage', collection_id=collection.id)
+
+ collection_posts = collection.collection_posts.select_related('post').all()
+ # Available posts: authored by user and not already in this collection
+ existing_ids = collection.collection_posts.values_list('post_id', flat=True)
+ available_posts = Post.objects.filter(author=request.user).exclude(id__in=existing_ids)
+
+ return render(request, 'forum/collection_manage.html', {
+ 'collection': collection,
+ 'collection_posts': collection_posts,
+ 'available_posts': available_posts,
+ })
+
+
+def collection_post_detail(request, collection_id, post_id):
+ collection = get_object_or_404(Collection, id=collection_id)
+ current_cp = get_object_or_404(CollectionPost, collection=collection, post_id=post_id)
+ post = current_cp.post
+
+ prev_cp = collection.collection_posts.filter(order__lt=current_cp.order).last()
+ next_cp = collection.collection_posts.filter(order__gt=current_cp.order).first()
+
+ forms = None
+ if request.user.is_authenticated:
+ forms = MDEditorCommentForm(user=request.user, post=post)
+
+ if request.method == 'POST' and request.user.is_authenticated:
+ forms = MDEditorCommentForm(request.POST, user=request.user, post=post)
+ if forms.is_valid():
+ forms.save()
+ return redirect('collection_post_detail', collection_id=collection.id, post_id=post.id)
+
+ comments = post.comments.all().order_by('created_at')
+ paginator = Paginator(comments, 8)
+ page_obj = paginator.get_page(request.GET.get('page', 1))
+
+ return render(request, 'forum/post_detail.html', {
+ 'post': post,
+ 'collection': collection,
+ 'collection_posts_all': collection.collection_posts.select_related('post').all(),
+ 'prev_post': prev_cp.post if prev_cp else None,
+ 'next_post': next_cp.post if next_cp else None,
+ 'comments': page_obj,
+ 'page_obj': page_obj,
+ 'total_comments': paginator.count,
+ 'forms': forms,
+ 'can_delete': (post.author == request.user),
+ })
+
+
+@login_required
+def post_add_to_collection(request, post_id):
+ post = get_object_or_404(Post, id=post_id, author=request.user)
+ collections = request.user.collections.all()
+
+ if request.method == 'POST':
+ collection_id = request.POST.get('collection_id')
+ collection = get_object_or_404(Collection, id=collection_id, owner=request.user)
+ if not CollectionPost.objects.filter(collection=collection, post=post).exists():
+ max_order = collection.collection_posts.aggregate(db_models.Max('order'))['order__max'] or 0
+ CollectionPost.objects.create(collection=collection, post=post, order=max_order + 1)
+ return redirect('post_detail', post_id=post.id)
+
+ return render(request, 'forum/post_add_to_collection.html', {
+ 'post': post,
+ 'collections': collections,
+ })