Files
market_page/backend/shop/admin.py
jeremygan2021 96c12b9e58
All checks were successful
Deploy to Server / deploy (push) Successful in 36s
new
2026-02-25 00:33:34 +08:00

566 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, AdminPhoneNumber
import qrcode
from io import BytesIO
import base64
# 自定义后台标题1
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('<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:
# 找到排在它前面的一个 (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):
# 只有专家用户才显示排序按钮
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型WeChatUser有is_star字段
return "默认排序"
# 使用 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(
'<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
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')
actions = ['reset_ordering']
@admin.action(description="重置排序 (按ID顺序)")
def reset_ordering(self, request, queryset):
"""
将选中的课程或全部按ID顺序重新分配order值
"""
# 如果没有选中任何项默认处理所有Django Admin默认行为是选中了才会触发Action但为了稳健
# 这里既然是Action用户必须选中。建议用户选中所有。
# 为了方便如果用户只选了一个我们可以提示他选更多或者我们其实可以忽略queryset直接重置所有
# 通常Action是针对queryset的。
# 更好的做法对选中的queryset按ID排序然后更新order。
# 这种实现方式只重置选中的部分可能会导致order冲突。
# 稳妥方式:重置整个表的排序。
all_objects = VCCourse.objects.all().order_by('id')
for index, obj in enumerate(all_objects, start=1):
obj.order = index
obj.save(update_fields=['order'])
self.message_user(request, f"成功重置了 {all_objects.count()} 个课程的排序权重。")
fieldsets = (
('基本信息', {
'fields': ('title', 'description', 'course_type', 'tag', 'price')
}),
('课程安排', {
'fields': ('is_fixed_schedule', 'start_time', 'end_time'),
'description': '勾选“是否固定时间课程”后,请设置开始和结束时间'
}),
('讲师信息', {
'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('<a href="{}" target="_blank" class="button">打开推广链接</a>', 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 src="data:image/png;base64,{}" width="200" height="200" class="qr-code" />', 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}"
if obj.activity:
return f"[活动] {obj.activity.title}"
return "未知商品"
get_item_name.short_description = "购买商品"
fieldsets = (
('订单信息', {
'fields': ('config', 'course', 'activity', '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',)
}),
)
class GenderFilter(admin.SimpleListFilter):
title = '性别'
parameter_name = 'gender'
def lookups(self, request, model_admin):
return (
(1, ''),
(2, ''),
(0, '未知'),
)
def queryset(self, request, queryset):
if self.value():
return queryset.filter(gender=self.value())
return queryset
class UserSourceFilter(admin.SimpleListFilter):
title = '用户来源'
parameter_name = 'user_source'
def lookups(self, request, model_admin):
return (
('miniprogram', '仅小程序用户'),
('both', '网页小程序都已注册'),
)
def queryset(self, request, queryset):
if self.value() == 'miniprogram':
return queryset.filter(user__isnull=True)
if self.value() == 'both':
return queryset.filter(user__isnull=False)
return queryset
@admin.register(WeChatUser)
class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at', 'order_actions')
search_fields = ('nickname', 'openid', 'phone_number')
list_filter = ('is_star', GenderFilter, UserSourceFilter, '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('<img src="{}" width="50" height="50" style="border-radius: 50%;" />', 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 = "性别"
def get_fieldsets(self, request, obj=None):
fieldsets = [
('基本信息', {
'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender')
}),
]
if obj and obj.is_star:
fieldsets.append(('专家认证', {
'fields': ('is_star', 'title', 'skills', 'order'),
'description': '标记为明星技术用户/专家,将在社区中展示'
}))
else:
fieldsets.append(('专家认证', {
'fields': ('is_star',),
'description': '标记为明星技术用户/专家,将在社区中展示。保存后若为专家用户,可进一步编辑专家信息。'
}))
fieldsets.append(('位置信息', {
'fields': ('country', 'province', 'city')
}))
fieldsets.append(('认证信息', {
'fields': ('openid', 'unionid', 'session_key'),
'classes': ('collapse',)
}))
fieldsets.append(('时间信息', {
'fields': ('created_at', 'updated_at')
}))
return fieldsets
@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')
}),
)
@admin.register(AdminPhoneNumber)
class AdminPhoneNumberAdmin(ModelAdmin):
list_display = ('name', 'phone_number', 'is_active', 'created_at')
list_filter = ('is_active',)
search_fields = ('name', 'phone_number')