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('/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('/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( '
' '' '' '' '{}' '' '' '' '
', 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('', obj.banner.url) elif obj.banner_url: return format_html('', 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('
{}
', 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('Order #{}', 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('', 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('', 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