Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion forum/form.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .models import Post, Comment
from .models import Post, Comment, Collection

from django import forms
import re
Expand Down Expand Up @@ -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):
Expand Down
42 changes: 42 additions & 0 deletions forum/migrations/0005_collection_collectionpost.py
Original file line number Diff line number Diff line change
@@ -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')},
},
),
]
18 changes: 18 additions & 0 deletions forum/migrations/0006_collection_description_html.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
43 changes: 43 additions & 0 deletions forum/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

from .markdown import MarkdownModel

import markdown
import bleach

# Create your models here.

class Item(MarkdownModel):
Expand Down Expand Up @@ -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}"
3 changes: 3 additions & 0 deletions forum/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'post_list' %}">所有文章</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'collection_list' %}">合集</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'about' %}">关于</a>
</li>
Expand Down
36 changes: 36 additions & 0 deletions forum/templates/forum/collection_check_delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% extends 'base.html' %}

{% block title %}确认删除合集{% endblock %}

{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card border-danger">
<div class="card-body">
<h5 class="card-title">删除合集 "{{ object.name }}"</h5>
<form method="post">
{% csrf_token %}
<p>合集将被永久删除,但其中的文章不会被删除。</p>
<p>请输入合集名称 <strong>{{ object.name }}</strong> 以确认:</p>
<input type="text" name="confirm_name" class="form-control" required autocomplete="off">
<br>
<div class="d-grid gap-2">
<input type="submit" value="确认删除" class="btn btn-danger" id="delete-btn" disabled>
<a href="{% url 'collection_detail' object.id %}" class="btn btn-outline-secondary">取消</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>

<script>
const input = document.querySelector('input[name="confirm_name"]');
const btn = document.getElementById('delete-btn');
input.addEventListener("input", () => {
btn.disabled = input.value !== "{{ object.name }}";
});
</script>
{% endblock %}
36 changes: 36 additions & 0 deletions forum/templates/forum/collection_detail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% extends 'base.html' %}

{% block title %}{{ collection.name }}{% endblock %}

{% block content %}
<h1>{{ collection.name }}</h1>
{% if collection.description_html %}
<div class="markdown-body mb-3">{{ collection.description_html|safe }}</div>
{% endif %}

<p class="text-muted">
<small>创建者:{{ collection.owner.username }} · {{ collection.created_at|date:"Y-m-d" }}</small>
</p>

{% if user == collection.owner %}
<div class="mb-3">
<a href="{% url 'collection_edit' collection.id %}" class="btn btn-outline-secondary btn-sm">编辑</a>
<a href="{% url 'collection_manage' collection.id %}" class="btn btn-outline-secondary btn-sm">管理文章</a>
<a href="{% url 'collection_delete' collection.id %}" class="btn btn-outline-danger btn-sm">删除</a>
</div>
{% endif %}

<ol class="list-group list-group-numbered">
{% for cp in collection_posts %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="{% url 'collection_post_detail' collection.id cp.post.id %}">{{ cp.post.title }}</a>
<small class="text-muted ms-2">{{ cp.post.author.username }}</small>
</div>
<small class="text-muted">{{ cp.post.created_at|date:"Y-m-d" }}</small>
</li>
{% empty %}
<li class="list-group-item">合集中暂无文章</li>
{% endfor %}
</ol>
{% endblock %}
22 changes: 22 additions & 0 deletions forum/templates/forum/collection_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends 'base.html' %}

{% block title %}{{ title }}{% endblock %}

{% block content %}
<h1>{{ title }}</h1>

<form method="post" class="mt-3">
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="text-danger">{{ field.errors }}</div>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">保存</button>
<a href="{% url 'collection_list' %}" class="btn btn-outline-secondary">取消</a>
</form>
{% endblock %}
27 changes: 27 additions & 0 deletions forum/templates/forum/collection_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{% extends 'base.html' %}

{% block title %}合集{% endblock %}

{% block content %}
<h1>合集</h1>
{% if user.is_authenticated %}
<a href="{% url 'collection_create' %}" class="btn btn-primary btn-sm mb-3">创建合集</a>
{% endif %}

