Files
Scoring-System/backend/shop/admin.py

549 lines
22 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, IdentityTag, UserIdentity
from .admin_actions import export_to_csv, export_to_excel
import qrcode
from io import BytesIO
import base64
# 自定义后台标题
admin.site.site_header = "创赢未来评分系统"
admin.site.site_title = "创赢未来"
admin.site.index_title = "欢迎使用创赢未来评分系统"
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', 'is_video_course', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order_actions')
search_fields = ('title', 'description', 'instructor', 'tag')
list_filter = ('course_type', 'is_video_course', '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_video_course', 'video_url', 'video_embed_code'),
'description': '设置是否为视频课程及视频链接'
}),
('课程安排', {
'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):
pass
# 分销佣金记录已隐藏
# @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', 'distributor__user__phone_number', 'order__id')
readonly_fields = ('amount', 'level', 'created_at')
fieldsets = (
('基本信息', {
'fields': ('salesperson', 'distributor', 'order', 'amount', 'level')
}),
('状态管理', {
'fields': ('status', 'created_at')
}),
)
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
class PriceRangeFilter(admin.SimpleListFilter):
title = '价格区间'
parameter_name = 'price_range'
def lookups(self, request, model_admin):
return (
('0-50', '¥0 - ¥50'),
('50-100', '¥50 - ¥100'),
('100-500', '¥100 - ¥500'),
('500-1000', '¥500 - ¥1000'),
('1000+', '¥1000以上'),
)
def queryset(self, request, queryset):
value = self.value()
if value == '0-50':
return queryset.filter(total_price__gte=0, total_price__lte=50)
elif value == '50-100':
return queryset.filter(total_price__gt=50, total_price__lte=100)
elif value == '100-500':
return queryset.filter(total_price__gt=100, total_price__lte=500)
elif value == '500-1000':
return queryset.filter(total_price__gt=500, total_price__lte=1000)
elif value == '1000+':
return queryset.filter(total_price__gt=1000)
return queryset
class ProductTypeFilter(admin.SimpleListFilter):
title = '商品类型'
parameter_name = 'product_type'
def lookups(self, request, model_admin):
return (
('hardware', '硬件产品'),
('course', '课程'),
('activity', '活动'),
)
def queryset(self, request, queryset):
value = self.value()
if value == 'hardware':
return queryset.filter(config__isnull=False)
elif value == 'course':
return queryset.filter(course__isnull=False)
elif value == 'activity':
return queryset.filter(activity__isnull=False)
return queryset
@admin.register(Order)
class OrderAdmin(ModelAdmin):
list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at')
list_filter = ('status', ProductTypeFilter, 'config', 'course', 'activity', PriceRangeFilter, 'salesperson', 'distributor', 'created_at')
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no', 'wechat_user__phone_number')
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
actions = [export_to_csv, export_to_excel]
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',)
}),
)
@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')
actions = [export_to_csv, export_to_excel]
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):
pass
# 提现管理已隐藏
# @admin.register(Withdrawal)
class WithdrawalAdmin(ModelAdmin):
pass
@admin.register(AdminPhoneNumber)
class AdminPhoneNumberAdmin(ModelAdmin):
list_display = ('name', 'phone_number', 'is_active', 'created_at')
list_filter = ('is_active',)
search_fields = ('name', 'phone_number')
class UserIdentityInline(TabularInline):
model = UserIdentity
extra = 1
autocomplete_fields = ['tag']
@admin.register(IdentityTag)
class IdentityTagAdmin(ModelAdmin):
list_display = ('name', 'color_preview', 'icon', 'sort_order', 'is_active', 'created_at')
list_editable = ['sort_order', 'is_active']
list_filter = ('is_active', 'created_at')
search_fields = ('name', 'description')
@display(description='颜色预览')
def color_preview(self, obj):
return format_html(
'<span style="display: inline-block; width: 20px; height: 20px; background-color: {}; border-radius: 4px; border: 1px solid #ddd;"></span> {}',
obj.color, obj.color
)
@admin.register(UserIdentity)
class UserIdentityAdmin(ModelAdmin):
list_display = ('user_info', 'tag', 'assigned_at', 'assigned_by')
list_filter = ('tag', 'assigned_at')
search_fields = ('user__nickname', 'user__phone_number', 'user__openid', 'tag__name')
autocomplete_fields = ['user', 'tag']
date_hierarchy = 'assigned_at'
@display(description='用户信息')
def user_info(self, obj):
return f"{obj.user.nickname or ''} {obj.user.phone_number or ''}".strip() or obj.user.openid[:20]