Initial commit

This commit is contained in:
admin
2025-12-08 14:39:07 +08:00
commit 9d4f78656b
782 changed files with 66418 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
const app = getApp()
Page({
data: {
project: null,
bestCoupon: null,
finalPrice: 0,
isEnrolled: false,
tagStyle: {
video: 'width: 100%;',
img: 'width: 100%; height: auto; display: block;'
}
},
onLoad(options) {
const { id } = options;
this.fetchProject(id);
this.checkEnrollment(id);
},
checkEnrollment(projectId) {
const user = app.globalData.userInfo;
if (!user) return;
const { request } = require('../../utils/request')
request({
url: `/student-projects/?student=${user.id}&project=${projectId}`
}).then(res => {
// Search result from list
if (res && res.results && res.results.length > 0) {
this.setData({ isEnrolled: true });
} else if (Array.isArray(res) && res.length > 0) {
this.setData({ isEnrolled: true });
}
}).catch(err => console.error('Check enrollment failed', err));
},
fetchProject(id) {
const { request } = require('../../utils/request')
// Fetch project details
request({ url: `/projects/${id}/` })
.then((data) => {
this.setData({ project: data })
// Fetch coupons to calculate best discount
if (parseFloat(data.price) > 0 && data.show_price) {
this.fetchCoupons(data.id, data.price);
} else {
this.setData({
finalPrice: data.price,
bestCoupon: null,
bestCouponId: null
});
}
})
.catch(() => {
// Mock data fallback
this.setData({
project: {
id: id,
title: '示例项目',
category: '示例分类',
rating: 4.8,
students: 120,
duration: '4周',
price: 299,
show_price: true,
detail: '<p>这是一个示例项目详情。</p>',
image: 'https://images.unsplash.com/photo-1526379095098-d400fd0bf935'
}
})
})
},
fetchCoupons(projectId, originalPrice) {
const { request } = require('../../utils/request')
request({ url: '/user-coupons/' }).then(coupons => {
// Filter applicable coupons
const validCoupons = coupons.filter(sc => {
// Check if coupon is applicable to this project
const c = sc.coupon_detail;
const isApplicable = c.applicable_project_titles.length === 0 || c.applicable_project_titles.includes(this.data.project.title);
return isApplicable && sc.status === 'assigned';
});
if (validCoupons.length > 0) {
// Find the best coupon (highest discount)
let maxDiscount = 0;
let bestCoupon = null;
validCoupons.forEach(sc => {
const c = sc.coupon_detail;
const discount = parseFloat(c.amount);
if (discount > maxDiscount) {
maxDiscount = discount;
bestCoupon = sc; // Keep the student_coupon object to get ID
}
});
if (bestCoupon) {
const finalPrice = Math.max(0, originalPrice - maxDiscount);
this.setData({
bestCoupon: bestCoupon.coupon_detail, // For display
bestCouponId: bestCoupon.id, // For submission (StudentCoupon ID)
finalPrice: finalPrice.toFixed(2)
});
} else {
this.setData({ finalPrice: originalPrice });
}
} else {
this.setData({ finalPrice: originalPrice });
}
}).catch(() => {
this.setData({ finalPrice: originalPrice });
});
},
handleEnroll() {
const { request } = require('../../utils/request')
const user = app.globalData.userInfo;
if (!user) {
wx.showToast({ title: '请先登录', icon: 'none' });
return;
}
if (this.data.isEnrolled) {
wx.showToast({ title: '您已报名该项目', icon: 'none' });
return;
}
const payload = {
student: user.id,
project: this.data.project.id,
coupon_id: this.data.bestCouponId || null
};
wx.showLoading({ title: '报名中...' });
request({
url: '/student-projects/',
method: 'POST',
data: payload
}).then(res => {
wx.hideLoading();
wx.showToast({ title: '报名成功', icon: 'success' });
this.setData({ isEnrolled: true });
}).catch(err => {
wx.hideLoading();
console.error('Enrollment error:', err);
let msg = '报名失败';
if (err.data) {
if (err.data.detail) {
msg = err.data.detail;
} else if (err.data.coupon_id) {
msg = Array.isArray(err.data.coupon_id) ? err.data.coupon_id[0] : err.data.coupon_id;
} else if (typeof err.data === 'string') {
msg = err.data;
} else {
// Try to find first error value
const keys = Object.keys(err.data);
if (keys.length > 0) {
const firstVal = err.data[keys[0]];
msg = Array.isArray(firstVal) ? firstVal[0] : firstVal;
}
}
} else if (err.detail) {
msg = err.detail;
}
wx.showToast({ title: msg, icon: 'none' });
if (msg && (msg.includes('重复') || msg.includes('exist'))) {
this.setData({ isEnrolled: true });
}
});
}
})

View File