<ul class="list-group">
{% for collection in collections %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="{% url 'collection_detail' collection.id %}">{{ collection.name }}</a>
<small class="text-muted ms-2">by {{ collection.owner.username }}</small>
{% if collection.description %}
<br><small class="text-muted">{{ collection.description }}</small>
{% endif %}
</div>
<span class="badge bg-secondary">{{ collection.collection_posts.count }} 篇</span>
</li>
{% empty %}
<li class="list-group-item">暂无合集</li>
{% endfor %}
</ul>
{% endblock %}
75 changes: 75 additions & 0 deletions forum/templates/forum/collection_manage.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{% extends 'base.html' %}

{% block title %}管理合集 - {{ collection.name }}{% endblock %}

{% block content %}
<h1>管理合集:{{ collection.name }}</h1>

<h3 class="mt-4">当前文章</h3>
{% if collection_posts %}
<ul class="list-group mb-3" id="sortable-list">
{% for cp in collection_posts %}
<li class="list-group-item d-flex justify-content-between align-items-center" data-id="{{ cp.id }}">
<span>
<i class="bi bi-grip-vertical text-muted me-2" style="cursor:grab"></i>
<span class="badge bg-secondary me-2">{{ forloop.counter }}</span>
{{ cp.post.title }}
</span>
<form method="post" class="d-inline">
{% csrf_token %}
<input type="hidden" name="cp_id" value="{{ cp.id }}">
<button name="action" value="remove" class="btn btn-sm btn-outline-danger">
<i class="bi bi-x-lg"></i> 移除
</button>
</form>
</li>
{% endfor %}
</ul>
<p class="text-muted"><small>拖拽条目可调整顺序,松手自动保存。</small></p>
{% else %}
<p class="text-muted">合集中暂无文章</p>
{% endif %}

<h3 class="mt-4">添加文章</h3>
{% if available_posts %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="add">
<div class="list-group mb-3">
{% for post in available_posts %}
<label class="list-group-item d-flex align-items-center">
<input type="checkbox" name="post_id" value="{{ post.id }}" class="form-check-input me-2">
{{ post.title }}
</label>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary btn-sm">批量添加</button>
</form>
{% else %}
<p class="text-muted">没有可添加的文章</p>
{% endif %}

<a href="{% url 'collection_detail' collection.id %}" class="btn btn-outline-secondary mt-3">返回合集</a>

<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
<script>
const list = document.getElementById('sortable-list');
if (list) {
Sortable.create(list, {
animation: 150,
handle: '.bi-grip-vertical',
onEnd: function () {
const ids = [...list.querySelectorAll('li')].map(li => li.dataset.id).join(',');
const csrf = document.querySelector('[name=csrfmiddlewaretoken]').value;
fetch('', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': csrf},
body: 'action=reorder&order=' + ids
}).then(() => {
list.querySelectorAll('.badge').forEach((b, i) => b.textContent = i + 1);
});
}
});
}
</script>
{% endblock %}
28 changes: 28 additions & 0 deletions forum/templates/forum/post_add_to_collection.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% extends 'base.html' %}

{% block title %}添加到合集{% endblock %}

{% block content %}
<h1>将「{{ post.title }}」添加到合集</h1>

{% if collections %}
<form method="post" class="mt-3">
{% csrf_token %}
<div class="list-group mb-3">
{% for collection in collections %}
<label class="list-group-item d-flex align-items-center">
<input type="radio" name="collection_id" value="{{ collection.id }}" class="form-check-input me-2" required>
{{ collection.name }}
<span class="badge bg-secondary ms-auto">{{ collection.collection_posts.count }} 篇</span>
</label>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">添加</button>
<a href="{% url 'post_detail' post.id %}" class="btn btn-outline-secondary">取消</a>
</form>
{% else %}
<p class="text-muted mt-3">你还没有创建任何合集。</p>
<a href="{% url 'collection_create' %}" class="btn btn-primary">创建合集</a>
<a href="{% url 'post_detail' post.id %}" class="btn btn-outline-secondary">返回</a>
{% endif %}
{% endblock %}
Loading