This commit is contained in:
@@ -46,8 +46,13 @@ class OrderableAdminMixin:
|
|||||||
def move_up_view(self, request, object_id):
|
def move_up_view(self, request, object_id):
|
||||||
obj = self.get_object(request, object_id)
|
obj = self.get_object(request, object_id)
|
||||||
if obj:
|
if obj:
|
||||||
|
qs = self.model.objects.all()
|
||||||
|
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
|
||||||
|
if hasattr(obj, 'is_pinned'):
|
||||||
|
qs = qs.filter(is_pinned=obj.is_pinned)
|
||||||
|
|
||||||
# 找到排在它前面的一个 (order 小于它的最大值)
|
# 找到排在它前面的一个 (order 小于它的最大值)
|
||||||
prev_obj = self.model.objects.filter(order__lt=obj.order).order_by('-order').first()
|
prev_obj = qs.filter(order__lt=obj.order).order_by('-order').first()
|
||||||
if prev_obj:
|
if prev_obj:
|
||||||
# 交换
|
# 交换
|
||||||
obj.order, prev_obj.order = prev_obj.order, obj.order
|
obj.order, prev_obj.order = prev_obj.order, obj.order
|
||||||
@@ -62,8 +67,13 @@ class OrderableAdminMixin:
|
|||||||
def move_down_view(self, request, object_id):
|
def move_down_view(self, request, object_id):
|
||||||
obj = self.get_object(request, object_id)
|
obj = self.get_object(request, object_id)
|
||||||
if obj:
|
if obj:
|
||||||
|
qs = self.model.objects.all()
|
||||||
|
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
|
||||||
|
if hasattr(obj, 'is_pinned'):
|
||||||
|
qs = qs.filter(is_pinned=obj.is_pinned)
|
||||||
|
|
||||||
# 找到排在它后面的一个 (order 大于它的最小值)
|
# 找到排在它后面的一个 (order 大于它的最小值)
|
||||||
next_obj = self.model.objects.filter(order__gt=obj.order).order_by('order').first()
|
next_obj = qs.filter(order__gt=obj.order).order_by('order').first()
|
||||||
if next_obj:
|
if next_obj:
|
||||||
# 交换
|
# 交换
|
||||||
obj.order, next_obj.order = next_obj.order, obj.order
|
obj.order, next_obj.order = next_obj.order, obj.order
|
||||||
@@ -202,6 +212,12 @@ class TopicAdmin(OrderableAdminMixin, ModelAdmin):
|
|||||||
inlines = [TopicMediaInline, ReplyInline]
|
inlines = [TopicMediaInline, ReplyInline]
|
||||||
actions = ['reset_ordering']
|
actions = ['reset_ordering']
|
||||||
|
|
||||||
|
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... 新帖子在前)")
|
@admin.action(description="重置排序 (0,1,2... 新帖子在前)")
|
||||||
def reset_ordering(self, request, queryset):
|
def reset_ordering(self, request, queryset):
|
||||||
"""
|
"""
|
||||||
@@ -223,7 +239,7 @@ class TopicAdmin(OrderableAdminMixin, ModelAdmin):
|
|||||||
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
|
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
|
||||||
}),
|
}),
|
||||||
('统计数据', {
|
('统计数据', {
|
||||||
'fields': ('view_count', 'created_at', 'updated_at'),
|
'fields': ('view_count', 'order', 'created_at', 'updated_at'),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ class TopicViewSet(viewsets.ModelViewSet):
|
|||||||
search_fields = ['title', 'content']
|
search_fields = ['title', 'content']
|
||||||
filterset_fields = ['category', 'is_pinned']
|
filterset_fields = ['category', 'is_pinned']
|
||||||
ordering_fields = ['created_at', 'view_count', 'order']
|
ordering_fields = ['created_at', 'view_count', 'order']
|
||||||
ordering = ['order', '-is_pinned', '-created_at']
|
ordering = ['-is_pinned', 'order', '-created_at']
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
user = get_current_wechat_user(self.request)
|
user = get_current_wechat_user(self.request)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-02-27 05:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shop', '0037_wechatuser_has_web_badge'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vccourse',
|
||||||
|
name='is_video_course',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='是否视频课程'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vccourse',
|
||||||
|
name='video_url',
|
||||||
|
field=models.URLField(blank=True, help_text='仅当用户付费或报名后可见', null=True, verbose_name='视频课程URL'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -362,6 +362,10 @@ class VCCourse(models.Model):
|
|||||||
|
|
||||||
tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶")
|
tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶")
|
||||||
|
|
||||||
|
# 视频课程相关
|
||||||
|
is_video_course = models.BooleanField(default=False, verbose_name="是否视频课程")
|
||||||
|
video_url = models.URLField(blank=True, null=True, verbose_name="视频课程URL", help_text="仅当用户付费或报名后可见")
|
||||||
|
|
||||||
# 课程时间安排
|
# 课程时间安排
|
||||||
is_fixed_schedule = models.BooleanField(default=False, verbose_name="是否固定时间课程", help_text="勾选后,前端将显示具体的开课时间")
|
is_fixed_schedule = models.BooleanField(default=False, verbose_name="是否固定时间课程", help_text="勾选后,前端将显示具体的开课时间")
|
||||||
start_time = models.DateTimeField(blank=True, null=True, verbose_name="开始时间")
|
start_time = models.DateTimeField(blank=True, null=True, verbose_name="开始时间")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import ESP32Config, Order, Salesperson, Service, VCCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal, CommissionLog, CourseEnrollment
|
from .models import ESP32Config, Order, Salesperson, Service, VCCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal, CommissionLog, CourseEnrollment
|
||||||
|
from .utils import get_current_wechat_user
|
||||||
|
|
||||||
class CommissionLogSerializer(serializers.ModelSerializer):
|
class CommissionLogSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
@@ -174,6 +175,7 @@ class VCCourseSerializer(serializers.ModelSerializer):
|
|||||||
display_cover_image = serializers.SerializerMethodField()
|
display_cover_image = serializers.SerializerMethodField()
|
||||||
display_detail_image = serializers.SerializerMethodField()
|
display_detail_image = serializers.SerializerMethodField()
|
||||||
course_type_display = serializers.CharField(source='get_course_type_display', read_only=True)
|
course_type_display = serializers.CharField(source='get_course_type_display', read_only=True)
|
||||||
|
video_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = VCCourse
|
model = VCCourse
|
||||||
@@ -193,6 +195,38 @@ class VCCourseSerializer(serializers.ModelSerializer):
|
|||||||
return obj.detail_image.url
|
return obj.detail_image.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_video_url(self, obj):
|
||||||
|
"""
|
||||||
|
仅当用户已付费/报名时返回视频URL
|
||||||
|
"""
|
||||||
|
if not obj.is_video_course:
|
||||||
|
return None
|
||||||
|
|
||||||
|
request = self.context.get('request')
|
||||||
|
if not request:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 尝试获取当前用户
|
||||||
|
user = get_current_wechat_user(request)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 如果是管理员,直接返回
|
||||||
|
if user.user and user.user.is_staff:
|
||||||
|
return obj.video_url
|
||||||
|
|
||||||
|
# 检查是否已购买/报名 (通过已支付的订单)
|
||||||
|
has_paid = Order.objects.filter(
|
||||||
|
wechat_user=user,
|
||||||
|
course=obj,
|
||||||
|
status__in=['paid', 'shipped', 'completed'] # 包含所有已完成状态
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if has_paid:
|
||||||
|
return obj.video_url
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
class ESP32ConfigSerializer(serializers.ModelSerializer):
|
class ESP32ConfigSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
ESP32配置序列化器
|
ESP32配置序列化器
|
||||||
|
|||||||
@@ -1,11 +1,50 @@
|
|||||||
import requests
|
import requests
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from .models import WeChatPayConfig
|
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||||
|
from .models import WeChatPayConfig, WeChatUser
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_current_wechat_user(request):
|
||||||
|
"""
|
||||||
|
根据 Authorization 头获取当前微信用户
|
||||||
|
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
|
||||||
|
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
if not auth_header or not auth_header.startswith('Bearer '):
|
||||||
|
return None
|
||||||
|
token = auth_header.split(' ')[1]
|
||||||
|
signer = TimestampSigner()
|
||||||
|
try:
|
||||||
|
# 签名包含 openid
|
||||||
|
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
|
||||||
|
user = WeChatUser.objects.filter(openid=openid).first()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
|
||||||
|
# 场景:Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
|
||||||
|
if openid.startswith('web_'):
|
||||||
|
try:
|
||||||
|
# 格式: web_13800138000
|
||||||
|
parts = openid.split('_', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
phone = parts[1]
|
||||||
|
# 尝试通过手机号查找(查找合并后的主账号)
|
||||||
|
user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
def get_access_token(config=None):
|
def get_access_token(config=None):
|
||||||
"""
|
"""
|
||||||
获取微信接口调用凭证 (client_credential)
|
获取微信接口调用凭证 (client_credential)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.http import HttpResponse
|
|||||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
||||||
from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment
|
from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment
|
||||||
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer
|
from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer
|
||||||
from .utils import get_access_token
|
from .utils import get_access_token, get_current_wechat_user
|
||||||
from .services import handle_post_payment
|
from .services import handle_post_payment
|
||||||
from django.db import transaction, models
|
from django.db import transaction, models
|
||||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||||
@@ -934,43 +934,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return Response({'status': 'success', 'message': '支付成功'})
|
return Response({'status': 'success', 'message': '支付成功'})
|
||||||
|
|
||||||
def get_current_wechat_user(request):
|
|
||||||
"""
|
|
||||||
根据 Authorization 头获取当前微信用户
|
|
||||||
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
|
|
||||||
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
|
|
||||||
"""
|
|
||||||
auth_header = request.headers.get('Authorization')
|
|
||||||
if not auth_header or not auth_header.startswith('Bearer '):
|
|
||||||
return None
|
|
||||||
token = auth_header.split(' ')[1]
|
|
||||||
signer = TimestampSigner()
|
|
||||||
try:
|
|
||||||
# 签名包含 openid
|
|
||||||
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
|
|
||||||
user = WeChatUser.objects.filter(openid=openid).first()
|
|
||||||
|
|
||||||
if user:
|
|
||||||
return user
|
|
||||||
|
|
||||||
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
|
|
||||||
# 场景:Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
|
|
||||||
if openid.startswith('web_'):
|
|
||||||
try:
|
|
||||||
# 格式: web_13800138000
|
|
||||||
parts = openid.split('_', 1)
|
|
||||||
if len(parts) == 2:
|
|
||||||
phone = parts[1]
|
|
||||||
# 尝试通过手机号查找(查找合并后的主账号)
|
|
||||||
user = WeChatUser.objects.filter(phone_number=phone).first()
|
|
||||||
if user:
|
|
||||||
return user
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
except (BadSignature, SignatureExpired):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
summary="微信小程序登录",
|
summary="微信小程序登录",
|
||||||
|
|||||||
@@ -10,6 +10,32 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-video-container {
|
||||||
|
width: 100%;
|
||||||
|
margin: 16px 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #0a0a1a;
|
||||||
|
border: 1px solid rgba(0, 243, 255, 0.3);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 243, 255, 0.1);
|
||||||
|
|
||||||
|
.markdown-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 225px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-video-caption {
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(10, 10, 26, 0.9);
|
||||||
|
color: rgba(0, 243, 255, 0.9);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-code-block {
|
.markdown-code-block {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { View, RichText } from '@tarojs/components'
|
import { View, RichText, Video } from '@tarojs/components'
|
||||||
import { marked, Renderer } from 'marked'
|
import { marked, Renderer } from 'marked'
|
||||||
import CodeBlock from './CodeBlock'
|
import CodeBlock from './CodeBlock'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
@@ -51,6 +51,11 @@ const MarkdownReader: React.FC<Props> = ({ content, themeColor = '#00b96b' }) =>
|
|||||||
// Process tokens
|
// Process tokens
|
||||||
tokens.forEach((token, index) => {
|
tokens.forEach((token, index) => {
|
||||||
if (token.type === 'code') {
|
if (token.type === 'code') {
|
||||||
|
// Skip css blocks that look like the video component styles
|
||||||
|
if ((token.lang === 'css' || !token.lang) && token.text.includes('.simple-tech-video')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Flush accumulated tokens
|
// Flush accumulated tokens
|
||||||
if (currentTokens.length > 0) {
|
if (currentTokens.length > 0) {
|
||||||
// preserve links if any
|
// preserve links if any
|
||||||
@@ -69,6 +74,57 @@ const MarkdownReader: React.FC<Props> = ({ content, themeColor = '#00b96b' }) =>
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
} else if (token.type === 'html') {
|
||||||
|
// Check for video tag
|
||||||
|
const videoRegex = /<video[^>]*>[\s\S]*?<source[^>]*src=["'](.*?)["'][^>]*>[\s\S]*?<\/video>/i
|
||||||
|
const simpleVideoRegex = /<video[^>]*src=["'](.*?)["'][^>]*>/i
|
||||||
|
|
||||||
|
const match = token.text.match(videoRegex) || token.text.match(simpleVideoRegex)
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
// Flush accumulated tokens
|
||||||
|
if (currentTokens.length > 0) {
|
||||||
|
(currentTokens as any).links = (tokens as any).links
|
||||||
|
const html = marked.parser(currentTokens as any, { renderer, breaks: true })
|
||||||
|
result.push(<RichText key={`rt-${index}`} nodes={html} className='markdown-text' />)
|
||||||
|
currentTokens = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = match[1]
|
||||||
|
// Try to extract caption
|
||||||
|
const captionRegex = /class=["']video-caption["'][^>]*>(.*?)<\/div>/i
|
||||||
|
const captionMatch = token.text.match(captionRegex)
|
||||||
|
const caption = captionMatch ? captionMatch[1] : null
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
<View key={`video-${index}`} className='markdown-video-container'>
|
||||||
|
<Video
|
||||||
|
src={src}
|
||||||
|
className='markdown-video'
|
||||||
|
controls
|
||||||
|
autoplay={false}
|
||||||
|
objectFit='contain'
|
||||||
|
showFullscreenBtn
|
||||||
|
showPlayBtn
|
||||||
|
showCenterPlayBtn
|
||||||
|
enablePlayGesture
|
||||||
|
/>
|
||||||
|
{caption && <View className='markdown-video-caption'>{caption}</View>}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Filter out style tags for video component if they are parsed as HTML
|
||||||
|
if (token.text.includes('<style>') && token.text.includes('.simple-tech-video')) {
|
||||||
|
// If it's JUST the style tag, ignore it. If it's mixed with other content, we might need to be careful.
|
||||||
|
// But usually marked parses block HTML separately.
|
||||||
|
// Let's verify if we can just skip it.
|
||||||
|
// If the token is ONLY the style block, we skip it.
|
||||||
|
// If it contains other content, we might need to strip the style.
|
||||||
|
// For now, let's assume it's a block HTML token.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentTokens.push(token)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
currentTokens.push(token)
|
currentTokens.push(token)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user