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,188 @@
const app = getApp()
Page({
data: {
coupons: [],
user: {}
},
onLoad() {
this.fetchCoupons();
},
onShow() {
this.getUserInfo();
this.fetchCoupons();
},
getUserInfo() {
const { request } = require('../../utils/request')
const app = getApp()
// Fetch latest user info from backend to check if phone exists in DB
request({ url: '/user/' }).then(user => {
app.globalData.userInfo = user; // Sync global data
this.setData({ user: user });
}).catch(err => {
console.error('Failed to fetch user info', err)
// Fallback to global data if fetch fails
if (app.globalData.userInfo) {
this.setData({ user: app.globalData.userInfo });
}
})
},
goToProfile() {
const app = getApp();
app.globalData.profileAction = 'bind_phone';
wx.switchTab({
url: '/pages/profile/profile'
})
},
fetchCoupons() {
const { request } = require('../../utils/request')
Promise.all([
request({ url: '/user-coupons/' }).catch(() => []),
request({ url: '/available-coupons/' }).catch(() => [])
]).then(([userCoupons, availableCoupons]) => {
// 1. Process User Coupons
const userList = Array.isArray(userCoupons) ? userCoupons : (userCoupons && userCoupons.results) || [];
const formattedUserList = userList.map(item => {
if (item.coupon_detail) {
let scopeText = item.coupon_detail.scope_text || '全场通用';
if (scopeText === '通用') scopeText = '全场通用';
return {
...item.coupon_detail,
id: item.id,
coupon_id: item.coupon_detail.id,
status: item.status,
expiry: item.coupon_detail.expiry,
displayDesc: scopeText,
is_time_limited: item.coupon_detail.is_time_limited
}
}
return {
...item,
displayDesc: item.desc
}
});
// 2. Process Available Coupons
const userCouponIds = new Set(formattedUserList.map(item => item.coupon_id));
const availableList = Array.isArray(availableCoupons) ? availableCoupons : (availableCoupons && availableCoupons.results) || [];
const formattedAvailableList = availableList
.filter(c => !userCouponIds.has(c.id))
.map(c => {
let scopeText = c.scope_text || '全场通用';
if (scopeText === '通用') scopeText = '全场通用';
return {
...c,
id: 'avail_' + c.id, // Virtual ID
coupon_id: c.id,
status: 'can_claim', // Virtual Status
displayDesc: scopeText
}
});
// 3. Merge and Sort
const allCoupons = [...formattedAvailableList, ...formattedUserList];
// Sort: can_claim -> assigned -> used -> expired
const statusOrder = { 'can_claim': 0, 'assigned': 1, 'used': 2, 'expired': 3, 'revoked': 4 };
allCoupons.sort((a, b) => {
const orderA = statusOrder[a.status] !== undefined ? statusOrder[a.status] : 99;
const orderB = statusOrder[b.status] !== undefined ? statusOrder[b.status] : 99;
return orderA - orderB;
});
this.setData({ coupons: allCoupons });
})
.catch((err) => {
console.error(err);
// Mock data fallback
this.setData({
coupons: [
{
id: 1,
amount: '50',
unit: '元',
title: '新人见面礼',
desc: '无门槛使用,适用于所有课程',
displayDesc: '全场通用',
expiry: '2023-12-31',
status: 'available',
color: 'from-blue-500 to-cyan-400',
bgStart: 'from-blue-50',
bgEnd: 'to-cyan-50',
shadow: 'shadow-blue-100',
is_time_limited: true
},
// ... (existing mocks)
]
})
})
},
handleCouponClick(e) {
if (!this.data.user.phone) {
wx.showModal({
title: '提示',
content: '请先绑定手机号激活会员权益',
confirmText: '去绑定',
success: (res) => {
if (res.confirm) {
this.goToProfile();
}
}
});
return;
}
const status = e.currentTarget.dataset.status;
if (status === 'can_claim') {
this.handleClaim(e);
return;
}
// If coupon is available/assigned, redirect to home page to use it
if (status !== 'used') {
wx.switchTab({
url: '/pages/index/index'
});
return;
}
},
handleClaim(e) {
const id = e.currentTarget.dataset.id; // This is 'avail_ID'
const realId = id.toString().replace('avail_', '');
wx.showLoading({ title: '领取中...' });
const { request } = require('../../utils/request')
request({
url: '/user-coupons/',
method: 'POST',
data: {
coupon_id: realId
}
}).then(() => {
wx.hideLoading();
wx.showToast({
title: '领取成功',
icon: 'success'
});
// Refresh list
this.fetchCoupons();
}).catch(err => {
wx.hideLoading();
console.error(err);
// Handle specific error message from backend
const msg = (err && err.error) || (err && err.detail) || '领取失败';
wx.showToast({
title: msg,
icon: 'none'
});
});
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "优惠券"
}

View File

@@ -0,0 +1,43 @@
<view class="container">
<view class="header">
<text class="title">优惠券中心</text>
</view>
<!-- VIP Banner -->
<view class="vip-banner" wx:if="{{!user.phone}}">
<view class="vip-info">
<view class="vip-title">VIP会员权益</view>
<view class="vip-desc">开通会员享受更多优惠</view>
</view>
<button class="vip-btn" bindtap="goToProfile">立即开通</button>
</view>
<!-- Coupon List -->
<view class="section-title">{{user.phone ? '可使用的优惠券' : '可领取的优惠券'}}</view>
<view class="coupon-list" style="{{!user.phone ? 'filter: grayscale(100%); opacity: 0.6;' : ''}}">
<block wx:for="{{coupons}}" wx:key="id">
<view class="coupon-card {{item.status === 'used' ? 'used' : ''}}" bindtap="handleCouponClick" data-id="{{item.id}}" data-status="{{item.status}}">
<view class="limit-tag" wx:if="{{item.is_time_limited}}">限时</view>
<view class="coupon-left">
<view class="amount">
<text class="num">{{item.amount}}</text>
<text class="unit">{{item.unit}}</text>
</view>
<view class="info">
<view class="coupon-name">{{item.title}}</view>
<view class="coupon-desc">{{item.displayDesc}}</view>
</view>
</view>
<view class="coupon-right">
<view class="btn-text" wx:if="{{item.status === 'can_claim'}}">立即领取</view>
<view class="btn-text" wx:elif="{{item.status === 'assigned'}}">去使用</view>
<view class="btn-text" wx:elif="{{item.status === 'used'}}">已使用</view>
<view class="btn-text" wx:elif="{{item.status === 'expired'}}">已过期</view>
<view class="btn-text" wx:else>去使用</view>
<text class="expiry-date" wx:if="{{item.expiry}}">{{item.expiry}}到期</text>
</view>
</view>
</block>
</view>
</view>

View File

@@ -0,0 +1,231 @@
.container {
padding: 30rpx;
background-color: var(--background-color);
min-height: 100vh;
}
.header {
padding: 20rpx 0;
text-align: center;
margin-bottom: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: var(--text-main);
}
.vip-banner {
background: linear-gradient(to bottom right, #111827, #1f2937);
border-radius: 30rpx;
padding: 40rpx;
color: var(--text-white);
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40rpx;
box-shadow: 0 10rpx 20rpx rgba(0,0,0,0.1);
}
.vip-title {
color: #fcd34d;
font-weight: bold;
font-size: 32rpx;
margin-bottom: 10rpx;
}
.vip-desc {
color: #d1d5db;
font-size: 24rpx;
}
.vip-btn {
background: linear-gradient(to right, #fcd34d, #fbbf24);
color: #111827;
font-size: 24rpx;
font-weight: bold;
padding: 10rpx 30rpx;
border-radius: 40rpx;
margin: 0;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
color: var(--text-main);
}
.coupon-list.disabled-list {
filter: grayscale(100%);
opacity: 0.6;
pointer-events: none;
}
.coupon-card {
background: #ffffff;
border-radius: 20rpx;
margin-bottom: 24rpx;
display: flex;
justify-content: space-between;
align-items: stretch;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
position: relative;
overflow: hidden;
}
/* Colorful style for unused coupons */
.coupon-card:not(.used) {
background: linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%); /* More saturated gradient */
border: 1px solid #fecdd3;
}
.coupon-card.used {
background: #f9fafb;
box-shadow: none;
border: 1px solid #eee;
}
.coupon-left {
flex: 1;
display: flex;
align-items: center;
padding: 30rpx;
position: relative;
}
/* Dashed line separator */
.coupon-left::after {
content: '';
position: absolute;
right: 0;
top: 20rpx;
bottom: 20rpx;
border-right: 2px dashed #e5e7eb;
}
.coupon-card:not(.used) .coupon-left::after {
border-right: 2px dashed #fca5a5; /* Reddish dashed line for unused */
opacity: 0.5;
}
.amount {
min-width: 120rpx;
text-align: center;
color: #ef4444; /* Red */
margin-right: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.coupon-card.used .amount {
color: #9ca3af; /* Gray */
}
.num {
font-size: 56rpx;
font-weight: bold;
line-height: 1;
}
.unit {
font-size: 24rpx;
margin-top: 4rpx;
}
.info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.coupon-name {
font-size: 30rpx;
font-weight: bold;
color: #1f2937;
margin-bottom: 8rpx;
}
.coupon-card.used .coupon-name {
color: #9ca3af;
}
.coupon-desc {
font-size: 22rpx;
color: #6b7280;
}
.coupon-card:not(.used) .coupon-desc {
color: #7f1d1d; /* Darker red for description on colorful bg */
opacity: 0.7;
}
.coupon-right {
width: 160rpx; /* Increased width to fit date */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
padding: 0 10rpx;
}
.btn-text {
padding: 10rpx 24rpx;
border-radius: 30rpx;
font-size: 24rpx;
font-weight: bold;
margin-bottom: 8rpx;
}
.expiry-date {
font-size: 18rpx;
color: #9ca3af;
text-align: center;
line-height: 1.2;
}
.coupon-card:not(.used) .expiry-date {
color: #ef4444;
opacity: 0.8;
}
/* Active button style */
.coupon-card:not(.used) .btn-text {
background: linear-gradient(to right, #ef4444, #f87171);
color: white;
box-shadow: 0 4rpx 10rpx rgba(239, 68, 68, 0.3);
}
/* Used button style */
.coupon-card.used .btn-text {
background-color: #e5e7eb;
color: #9ca3af;
}
/* Circles for ticket effect */
.coupon-card::before, .coupon-card::after {
content: '';
position: absolute;
width: 24rpx;
height: 24rpx;
background-color: var(--background-color); /* Assuming this var is set on page/container */
border-radius: 50%;
right: 148rpx; /* Position at the separator (adjusted for new width) */
z-index: 10;
}
.coupon-card::before {
top: -12rpx;
}
.coupon-card::after {
bottom: -12rpx;
}
.limit-tag {
position: absolute;
top: 0;
left: 0;
background: linear-gradient(to bottom right, #f59e0b, #d97706);
color: white;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-bottom-right-radius: 16rpx;
z-index: 20;
}

View File

@@ -0,0 +1,148 @@
const app = getApp()
const { request } = require('../../utils/request')
Page({
data: {
user: {},
honors: [],
projects: [],
filteredProjects: [],
currentFilter: 'all', // all, enrolled, completed
showHonorModal: false,
currentHonor: null
},
onLoad() {
this.fetchHonors()
this.fetchProjects()
},
onShow() {
this.getUserInfo()
},
getUserInfo() {
const app = getApp()
// Try to get from globalData first to speed up rendering
if (app.globalData.userInfo) {
this.setData({ user: app.globalData.userInfo })
}
request({ url: '/user/' }).then(user => {
app.globalData.userInfo = user;
this.setData({ user: user });
// Re-fetch data if needed based on user status change
// this.fetchHonors();
// this.fetchProjects();
}).catch(err => {
console.error('Failed to fetch user info', err)
})
},
onPullDownRefresh() {
Promise.all([this.fetchHonors(), this.fetchProjects()]).then(() => {
wx.stopPullDownRefresh()
})
},
fetchHonors() {
return request({
url: '/user-honors/',
method: 'GET'
}).then(res => {
this.setData({ honors: res || [] })
}).catch(err => {
console.error('Failed to fetch honors', err)
})
},
fetchProjects() {
return request({
url: '/user-projects/',
method: 'GET'
}).then(res => {
const list = Array.isArray(res) ? res : []
const projects = list.map(item => {
// Format date
if (item.enrolled_at) {
item.enrolled_at_formatted = item.enrolled_at.split('T')[0]
}
const statusMap = {
'enrolled': '已报名',
'studying': '在读',
'graduated': '毕业',
'finished': '结束',
'completed': '已完成',
'cancelled': '已取消'
};
item.status_display = statusMap[item.status] || item.status;
return item
})
// Sort projects by status: enrolled, studying, finished, graduated
const sortOrder = {
'enrolled': 1,
'studying': 2,
'finished': 3,
'graduated': 4
};
projects.sort((a, b) => {
const orderA = sortOrder[a.status] || 99;
const orderB = sortOrder[b.status] || 99;
return orderA - orderB;
});
this.setData({ projects })
this.filterProjects()
}).catch(err => {
console.error('Failed to fetch projects', err)
})
},
setFilter(e) {
const type = e.currentTarget.dataset.type
this.setData({ currentFilter: type })
this.filterProjects()
},
filterProjects() {
const { projects, currentFilter } = this.data
let filtered = []
if (currentFilter === 'all') {
filtered = projects
} else if (currentFilter === 'enrolled') {
// 进行中: 已报名, 在读
const activeStatuses = ['enrolled', 'studying']
filtered = projects.filter(p => activeStatuses.includes(p.status))
} else if (currentFilter === 'completed') {
// 已完成: 毕业, 结束, 已完成
const completedStatuses = ['graduated', 'finished', 'completed']
filtered = projects.filter(p => completedStatuses.includes(p.status))
} else {
filtered = projects.filter(p => p.status === currentFilter)
}
this.setData({ filteredProjects: filtered })
},
showHonorDetail(e) {
const item = e.currentTarget.dataset.item
this.setData({
showHonorModal: true,
currentHonor: item
})
},
closeHonorModal() {
this.setData({
showHonorModal: false,
currentHonor: null
})
},
preventBubble() {
// Prevent tap event from bubbling to mask
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "项目&活动",
"navigationBarBackgroundColor": "#ff9900",
"navigationBarTextStyle": "white",
"usingComponents": {}
}

View File

@@ -0,0 +1,72 @@
<view class="page-container">
<block wx:if="{{user.phone}}">
<!-- 头部荣誉区域 -->
<view class="honor-section" wx:if="{{honors.length > 0}}">
<view class="section-header">
<text class="section-title">我的荣誉</text>
<text class="honor-count">共 {{honors.length}} 项</text>
</view>
<view class="honor-list">
<view class="honor-item" wx:for="{{honors}}" wx:key="id" bindtap="showHonorDetail" data-item="{{item}}">
<image class="honor-image" src="{{item.image}}" mode="aspectFill"></image>
<view class="honor-info">
<text class="honor-title">{{item.title}}</text>
<text class="honor-date">{{item.date}}</text>
</view>
</view>
</view>
</view>
<!-- 课程赛事列表 -->
<view class="course-section">
<view class="section-header">
<text class="section-title">我的项目&活动</text>
</view>
<view class="filter-tabs">
<view class="tab-item {{currentFilter === 'all' ? 'active' : ''}}" bindtap="setFilter" data-type="all">全部</view>
<view class="tab-item {{currentFilter === 'enrolled' ? 'active' : ''}}" bindtap="setFilter" data-type="enrolled">进行中</view>
<view class="tab-item {{currentFilter === 'completed' ? 'active' : ''}}" bindtap="setFilter" data-type="completed">已完成</view>
</view>
<view class="project-list" wx:if="{{filteredProjects.length > 0}}">
<view class="project-card" wx:for="{{filteredProjects}}" wx:key="id">
<image class="project-image" src="{{item.project_image}}" mode="aspectFill"></image>
<view class="project-info">
<view class="project-header">
<text class="project-title">{{item.project_title}}</text>
<text class="project-type-tag {{item.project_type}}">{{item.project_type_display}}</text>
</view>
<view class="project-meta">
<text class="status-text {{item.status}}">{{item.status_display}}</text>
<text class="date-text">报名时间: {{item.enrolled_at_formatted}}</text>
</view>
</view>
</view>
</view>
<view class="empty-state" wx:else>
<text class="empty-text">暂无相关记录</text>
</view>
</view>
</block>
<view class="empty-state-global" wx:else style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top: 200rpx;">
<image src="/assets/empty-honor.png" mode="aspectFit" style="width: 200rpx; height: 200rpx; margin-bottom: 20rpx; filter: grayscale(100%); opacity: 0.5;"></image>
<text style="color: #999; font-size: 28rpx;">暂无数据,请先完善个人信息</text>
</view>
<!-- 荣誉详情弹窗 -->
<view class="modal-mask" wx:if="{{showHonorModal}}" bindtap="closeHonorModal">
<view class="modal-content" catchtap="preventBubble">
<image class="modal-image" src="{{currentHonor.image}}" mode="widthFix"></image>
<view class="modal-info">
<text class="modal-title">{{currentHonor.title}}</text>
<text class="modal-date">获得时间:{{currentHonor.date}}</text>
<text class="modal-desc" wx:if="{{currentHonor.description}}">{{currentHonor.description}}</text>
</view>
<view class="modal-close" bindtap="closeHonorModal">×</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,309 @@
.page-container {
min-height: 100vh;
background-color: var(--background-color);
padding-bottom: 40rpx;
}
/* Honor Section */
.honor-section {
background-color: var(--surface-color);
padding: 30rpx;
margin-bottom: 20rpx;
border-bottom-left-radius: 30rpx;
border-bottom-right-radius: 30rpx;
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.05);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.section-title {
font-size: 34rpx;
font-weight: bold;
color: var(--text-main);
position: relative;
padding-left: 20rpx;
}
.section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 8rpx;
height: 32rpx;
background-color: var(--primary-color);
border-radius: 4rpx;
}
.honor-count {
font-size: 24rpx;
color: var(--text-secondary);
}
.honor-scroll {
width: 100%;
white-space: nowrap;
}
.honor-list {
display: flex;
flex-wrap: wrap;
padding-bottom: 10rpx;
margin-right: -20rpx; /* Compensate for the right margin of items */
}
.honor-item {
display: inline-block;
width: calc(50% - 20rpx); /* 2 items per row with gap */
margin-right: 20rpx;
margin-bottom: 20rpx;
background-color: var(--surface-color);
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
border: 1px solid var(--border-color);
box-sizing: border-box;
}
.honor-image {
width: 100%;
height: 160rpx;
background-color: #e5e7eb;
}
.honor-info {
padding: 16rpx;
}
.honor-title {
font-size: 26rpx;
font-weight: bold;
color: #374151;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8rpx;
}
.honor-date {
font-size: 20rpx;
color: var(--text-light);
display: block;
}
/* Course Section */
.course-section {
padding: 30rpx;
}
.filter-tabs {
display: flex;
background-color: #e5e7eb;
border-radius: 16rpx;
padding: 6rpx;
margin-bottom: 30rpx;
}
.tab-item {
flex: 1;
text-align: center;
font-size: 26rpx;
color: var(--text-secondary);
padding: 12rpx 0;
border-radius: 12rpx;
transition: all 0.3s;
}
.tab-item.active {
background-color: var(--surface-color);
color: var(--primary-color);
font-weight: bold;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.05);
}
.project-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.project-card {
background-color: var(--surface-color);
border-radius: 20rpx;
padding: 24rpx;
display: flex;
gap: 24rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.04);
}
.project-image {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
background-color: var(--surface-secondary);
flex-shrink: 0;
}
.project-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.project-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.project-title {
font-size: 30rpx;
font-weight: bold;
color: var(--text-main);
flex: 1;
margin-right: 16rpx;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-type-tag {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
white-space: nowrap;
}
.project-type-tag.training {
background-color: var(--primary-light);
color: var(--primary-color);
}
.project-type-tag.competition {
background-color: var(--warning-bg);
color: var(--warning-color);
}
.project-type-tag.grading {
background-color: var(--success-bg);
color: var(--success-color);
}
.project-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16rpx;
}
.status-text {
font-size: 24rpx;
font-weight: 500;
}
.status-text.enrolled {
color: var(--info-color);
}
.status-text.studying {
color: #07c160;
}
.status-text.completed {
color: #10b981; /* Keep or use success-color */
}
.status-text.cancelled {
color: var(--text-light);
}
.date-text {
font-size: 22rpx;
color: var(--text-light);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 20rpx;
opacity: 0.5;
}
.empty-text {
font-size: 26rpx;
color: var(--text-light);
}
/* Modal */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
width: 80%;
background-color: var(--surface-color);
border-radius: 24rpx;
padding: 40rpx;
position: relative;
max-height: 80vh;
overflow-y: auto;
}
.modal-image {
width: 100%;
border-radius: 16rpx;
margin-bottom: 30rpx;
}
.modal-info {
text-align: center;
}
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: var(--text-main);
display: block;
margin-bottom: 16rpx;
}
.modal-date {
font-size: 24rpx;
color: var(--text-secondary);
display: block;
margin-bottom: 20rpx;
}
.modal-desc {
font-size: 28rpx;
color: #4b5563;
line-height: 1.6;
text-align: left;
}

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;
}

View File

@@ -0,0 +1,158 @@
const app = getApp()
Page({
data: {
projects: [],
banners: [],
categories: [],
userInfo: {},
hasUserInfo: false,
canIUse: wx.canIUse('button.open-type.getUserInfo'),
canIUseGetUserProfile: false,
canIUseOpenData: wx.canIUse('open-data.type.userAvatarUrl') && wx.canIUse('open-data.type.userNickName'), // 如需尝试获取用户信息可改为false
selectedCategory: 'all',
showcases: [],
categoryStats: {}
},
onLoad() {
if (wx.getUserProfile) {
this.setData({
canIUseGetUserProfile: true
})
}
this.fetchData();
this.fetchBanners();
this.fetchShowcases();
this.fetchCategoryStats();
},
handleCategorySelect(e) {
const type = e.currentTarget.dataset.type;
this.setData({ selectedCategory: type });
this.fetchData(type);
},
fetchBanners() {
const { request } = require('../../utils/request')
request({ url: '/banners/?is_active=true' })
.then(data => {
const list = Array.isArray(data) ? data : (data && data.results) || []
this.setData({ banners: list })
})
.catch(err => console.error('Fetch banners error:', err))
},
handleBannerClick(e) {
const item = e.currentTarget.dataset.item;
if (item.project) {
wx.navigateTo({
url: `/pages/detail/detail?id=${item.project}`
})
} else if (item.link) {
// Simple link handling (copy or webview)
// For simplicity, if it starts with http, copy it
if (item.link.startsWith('http')) {
wx.setClipboardData({
data: item.link,
success: () => wx.showToast({ title: '链接已复制', icon: 'none' })
})
}
}
},
fetchShowcases() {
const { request } = require('../../utils/request')
request({ url: '/student-showcases/?is_active=true' })
.then(data => {
const list = Array.isArray(data) ? data : (data && data.results) || []
this.setData({ showcases: list })
})
.catch(err => console.error('Fetch showcases error:', err))
},
playVideo(e) {
let url = e.currentTarget.dataset.url;
if (!url) return;
// Fix for local development: replace localhost/127.0.0.1 with actual API host
if (url.includes('localhost') || url.includes('127.0.0.1')) {
const app = getApp();
// Extract host from baseUrl (e.g. http://127.0.0.1:8000/api -> http://127.0.0.1:8000)
const apiBase = app.globalData.baseUrl.split('/api')[0];
// Replace the localhost part
url = url.replace(/http:\/\/localhost:\d+/, apiBase)
.replace(/http:\/\/127.0.0.1:\d+/, apiBase);
}
// Use previewMedia for all video urls as our backend uploads files directly now
wx.previewMedia({
sources: [{ url: url, type: 'video' }],
success: () => console.log('Preview success'),
fail: (err) => {
console.error('Preview failed', err);
// Fallback for non-previewable links
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: '视频链接已复制', icon: 'none' })
})
}
})
},
fetchData(type = 'all') {
const { request } = require('../../utils/request')
let url = '/projects/';
if (type !== 'all') {
url += `?project_type=${type}`;
}
request({ url: url })
.then((data) => {
const list = Array.isArray(data) ? data : (data && data.results) || []
this.setData({ projects: list })
})
.catch((err) => {
console.error(err)
this.setData({
projects: [
{
id: 1,
title: 'Python 数据分析实战',
category: '编程开发',
image:
'https://images.unsplash.com/photo-1526379095098-d400fd0bf935?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3',
students: 1205,
rating: 4.8,
duration: '12 周'
},
{
id: 2,
title: '零基础英语口语速成',
category: '语言学习',
image:
'https://images.unsplash.com/photo-1543269865-cbf427effbad?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3',
students: 850,
rating: 4.9,
duration: '8 周'
}
]
})
})
},
fetchCategoryStats() {
const { request } = require('../../utils/request')
request({ url: '/dashboard/stats' })
.then(res => {
const stats = {};
if (res.pie_chart_data) {
res.pie_chart_data.forEach(item => {
if (item.type) {
stats[item.type] = item.value;
}
});
}
this.setData({ categoryStats: stats });
})
.catch(err => console.error('Fetch stats error:', err))
},
goToDetail(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({
url: `/pages/detail/detail?id=${id}`,
})
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "首页",
"navigationBarBackgroundColor": "#ff9900",
"navigationBarTextStyle": "white",
"usingComponents": {}
}

