new
All checks were successful
Deploy to Server / deploy (push) Successful in 38s

This commit is contained in:
jeremygan2021
2026-02-25 00:22:15 +08:00
parent 15a2d66eae
commit 5916d7eb3a
7 changed files with 206 additions and 14 deletions

View File

@@ -232,7 +232,8 @@ class ServiceOrderAdmin(ModelAdmin):
@admin.register(VCCourse)
class VCCourseAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('title', 'course_type', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order_actions')
list_display = ('title', 'course_type', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order', 'order_actions')
list_editable = ('order',)
search_fields = ('title', 'description', 'instructor', 'tag')
list_filter = ('course_type', 'instructor', 'tag')
fieldsets = (
@@ -404,11 +405,44 @@ class OrderAdmin(ModelAdmin):
}),
)
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', 'gender', 'province', 'city', 'created_at')
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):

View File

@@ -376,6 +376,13 @@ class VCCourse(models.Model):
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and self.order == 0:
VCCourse.objects.filter(pk=self.pk).update(order=self.pk)
self.order = self.pk
def __str__(self):
return self.title

View File

@@ -5,6 +5,14 @@ import { ArrowLeftOutlined, ClockCircleOutlined, UserOutlined, BookOutlined, For
import { getVCCourseDetail, createOrder, nativePay, queryOrderStatus } from '../api';
import { useAuth } from '../context/AuthContext';
import { QRCodeSVG } from 'qrcode.react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import 'katex/dist/katex.min.css';
import styles from './VCCourseDetail.module.less';
import CodeBlock from '../components/CodeBlock';
const { Title, Paragraph } = Typography;
@@ -28,6 +36,34 @@ const VCCourseDetail = () => {
// 优先从 URL 获取,如果没有则从 localStorage 获取
const refCode = searchParams.get('ref') || localStorage.getItem('ref_code');
const markdownComponents = {
// eslint-disable-next-line no-unused-vars
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<CodeBlock
language={match[1]}
{...props}
>
{String(children).replace(/\n$/, '')}
</CodeBlock>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
// eslint-disable-next-line no-unused-vars
img({node, ...props}) {
return (
<img
{...props}
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%', margin: '10px 0' }}
/>
);
}
};
useEffect(() => {
const fetchDetail = async () => {
try {
@@ -262,8 +298,14 @@ const VCCourseDetail = () => {
{course.content && (
<div style={{ marginTop: 40 }}>
<Title level={3} style={{ color: '#fff', marginBottom: 20 }}>课程大纲与详情</Title>
<div style={{ color: '#ccc', lineHeight: '1.8', fontSize: '16px', whiteSpace: 'pre-line' }}>
<div className={styles['markdown-body']}>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={markdownComponents}
>
{course.content}
</ReactMarkdown>
</div>
</div>
)}

View File

@@ -0,0 +1,109 @@
.markdown-body {
color: #ddd;
font-size: 16px;
line-height: 1.8;
h1, h2, h3, h4, h5, h6 {
color: #fff;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1em; }
h5 { font-size: 0.875em; }
h6 { font-size: 0.85em; color: #888; }
p {
margin-top: 0;
margin-bottom: 16px;
}
a {
color: #1890ff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul, ol {
margin-top: 0;
margin-bottom: 16px;
padding-left: 2em;
}
li {
word-wrap: break-all;
}
blockquote {
margin: 0 0 16px;
padding: 0 1em;
color: #8b949e;
border-left: 0.25em solid #30363d;
}
/* Table Styles */
table {
display: block;
width: 100%;
width: max-content;
max-width: 100%;
overflow: auto;
border-spacing: 0;
border-collapse: collapse;
margin-top: 0;
margin-bottom: 16px;
thead {
background-color: rgba(255, 255, 255, 0.1);
}
tr {
background-color: transparent;
border-top: 1px solid rgba(255, 255, 255, 0.1);
&:nth-child(2n) {
background-color: rgba(255, 255, 255, 0.05);
}
}
th, td {
padding: 6px 13px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
th {
font-weight: 600;
text-align: left;
/* Ensure text color is readable */
color: #fff;
}
td {
color: #ddd;
}
}
/* Inline Code */
code:not([class*="language-"]) {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(110, 118, 129, 0.4);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}
/* Images */
img {
max-width: 100%;
box-sizing: content-box;
background-color: transparent;
}
}

View File

@@ -35,7 +35,7 @@ const VCCourses = () => {
VC <span style={{ color: '#00f0ff' }}>CODING COURSES</span>
</Title>
<Paragraph style={{ color: '#aaa', fontSize: 18, maxWidth: 600, margin: '0 auto' }}>
探索 VB Coding 软件与硬件课程开启您的编程之旅
探索 VC Coding 软件与硬件课程开启您的编程之旅
</Paragraph>
</div>

View File

@@ -39,7 +39,7 @@ export default function CourseDetail() {
useShareAppMessage(() => {
return {
title: detail?.title || 'VB 课程详情',
title: detail?.title || 'VC 课程详情',
path: `/pages/courses/detail?id=${detail?.id}`,
imageUrl: detail?.cover_image_url
}
@@ -47,7 +47,7 @@ export default function CourseDetail() {
useShareTimeline(() => {
return {
title: detail?.title || 'VB 课程详情',
title: detail?.title || 'VC 课程详情',
query: `id=${detail?.id}`,
imageUrl: detail?.cover_image_url
}
@@ -80,7 +80,7 @@ export default function CourseDetail() {
<View className='header-section'>
<Text className='title'>{detail.title}</Text>
<View className='tags-row'>
<Text className='tag'>{typeMap[detail.course_type] || 'VB课程'}</Text>
<Text className='tag'>{typeMap[detail.course_type] || 'VC课程'}</Text>
{detail.tag && <Text className='tag highlight'>{detail.tag}</Text>}
</View>
<Text className='price'>¥{detail.price}</Text>

View File

@@ -26,14 +26,14 @@ export default function CourseIndex() {
useShareAppMessage(() => {
return {
title: 'VB COURSES - 探索 VB 编程课程',
title: 'VC COURSES - 探索 VC 编程课程',
path: '/pages/courses/index'
}
})
useShareTimeline(() => {
return {
title: 'VB COURSES - 探索 VB 编程课程'
title: 'VC COURSES - 探索 VC 编程课程'
}
})
@@ -48,14 +48,14 @@ export default function CourseIndex() {
<View className='bg-decoration' />
<View className='header'>
<Text className='title'>VB <Text className='highlight'>COURSES</Text></Text>
<Text className='desc'> VB </Text>
<Text className='title'>VC <Text className='highlight'>COURSES</Text></Text>
<Text className='desc'> VC </Text>
</View>
<View className='ar-grid'>
{courseList.length === 0 ? (
<View style={{ width: '100%', textAlign: 'center', color: '#666', marginTop: 50 }}>
<Text> VB </Text>
<Text> VC </Text>
</View>
) : (
courseList.map((item) => (
@@ -64,7 +64,7 @@ export default function CourseIndex() {
{item.cover_image_url ? (
<Image src={item.cover_image_url} className='cover-img' mode='aspectFill' />
) : (
<Text className='placeholder-icon'>VB</Text>
<Text className='placeholder-icon'>VC</Text>
)}
</View>
<View className='content'>