from django.contrib import admin from django.utils.html import format_html from django.db.models import Sum from django import forms 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 ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment import qrcode from io import BytesIO import base64 # 自定义后台标题 admin.site.site_header = "量迹AI科技硬件/服务商场后台" admin.site.site_title = "量迹AI后台" admin.site.index_title = "欢迎使用量迹AI管理系统" 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: # 找到排在它前面的一个 (order 小于它的最大值) prev_obj = self.model.objects.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 # 尝试查找 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: # 找到排在它后面的一个 (order 大于它的最小值) next_obj = self.model.objects.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): # 使用 inline style 实现基本样式,hover 效果如果不能用 CSS 文件,就只能妥协或者用 onmouseover 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 class ExternalUploadWidget(forms.URLInput): def __init__(self, upload_url, accept='*', *args, **kwargs): super().__init__(*args, **kwargs) self.upload_url = upload_url self.attrs.update({ 'class': 'upload-url-input vTextField', 'data-upload-url': upload_url, 'data-accept': accept, 'placeholder': '上传文件后自动生成URL', 'style': 'width: 100%;' }) class Media: js = ('shop/js/admin_upload.js',) css = { 'all': ('shop/css/admin_upload.css',) } class ESP32ConfigAdminForm(forms.ModelForm): class Meta: model = ESP32Config fields = '__all__' widgets = { 'static_image_url': ExternalUploadWidget( upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_static_image', accept='image/*' ), 'model_3d_url': ExternalUploadWidget( upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_3D_image', accept='.zip' ), } class ProductFeatureInline(TabularInline): model = ProductFeature extra = 1 fields = ('title', 'description', 'icon_name', 'icon_image', 'icon_url', 'order') @admin.register(WeChatPayConfig) class WeChatPayConfigAdmin(ModelAdmin): list_display = ('app_id', 'mch_id', 'is_active', 'notify_url', 'updated_at_display') list_filter = ('is_active',) search_fields = ('app_id', 'mch_id') def updated_at_display(self, obj): # 假设模型没有 updated_at,如果有可以显示,这里仅作占位或移除 return "N/A" updated_at_display.short_description = "更新时间" fieldsets = ( ('核心配置 (登录与支付)', { 'fields': ('app_id', 'app_secret', 'mch_id', 'is_active'), 'description': 'AppID 和 AppSecret 是小程序登录和支付的基础凭证。请确保 AppID 与小程序后台一致 (项目中优先使用 wxdf2ca73e6c0929f0)。' }), ('微信支付 V3 安全配置 (推荐)', { 'fields': ('apiv3_key', 'mch_cert_serial_no', 'mch_private_key'), 'description': '使用 Native 支付必须配置这些项。私钥可以粘贴在这里,或者放在 backend/certs/apiclient_key.pem 文件中。' }), ('微信支付 V2 安全配置 (旧版)', { 'fields': ('api_key',), 'classes': ('collapse',), 'description': '仅旧版支付接口需要 API Key (V2)。' }), ('回调配置', { 'fields': ('notify_url',) }), ) @admin.register(ESP32Config) class ESP32ConfigAdmin(OrderableAdminMixin, ModelAdmin): form = ESP32ConfigAdminForm list_display = ('name', 'chip_type', 'price', 'stock', 'has_camera', 'has_microphone', 'order_actions') list_filter = ('chip_type', 'has_camera') search_fields = ('name', 'description') inlines = [ProductFeatureInline] fieldsets = ( ('基本信息', { 'fields': ('name', 'price', 'stock', 'commission_rate', 'description') }), ('硬件参数', { 'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone') }), ('详情页图片', { 'fields': ('detail_image', 'detail_image_url'), 'description': '图片上传和URL二选一,优先使用URL' }), ('多媒体资源', { 'fields': ('static_image_url', 'model_3d_url'), 'description': '产品静态图和3D模型的外部链接' }), ) @admin.register(Service) class ServiceAdmin(OrderableAdminMixin, ModelAdmin): list_display = ('title', 'created_at', 'order_actions') search_fields = ('title', 'description') fieldsets = ( ('基本信息', { 'fields': ('title', 'description', 'color') }), ('价格与交付', { 'fields': ('price', 'unit', 'delivery_time', 'delivery_content') }), ('图标', { 'fields': ('icon', 'icon_url'), 'description': '图标上传和URL二选一,优先使用URL' }), ('详情页图片', { 'fields': ('detail_image', 'detail_image_url'), 'description': '图片上传和URL二选一,优先使用URL' }), ('详细内容', { 'fields': ('features',) }), ) @admin.register(ServiceOrder) class ServiceOrderAdmin(ModelAdmin): list_display = ('id', 'customer_name', 'service', 'total_price', 'status', 'salesperson', 'created_at') list_filter = ('status', 'service', 'salesperson', 'created_at') search_fields = ('id', 'customer_name', 'phone_number', 'email') readonly_fields = ('total_price', 'created_at', 'updated_at') fieldsets = ( ('订单信息', { 'fields': ('service', 'status', 'total_price', 'created_at') }), ('客户信息', { 'fields': ('customer_name', 'company_name', 'phone_number', 'email', 'requirements') }), ('销售归属', { 'fields': ('salesperson',) }), ) @admin.register(VCCourse) class VCCourseAdmin(OrderableAdminMixin, ModelAdmin): list_display = ('title', 'course_type', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order_actions') search_fields = ('title', 'description', 'instructor', 'tag') list_filter = ('course_type', 'instructor', 'tag') fieldsets = ( ('基本信息', { 'fields': ('title', 'description', 'course_type', 'tag', 'price') }), ('讲师信息', { 'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'), 'description': '讲师头像上传和URL二选一,优先使用URL' }), ('课程详情', { 'fields': ('duration', 'lesson_count', 'content') }), ('封面', { 'fields': ('cover_image', 'cover_image_url'), 'description': '图片上传和URL二选一,优先使用URL' }), ('详情页长图', { 'fields': ('detail_image', 'detail_image_url'), 'description': '图片上传和URL二选一,优先使用URL' }), ) @admin.register(CourseEnrollment) class CourseEnrollmentAdmin(ModelAdmin): list_display = ('customer_name', 'course', 'phone_number', 'status', 'created_at') list_filter = ('status', 'course', 'created_at') search_fields = ('customer_name', 'phone_number', 'wechat_id') fieldsets = ( ('报名信息', { 'fields': ('course', 'status', 'created_at') }), ('客户资料', { 'fields': ('customer_name', 'phone_number', 'wechat_id', 'email', 'message') }), ('销售归属', { 'fields': ('salesperson', 'distributor') }), ) @admin.register(Salesperson) class SalespersonAdmin(ModelAdmin): list_display = ('name', 'code', 'total_sales', 'view_promotion_url') search_fields = ('name', 'code') readonly_fields = ('promotion_qr_code', 'promotion_url_display', 'total_sales_display') def get_queryset(self, request): queryset = super().get_queryset(request) queryset = queryset.annotate( _total_sales=Sum('orders__total_price', default=0) ) return queryset @display(description="累计销售额 (已支付)", ordering='_total_sales') def total_sales(self, obj): # 仅计算已支付的订单 paid_sales = obj.orders.filter(status='paid').aggregate(total=Sum('total_price'))['total'] return f"¥{paid_sales or 0:.2f}" def total_sales_display(self, obj): return self.total_sales(obj) total_sales_display.short_description = "累计销售额 (已支付)" def promotion_url(self, obj): # 生产环境配置 base_url = "https://market.quant-speed.com" return f"{base_url}/?ref={obj.code}" @display(description="推广链接") def view_promotion_url(self, obj): url = self.promotion_url(obj) return format_html('打开推广链接', url) def promotion_url_display(self, obj): return self.promotion_url(obj) promotion_url_display.short_description = "完整推广链接" @display(description="推广二维码") def promotion_qr_code(self, obj): if not obj.code: return "请先保存以生成二维码" url = self.promotion_url(obj) qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4, ) qr.add_data(url) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") buffer = BytesIO() img.save(buffer, format="PNG") img_str = base64.b64encode(buffer.getvalue()).decode() return format_html('', img_str) fieldsets = ( ('基本信息', { 'fields': ('name', 'code') }), ('推广工具', { 'fields': ('promotion_url_display', 'promotion_qr_code') }), ('业绩统计', { 'fields': ('total_sales_display',) }), ('分销设置', { 'fields': ('parent', 'commission_rate', 'second_level_rate'), 'description': '设置上级分销员及各级分润比例' }), ) @admin.register(CommissionLog) class CommissionLogAdmin(ModelAdmin): list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at') list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at') search_fields = ('salesperson__name', 'distributor__user__nickname', 'order__id') readonly_fields = ('amount', 'level', 'created_at') fieldsets = ( ('基本信息', { 'fields': ('salesperson', 'distributor', 'order', 'amount', 'level') }), ('状态管理', { 'fields': ('status', 'created_at') }), ) @admin.register(Order) class OrderAdmin(ModelAdmin): list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at') list_filter = ('status', 'salesperson', 'distributor', 'created_at') search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no') readonly_fields = ('total_price', 'created_at', 'wechat_trade_no') def get_item_name(self, obj): if obj.config: return f"[硬件] {obj.config.name}" if obj.course: return f"[课程] {obj.course.title}" return "未知商品" get_item_name.short_description = "购买商品" fieldsets = ( ('订单信息', { 'fields': ('config', 'course', 'quantity', 'total_price', 'status', 'created_at') }), ('客户信息', { 'fields': ('customer_name', 'phone_number', 'shipping_address', 'wechat_user') }), ('物流信息', { 'fields': ('courier_name', 'tracking_number') }), ('销售归属', { 'fields': ('salesperson', 'distributor') }), ('支付信息', { 'fields': ('wechat_trade_no',) }), ) @admin.register(WeChatUser) class WeChatUserAdmin(ModelAdmin): list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at') search_fields = ('nickname', 'openid', 'phone_number') list_filter = ('is_star', 'gender', 'province', 'city', 'created_at') readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at') def avatar_display(self, obj): if obj.avatar_url: return format_html('', obj.avatar_url) return "暂无" avatar_display.short_description = "头像" def gender_display(self, obj): choices = {0: '未知', 1: '男', 2: '女'} return choices.get(obj.gender, '未知') gender_display.short_description = "性别" fieldsets = ( ('基本信息', { 'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender') }), ('专家认证', { 'fields': ('is_star', 'title'), 'description': '标记为明星技术用户/专家,将在社区中展示' }), ('位置信息', { 'fields': ('country', 'province', 'city') }), ('认证信息', { 'fields': ('openid', 'unionid', 'session_key'), 'classes': ('collapse',) }), ('时间信息', { 'fields': ('created_at', 'updated_at') }), ) @admin.register(Distributor) class DistributorAdmin(ModelAdmin): list_display = ('get_nickname', 'level', 'status', 'total_earnings', 'withdrawable_balance', 'invite_code', 'created_at') search_fields = ('user__nickname', 'invite_code') list_filter = ('status', 'level', 'created_at') readonly_fields = ('total_earnings', 'withdrawable_balance', 'qr_code_url', 'created_at', 'updated_at') autocomplete_fields = ['user', 'parent'] def get_nickname(self, obj): return obj.user.nickname get_nickname.short_description = "微信昵称" get_nickname.admin_order_field = 'user__nickname' fieldsets = ( ('分销员信息', { 'fields': ('user', 'parent', 'level', 'status') }), ('收益概览', { 'fields': ('commission_rate', 'total_earnings', 'withdrawable_balance') }), ('推广信息', { 'fields': ('invite_code', 'qr_code_url') }), ('时间信息', { 'fields': ('created_at', 'updated_at') }), ) @admin.register(Withdrawal) class WithdrawalAdmin(ModelAdmin): list_display = ('get_distributor', 'amount', 'status', 'created_at') list_filter = ('status', 'created_at') search_fields = ('distributor__user__nickname',) def get_distributor(self, obj): return obj.distributor.user.nickname get_distributor.short_description = "分销员" fieldsets = ( ('提现详情', { 'fields': ('distributor', 'amount', 'status', 'remark') }), ('时间信息', { 'fields': ('created_at', 'updated_at') }), )