View File

@@ -0,0 +1,95 @@
<view class="page-container">
<!-- Header -->
<view class="header">
<view class="header-content">
<view class="title-container">
<view class="title-sub">致力成就</view>
<view class="title-main">终身教育伟大事业</view>
</view>
<view class="bell-btn">🔔</view>
</view>
<!-- Search Bar -->
<view class="search-bar">
<icon type="search" size="16" color="#9ca3af"></icon>
<input type="text" placeholder="搜索项目名称..." placeholder-class="search-placeholder"/>
</view>
</view>
<view class="content-body">
<!-- Banner Swiper -->
<view class="banner-swiper-container">
<swiper class="banner-swiper" indicator-dots="{{true}}" autoplay="{{true}}" interval="{{5000}}" duration="{{500}}" circular="{{true}}" indicator-active-color="#ffffff">
<block wx:for="{{banners}}" wx:key="id">
<swiper-item>
<view class="banner-item" bindtap="handleBannerClick" data-item="{{item}}">
<image src="{{item.image}}" mode="aspectFill" class="banner-image"></image>
<!-- Optional: Overlay text if needed, but usually banners are just images -->
</view>
</swiper-item>
</block>
</swiper>
</view>
<!-- Categories -->
<view class="section">
<view class="section-header">
<text class="section-title">热门分类</text>
</view>
<scroll-view scroll-x class="categories-scroll" enable-flex>
<view class="category-item {{selectedCategory === 'all' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="all">
<text>全部</text>
</view>
<view class="category-item {{selectedCategory === 'training' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="training" wx:if="{{categoryStats.training > 0}}">
<text>在职项目</text>
</view>
<view class="category-item {{selectedCategory === 'competition' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="competition" wx:if="{{categoryStats.competition > 0}}">
<text>典礼&论坛</text>
</view>
<view class="category-item {{selectedCategory === 'grading' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="grading" wx:if="{{categoryStats.grading > 0}}">
<text>校友活动</text>
</view>
</scroll-view>
</view>
<!-- Projects -->
<view class="projects-list">
<block wx:for="{{projects}}" wx:key="id">
<view class="project-card" bindtap="goToDetail" data-id="{{item.id}}">
<image class="project-image" src="{{item.image}}" mode="aspectFill"></image>
<view class="project-info">
<view class="project-header-row">
<view class="project-title">{{item.title}}</view>
<view class="project-category-tag" wx:if="{{item.project_type_display || item.category}}">{{item.project_type_display || item.category}}</view>
</view>
<view class="project-teacher" wx:if="{{item.teacher_name}}">
{{item.teacher_name}}
</view>
<view class="project-meta">
<view class="meta-item"><text class="icon">👥</text> {{item.students}}</view>
<view class="meta-item">{{item.address}}</view>
<view class="meta-item"><text class="icon">🕒</text> {{item.duration}}</view>
</view>
</view>
</view>
</block>
</view>
<!-- Student Showcase -->
<view class="section" wx:if="{{showcases && showcases.length > 0}}" style="margin-top: 40rpx;">
<view class="section-header">
<text class="section-title">精彩视频</text>
</view>
<scroll-view scroll-x class="showcase-scroll" enable-flex>
<view class="showcase-item" wx:for="{{showcases}}" wx:key="id" bindtap="playVideo" data-url="{{item.video_url}}">
<image class="showcase-image" src="{{item.cover_image}}" mode="aspectFill"></image>
<view class="showcase-info">
<text class="showcase-title">{{item.title}}</text>
<text class="showcase-student" wx:if="{{item.student_name}}">{{item.student_name}}</text>
</view>
<view class="play-icon" wx:if="{{item.video_url}}">▶</view>
</view>
</scroll-view>
</view>
</view>
</view>

