This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
109
frontend/src/pages/VCCourseDetail.module.less
Normal file
109
frontend/src/pages/VCCourseDetail.module.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user