创赢未来评分系统 - 初始化提交(移除大文件)

This commit is contained in:
爽哒哒
2026-03-18 22:28:45 +08:00
commit f26d35da66
315 changed files with 36043 additions and 0 deletions

404
backend/community/admin.py Normal file
View File

@@ -0,0 +1,404 @@
from django.contrib import admin
from django.utils.html import format_html
from django.urls import path, reverse
from django.shortcuts import redirect
from unfold.admin import ModelAdmin, TabularInline
from unfold.decorators import display
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
from .admin_actions import export_signups_csv, export_signups_excel
class ActivitySignupInline(TabularInline):
model = ActivitySignup
extra = 0
readonly_fields = ('signup_time',)
fields = ('user', 'status', 'signup_time')
autocomplete_fields = ['user']
can_delete = True
show_change_link = True
class ReplyInline(TabularInline):
model = Reply
extra = 0
readonly_fields = ('created_at',)
fields = ('content', 'author', 'created_at')
can_delete = True
show_change_link = True
class TopicMediaInline(TabularInline):
model = TopicMedia
extra = 0
fields = ('file', 'file_url', 'media_type', 'created_at')
readonly_fields = ('created_at',)
can_delete = True
class OrderableAdminMixin:
"""
为 Admin 添加排序功能的 Mixin
提供上移、下移按钮,直接交换 order 值
"""
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('<path:object_id>/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'),
path('<path:object_id>/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'),
]
return custom_urls + urls
def move_up_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
qs = self.model.objects.all()
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
if hasattr(obj, 'is_pinned'):
qs = qs.filter(is_pinned=obj.is_pinned)
# 找到排在它前面的一个 (order 小于它的最大值)
prev_obj = qs.filter(order__lt=obj.order).order_by('-order').first()
if prev_obj:
# 交换
obj.order, prev_obj.order = prev_obj.order, obj.order
obj.save()
prev_obj.save()
self.message_user(request, f"成功将 {obj} 上移")
else:
# 已经是第一个,或者前面没有更小的 order
pass
return redirect(request.META.get('HTTP_REFERER', '..'))
def move_down_view(self, request, object_id):
obj = self.get_object(request, object_id)
if obj:
qs = self.model.objects.all()
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
if hasattr(obj, 'is_pinned'):
qs = qs.filter(is_pinned=obj.is_pinned)
# 找到排在它后面的一个 (order 大于它的最小值)
next_obj = qs.filter(order__gt=obj.order).order_by('order').first()
if next_obj:
# 交换
obj.order, next_obj.order = next_obj.order, obj.order
obj.save()
next_obj.save()
self.message_user(request, f"成功将 {obj} 下移")
return redirect(request.META.get('HTTP_REFERER', '..'))
def order_actions(self, obj):
# 只有专家用户才显示排序按钮
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型WeChatUser有is_star字段
return "默认排序"
# 使用 inline style 实现基本样式
btn_style = (
"display: inline-flex; align-items: center; justify-content: center; "
"width: 26px; height: 26px; border-radius: 6px; "
"background-color: #f3f4f6; color: #4b5563; text-decoration: none; "
"border: 1px solid #e5e7eb; transition: all 0.2s;"
)
# onmouseover js
hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';"
out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';"
return format_html(
'<div style="display: flex; align-items: center; gap: 6px;">'
'<a href="{}" title="上移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>'
'</a>'
'<span style="font-weight: 700; font-family: system-ui, -apple-system, sans-serif; min-width: 20px; text-align: center; color: #374151; font-size: 13px;">{}</span>'
'<a href="{}" title="下移" style="{}" onmouseover="{}" onmouseout="{}">'
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
'</a>'
'</div>',
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]),
btn_style, hover_js, out_js,
obj.order,
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]),
btn_style, hover_js, out_js,
)
order_actions.short_description = "排序调节"
order_actions.allow_tags = True
@admin.register(Activity)
class ActivityAdmin(ModelAdmin):
list_display = ('title', 'author_info_display', 'banner_display', 'start_time', 'location', 'signup_count', 'is_visible', 'is_active', 'auto_confirm', 'created_at')
list_filter = ('is_visible', 'is_active', 'auto_confirm', 'start_time')
search_fields = ('title', 'location', 'author__phone_number')
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
raw_id_fields = ('author',)
inlines = [ActivitySignupInline]
fieldsets = (
('基本信息', {
'fields': ('title', 'author', 'description', 'banner', 'banner_url', 'is_visible', 'is_active', 'auto_confirm')
}),
('费用与时间', {
'fields': ('is_paid', 'price', 'start_time', 'end_time', 'location'),
'classes': ('tab',)
}),
('报名设置', {
'fields': ('max_participants', 'ask_name', 'ask_phone', 'ask_wechat', 'ask_company', 'signup_form_config'),
'description': '勾选需要收集的信息或者在下方“自定义报名配置”中填写高级JSON配置'
}),
)
@display(description="发布者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="Banner")
def banner_display(self, obj):
if obj.banner:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.banner.url)
elif obj.banner_url:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.banner_url)
return "暂无"
@display(description="报名人数")
def signup_count(self, obj):
return obj.signups.count()
@admin.register(ActivitySignup)
class ActivitySignupAdmin(ModelAdmin):
list_display = ('activity', 'user_info_display', 'signup_time', 'status_label', 'order_link')
list_filter = ('status', 'signup_time', 'activity')
search_fields = ('user__nickname', 'user__phone_number', 'activity__title')
autocomplete_fields = ['activity', 'user']
actions = [export_signups_csv, export_signups_excel]
fieldsets = (
('报名详情', {
'fields': ('activity', 'user', 'status', 'order', 'signup_info_display')
}),
('时间信息', {
'fields': ('signup_time',),
'classes': ('collapse',)
}),
)
readonly_fields = ('signup_time', 'signup_info_display')
@display(description="报名用户 (手机号/昵称)")
def user_info_display(self, obj):
phone = obj.user.phone_number or "无手机号"
nickname = obj.user.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="报名信息")
def signup_info_display(self, obj):
import json
if not obj.signup_info:
return ""
try:
# Format JSON nicely
formatted_json = json.dumps(obj.signup_info, indent=2, ensure_ascii=False)
return format_html('<pre style="white-space: pre-wrap; word-break: break-all;">{}</pre>', formatted_json)
except:
return str(obj.signup_info)
@display(
description="状态",
label={
"pending": "warning",
"confirmed": "success",
"cancelled": "danger",
"unpaid": "secondary",
}
)
def status_label(self, obj):
# Auto sync with order status on display
if obj.check_payment_status():
# If status changed, return new status
return obj.status
return obj.status
@display(description="关联订单")
def order_link(self, obj):
if obj.order:
return format_html('<a href="/admin/shop/order/{}/change/">Order #{}</a>', obj.order.id, obj.order.id)
return "-"
@admin.register(Topic)
class TopicAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('title', 'status', 'category', 'author_info_display', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at', 'order_actions')
list_filter = ('status', 'category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
search_fields = ('title', 'content', 'author__nickname', 'author__phone_number')
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
filter_horizontal = ('likes',)
inlines = [TopicMediaInline, ReplyInline]
actions = ['reset_ordering', 'approve_topics', 'reject_topics']
list_editable = ('status', 'is_pinned', 'view_count')
@display(description="作者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@admin.action(description="批量通过审核")
def approve_topics(self, request, queryset):
rows_updated = queryset.update(status='published')
self.message_user(request, f"{rows_updated} 个帖子已通过审核")
@admin.action(description="批量拒绝")
def reject_topics(self, request, queryset):
rows_updated = queryset.update(status='rejected')
self.message_user(request, f"{rows_updated} 个帖子已拒绝")
def save_model(self, request, obj, form, change):
# 当帖子被置顶时(新建或修改状态)默认将排序值设为0
if obj.is_pinned and (not change or 'is_pinned' in form.changed_data):
obj.order = 0
super().save_model(request, obj, form, change)
@admin.action(description="重置排序 (0,1,2... 新帖子在前)")
def reset_ordering(self, request, queryset):
"""
将所有帖子按时间倒序重新分配order值 (0, 1, 2, ...)
"""
all_objects = Topic.objects.all().order_by('-created_at')
for index, obj in enumerate(all_objects):
if obj.order != index:
obj.order = index
obj.save(update_fields=['order'])
self.message_user(request, f"成功重置了 {all_objects.count()} 个帖子的排序权重从0开始")
fieldsets = (
('帖子内容', {
'fields': ('title', 'status', 'category', 'content', 'is_pinned', 'likes')
}),
('关联信息', {
'fields': ('author', 'related_product', 'related_service', 'related_course'),
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
}),
('统计数据', {
'fields': ('view_count', 'order', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('created_at', 'updated_at')
@display(description="关联项目")
def get_related_item(self, obj):
if obj.related_product:
return f"[硬件] {obj.related_product.name}"
if obj.related_service:
return f"[服务] {obj.related_service.title}"
if obj.related_course:
return f"[课程] {obj.related_course.title}"
return "-"
@display(description="回复数")
def reply_count(self, obj):
return obj.replies.count()
@admin.register(Reply)
class ReplyAdmin(ModelAdmin):
list_display = ('short_content', 'topic', 'author_info_display', 'is_pinned', 'like_count', 'created_at')
list_filter = ('is_pinned', 'created_at')
search_fields = ('content', 'author__nickname', 'author__phone_number', 'topic__title')
autocomplete_fields = ['author', 'topic', 'reply_to']
filter_horizontal = ('likes',)
list_editable = ('is_pinned',)
inlines = [TopicMediaInline]
fieldsets = (
('回复内容', {
'fields': ('topic', 'reply_to', 'content', 'likes')
}),
('发布信息', {
'fields': ('author', 'is_pinned', 'created_at')
}),
)
readonly_fields = ('created_at',)
@display(description="回复者 (手机号/昵称)")
def author_info_display(self, obj):
if not obj.author:
return "-"
phone = obj.author.phone_number or "无手机号"
nickname = obj.author.nickname or "无昵称"
return f"{phone} ({nickname})"
@display(description="点赞数")
def like_count(self, obj):
return obj.likes.count()
@display(description="内容摘要")
def short_content(self, obj):
return obj.content[:30] + '...' if len(obj.content) > 30 else obj.content
@admin.register(TopicMedia)
class TopicMediaAdmin(ModelAdmin):
list_display = ('id', 'media_type', 'file_preview', 'topic', 'reply', 'created_at')
list_filter = ('media_type', 'created_at')
search_fields = ('file', 'topic__title')
autocomplete_fields = ['topic', 'reply']
@display(description="预览")
def file_preview(self, obj):
url = ""
if obj.file:
url = obj.file.url
elif obj.file_url:
url = obj.file_url
if obj.media_type == 'image' and url:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
return obj.file.name or "外部文件"
@admin.register(Announcement)
class AnnouncementAdmin(ModelAdmin):
list_display = ('title', 'image_preview', 'active_label', 'pinned_label', 'priority', 'start_time', 'end_time', 'created_at')
list_filter = ('is_active', 'is_pinned', 'created_at')
search_fields = ('title', 'content')
fieldsets = (
('公告信息', {
'fields': ('title', 'content', 'link_url')
}),
('图片设置', {
'fields': ('image', 'image_url'),
'description': '上传图片或填写图片链接,优先显示上传的图片'
}),
('显示设置', {
'fields': ('is_active', 'is_pinned', 'priority'),
'classes': ('tab',)
}),
('排期设置', {
'fields': ('start_time', 'end_time'),
'classes': ('tab',)
}),
)
@display(description="图片预览")
def image_preview(self, obj):
url = obj.display_image_url
if url:
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
return "无图片"
@display(
description="状态",
label={
True: "success",
False: "danger",
}
)
def active_label(self, obj):
return obj.is_active
@display(
description="置顶",
label={
True: "warning",
False: "default",
}
)
def pinned_label(self, obj):
return obj.is_pinned