View File

@@ -0,0 +1,316 @@
.page-container {
padding-bottom: 50rpx;
background-color: var(--background-color);
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #ffaa33 0%, #ff8800 100%);
padding: 40rpx 40rpx 100rpx 40rpx;
border-bottom-left-radius: 50rpx;
border-bottom-right-radius: 50rpx;
position: relative;
box-shadow: 0 10rpx 20rpx rgba(255, 153, 0, 0.2);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10rpx;
padding-top: 10rpx;
}
.greeting {
color: rgba(255, 255, 255, 0.9);
font-size: 28rpx;
}
.title-container {
display: flex;
flex-direction: column;
margin-left: 20rpx;
}
.title-sub {
color: rgba(255, 255, 255, 0.95);
font-size: 34rpx;
letter-spacing: 4rpx;
margin-bottom: 8rpx;
font-weight: 500;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
}
.title-main {
color: var(--text-white);
font-size: 60rpx;
font-weight: normal;
font-family: "Xingkai SC", "STXingkai", "STKaiti", "KaiTi", "楷体", "cursive";
letter-spacing: 2rpx;
text-shadow: 0 2rpx 6rpx rgba(0,0,0,0.15);
line-height: 1.2;
}
.bell-btn {
background-color: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
padding: 16rpx;
border-radius: 50%;
color: var(--text-white);
font-size: 32rpx;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1rpx solid rgba(255, 255, 255, 0.3);
}
.search-bar {
position: absolute;
bottom: -40rpx;
left: 30rpx;
right: 30rpx;
background-color: var(--surface-color);
border-radius: 30rpx;
padding: 20rpx 30rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.search-bar input {
margin-left: 20rpx;
flex: 1;
font-size: 28rpx;
color: var(--text-main);
}
.search-placeholder {
color: var(--text-light);
}
.content-body {
padding: 60rpx 30rpx 0 30rpx;
}
.banner-swiper-container {
border-radius: 30rpx;
overflow: hidden;
box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.1);
margin-bottom: 40rpx;
}
.banner-swiper {
height: 300rpx;
width: 100%;
}
.banner-item {
width: 100%;
height: 100%;
}
.banner-image {
width: 100%;
height: 100%;
border-radius: 30rpx;
}
.section {
margin-bottom: 40rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: var(--text-main);
}
.see-all {
font-size: 24rpx;
color: var(--text-light);
}
.categories-scroll {
white-space: nowrap;
width: 100%;
}
.category-item {
display: inline-block;
padding: 16rpx 40rpx;
border-radius: 40rpx;
font-size: 28rpx;
margin-right: 20rpx;
font-weight: 500;
background-color: var(--surface-secondary);
color: var(--text-secondary);
transition: all 0.3s;
}
.category-item.active {
background-color: var(--primary-light);
color: var(--primary-color);
}
.projects-list {
display: flex;
flex-direction: column;
gap: 30rpx;
}
.project-card {
background-color: var(--surface-color);
padding: 24rpx;
border-radius: 30rpx;
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: row;
gap: 24rpx;
}
.project-image {
width: 192rpx;
height: 192rpx;
border-radius: 20rpx;
flex-shrink: 0;
background-color: #eee;
}
.project-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.project-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8rpx;
}
.project-category-tag {
font-size: 20rpx;
color: var(--primary-color);
background-color: var(--primary-light);
padding: 4rpx 12rpx;
border-radius: 8rpx;
white-space: nowrap;
flex-shrink: 0;
}
.project-title {
font-size: 28rpx;
font-weight: bold;
color: var(--text-main);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
flex: 1;
margin-right: 16rpx;
}
.project-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10rpx; /* Ensure space from teacher */
}
.meta-item {
font-size: 20rpx;
color: var(--text-light);
display: flex;
align-items: center;
}
.icon {
margin-right: 4rpx;
}
/* Project Teacher */
.project-teacher {
font-size: 24rpx; /* Slightly larger */
color: var(--text-secondary);
margin-bottom: auto;
margin-top: auto; /* Center vertically in remaining space */
font-weight: 500;
}
/* Student Showcase */
.showcase-scroll {
white-space: nowrap;
width: 100%;
}
.showcase-item {
display: inline-block;
width: 320rpx;
height: 220rpx;
margin-right: 24rpx;
border-radius: 16rpx;
overflow: hidden;
position: relative;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.showcase-image {
width: 100%;
height: 100%;
background-color: #e5e7eb;
}
.showcase-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 16rpx;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
color: var(--text-white);
display: flex;
flex-direction: column;
}
.showcase-title {
font-size: 26rpx;
font-weight: bold;
margin-bottom: 4rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.showcase-student {
font-size: 20rpx;
opacity: 0.9;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60rpx;
height: 60rpx;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
color: var(--text-white);
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
backdrop-filter: blur(4px);
}

View File

@@ -0,0 +1,131 @@
const app = getApp()
const { request } = require('../../utils/request')
Page({
data: {
hasUserInfo: false,
hasPhone: false
},
onLoad() {
this.checkStatus();
},
checkStatus() {
// If the user is already here, we check if they are valid.
// However, app.js handles the redirection for existing users.
// If we are here, it means either app.js sent us here (New User),
// or app.js hasn't finished checking yet,
// or the user manually navigated here (unlikely).
const user = app.globalData.userInfo;
if (user) {
// We can't rely on is_new_user here because we don't persist it in globalData (though we could).
// But based on new logic:
// If user exists, app.js wouldn't redirect here.
// If we are here, user probably needs to authorize.
// Update UI state
const hasNick = user.wechat_nickname && user.wechat_nickname !== '微信用户';
const hasPhone = !!user.phone;
this.setData({
hasUserInfo: hasNick,
hasPhone: hasPhone
})
// If somehow we ended up here but profile is full, go home
if (hasNick && hasPhone) {
wx.switchTab({ url: '/pages/index/index' })
}
} else {
// Wait for app.js login callback
app.loginCallback = (user) => {
// Re-run check
this.checkStatus();
// If app.js determines it's an existing user, it might redirect.
// But if not, we update UI here.
}
}
},
getUserProfile(e) {
wx.getUserProfile({
desc: '用于完善会员资料',
success: (res) => {
const { userInfo } = res
console.log('getUserProfile success', userInfo)
// Update backend
// We need user ID from globalData
if (app.globalData.userInfo && app.globalData.userInfo.id) {
this.updateUserInfo(userInfo)
} else {
// Should not happen if app.js login succeeded
wx.showToast({ title: '登录状态异常,请重启', icon: 'none' })
}
},
fail: (err) => {
console.error('getUserProfile failed', err)
wx.showToast({ title: '需要授权才能继续', icon: 'none' })
}
})
},
updateUserInfo(wxUserInfo) {
request({
url: `/students/${app.globalData.userInfo.id}/`,
method: 'PATCH',
data: {
wechat_nickname: wxUserInfo.nickName,
avatar: wxUserInfo.avatarUrl
// name: wxUserInfo.nickName // Optional: sync name too
}
}).then(res => {
console.log('Update user info success', res)
app.globalData.userInfo = { ...app.globalData.userInfo, ...res }
this.setData({ hasUserInfo: true })
}).catch(err => {
console.error('Update user info failed', err)
wx.showToast({ title: '更新资料失败', icon: 'none' })
})
},
getPhoneNumber(e) {
console.log('getPhoneNumber', e)
if (e.detail.errMsg === 'getPhoneNumber:ok') {
request({
url: '/user/phone/',
method: 'POST',
data: {
code: e.detail.code
}
}).then(res => {
console.log('Get phone success', res)
if (res.phone) {
this.setData({ hasPhone: true })
if (app.globalData.userInfo) {
app.globalData.userInfo.phone = res.phone
}
wx.showToast({
title: '登录成功',
icon: 'success'
})
// Redirect to home
setTimeout(() => {
wx.switchTab({ url: '/pages/index/index' })
}, 1500)
}
}).catch(err => {
console.error('Get phone failed', err)
wx.showToast({ title: '获取手机号失败', icon: 'none' })
})
} else {
console.log('User denied phone number')
// If user denies, we can optionally let them in anyway if that's the requirement?
// User said: "If openID exists, direct login".
// This implies for NEW users, they MIGHT need to authorize phone.
// But strictly speaking, once they authorized UserProfile (Avatar/Nick),
// the OpenID exists.
// But the "User Phone" part is usually critical.
// Let's keep strict requirement for phone for NEW users for now unless asked otherwise.
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "用户登录"
}

View File

@@ -0,0 +1,20 @@
<view class="container">
<view class="header">
<!-- Use a generic icon if specific one not found, or use text -->
<image class="logo" src="/assets/logo.png" mode="aspectFit"></image>
<view class="title">正在登录</view>
</view>
<view class="content">
<view class="tips">为了提供更好的服务,请授权以下信息</view>
<block wx:if="{{!hasUserInfo}}">
<button class="btn-login" bindtap="getUserProfile">获取微信昵称</button>
</block>
<block wx:if="{{hasUserInfo && !hasPhone}}">
<button class="btn-login" open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber">获取手机号码</button>
</block>
</view>
</view>

View File

@@ -0,0 +1,56 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx;
height: 100vh;
background-color: #fff;
}
.header {
margin-top: 100rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.logo {
width: 200rpx;
height: 200rpx;
margin-bottom: 40rpx;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #333;
}
.content {
margin-top: 120rpx;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.tips {
font-size: 30rpx;
color: #888;
text-align: center;
margin-bottom: 80rpx;
}
.btn-login {
width: 80%;
background-color: #07c160;
color: white;
border-radius: 44rpx;
margin-bottom: 40rpx;
font-size: 32rpx;
padding: 10rpx 0;
}
.btn-login:active {
opacity: 0.9;
}

View File

@@ -0,0 +1,339 @@
const app = getApp()
Page({
data: {
user: {},
isFormOpen: false,
isDevtools: false,
formData: {
name: '',
phone: '',
age: '',
company_name: '',
position: '',
wechat_nickname: '',
avatar: ''
}
},
onLoad(options) {
try {
const sys = wx.getSystemInfoSync()
this.setData({ isDevtools: sys.platform === 'devtools' })
} catch (e) {}
// 检查是否已登录
const app = getApp();
if (app.globalData.userInfo) {
this.setData({ user: app.globalData.userInfo });
this.initFormData(app.globalData.userInfo);
} else {
// 设置回调,等待登录完成
app.loginCallback = (user) => {
this.setData({ user: user });
this.initFormData(user);
}
}
this.fetchUser(); // Still fetch latest data just in case
if (options.action === 'bind_phone') {
this.checkBindPhone();
}
this.ensureNickname()
},
onShow() {
const app = getApp();
if (app.globalData.profileAction === 'bind_phone') {
app.globalData.profileAction = null; // Clear it
this.checkBindPhone();
}
this.ensureNickname()
this.fetchUser() // Always refresh user data on show to update stats
},
checkBindPhone() {
// Only prompt if phone is truly missing
const app = getApp()
const hasPhone = this.data.user.phone || (app.globalData.userInfo && app.globalData.userInfo.phone);
if (!hasPhone) {
this.setData({ isFormOpen: true });
wx.showToast({
title: '请先激活手机号',
icon: 'none',
duration: 2000
})
}
},
initFormData(user) {
this.setData({
formData: {
name: user.name,
phone: user.phone,
age: user.age,
company_name: user.company_name,
position: user.position,
wechat_nickname: user.wechat_nickname,
avatar: user.avatar
}
})
},
toggleForm() {
this.setData({
isFormOpen: !this.data.isFormOpen
});
},
fetchUser() {
const { request } = require('../../utils/request')
request({ url: '/user/' })
.then((data) => {
this.setData({
user: data,
formData: {
name: data.name,
phone: data.phone,
age: data.age,
company_name: data.company_name,
position: data.position,
wechat_nickname: data.wechat_nickname,
avatar: data.avatar
}
})
this.ensureNickname()
})
.catch(() => {
this.setData({
user: {
id: '8839201',
name: '学员用户',
avatar:
'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200&auto=format&fit=crop&q=60',
stats: {
learning: 12,
coupons: 3,
hours: 28
}
}
})
})
},
handleInput(e) {
const field = e.currentTarget.dataset.field;
const value = e.detail.value;
this.setData({
[`formData.${field}`]: value
});
},
onChooseAvatar(e) {
const { avatarUrl } = e.detail
const app = getApp()
wx.showLoading({ title: '上传中...' })
const header = {}
if (app.globalData.token) {
header['Authorization'] = `Bearer ${app.globalData.token}`
}
wx.uploadFile({
url: `${app.globalData.baseUrl}/file/`,
filePath: avatarUrl,
name: 'file',
header: header,
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200 || res.statusCode === 201) {
const data = JSON.parse(res.data)
// Handle wrapped response from FitJSONRenderer
const path = data.data && data.data.path ? data.data.path : data.path;
if (path) {
this.setData({
'formData.avatar': path,
'user.avatar': path
})
} else {
wx.showToast({ title: '上传失败:无路径', icon: 'none' })
console.error('Upload response missing path:', data)
}
} else {
wx.showToast({ title: '上传失败', icon: 'none' })
console.error('Upload failed:', res)
}
},
fail: (err) => {
wx.hideLoading()
console.error(err)
wx.showToast({ title: '上传出错', icon: 'none' })
}
})
},
onNicknameChange(e) {
this.setData({
'formData.wechat_nickname': e.detail.value
})
},
ensureNickname() {
const has = this.data.formData && this.data.formData.wechat_nickname
if (has) return
// Only auto-fetch if we are in devtools (mock mode).
// Real getUserProfile requires a tap event, so we cannot auto-call it.
if (this.data.isDevtools) {
this.fetchNickname()
}
},
fetchNickname() {
if (this.data.isDevtools) {
const nick = '微信用户'
const app = getApp()
app.globalData.userInfo = {
...app.globalData.userInfo,
wechat_nickname: nick,
name: app.globalData.userInfo && app.globalData.userInfo.name ? app.globalData.userInfo.name : nick
}
this.setData({
'formData.wechat_nickname': nick,
'formData.name': this.data.formData.name || nick,
'user.name': this.data.user.name || nick
})
return
}
if (wx.getUserProfile) {
wx.getUserProfile({ desc: '完善资料' }).then(res => {
const info = res.userInfo || {}
const nick = info.nickName || '微信用户'
const avatar = info.avatarUrl || this.data.formData.avatar
const app = getApp()
app.globalData.userInfo = {
...app.globalData.userInfo,
wechat_nickname: nick,
name: app.globalData.userInfo && app.globalData.userInfo.name ? app.globalData.userInfo.name : nick,
avatar: avatar
}
this.setData({
'formData.wechat_nickname': nick,
'formData.name': this.data.formData.name || nick,
'formData.avatar': avatar,
'user.name': this.data.user.name || nick,
'user.avatar': avatar
})
}).catch(err => {
console.error('getUserProfile error:', err)
})
}
},
getPhoneNumber(e) {
const detail = e && e.detail ? e.detail : {}
if (detail.code) {
const { request } = require('../../utils/request')
wx.showLoading({ title: '获取中...' })
request({
url: '/user/phone/',
method: 'POST',
data: { code: detail.code }
}).then(res => {
wx.hideLoading()
const app = getApp()
app.globalData.userInfo = {
...app.globalData.userInfo,
phone: res.phone
}
this.setData({
'user.phone': res.phone,
'formData.phone': res.phone
})
wx.showToast({ title: '获取成功', icon: 'success' })
}).catch(err => {
wx.hideLoading()
console.error('user/phone request error:', err)
wx.showToast({ title: '获取失败,请检查网络', icon: 'none' })
})
} else {
// DevTools 兜底:工具环境无法下发 code直接模拟请求后端获取默认手机号
if (this.data.isDevtools) {
const { request } = require('../../utils/request')
wx.showLoading({ title: '模拟获取中...' })
request({
url: '/user/phone/',
method: 'POST',
data: { code: 'mock_devtools' }
}).then(res => {
wx.hideLoading()
const app = getApp()
app.globalData.userInfo = {
...app.globalData.userInfo,
phone: res.phone
}
this.setData({
'user.phone': res.phone,
'formData.phone': res.phone
})
wx.showToast({ title: '工具模拟成功', icon: 'success' })
}).catch(err => {
wx.hideLoading()
console.error('devtools mock phone error:', err)
wx.showToast({ title: '工具模拟失败', icon: 'none' })
})
return
}
const msg = detail.errMsg || '未获取到授权码'
console.error('getPhoneNumber error:', detail)
wx.showToast({ title: msg.includes('deny') ? '用户拒绝授权' : msg, icon: 'none' })
}
},
simulateGetPhone() {
if (!this.data.isDevtools) return
const { request } = require('../../utils/request')
wx.showLoading({ title: '模拟获取中...' })
request({
url: '/user/phone/',
method: 'POST',
data: { code: 'mock_devtools' }
}).then(res => {
wx.hideLoading()
const app = getApp()
app.globalData.userInfo = {
...app.globalData.userInfo,
phone: res.phone
}
this.setData({
'user.phone': res.phone,
'formData.phone': res.phone
})
wx.showToast({ title: '工具模拟成功', icon: 'success' })
}).catch(err => {
wx.hideLoading()
console.error('simulateGetPhone error:', err)
wx.showToast({ title: '工具模拟失败', icon: 'none' })
})
},
handleSubmit() {
this.submitData();
},
submitData() {
const { request } = require('../../utils/request')
console.log(this.data.formData);
request({ url: '/user/', method: 'POST', data: this.data.formData })
.then((data) => {
console.log(data)
// Update user data to reflect changes immediately
const app = getApp()
app.globalData.userInfo = {
...app.globalData.userInfo,
...data
}
this.setData({
user: {
...this.data.user,
...data,
stats: this.data.user.stats // preserve stats
},
isFormOpen: false
});
wx.showToast({
title: '保存成功',
icon: 'success'
})
})
},
navigateToCoupons() {
wx.navigateTo({
url: '/pages/coupon/coupon'
})
}
})

View File

@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "个人中心",
"navigationBarBackgroundColor": "#ff9900",
"navigationBarTextStyle": "white"
}

View File

@@ -0,0 +1,107 @@
<view class="container">
<view class="profile-header">
<view class="user-info">
<button class="avatar-btn" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar" style="padding:0;border:none;background:transparent;">
<image class="avatar" src="{{user.avatar}}" mode="aspectFill"></image>
</button>
<view class="user-details">
<view class="username">{{user.name}}</view>
<!-- <view class="userid">ID: {{user.id}}</view> -->
</view>
</view>
<view class="stats-card">
<view class="stat-item">
<view class="stat-val">{{user.stats.learning}}</view>
<view class="stat-label">在学课程</view>
</view>
<view class="stat-item">
<view class="stat-val">{{user.stats.coupons}}</view>
<view class="stat-label">可用优惠券</view>
</view>
<!-- <view class="stat-item">
<view class="stat-val">{{user.stats.hours}}h</view>
<view class="stat-label">学习时长</view>
</view> -->
</view>
</view>
<!-- Menu List -->
<view class="menu-list">
<view class="menu-item" bindtap="navigateToCoupons">
<view class="menu-left">
<image class="menu-icon" src="/assets/icons/coupon-icon.png" mode="aspectFit"></image>
<text class="menu-text">我的优惠券</text>
</view>
<view class="menu-right">
<text class="menu-arrow">></text>
</view>
</view>
</view>
<view class="form-section">
<view class="form-title-bar" bindtap="toggleForm">
<view class="title-left">
<image class="menu-icon" src="/assets/icons/profile-icon.png" mode="aspectFit"></image>
<text class="form-title">绑定个人信息</text>
</view>
<text class="toggle-icon">{{isFormOpen ? '▲' : '▼'}}</text>
</view>
<view class="form-content" wx:if="{{isFormOpen}}">
<view class="form-group">
<text class="label">头像</text>
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
<image class="avatar-upload" src="{{formData.avatar || user.avatar || '/assets/icons/default-avatar.png'}}" mode="aspectFill"></image>
</button>
</view>
<view class="form-group">
<text class="label">微信昵称</text>
<view class="input-wrap">
<input type="nickname" placeholder="请输入微信昵称" bind:change="onNicknameChange" bindinput="handleInput" data-field="wechat_nickname" value="{{formData.wechat_nickname}}"/>
<button size="mini" type="default" bindtap="fetchNickname" wx:if="{{!formData.wechat_nickname}}" style="font-size:24rpx;margin-left:10rpx;padding:0 20rpx;line-height:60rpx;height:60rpx;">获取微信昵称</button>
</view>
</view>
<view class="form-group">
<text class="label">学员姓名</text>
<view class="input-wrap">
<input type="text" placeholder="请输入真实姓名" bindinput="handleInput" data-field="name" value="{{formData.name}}"/>
</view>
</view>
<view class="form-group">
<text class="label">联系电话</text>
<view class="input-wrap" style="display:flex;align-items:center;">
<input type="number" placeholder="请输入手机号码" disabled="true" bindinput="handleInput" data-field="phone" value="{{formData.phone}}" style="flex:1;"/>
<button size="mini" type="primary" open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber" style="font-size:24rpx;margin-left:10rpx;padding:0 20rpx;line-height:60rpx;height:60rpx;">获取号码</button>
<button wx:if="{{isDevtools}}" size="mini" type="default" bindtap="simulateGetPhone" style="font-size:24rpx;margin-left:10rpx;padding:0 20rpx;line-height:60rpx;height:60rpx;">模拟获取号码</button>
</view>
</view>
<view class="form-group">
<text class="label">年龄</text>
<view class="input-wrap">
<input type="number" placeholder="0" bindinput="handleInput" data-field="age" value="{{formData.age}}"/>
</view>
</view>
<view class="form-group">
<text class="label">公司名称</text>
<view class="input-wrap">
<input type="text" placeholder="请输入公司名称" bindinput="handleInput" data-field="company_name" value="{{formData.company_name}}"/>
</view>
</view>
<view class="form-group">
<text class="label">职位</text>
<view class="input-wrap">
<input type="text" placeholder="请输入职位" bindinput="handleInput" data-field="position" value="{{formData.position}}"/>
</view>
</view>
<button class="submit-btn" bindtap="handleSubmit">保存信息</button>
</view>
</view>
</view>

View File

@@ -0,0 +1,206 @@
.container {
min-height: 100vh;
background-color: var(--background-color);
}
.profile-header {
background-color: var(--surface-color);
padding: 40rpx 40rpx 100rpx 40rpx;
position: relative;
margin-bottom: 60rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 30rpx;
justify-content: flex-start; /* Ensure items start from left */
}
.avatar-btn {
padding: 0;
border: none;
background: transparent;
margin: 0; /* Ensure no auto margin */
width: 128rpx !important;
height: 128rpx !important;
line-height: 1;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0; /* Prevent shrinking */
}
.avatar-btn::after {
border: none;
}
.avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
border: 4rpx solid var(--surface-color);
box-shadow: 0 4rpx 10rpx rgba(0,0,0,0.1);
background-color: #eee;
display: block;
}
.user-details {
display: flex;
flex-direction: column;
align-items: flex-start; /* Ensure text aligns left */
flex: 1; /* Take remaining space */
text-align: left; /* Force text align left */
}
.username {
font-size: 40rpx;
font-weight: bold;
color: var(--text-main);
}
.userid {
font-size: 28rpx;
color: var(--text-secondary);
}
.stats-card {
position: absolute;
bottom: -40rpx;
left: 40rpx;
right: 40rpx;
background-color: var(--surface-color);
border-radius: 20rpx;
box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.05);
padding: 30rpx;
display: flex;
justify-content: space-around;
text-align: center;
}
.stat-val {
font-size: 36rpx;
font-weight: bold;
color: var(--text-main);
}
.stat-label {
font-size: 24rpx;
color: var(--text-light);
}
/* Menu List */
.menu-list {
margin: 0 30rpx 30rpx 30rpx;
background-color: var(--surface-color);
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 40rpx;
transition: background-color 0.2s;
}
.menu-item:active {
background-color: var(--surface-hover);
}
.menu-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.menu-icon {
width: 40rpx;
height: 40rpx;
}
.menu-text {
font-size: 30rpx;
color: var(--text-main);
font-weight: 500;
}
.menu-arrow {
color: var(--text-light);
font-size: 28rpx;
}
.form-section {
padding: 40rpx;
background-color: var(--surface-color);
margin: 0 30rpx 40rpx 30rpx;
border-radius: 30rpx;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
}
.form-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.title-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.toggle-icon {
font-size: 24rpx;
color: var(--text-light);
padding: 10rpx;
}
.form-content {
margin-top: 20rpx;
}
.blue-line {
width: 8rpx;
height: 32rpx;
background-color: var(--primary-color);
border-radius: 4rpx;
}
.form-title {
font-size: 30rpx;
font-weight: 500;
color: var(--text-main);
}
.form-group {
margin-bottom: 30rpx;
}
.label {
font-size: 24rpx;
color: var(--text-secondary);
margin-bottom: 10rpx;
display: block;
}
.input-wrap {
background-color: var(--background-color);
padding: 24rpx;
border-radius: 20rpx;
border: 2rpx solid var(--border-color);
}
.form-row {
display: flex;
gap: 30rpx;
}
.half {
flex: 1;
}
.submit-btn {
background: linear-gradient(to right, var(--primary-gradient-start), var(--primary-gradient-end));
color: var(--text-white);
border-radius: 20rpx;
margin-top: 40rpx;
font-weight: bold;
font-size: 32rpx;
}
/* Avatar Upload */
.avatar-wrapper {
padding: 0;
width: 120rpx !important;
height: 120rpx !important;
border-radius: 20rpx;
margin: 0;
background-color: transparent;
border: none;
}
.avatar-wrapper::after {
border: none;
}
.avatar-upload {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background-color: #eee;
}