@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "详情",
"navigationBarBackgroundColor": "#ff9900",
"navigationBarTextStyle": "white",
"usingComponents": {
"mp-html": "/components/mp-html/dist/mp-weixin/index"
}
}

View File

@@ -0,0 +1,48 @@
<view class="container" wx:if="{{project}}">
<image class="hero-image" src="{{project.image}}" mode="aspectFill"></image>
<view class="content">
<view class="header">
<view class="badge">{{project.category}}</view>
<view class="rating">
{{project.address}}
</view>
</view>
<view class="title">{{project.title}}</view>
<view class="meta-row">
<view class="meta-item">
<text class="icon">👥</text> {{project.students}} 人已报名
</view>
<view class="meta-item">
<text class="icon">🕒</text> {{project.duration}}
</view>
</view>
<view class="section">
<view class="desc">
<mp-html content="{{project.detail}}" tag-style="{{tagStyle}}" />
</view>
</view>
</view>
<view class="footer-bar">
<view class="price-container" wx:if="{{project.show_price}}">
<block wx:if="{{bestCoupon}}">
<view class="price-row">
<view class="final-price">¥ {{finalPrice}}</view>
<view class="original-price">¥ {{project.price}}</view>
</view>
<view class="coupon-tip">已抵扣{{bestCoupon.amount}}元</view>
</block>
<block wx:else>
<view class="final-price" style="color: #ff9900;">¥ {{project.price}}</view>
</block>
</view>
<view class="price-container" wx:else>
<view class="contact-tip" style="color: #666; font-size: 28rpx;">请咨询客服</view>
</view>
<button class="enroll-btn" bindtap="handleEnroll" disabled="{{isEnrolled}}" style="{{isEnrolled ? 'background-color: #ccc;' : ''}}">{{isEnrolled ? '已报名' : '立即报名'}}</button>
</view>
</view>

View File

@@ -0,0 +1,172 @@
.container {
background-color: var(--surface-color);
min-height: 100vh;
padding-bottom: 120rpx;
}
.hero-image {
width: 100%;
height: 400rpx;
}
.content {
padding: 40rpx;
border-top-left-radius: 40rpx;
border-top-right-radius: 40rpx;
margin-top: -40rpx;
background-color: var(--surface-color);
position: relative;
z-index: 10;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.badge {
background-color: var(--primary-light);
color: var(--primary-color);
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 12rpx;
font-weight: bold;
}
.rating {
color: var(--warning-color);
font-size: 24rpx;
font-weight: bold;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: var(--text-main);
margin-bottom: 20rpx;
line-height: 1.4;
}
.meta-row {
display: flex;
gap: 30rpx;
margin-bottom: 40rpx;
}
.meta-item {
font-size: 24rpx;
color: var(--text-secondary);
display: flex;
align-items: center;
}
.icon {
margin-right: 8rpx;
}
.section {
margin-bottom: 40rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: var(--text-main);
margin-bottom: 20rpx;
border-left: 8rpx solid var(--primary-color);
padding-left: 20rpx;
}
.desc {
font-size: 28rpx;
color: #4b5563;
line-height: 1.6;
}
.chapter-header {
font-size: 28rpx;
font-weight: bold;
color: #374151;
margin: 30rpx 0 20rpx 0;
background-color: var(--surface-secondary);
padding: 10rpx 20rpx;
border-radius: 10rpx;
}
.lesson-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid var(--border-color);
}
.lesson-info {
flex: 1;
}
.lesson-title {
font-size: 28rpx;
color: var(--text-main);
margin-bottom: 6rpx;
}
.lesson-time {
font-size: 22rpx;
color: var(--text-light);
}
.play-btn {
color: var(--primary-color);
font-size: 32rpx;
}
.lock-btn {
color: var(--text-light);
font-size: 28rpx;
}
.footer-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--surface-color);
padding: 20rpx 40rpx;
box-shadow: 0 -4rpx 10rpx rgba(0,0,0,0.05);
display: flex;
justify-content: space-between;
align-items: center;
z-index: 20;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.price-container {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
margin-right: 20rpx;
}
.price-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
}
.final-price {
color: var(--price-color);
font-weight: bold;
font-size: 36rpx;
line-height: 1.2;
}
.original-price {
text-decoration: line-through;
color: var(--text-light);
font-size: 24rpx;
margin-left: 10rpx;
}
.coupon-tip {
font-size: 20rpx;
color: var(--price-color);
border: 1px solid var(--price-color);
padding: 2rpx 6rpx;
border-radius: 4rpx;
margin-top: 4rpx;
}
.enroll-btn {
background: linear-gradient(to right, var(--primary-gradient-start), var(--primary-gradient-end));
color: var(--text-white);
font-size: 30rpx;
font-weight: bold;
padding: 20rpx 60rpx;
border-radius: 50rpx;
margin: 0;
flex-shrink: 0;
white-space: nowrap;
}