Compare commits

...

10 Commits

Author SHA1 Message Date
Blizzard 058a575e10 feat: ai问答 2026-04-28 10:36:51 +08:00
Blizzard 0715a16d91 feat: 限制用户单日提问次数 2026-04-24 16:48:26 +08:00
Blizzard 9fe2fd42e0 feat: 百科rag 2026-04-23 11:13:23 +08:00
Blizzard 40f3a8cfa8 feat: 优化订阅逻辑 2026-03-09 09:12:33 +08:00
Blizzard 2031e788b0 feat: 修改post点赞显示 2026-03-05 09:11:08 +08:00
Blizzard a34d7df090 feat: 位置信息处理 2026-02-26 11:51:30 +08:00
Blizzard bcba77f912 feat:兑换中心 2026-02-25 13:28:17 +08:00
Blizzard 5789e8bf17 feat: 调整分享标题 2026-02-14 16:23:22 +08:00
Blizzard 5800466e69 feat: 完成任务添加订阅 2026-02-14 15:38:48 +08:00
Blizzard 2d8ffd842a feat: 删除无用的console输出 2026-02-14 13:17:05 +08:00
48 changed files with 2946 additions and 254 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -21,7 +21,7 @@ App({
if (token && typeof token === 'string') {
wx.setStorageSync('token', token);
console.log('Login successful');
if (this._resolveLogin) this._resolveLogin(token);
// Background Profile Update
+14 -2
View File
@@ -12,6 +12,8 @@
"pages/plant-detail/growth-record/index",
"pages/wiki/detail/index",
"pages/wiki/identify/index",
"pages/wiki/chat/index",
"pages/wiki/chat/history/index",
"pages/profile/identify-history/index",
"pages/profile/badges/index",
"pages/profile/badges/level-detail/index",
@@ -19,7 +21,8 @@
"pages/profile/favorites/index",
"pages/profile/posts/index",
"pages/profile/about/index",
"pages/profile/exchange/index"
"pages/profile/exchange/index",
"pages/profile/exchange/orders/index"
],
"window": {
"backgroundTextStyle": "light",
@@ -65,5 +68,14 @@
}
]
},
"sitemapLocation": "sitemap.json"
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents",
"requiredPrivateInfos": [
"chooseLocation"
],
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于发布内容时展示地理位置"
}
}
}
BIN
View File
Binary file not shown.
+38 -12
View File
@@ -138,23 +138,49 @@ Page({
},
formatLocation(address, name) {
if (!address) return name || '';
if (!address && !name) return '';
// Municipalities
const source = address || '';
// 直辖市
const munis = ['北京', '上海', '天津', '重庆'];
for (let m of munis) {
if (address.startsWith(m)) {
if (source.startsWith(m + '市') || source.startsWith(m)) {
return m;
}
}
// Standard: Prov + City (Simplify names)
const match = address.match(/^(.+?)(?:省|自治区)(.+?)(?:市|自治州|地区|盟)/);
if (match) {
return `${match[1]}.${match[2]}`;
// 特别行政区
if (source.startsWith('香港') || source.startsWith('澳门')) {
return source.substring(0, 2);
}
return name || address;
// 自治区:提取简称
const autoRegions = [
{ prefix: '内蒙古', full: '内蒙古自治区' },
{ prefix: '广西', full: '广西壮族自治区' },
{ prefix: '西藏', full: '西藏自治区' },
{ prefix: '宁夏', full: '宁夏回族自治区' },
{ prefix: '新疆', full: '新疆维吾尔自治区' }
];
for (let region of autoRegions) {
if (source.startsWith(region.prefix)) {
return region.prefix;
}
}
// 标准省份:只提取省名
const provinceMatch = source.match(/^(.+?)省/);
if (provinceMatch) {
return provinceMatch[1];
}
// 如果都不匹配,截取短地址
if (source.length > 6) {
return source.substring(0, 6);
}
return source || name || '';
},
toggleTopic(e) {
@@ -236,11 +262,11 @@ Page({
wx.hideLoading();
wx.showToast({ title: '发布成功', icon: 'success' });
// Refresh previous page
// 直接通知上一页(社区列表页)刷新数据
const pages = getCurrentPages();
const prevPage = pages[pages.length - 2];
if (prevPage && prevPage.onRefresh) {
prevPage.onRefresh();
const communityPage = pages[pages.length - 2];
if (communityPage && communityPage.onRefresh) {
communityPage.onRefresh();
}
setTimeout(() => {
+14 -2
View File
@@ -81,6 +81,15 @@ Page({
const publisher = item.publisher || {};
const avatarObj = publisher.avatar || {};
// 从 likeList 提取所有点赞者名字,当前用户显示"我"
const app = getApp();
const myId = (app.globalData.userInfo || {}).id;
const likeNames = (item.likeList || []).map(like => {
const liker = like.liker || {};
if (myId && liker.id === myId) return '我';
return liker.nickName || liker.name || '花友';
});
return {
id: item.id,
user: publisher.nickName || publisher.name || '花友',
@@ -89,7 +98,7 @@ Page({
images: (item.imgList || []).map(img => img.url),
location: item.location || '',
time: item.createdAtStr || '刚刚',
likes: item.hasLiked === 1 ? ['我'] : [],
likes: likeNames,
comments: (item.commentList || []).map(c => ({
id: c.id,
user: c.commentator ? (c.commentator.nickName || c.commentator.name) : '花友',
@@ -180,10 +189,13 @@ Page({
const updatedPosts = this.data.posts.map(p => {
if (p.id === postId) {
const liked = !p.likedByMe;
const updatedLikes = liked
? (p.likes.includes('我') ? p.likes : [...p.likes, '我'])
: p.likes.filter(n => n !== '我');
return {
...p,
likedByMe: liked,
likes: liked ? ['我'] : []
likes: updatedLikes
};
}
return p;
+8 -3
View File
@@ -135,11 +135,16 @@
<!-- Empty State -->
<view wx:else class="empty-feed">
<view class="empty-icon">
<t-icon name="chat" size="80rpx" color="#ccc" />
<view class="empty-scene">
<view class="empty-glow"></view>
<view class="empty-emoji anim-breathe">💬</view>
</view>
<text class="empty-text">暂无相关动态</text>
<text class="empty-title">暂无相关动态</text>
<text class="empty-hint">快来发布第一条动态吧</text>
<view class="empty-cta" bindtap="goToCreatePost">
<t-icon name="add" size="28rpx" color="#fff" />
<text>发布动态</text>
</view>
</view>
<!-- Bottom Spacer -->
+63 -19
View File
@@ -276,31 +276,75 @@ page {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
color: #999;
padding: 100rpx 40rpx 60rpx;
}
.empty-icon {
width: 160rpx;
height: 160rpx;
background: #f5f5f5;
border-radius: 50%;
.empty-scene {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
}
.empty-text {
.empty-glow {
position: absolute;
width: 240rpx;
height: 240rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(85,139,47,0.12) 0%, transparent 70%);
animation: emptyPulse 3s ease-in-out infinite;
}
@keyframes emptyPulse {
0%, 100% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.1); opacity: 1; }
}
.empty-emoji {
font-size: 96rpx;
position: relative;
z-index: 2;
}
.anim-breathe {
animation: breathe 3s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { transform: scale(1) translateY(0); }
50% { transform: scale(1.05) translateY(-8rpx); }
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #666;
margin-bottom: 12rpx;
font-weight: 700;
color: #558B2F;
margin-bottom: 8rpx;
}
.empty-hint {
font-size: 26rpx;
color: #999;
color: #90A4AE;
margin-bottom: 36rpx;
}
.empty-cta {
display: flex;
align-items: center;
gap: 8rpx;
padding: 16rpx 40rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #558B2F, #7CB342);
color: #fff;
font-size: 28rpx;
font-weight: 600;
box-shadow: 0 6rpx 20rpx rgba(85,139,47,0.3);
transition: all 0.15s;
}
.empty-cta:active {
transform: scale(0.95);
}
/* Floating Action Button */
@@ -308,23 +352,23 @@ page {
position: fixed;
right: 40rpx;
bottom: 60rpx;
background: #558B2F;
background: rgba(85, 139, 47, 0.92);
backdrop-filter: blur(12px);
color: white;
padding: 24rpx 40rpx;
border-radius: 60rpx;
padding: 20rpx 36rpx;
border-radius: 48rpx;
display: flex;
align-items: center;
gap: 12rpx;
box-shadow: 0 12rpx 32rpx rgba(85, 139, 47, 0.4);
gap: 10rpx;
box-shadow: 0 8rpx 28rpx rgba(85, 139, 47, 0.35);
z-index: 100;
font-size: 28rpx;
font-size: 26rpx;
font-weight: 700;
transition: all 0.2s ease;
}
.floating-add-btn:active {
transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(85, 139, 47, 0.2);
}
/* WeChat Style Action Container */
+31 -3
View File
@@ -7,6 +7,8 @@ Page({
plants: [],
dateString: '',
greeting: '',
bannerList: [],
currentBanner: 0,
// Pagination
currentPage: 1,
@@ -14,12 +16,14 @@ Page({
total: 0,
isLastPage: false,
isLoading: false,
isRefreshing: false,
scrollTop: 0
},
onLoad(options) {
this.initTime();
this.loadPlants(true);
this.loadBanners();
},
onShow() {
@@ -34,13 +38,23 @@ Page({
this.loadPlants(true);
},
// Pull to refresh
// Pull to refresh (page-level)
onPullDownRefresh() {
this.loadPlants(true).then(() => {
wx.stopPullDownRefresh();
});
},
// Pull to refresh (scroll-view)
onRefresh() {
this.setData({ isRefreshing: true });
this.loadPlants(true).then(() => {
this.setData({ isRefreshing: false });
}).catch(() => {
this.setData({ isRefreshing: false });
});
},
// Infinite scroll
onReachBottom() {
if (!this.data.isLastPage && !this.data.isLoading) {
@@ -91,6 +105,16 @@ Page({
});
},
async loadBanners() {
try {
const res = await request.get('/plantBanner/activeList');
const list = (res.list || []).map(item => item.image ? item.image.url : '');
this.setData({ bannerList: list.filter(Boolean) });
} catch (err) {
console.error('Load banners failed', err);
}
},
initTime() {
const updateTime = () => {
const now = new Date();
@@ -115,6 +139,10 @@ Page({
updateTime();
},
onBannerChange(e) {
this.setData({ currentBanner: e.detail.current });
},
navigateToDetail(e) {
const { id } = e.currentTarget.dataset;
wx.navigateTo({
@@ -136,14 +164,14 @@ Page({
onShareAppMessage() {
return {
title: '我的植物花园 - Sundynix Plant',
title: '我的私人花园 - 植趣',
path: '/pages/garden/index'
};
},
onShareTimeline() {
return {
title: '我的植物花园 - Sundynix Plant'
title: '我的私人花园 - 植趣'
};
},
+27 -3
View File
@@ -17,7 +17,27 @@
</view>
<view class="banner-container">
<image src="https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=800" class="garden-banner" mode="aspectFill" />
<swiper
class="banner-swiper"
indicator-dots="{{false}}"
autoplay="{{true}}"
interval="{{4000}}"
duration="{{600}}"
circular="{{true}}"
bindchange="onBannerChange"
easing-function="easeInOutCubic"
>
<swiper-item wx:for="{{bannerList}}" wx:key="*this">
<image src="{{item}}" mode="aspectFill" class="banner-image" />
</swiper-item>
</swiper>
<view class="banner-dots">
<view
wx:for="{{bannerList}}"
wx:key="*this"
class="dot {{currentBanner === index ? 'active' : ''}}"
/>
</view>
<view class="banner-overlay">
<text class="count-tag">共养护 {{total}} 盆植物</text>
</view>
@@ -45,7 +65,7 @@
</view>
</view>
<scroll-view wx:else scroll-y class="garden-list-container" enhanced show-scrollbar="{{false}}" bindscrolltolower="onScrollLower" scroll-top="{{scrollTop}}">
<scroll-view wx:else scroll-y class="garden-list-container" enhanced show-scrollbar="{{false}}" bindscrolltolower="onScrollLower" scroll-top="{{scrollTop}}" refresher-enabled="{{true}}" bindrefresherrefresh="onRefresh" refresher-triggered="{{isRefreshing}}">
<view class="plant-grid">
<view wx:for="{{plants}}" wx:key="id" class="plant-card" bindtap="navigateToDetail" data-id="{{item.id}}">
<view class="plant-image-container">
@@ -55,13 +75,17 @@
width="100%"
height="100%"
t-class="uploaded-img"
lazy
/>
<view class="days-badge">{{item.daysPlanted}}天</view>
</view>
<view class="plant-info">
<text class="plant-name">{{item.name}}</text>
<view class="status-wrap">
<text class="status">生长中</text>
<text class="status">{{item.desc || '生长中'}}</text>
<view wx:if="{{item.carePlans && item.carePlans.length > 0}}" class="care-dot-badge">
<text>{{item.carePlans.length}}项养护</text>
</view>
</view>
</view>
</view>
+55 -8
View File
@@ -83,11 +83,41 @@
flex-shrink: 0;
}
.garden-banner {
.banner-swiper {
width: 100%;
height: 100%;
}
.banner-image {
width: 100%;
height: 220rpx;
display: block;
}
/* Custom indicator dots */
.banner-dots {
position: absolute;
bottom: 48rpx;
right: 32rpx;
display: flex;
gap: 10rpx;
z-index: 10;
}
.banner-dots .dot {
width: 12rpx;
height: 12rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.4);
transition: all 0.3s ease;
}
.banner-dots .dot.active {
width: 32rpx;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 0 8rpx rgba(255, 255, 255, 0.5);
}
.banner-overlay {
position: absolute;
bottom: 0;
@@ -191,6 +221,9 @@
.status-wrap {
display: flex;
align-items: center;
gap: 8rpx;
flex-wrap: wrap;
}
.status {
@@ -200,6 +233,20 @@
padding: 4rpx 16rpx;
border-radius: 12rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180rpx;
}
.care-dot-badge {
font-size: 20rpx;
color: #1565C0;
background: #E3F2FD;
padding: 4rpx 12rpx;
border-radius: 10rpx;
font-weight: 600;
white-space: nowrap;
}
/* Custom Floating Button */
@@ -207,23 +254,23 @@
position: fixed;
right: 40rpx;
bottom: 60rpx;
background: #558B2F;
background: rgba(85, 139, 47, 0.92);
backdrop-filter: blur(12px);
color: white;
padding: 24rpx 40rpx;
border-radius: 60rpx;
padding: 20rpx 36rpx;
border-radius: 48rpx;
display: flex;
align-items: center;
gap: 12rpx;
box-shadow: 0 12rpx 32rpx rgba(85, 139, 47, 0.4);
gap: 10rpx;
box-shadow: 0 8rpx 28rpx rgba(85, 139, 47, 0.35);
z-index: 1000;
font-size: 28rpx;
font-size: 26rpx;
font-weight: 700;
transition: all 0.2s ease;
}
.floating-add-btn:active {
transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(85, 139, 47, 0.2);
}
/* List Footer */
.list-footer {
+6 -1
View File
@@ -72,7 +72,12 @@ Page({
}
// 2. Prepare payload
const mapTitle = { growth: '生长记录', repot: '换盆记录', pest: '病虫害记录', other: '日常记录' };
const mapTitle = {
growth: '生长记录', flower: '开花记录', repot: '换盆记录',
prune: '修剪记录', fertilize: '施肥记录', soil: '换土记录',
pest: '病虫害记录', medicine: '用药记录', move: '移位记录',
other: '日常记录'
};
const title = mapTitle[this.data.recordType] || '日常记录';
const payload = {
@@ -8,14 +8,38 @@
<t-icon name="thumb-up" size="32rpx" />
<text>生长</text>
</view>
<view class="chip {{recordType === 'flower' ? 'active' : ''}}" bindtap="setRecordType" data-type="flower">
<t-icon name="heart" size="32rpx" />
<text>开花</text>
</view>
<view class="chip {{recordType === 'repot' ? 'active' : ''}}" bindtap="setRecordType" data-type="repot">
<t-icon name="swap" size="32rpx" />
<text>换盆</text>
</view>
<view class="chip {{recordType === 'prune' ? 'active' : ''}}" bindtap="setRecordType" data-type="prune">
<t-icon name="cut" size="32rpx" />
<text>修剪</text>
</view>
<view class="chip {{recordType === 'fertilize' ? 'active' : ''}}" bindtap="setRecordType" data-type="fertilize">
<t-icon name="edit-1" size="32rpx" />
<text>施肥</text>
</view>
<view class="chip {{recordType === 'soil' ? 'active' : ''}}" bindtap="setRecordType" data-type="soil">
<t-icon name="layers" size="32rpx" />
<text>换土</text>
</view>
<view class="chip {{recordType === 'pest' ? 'active' : ''}}" bindtap="setRecordType" data-type="pest">
<t-icon name="error-circle" size="32rpx" />
<text>病虫害</text>
</view>
<view class="chip {{recordType === 'medicine' ? 'active' : ''}}" bindtap="setRecordType" data-type="medicine">
<t-icon name="heart-filled" size="32rpx" />
<text>用药</text>
</view>
<view class="chip {{recordType === 'move' ? 'active' : ''}}" bindtap="setRecordType" data-type="move">
<t-icon name="map-navigation" size="32rpx" />
<text>移位</text>
</view>
<view class="chip {{recordType === 'other' ? 'active' : ''}}" bindtap="setRecordType" data-type="other">
<t-icon name="file" size="32rpx" />
<text>其他</text>
+21 -2
View File
@@ -79,13 +79,32 @@ Page({
imageUrl = item.imgList[0].url;
}
// Type → icon/color mapping
const typeConfig = {
growth: { icon: 'thumb-up', color: '#4CAF50', accent: '#E8F5E9' },
flower: { icon: 'heart', color: '#E91E63', accent: '#FCE4EC' },
repot: { icon: 'swap', color: '#FF9800', accent: '#FFF3E0' },
prune: { icon: 'cut', color: '#9C27B0', accent: '#F3E5F5' },
fertilize: { icon: 'edit-1', color: '#FF9800', accent: '#FFF8E1' },
soil: { icon: 'layers', color: '#795548', accent: '#EFEBE9' },
pest: { icon: 'error-circle', color: '#F44336', accent: '#FFEBEE' },
medicine: { icon: 'heart-filled', color: '#E91E63', accent: '#FCE4EC' },
move: { icon: 'map-navigation', color: '#00BCD4', accent: '#E0F7FA' },
other: { icon: 'file', color: '#2196F3', accent: '#E3F2FD' },
};
const tag = item.tag || 'other';
const cfg = typeConfig[tag] || typeConfig.other;
return {
id: item.id,
date: item.createdAtStr ? item.createdAtStr.split(' ')[0] : '',
type: item.tag || 'growth',
type: tag,
title: item.name || '成长记录',
content: item.content || item.desc || '',
image: imageUrl
image: imageUrl,
iconName: cfg.icon,
iconColor: cfg.color,
accentColor: cfg.accent,
};
})
});
+11 -6
View File
@@ -7,6 +7,7 @@
</view>
<view class="header-gallery">
<block wx:if="{{swiperImages.length > 0}}">
<t-swiper
current="{{activeImageIndex}}"
autoplay="{{false}}"
@@ -15,6 +16,11 @@
bind:change="onSwiperChange"
height="500rpx"
/>
</block>
<view wx:else class="header-empty-placeholder">
<text class="empty-plant-emoji">🌱</text>
<text class="empty-photo-hint">去编辑页添加照片吧</text>
</view>
<view class="header-gradient"></view>
<!-- Image Counter -->
@@ -177,12 +183,11 @@
<view wx:for="{{displayRecords}}" wx:key="id" class="timeline-item">
<view class="timeline-dot"></view>
<text class="timeline-date">{{item.date}}</text>
<view class="timeline-content-box">
<view class="timeline-content-box" style="border-left-color: {{item.iconColor || '#81C784'}};">
<view class="timeline-title">
<t-icon wx:if="{{item.type === 'growth'}}" name="thumb-up" size="32rpx" color="#4CAF50" />
<t-icon wx:elif="{{item.type === 'repot'}}" name="swap" size="32rpx" color="#FF9800" />
<t-icon wx:elif="{{item.type === 'pest'}}" name="error-circle" size="32rpx" color="#F44336" />
<t-icon wx:else name="file" size="32rpx" color="#2196F3" />
<view class="timeline-type-badge" style="background: {{item.accentColor || '#E8F5E9'}};">
<t-icon name="{{item.iconName || 'file'}}" size="28rpx" color="{{item.iconColor || '#4CAF50'}}" />
</view>
<text>{{item.title}}</text>
</view>
<text class="timeline-desc">{{item.content}}</text>
@@ -190,7 +195,7 @@
wx:if="{{item.image}}"
src="{{item.image}}"
mode="aspectFill"
style="width: 220rpx; height: 220rpx; border-radius: 16rpx; margin-top: 16rpx;"
style="width: 100%; height: 280rpx; border-radius: 20rpx; margin-top: 20rpx;"
class="timeline-img"
bindtap="handlePreviewRecordImage"
data-src="{{item.image}}"
+128 -19
View File
@@ -110,34 +110,39 @@ page {
border-bottom: 2rpx solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.95);
flex-shrink: 0;
gap: 8rpx;
}
.pd-tab-btn {
flex: 1;
text-align: center;
padding: 32rpx 48rpx;
font-size: 30rpx;
padding: 24rpx 32rpx;
font-size: 28rpx;
font-weight: 500;
color: #6B7280;
background: none;
border: none;
position: relative;
border-radius: 20rpx 20rpx 0 0;
transition: all 0.3s ease;
}
.pd-tab-btn.active {
color: #558B2F;
font-weight: 600;
color: #2E7D32;
font-weight: 700;
background: rgba(85, 139, 47, 0.06);
}
.pd-tab-btn.active::after {
content: '';
position: absolute;
bottom: -2rpx;
left: 25%;
right: 25%;
left: 20%;
right: 20%;
height: 6rpx;
background: #558B2F;
background: linear-gradient(90deg, #689F38, #33691E);
border-radius: 4rpx;
transition: all 0.3s ease;
}
/* Content Area */
@@ -145,7 +150,7 @@ page {
flex: 1;
overflow-y: auto;
padding: 48rpx;
padding-bottom: 160rpx;
padding-bottom: 48rpx;
-webkit-overflow-scrolling: touch;
}
@@ -182,12 +187,25 @@ page {
color: #263238;
}
/* Care Log List - Matching Prototype */
/* Care Log List - Timeline Style */
.care-log-list {
display: flex;
flex-direction: column;
gap: 24rpx;
gap: 0;
margin-bottom: 40rpx;
position: relative;
padding-left: 20rpx;
}
.care-log-list::before {
content: '';
position: absolute;
left: 6rpx;
top: 32rpx;
bottom: 32rpx;
width: 3rpx;
background: linear-gradient(to bottom, #C8E6C9, #E8F5E9);
border-radius: 2rpx;
}
.care-log-item {
@@ -195,6 +213,27 @@ page {
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
position: relative;
margin-bottom: 20rpx;
margin-left: 24rpx;
transition: transform 0.2s ease;
}
.care-log-item::before {
content: '';
position: absolute;
left: -30rpx;
top: 40rpx;
width: 14rpx;
height: 14rpx;
background: #81C784;
border-radius: 50%;
border: 4rpx solid #F4F6F0;
z-index: 1;
}
.care-log-item:active {
transform: scale(0.98);
}
.log-left {
@@ -309,8 +348,19 @@ page {
top: 0;
left: 0;
right: 0;
height: 12rpx;
background: linear-gradient(90deg, #AED581, #2E7D32);
height: 10rpx;
background: linear-gradient(90deg, #81C784, #2E7D32, #81C784);
}
.archive-identity-card::after {
content: '🌿';
position: absolute;
top: -20rpx;
right: -10rpx;
font-size: 120rpx;
opacity: 0.08;
transform: rotate(-15deg);
pointer-events: none;
}
.aic-header {
@@ -420,7 +470,8 @@ page {
.archive-timeline {
position: relative;
padding-left: 48rpx;
border-left: 4rpx solid #E0E0E0;
border-left: 4rpx solid transparent;
border-image: linear-gradient(to bottom, #81C784, #C8E6C9, #E8F5E9) 1;
margin-left: 24rpx;
margin-bottom: 80rpx;
}
@@ -441,25 +492,43 @@ page {
width: 24rpx;
height: 24rpx;
background: white;
border: 6rpx solid #558B2F;
border: 6rpx solid #81C784;
border-radius: 50%;
z-index: 1;
box-shadow: 0 0 0 8rpx #F4F6F0;
}
.timeline-item:first-child .timeline-dot {
background: #558B2F;
border-color: #558B2F;
animation: dotPulse 2s ease-in-out infinite;
}
@keyframes dotPulse {
0%, 100% { box-shadow: 0 0 0 8rpx #F4F6F0; }
50% { box-shadow: 0 0 0 16rpx rgba(85, 139, 47, 0.15); }
}
.timeline-date {
font-size: 24rpx;
color: #9E9E9E;
font-size: 22rpx;
color: #81C784;
margin-bottom: 16rpx;
font-weight: 500;
font-weight: 600;
background: rgba(129, 199, 132, 0.1);
display: inline-block;
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
.timeline-content-box {
background: white;
border-radius: 32rpx;
border-radius: 28rpx;
padding: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
border: 2rpx solid rgba(0, 0, 0, 0.02);
border-left: 6rpx solid #81C784;
position: relative;
overflow: hidden;
}
.timeline-content-box:active {
@@ -467,7 +536,7 @@ page {
}
.timeline-title {
font-size: 32rpx;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
@@ -476,6 +545,16 @@ page {
gap: 16rpx;
}
.timeline-type-badge {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.timeline-desc {
font-size: 28rpx;
color: #546E7A;
@@ -840,3 +919,33 @@ page {
color: #EF5350;
font-weight: 500;
}
/* No-image Placeholder */
.header-empty-placeholder {
width: 100%;
height: 500rpx;
background: linear-gradient(135deg, #E8F5E9 0%, #C8E6C9 50%, #A5D6A7 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.empty-plant-emoji {
font-size: 120rpx;
opacity: 0.6;
animation: gentleBounce 3s ease-in-out infinite;
}
@keyframes gentleBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16rpx); }
}
.empty-photo-hint {
font-size: 26rpx;
color: rgba(46, 125, 50, 0.5);
font-weight: 500;
}
+22 -28
View File
@@ -25,45 +25,39 @@ Page({
this.setData({ isLoading: true });
wx.showLoading({ title: '加载中...' });
try {
// Fetch Config Tree
const treeRes = await request.get('/config/badge/tree');
// Parallel Fetch: Config Tree & User Badges
const [treeRes, userBadgesRes] = await Promise.all([
request.get('/config/badge/tree'),
request.get('/profile/badge')
]);
const list = Array.isArray(treeRes) ? treeRes : (treeRes.data || []);
// DEBUG: Force Unlock All
let achievedMap = {};
list.forEach(dim => {
if (dim.groups) {
dim.groups.forEach(grp => {
if (grp.badges) {
grp.badges.forEach(b => {
achievedMap[b.id] = true;
});
// Extract user badge list from response structure { list: [...] }
let userBadgeList = [];
if (Array.isArray(userBadgesRes)) {
userBadgeList = userBadgesRes;
} else if (userBadgesRes && Array.isArray(userBadgesRes.list)) {
userBadgeList = userBadgesRes.list;
} else if (userBadgesRes && userBadgesRes.data) {
userBadgeList = userBadgesRes.data || [];
}
});
}
});
// Original logic commented out for debug
/*
try {
const profile = await request.get('/profile/detail');
if (profile && profile.achievedBadges) {
profile.achievedBadges.forEach(b => {
const id = typeof b === 'string' ? b : b.id;
achievedMap[id] = true;
// Populate Achieved Map using Badge ID (not Record ID)
let achievedMap = {};
userBadgeList.forEach(b => {
const badgeId = b.badgeId || (b.badge ? b.badge.id : null);
if (badgeId) {
achievedMap[badgeId] = b;
}
});
}
} catch (e) {
// Silent fail
}
*/
this.setData({
dimensions: list,
achievedMap
});
} catch (e) {
console.error('Fetch badge tree failed', e);
console.error('Fetch badge data failed', e);
wx.showToast({ title: '加载失败', icon: 'none' });
} finally {
this.setData({ isLoading: false });
+4 -4
View File
@@ -26,7 +26,7 @@ Page({
try {
// Fetch levels
const levelRes = await request.get('/config/level/list');
console.log('Level Detail - API Response:', levelRes);
let list = [];
if (levelRes) {
@@ -41,14 +41,14 @@ Page({
}
}
console.log('Level Detail - Parsed List:', list);
list.sort((a, b) => a.minSunlight - b.minSunlight);
// Fetch profile if sunlight not passed
let currentSunlight = passedSunlight;
if (currentSunlight === undefined) {
const profileRes = await request.get('/profile/detail');
console.log('Level Detail - Profile:', profileRes);
currentSunlight = profileRes.totalSunlight || 0;
this.setData({ currentSunlight });
}
@@ -63,7 +63,7 @@ Page({
}
}
console.log('Level Detail - Calculated Level:', currentLevel);
this.setData({
levels: list,
+196 -1
View File
@@ -1 +1,196 @@
Page({});
import request from '../../../utils/request';
Page({
data: {
items: [],
currentSunlight: 0,
isLoading: true,
hasMore: true,
current: 1,
pageSize: 10,
activeType: 'PHYSICAL',
// Redeem popup
showRedeemPopup: false,
selectedItem: null,
redeemForm: {
recipientName: '',
phone: '',
address: ''
}
},
onLoad() {
this.fetchProfile();
this.fetchItems();
},
onShow() {
this.fetchProfile();
},
async fetchProfile() {
try {
const res = await request.get('/profile/detail');
if (res) {
this.setData({ currentSunlight: res.currentSunlight || 0 });
}
} catch (e) {
// Silent
}
},
async fetchItems(append = false) {
if (!append) {
this.setData({ isLoading: true, current: 1, items: [] });
}
try {
const res = await request.get('/exchange/list', {
current: this.data.current,
pageSize: this.data.pageSize,
type: this.data.activeType
});
const rawList = (res && res.list) ? res.list : [];
const total = (res && res.total) ? res.total : 0;
const now = Date.now();
const list = rawList.map(item => {
const hasStart = !!item.startTime;
const hasEnd = !!item.endTime;
const startTs = hasStart ? new Date(item.startTime).getTime() : 0;
const endTs = hasEnd ? new Date(item.endTime).getTime() : 0;
const notStarted = hasStart && now < startTs;
const hasEnded = hasEnd && now > endTs;
const isActive = !notStarted && !hasEnded;
let timeLabel = '';
if (hasStart && hasEnd) {
timeLabel = this.formatDate(item.startTime) + ' ~ ' + this.formatDate(item.endTime);
} else if (hasStart) {
timeLabel = this.formatDate(item.startTime) + ' 起';
} else if (hasEnd) {
timeLabel = '截止 ' + this.formatDate(item.endTime);
}
return {
...item,
hasTimeLimit: hasStart || hasEnd,
timeLabel,
notStarted,
hasEnded,
isActive
};
});
this.setData({
items: append ? [...this.data.items, ...list] : list,
hasMore: this.data.items.length + list.length < total
});
} catch (e) {
console.error('Fetch exchange items failed', e);
} finally {
this.setData({ isLoading: false });
wx.stopPullDownRefresh();
}
},
onReachBottom() {
if (!this.data.hasMore || this.data.isLoading) return;
this.setData({ current: this.data.current + 1 });
this.fetchItems(true);
},
onPullDownRefresh() {
this.fetchProfile();
this.fetchItems();
},
switchType(e) {
const key = e.currentTarget.dataset.key;
if (key === this.data.activeType) return;
this.setData({ activeType: key });
this.fetchItems();
},
// Redeem Flow
onItemTap(e) {
const item = e.currentTarget.dataset.item;
if (item.notStarted) {
wx.showToast({ title: '活动尚未开始', icon: 'none' });
return;
}
if (item.hasEnded) {
wx.showToast({ title: '活动已结束', icon: 'none' });
return;
}
if (item.stock === 0) {
wx.showToast({ title: '已兑完', icon: 'none' });
return;
}
this.setData({
selectedItem: item,
showRedeemPopup: true,
redeemForm: { recipientName: '', phone: '', address: '' }
});
},
onPopupClose() {
this.setData({ showRedeemPopup: false });
},
onFormInput(e) {
const field = e.currentTarget.dataset.field;
this.setData({ [`redeemForm.${field}`]: e.detail.value });
},
async confirmRedeem() {
const item = this.data.selectedItem;
if (!item) return;
// Check sunlight
if (this.data.currentSunlight < item.costSunlight) {
wx.showToast({ title: '阳光值不足', icon: 'none' });
return;
}
// Physical items require address
if (item.type === 'PHYSICAL') {
const { recipientName, phone, address } = this.data.redeemForm;
if (!recipientName || !phone || !address) {
wx.showToast({ title: '请填写完整收货信息', icon: 'none' });
return;
}
}
wx.showLoading({ title: '兑换中...', mask: true });
try {
await request.post('/exchange/redeem', {
itemId: item.id,
quantity: 1,
...this.data.redeemForm
});
wx.hideLoading();
wx.showToast({ title: '兑换成功!', icon: 'success' });
this.setData({ showRedeemPopup: false });
// Refresh data
this.fetchProfile();
this.fetchItems();
} catch (e) {
wx.hideLoading();
wx.showToast({ title: e.message || '兑换失败', icon: 'none' });
}
},
goToOrders() {
wx.navigateTo({ url: '/pages/profile/exchange/orders/index' });
},
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
const m = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
return m + '.' + day;
}
});
+6 -1
View File
@@ -1,6 +1,11 @@
{
"navigationBarTitleText": "兑换中心",
"disableScroll": true,
"usingComponents": {
"t-empty": "tdesign-miniprogram/empty/empty"
"t-empty": "tdesign-miniprogram/empty/empty",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-tag": "tdesign-miniprogram/tag/tag",
"t-popup": "tdesign-miniprogram/popup/popup"
}
}
+139 -1
View File
@@ -1,3 +1,141 @@
<view class="exchange-page">
<t-empty icon="info-circle-filled" description="兑换中心功能正在开发中,敬请期待" />
<!-- Balance Header (sticky) -->
<view class="balance-header">
<view class="balance-card">
<view class="balance-left">
<text class="balance-label">我的阳光值</text>
<view class="balance-value-row">
<text class="balance-emoji">☀️</text>
<text class="balance-value">{{currentSunlight}}</text>
</view>
</view>
<view class="balance-right" bindtap="goToOrders">
<t-icon name="assignment" size="40rpx" color="#558B2F" />
<text class="orders-text">兑换记录</text>
</view>
</view>
</view>
<!-- Scrollable Content -->
<scroll-view scroll-y class="page-scroll" show-scrollbar="{{false}}" enhanced="{{true}}" lower-threshold="200" bindscrolltolower="onReachBottom">
<!-- Items Grid -->
<view class="items-grid" wx:if="{{items.length > 0}}">
<view class="item-card {{!item.isActive ? 'inactive' : ''}}" wx:for="{{items}}" wx:key="id" bindtap="onItemTap" data-item="{{item}}">
<view class="item-image-wrap">
<image wx:if="{{item.image}}" src="{{item.image.url}}" mode="aspectFill" class="item-img" lazy-load />
<view wx:else class="item-img-placeholder">
<t-icon name="gift" size="64rpx" color="#C5E1A5" />
</view>
<!-- Stock Badge -->
<view class="stock-badge" wx:if="{{item.isActive && item.stock >= 0 && item.stock <= 10 && item.stock > 0}}">
<text>仅剩 {{item.stock}}</text>
</view>
<!-- Status overlays -->
<view class="sold-out-mask" wx:if="{{item.stock === 0}}">
<text>已兑完</text>
</view>
<view class="sold-out-mask not-started" wx:elif="{{item.notStarted}}">
<text>未开始</text>
</view>
<view class="sold-out-mask ended" wx:elif="{{item.hasEnded}}">
<text>已结束</text>
</view>
</view>
<view class="item-info">
<text class="item-name">{{item.name}}</text>
<view class="item-price-row">
<text class="price-sun">☀️</text>
<text class="price-val">{{item.costSunlight}}</text>
</view>
<view class="item-time" wx:if="{{item.hasTimeLimit}}">
<t-icon name="time" size="24rpx" color="#9CA3AF" />
<text>{{item.timeLabel}}</text>
</view>
</view>
</view>
</view>
<!-- Loading / Empty States -->
<view class="state-footer" wx:if="{{isLoading}}">
<t-loading theme="circular" size="40rpx" text="加载中..." inherit-color />
</view>
<view class="empty-state" wx:elif="{{!isLoading && items.length === 0}}">
<text class="empty-icon">🎁</text>
<text class="empty-title">暂无可兑换商品</text>
<text class="empty-desc">新的好礼正在路上,敬请期待</text>
</view>
<view class="state-footer" wx:elif="{{!hasMore && items.length > 0}}">
<text class="no-more">— 已经到底啦 —</text>
</view>
<!-- Spacer -->
<view style="height: 60rpx;"></view>
</scroll-view>
<!-- Redeem Popup -->
<t-popup visible="{{showRedeemPopup}}" bind:visible-change="onPopupClose" placement="bottom">
<view class="redeem-popup" wx:if="{{selectedItem}}">
<view class="popup-header">
<text class="popup-title">确认兑换</text>
<view class="popup-close" bindtap="onPopupClose">
<t-icon name="close" size="40rpx" color="#999" />
</view>
</view>
<!-- Item Preview -->
<view class="redeem-item-preview">
<view class="preview-img-wrap">
<image wx:if="{{selectedItem.image}}" src="{{selectedItem.image.url}}" mode="aspectFill" class="preview-img" />
<view wx:else class="preview-img-placeholder">
<t-icon name="gift" size="48rpx" color="#C5E1A5" />
</view>
</view>
<view class="preview-info">
<text class="preview-name">{{selectedItem.name}}</text>
<text class="preview-desc">{{selectedItem.description}}</text>
<view class="preview-cost">
<text class="cost-sun">☀️</text>
<text class="cost-val">{{selectedItem.costSunlight}}</text>
<text class="cost-unit">阳光值</text>
</view>
</view>
</view>
<!-- Address Form (Physical only) -->
<view class="address-form" wx:if="{{selectedItem.type === 'PHYSICAL'}}">
<view class="form-title">收货信息</view>
<view class="form-item">
<text class="form-label">姓名</text>
<input placeholder="请输入收货人姓名" value="{{redeemForm.recipientName}}"
bindinput="onFormInput" data-field="recipientName" />
</view>
<view class="form-item">
<text class="form-label">电话</text>
<input type="number" placeholder="请输入联系电话" value="{{redeemForm.phone}}"
bindinput="onFormInput" data-field="phone" />
</view>
<view class="form-item">
<text class="form-label">地址</text>
<input placeholder="请输入详细收货地址" value="{{redeemForm.address}}"
bindinput="onFormInput" data-field="address" />
</view>
</view>
<!-- Balance Check -->
<view class="balance-check {{currentSunlight >= selectedItem.costSunlight ? '' : 'insufficient'}}">
<text>当前余额: ☀️ {{currentSunlight}}</text>
<text wx:if="{{currentSunlight < selectedItem.costSunlight}}" class="insufficient-tip">余额不足</text>
</view>
<!-- Confirm Button -->
<view class="redeem-btn {{currentSunlight >= selectedItem.costSunlight && selectedItem.stock !== 0 ? '' : 'disabled'}}"
bindtap="confirmRedeem">
<text>立即兑换</text>
</view>
</view>
</t-popup>
</view>
+439 -1
View File
@@ -1,9 +1,447 @@
page {
background: #F4F6F0;
}
.exchange-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-scroll {
flex: 1;
height: 0;
}
/* ========== Balance Header ========== */
.balance-header {
background: linear-gradient(180deg, #E8F5E9 0%, #F4F6F0 100%);
padding: 32rpx 32rpx 24rpx;
flex-shrink: 0;
}
.balance-card {
background: #fff;
border-radius: 24rpx;
padding: 32rpx 36rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.04);
}
.balance-label {
font-size: 22rpx;
color: #9CA3AF;
display: block;
margin-bottom: 8rpx;
font-weight: 500;
}
.balance-value-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.balance-emoji {
font-size: 36rpx;
}
.balance-value {
font-size: 48rpx;
font-weight: 800;
color: #558B2F;
line-height: 1;
}
.balance-right {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
padding: 16rpx 24rpx;
border-radius: 20rpx;
background: #F1F8E9;
}
.orders-text {
font-size: 20rpx;
color: #558B2F;
font-weight: 600;
}
/* ========== Type Tabs ========== */
.type-tabs {
display: flex;
white-space: nowrap;
padding: 20rpx 32rpx;
gap: 16rpx;
flex-shrink: 0;
}
.type-tab {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10rpx 28rpx;
background: #fff;
border: 2rpx solid #E5E7EB;
border-radius: 40rpx;
font-size: 26rpx;
color: #6B7280;
font-weight: 600;
flex-shrink: 0;
margin-right: 0;
transition: all 0.2s;
}
.type-tab.active {
background: #333;
color: #fff;
border-color: #333;
}
/* ========== Items Grid ========== */
.items-grid {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 32rpx;
gap: 20rpx;
}
.item-card {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
transition: transform 0.15s;
}
.item-card:active {
transform: scale(0.97);
}
.item-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 1;
background: #F8FAF5;
overflow: hidden;
}
.item-img {
width: 100%;
height: 100%;
display: block;
}
.item-img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: linear-gradient(135deg, #F1F8E9, #E8F5E9);
}
.stock-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
background: rgba(239, 83, 80, 0.85);
color: #fff;
font-size: 20rpx;
font-weight: 700;
padding: 4rpx 14rpx;
border-radius: 14rpx;
}
.sold-out-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 30rpx;
font-weight: 800;
letter-spacing: 4rpx;
}
.sold-out-mask.not-started {
background: rgba(33, 150, 243, 0.5);
}
.sold-out-mask.ended {
background: rgba(0, 0, 0, 0.5);
}
.item-card.inactive {
opacity: 0.7;
}
.item-info {
padding: 16rpx 20rpx 20rpx;
}
.item-name {
font-size: 28rpx;
font-weight: 600;
color: #1F2937;
display: block;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-price-row {
display: flex;
align-items: center;
gap: 6rpx;
}
.price-sun {
font-size: 24rpx;
}
.price-val {
font-size: 30rpx;
font-weight: 800;
color: #E65100;
}
.item-time {
display: flex;
align-items: center;
gap: 6rpx;
margin-top: 8rpx;
font-size: 22rpx;
color: #9CA3AF;
font-weight: 500;
}
/* ========== Empty & Footer States ========== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx 80rpx;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: #374151;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: #9CA3AF;
font-weight: 500;
}
.state-footer {
padding: 40rpx;
display: flex;
justify-content: center;
align-items: center;
}
.no-more {
font-size: 24rpx;
color: #D1D5DB;
font-weight: 500;
}
/* ========== Redeem Popup ========== */
.redeem-popup {
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 36rpx;
padding-bottom: calc(36rpx + env(safe-area-inset-bottom));
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28rpx;
}
.popup-title {
font-size: 34rpx;
font-weight: 700;
color: #1F2937;
}
.popup-close {
padding: 8rpx;
}
/* Item Preview in Popup */
.redeem-item-preview {
display: flex;
gap: 24rpx;
padding: 24rpx;
background: #F8FAF5;
border-radius: 20rpx;
margin-bottom: 28rpx;
}
.preview-img-wrap {
width: 140rpx;
height: 140rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
background: #E8F5E9;
}
.preview-img {
width: 100%;
height: 100%;
}
.preview-img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.preview-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8rpx;
}
.preview-name {
font-size: 30rpx;
font-weight: 700;
color: #1F2937;
}
.preview-desc {
font-size: 24rpx;
color: #9CA3AF;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.5;
}
.preview-cost {
display: flex;
align-items: center;
gap: 6rpx;
margin-top: 4rpx;
}
.cost-sun { font-size: 24rpx; }
.cost-val { font-size: 32rpx; font-weight: 800; color: #E65100; }
.cost-unit { font-size: 22rpx; color: #9CA3AF; margin-left: 2rpx; }
/* Address Form */
.address-form {
margin-bottom: 24rpx;
}
.form-title {
font-size: 28rpx;
font-weight: 700;
color: #374151;
margin-bottom: 16rpx;
}
.form-item {
display: flex;
align-items: center;
background: #F9FAFB;
border: 2rpx solid #F3F4F6;
border-radius: 16rpx;
padding: 20rpx 24rpx;
margin-bottom: 12rpx;
gap: 16rpx;
}
.form-label {
font-size: 26rpx;
color: #6B7280;
font-weight: 600;
flex-shrink: 0;
width: 60rpx;
}
.form-item input {
flex: 1;
font-size: 26rpx;
color: #1F2937;
}
/* Balance Check */
.balance-check {
padding: 20rpx 24rpx;
background: #F1F8E9;
border-radius: 16rpx;
margin-bottom: 20rpx;
font-size: 26rpx;
color: #558B2F;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.balance-check.insufficient {
background: #FFF3E0;
color: #E65100;
}
.insufficient-tip {
color: #EF5350;
font-weight: 700;
}
/* Redeem Button */
.redeem-btn {
width: 100%;
height: 92rpx;
background: linear-gradient(135deg, #558B2F, #689F38);
border-radius: 46rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 30rpx;
font-weight: 700;
transition: all 0.2s;
box-shadow: 0 8rpx 24rpx rgba(85, 139, 47, 0.25);
}
.redeem-btn:active {
transform: scale(0.97);
opacity: 0.9;
}
.redeem-btn.disabled {
background: #E5E7EB;
color: #9CA3AF;
pointer-events: none;
}
+80
View File
@@ -0,0 +1,80 @@
import request from '../../../../utils/request';
const STATUS_MAP = {
1: { text: '待处理', theme: 'warning' },
2: { text: '处理中', theme: 'primary' },
3: { text: '已发货', theme: 'primary' },
4: { text: '已完成', theme: 'success' },
5: { text: '已取消', theme: 'default' }
};
Page({
data: {
orders: [],
isLoading: true,
hasMore: true,
current: 1,
pageSize: 10,
activeStatus: 0,
statusTabs: [
{ key: 0, label: '全部' },
{ key: 1, label: '待处理' },
{ key: 4, label: '已完成' },
{ key: 5, label: '已取消' }
]
},
onLoad() {
this.fetchOrders();
},
onShow() {
this.fetchOrders();
},
async fetchOrders(append = false) {
if (!append) {
this.setData({ isLoading: true, current: 1, orders: [] });
}
try {
const params = {
current: this.data.current,
pageSize: this.data.pageSize
};
if (this.data.activeStatus) {
params.status = this.data.activeStatus;
}
const res = await request.get('/exchange/orders', params);
let list = (res && res.list) ? res.list : [];
const total = (res && res.total) ? res.total : 0;
// Enrich with status display info
list = list.map(order => ({
...order,
statusInfo: STATUS_MAP[order.status] || { text: '未知', theme: 'default' }
}));
this.setData({
orders: append ? [...this.data.orders, ...list] : list,
hasMore: this.data.orders.length + list.length < total
});
} catch (e) {
console.error('Fetch orders failed', e);
} finally {
this.setData({ isLoading: false });
}
},
switchStatus(e) {
const key = e.currentTarget.dataset.key;
if (key === this.data.activeStatus) return;
this.setData({ activeStatus: key });
this.fetchOrders();
},
onReachBottom() {
if (!this.data.hasMore || this.data.isLoading) return;
this.setData({ current: this.data.current + 1 });
this.fetchOrders(true);
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"navigationBarTitleText": "兑换记录",
"disableScroll": true,
"usingComponents": {
"t-empty": "tdesign-miniprogram/empty/empty",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-tag": "tdesign-miniprogram/tag/tag"
}
}
+68
View File
@@ -0,0 +1,68 @@
<view class="orders-page">
<!-- Status Tabs -->
<view class="status-tabs-wrap">
<scroll-view class="status-tabs" scroll-x enable-flex show-scrollbar="{{false}}">
<view wx:for="{{statusTabs}}" wx:key="key"
class="status-tab {{activeStatus === item.key ? 'active' : ''}}"
bindtap="switchStatus" data-key="{{item.key}}">
<text>{{item.label}}</text>
</view>
</scroll-view>
</view>
<scroll-view scroll-y class="page-scroll" show-scrollbar="{{false}}" enhanced="{{true}}" bindscrolltolower="onReachBottom">
<view class="orders-list" wx:if="{{orders.length > 0}}">
<view class="order-card" wx:for="{{orders}}" wx:key="id">
<view class="order-header">
<text class="order-time">{{item.createdAtStr}}</text>
<t-tag size="small" variant="light" theme="{{item.statusInfo.theme}}">
{{item.statusInfo.text}}
</t-tag>
</view>
<view class="order-body">
<view class="order-img-wrap">
<image wx:if="{{item.item && item.item.image}}" src="{{item.item.image.url}}" mode="aspectFill" class="order-img" />
<view wx:else class="order-img-placeholder">
<t-icon name="gift" size="48rpx" color="#C5E1A5" />
</view>
</view>
<view class="order-info">
<text class="order-item-name">{{item.itemName}}</text>
<text class="order-type-tag">{{item.itemType === 'PHYSICAL' ? '实物' : (item.itemType === 'VIRTUAL' ? '虚拟' : '优惠券')}}</text>
<view class="order-cost-row">
<text class="order-cost-sun">☀️</text>
<text class="order-cost-val">-{{item.costSunlight}}</text>
<text class="order-qty" wx:if="{{item.quantity > 1}}">x{{item.quantity}}</text>
</view>
</view>
</view>
<!-- Shipping Info (if physical and shipped) -->
<view class="order-shipping" wx:if="{{item.trackingNo}}">
<t-icon name="deliver" size="28rpx" color="#558B2F" />
<text class="tracking-text">快递单号: {{item.trackingNo}}</text>
</view>
</view>
</view>
<!-- Loading / Empty -->
<view class="state-footer" wx:if="{{isLoading}}">
<t-loading theme="circular" size="40rpx" text="加载中..." inherit-color />
</view>
<view class="empty-state" wx:elif="{{!isLoading && orders.length === 0}}">
<text class="empty-icon">📦</text>
<text class="empty-title">暂无兑换记录</text>
<text class="empty-desc">去兑换中心挑选心仪好礼吧</text>
</view>
<view class="state-footer" wx:elif="{{!hasMore && orders.length > 0}}">
<text class="no-more">— 已经到底啦 —</text>
</view>
<view style="height: 60rpx;"></view>
</scroll-view>
</view>
+224
View File
@@ -0,0 +1,224 @@
page {
background: #F4F6F0;
}
.orders-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-scroll {
flex: 1;
height: 0;
}
/* ========== Status Tabs ========== */
.status-tabs-wrap {
flex-shrink: 0;
background: #F4F6F0;
padding: 20rpx 0 12rpx;
}
.status-tabs {
display: flex;
white-space: nowrap;
padding-left: 32rpx;
height: 88rpx;
width: 100%;
box-sizing: border-box;
}
.status-tab {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 28rpx;
height: 72rpx;
background: #fff;
border-radius: 36rpx;
margin-right: 16rpx;
font-size: 26rpx;
color: #546E7A;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04);
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
flex-shrink: 0;
border: 2rpx solid transparent;
}
.status-tab:active {
transform: scale(0.95);
}
.status-tab.active {
background: #558B2F;
color: #fff;
font-weight: 700;
box-shadow: 0 8rpx 20rpx rgba(85, 139, 47, 0.3);
border-color: #558B2F;
}
/* ========== Orders List ========== */
.orders-list {
padding: 12rpx 32rpx;
}
.order-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.03);
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #F3F4F6;
}
.order-time {
font-size: 24rpx;
color: #9CA3AF;
font-weight: 500;
}
.order-body {
display: flex;
gap: 20rpx;
}
.order-img-wrap {
width: 120rpx;
height: 120rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
background: #F8FAF5;
}
.order-img {
width: 100%;
height: 100%;
}
.order-img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #F1F8E9, #E8F5E9);
}
.order-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8rpx;
}
.order-item-name {
font-size: 28rpx;
font-weight: 600;
color: #1F2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-type-tag {
display: inline-block;
width: fit-content;
font-size: 20rpx;
color: #6B7280;
background: #F3F4F6;
padding: 2rpx 14rpx;
border-radius: 10rpx;
font-weight: 500;
}
.order-cost-row {
display: flex;
align-items: center;
gap: 6rpx;
}
.order-cost-sun {
font-size: 22rpx;
}
.order-cost-val {
font-size: 28rpx;
font-weight: 800;
color: #E65100;
}
.order-qty {
font-size: 22rpx;
color: #9CA3AF;
margin-left: 4rpx;
}
/* Shipping */
.order-shipping {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #F3F4F6;
display: flex;
align-items: center;
gap: 8rpx;
}
.tracking-text {
font-size: 24rpx;
color: #558B2F;
font-weight: 600;
}
/* ========== Empty & Footer States ========== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx 80rpx;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: #374151;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: #9CA3AF;
font-weight: 500;
}
.state-footer {
padding: 40rpx;
display: flex;
justify-content: center;
align-items: center;
}
.no-more {
font-size: 24rpx;
color: #D1D5DB;
font-weight: 500;
}
+1
View File
@@ -1,5 +1,6 @@
{
"navigationBarTitleText": "我的收藏",
"disableScroll": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-image": "tdesign-miniprogram/image/image",
+1 -1
View File
@@ -130,7 +130,7 @@ Page({
// Open WeChat notification settings
wx.openSetting({
success: (res) => {
console.log('Settings opened', res);
}
});
},
+8 -2
View File
@@ -32,7 +32,6 @@
<!-- Stats Card (Fixed) -->
<scroll-view scroll-y class="profile-content" enhanced show-scrollbar="{{false}}" scroll-top="{{scrollTop}}">
<!-- Menu -->
<view class="profile-menu">
@@ -46,7 +45,6 @@
<text class="menu-text">兑换中心</text>
</view>
<view class="menu-right-info">
<text class="menu-badge-text">开发中</text>
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view>
@@ -70,8 +68,10 @@
</view>
<text class="menu-text">我的收藏</text>
</view>
<view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view>
<view class="menu-item" bindtap="goToPosts">
<view class="menu-left">
@@ -80,8 +80,10 @@
</view>
<text class="menu-text">我的发布</text>
</view>
<view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view>
<view class="menu-item" bindtap="goToIdentifyHistory">
<view class="menu-left">
@@ -90,8 +92,10 @@
</view>
<text class="menu-text">识别记录</text>
</view>
<view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view>
<view class="menu-group-title" style="margin-top: 32rpx;">更多服务</view>
@@ -102,9 +106,11 @@
</view>
<text class="menu-text">帮助与关于</text>
</view>
<view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view>
</view>
<view style="height: 100rpx;"></view>
</scroll-view>
+14
View File
@@ -1,5 +1,6 @@
// pages/tasks/index.js
import request from '../../utils/request';
import { requestSubscription } from '../../utils/subscribe';
Page({
data: {
@@ -239,6 +240,18 @@ Page({
const taskId = this.data.completingTask.id;
const remark = this.data.remark || '';
// Attempt to subscribe (silent mode avoids error popups if disabled)
// This encourages "Always Allow" behavior for seamless experience
requestSubscription(undefined, true).then((subResult) => {
// Debug failure feedback
if (!subResult.success) {
if (subResult.isMainSwitchOff) {
wx.showToast({ title: '提醒总开关已关闭', icon: 'none' });
} else {
console.log('[Task] Subscription quota not increased:', subResult.res || subResult.error);
}
}
wx.showLoading({ title: '提交中...', mask: true });
request.post('/plant/completeTask', {
@@ -305,6 +318,7 @@ Page({
console.error('Complete task failed', err);
wx.showToast({ title: '操作失败', icon: 'none' });
});
});
},
gotoGarden() {
+7
View File
@@ -459,6 +459,13 @@
.mini-check-btn.btn-checked {
background: #4CAF50 !important;
border-color: #4CAF50 !important;
animation: checkPop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes checkPop {
0% { transform: scale(0.6); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Urgent/Overdue State */
+113
View File
@@ -0,0 +1,113 @@
// pages/wiki/chat/history/index.js
import request from '../../../../utils/request';
Page({
data: {
list: [],
total: 0,
current: 1,
pageSize: 15,
loading: false,
hasMore: true,
showClearDialog: false,
},
onLoad() {
this.fetchHistory(true);
},
fetchHistory(reset = false) {
if (this.data.loading) return;
if (!reset && !this.data.hasMore) return;
const current = reset ? 1 : this.data.current;
this.setData({ loading: true });
request.get('/plant/chat/history', { current, pageSize: this.data.pageSize })
.then(res => {
const items = (res.list || []).map(item => ({
...item,
answerPreview: (item.answer || '').substring(0, 80) + ((item.answer || '').length > 80 ? '...' : ''),
}));
const total = res.total || 0;
if (reset) {
this.setData({
list: items,
total,
current: 2,
hasMore: items.length < total,
loading: false,
});
} else {
const old = this.data.list;
const update = {};
items.forEach((item, i) => {
update[`list[${old.length + i}]`] = item;
});
update.current = current + 1;
update.hasMore = (old.length + items.length) < total;
update.loading = false;
update.total = total;
this.setData(update);
}
})
.catch(() => {
this.setData({ loading: false });
});
},
loadMore() {
this.fetchHistory(false);
},
onTapItem(e) {
const item = e.currentTarget.dataset.item;
// Navigate to chat page with prefilled Q&A
wx.navigateTo({
url: '/pages/wiki/chat/index?fromHistory=1',
success(res) {
res.eventChannel.emit('historyData', {
question: item.question,
answer: item.answer,
});
},
});
},
onDeleteItem(e) {
const id = e.currentTarget.dataset.id;
wx.showModal({
title: '删除记录',
content: '确定删除这条问答记录吗?',
success: (res) => {
if (res.confirm) {
request.post('/plant/chat/history/delete', { id }).then(() => {
wx.showToast({ title: '已删除', icon: 'success' });
this.fetchHistory(true);
});
}
},
});
},
onClearAll() {
this.setData({ showClearDialog: true });
},
closeClearDialog() {
this.setData({ showClearDialog: false });
},
doClearAll() {
this.setData({ showClearDialog: false });
request.post('/plant/chat/history/clear').then(() => {
wx.showToast({ title: '已清空', icon: 'success' });
this.setData({ list: [], total: 0, hasMore: false });
});
},
goToChat() {
wx.navigateBack();
},
});
+12
View File
@@ -0,0 +1,12 @@
{
"navigationBarTitleText": "问答历史",
"navigationBarBackgroundColor": "#558B2F",
"navigationBarTextStyle": "white",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-dialog": "tdesign-miniprogram/dialog/dialog",
"t-swipe-cell": "tdesign-miniprogram/swipe-cell/swipe-cell"
}
}
+67
View File
@@ -0,0 +1,67 @@
<!--pages/wiki/chat/history/index.wxml-->
<view class="history-page">
<scroll-view
class="history-scroll"
scroll-y
bindscrolltolower="loadMore"
enhanced
show-scrollbar="{{false}}"
>
<!-- Header Actions -->
<view class="header-bar" wx:if="{{list.length > 0}}">
<text class="header-count">共 {{total}} 条记录</text>
<view class="clear-btn" bindtap="onClearAll">
<t-icon name="delete" size="32rpx" color="#EF4444" />
<text>清空全部</text>
</view>
</view>
<!-- History List -->
<view wx:for="{{list}}" wx:key="id" class="history-card" bindtap="onTapItem" data-item="{{item}}">
<view class="card-header">
<text class="card-time">{{item.createdAtStr}}</text>
<view class="card-del" catchtap="onDeleteItem" data-id="{{item.id}}">
<t-icon name="close" size="28rpx" color="#9CA3AF" />
</view>
</view>
<view class="card-question">
<text class="q-label">Q</text>
<text class="q-text">{{item.question}}</text>
</view>
<view class="card-answer">
<text class="a-label">A</text>
<text class="a-text">{{item.answerPreview}}</text>
<view class="card-arrow">
<t-icon name="chevron-right" size="28rpx" color="#CCC" />
</view>
</view>
</view>
<!-- States -->
<view class="footer">
<t-loading wx:if="{{loading}}" theme="circular" size="40rpx" text="加载中..." />
<text wx:elif="{{!hasMore && list.length > 0}}" class="no-more">没有更多了</text>
</view>
<view wx:if="{{!loading && list.length === 0}}" class="empty-wrap">
<view class="empty-icon">📝</view>
<text class="empty-text">暂无问答记录</text>
<text class="empty-sub">去和AI助手聊聊吧</text>
<view class="empty-cta" bindtap="goToChat">
<text>开始提问</text>
</view>
</view>
<view style="height: 60rpx;"></view>
</scroll-view>
<t-dialog
visible="{{showClearDialog}}"
title="清空全部历史"
content="确定要清空所有问答记录吗?此操作不可恢复。"
confirm-btn="确定清空"
cancel-btn="取消"
bind:confirm="doClearAll"
bind:cancel="closeClearDialog"
/>
</view>
+195
View File
@@ -0,0 +1,195 @@
/** pages/wiki/chat/history/index.wxss **/
.history-page {
height: 100vh;
background: linear-gradient(180deg, #EEF3E5 0%, #F4F6F0 100%);
}
.history-scroll {
height: 100%;
padding: 24rpx 28rpx;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
padding: 0 8rpx;
}
.header-count {
font-size: 26rpx;
color: #78909C;
}
.clear-btn {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 26rpx;
color: #EF4444;
font-weight: 600;
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
.clear-btn:active {
background: rgba(239,68,68,0.08);
}
/* Card */
.history-card {
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
border-radius: 24rpx;
padding: 24rpx 24rpx 20rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(85,139,47,0.05);
border: 1rpx solid rgba(85,139,47,0.04);
transition: all 0.15s;
animation: cardIn 0.3s ease-out;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(12rpx); }
to { opacity: 1; transform: translateY(0); }
}
.history-card:active {
transform: scale(0.98);
background: #FAFDF7;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.card-time {
font-size: 22rpx;
color: #9CA3AF;
}
.card-del {
padding: 8rpx;
margin: -8rpx;
}
.card-question {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
}
.q-label {
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
background: linear-gradient(135deg, #558B2F, #7CB342);
color: #fff;
font-size: 24rpx;
font-weight: 700;
text-align: center;
line-height: 40rpx;
flex-shrink: 0;
}
.q-text {
font-size: 30rpx;
font-weight: 600;
color: #1F2937;
line-height: 1.5;
flex: 1;
}
.card-answer {
display: flex;
gap: 12rpx;
}
.a-label {
width: 40rpx;
height: 40rpx;
border-radius: 10rpx;
background: #E8F5E9;
color: #2E7D32;
font-size: 24rpx;
font-weight: 700;
text-align: center;
line-height: 40rpx;
flex-shrink: 0;
}
.a-text {
font-size: 26rpx;
color: #6B7280;
line-height: 1.6;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-arrow {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
padding-left: 8rpx;
}
/* Footer */
.footer {
padding: 32rpx;
display: flex;
justify-content: center;
}
.no-more {
font-size: 24rpx;
color: #CCC;
}
/* Empty */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 32rpx;
font-weight: 600;
color: #9CA3AF;
margin-bottom: 8rpx;
}
.empty-sub {
font-size: 26rpx;
color: #CCC;
margin-bottom: 32rpx;
}
.empty-cta {
padding: 16rpx 48rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #558B2F, #7CB342);
color: #fff;
font-size: 28rpx;
font-weight: 600;
box-shadow: 0 6rpx 20rpx rgba(85,139,47,0.3);
transition: all 0.15s;
}
.empty-cta:active {
transform: scale(0.95);
}
+183
View File
@@ -0,0 +1,183 @@
// pages/wiki/chat/index.js
import request from '../../../utils/request';
Page({
data: {
messages: [],
inputValue: '',
isTyping: false,
scrollAnchor: '',
_counter: 0,
quotaRemaining: -1,
quotaLimit: 0,
_pendingPrefill: '',
},
onLoad(options) {
if (options && options.fromHistory === '1') {
const channel = this.getOpenerEventChannel();
channel.on('historyData', (data) => {
const msgs = [
{ id: 'h1', role: 'user', content: data.question },
{ id: 'h2', role: 'ai', content: this._cleanMd(data.answer) },
];
this.setData({ messages: msgs, _counter: 2 }, () => this.scrollToBottom());
});
} else if (options && options.prefillQuestion) {
// Only save the question; actual send waits for quota check in onShow
this._pendingPrefill = decodeURIComponent(options.prefillQuestion);
this.setData({ inputValue: this._pendingPrefill });
}
},
onShow() {
this._fetchQuota();
},
_fetchQuota() {
request.get('/plant/chat/quota').then(res => {
this.setData({
quotaRemaining: res.remaining,
quotaLimit: res.limit,
});
// Auto-send prefill question only after quota is confirmed
if (this._pendingPrefill) {
const q = this._pendingPrefill;
this._pendingPrefill = '';
if (res.remaining > 0) {
this.onSend();
} else {
wx.showToast({ title: '今日问答次数已用完,明天再来吧', icon: 'none', duration: 2500 });
}
}
}).catch(() => {});
},
goToHistory() {
wx.navigateTo({ url: '/pages/wiki/chat/history/index' });
},
onQuickAsk(e) {
const query = e.currentTarget.dataset.q;
this.setData({ inputValue: query }, () => this.onSend());
},
onInput(e) {
this.setData({ inputValue: e.detail.value });
},
onSend() {
const query = this.data.inputValue.trim();
if (!query || this.data.isTyping) return;
// 额度检查
if (this.data.quotaRemaining === 0) {
wx.showToast({ title: '今日问答次数已用完,明天再来吧', icon: 'none', duration: 2500 });
return;
}
const uid = 'u' + (++this.data._counter);
const aid = 'a' + (++this.data._counter);
const len = this.data.messages.length;
this.setData({
[`messages[${len}]`]: { id: uid, role: 'user', content: query },
[`messages[${len + 1}]`]: { id: aid, role: 'ai', content: '' },
inputValue: '',
isTyping: true,
}, () => {
this.scrollToBottom();
this._streamRequest(query, aid);
});
},
_streamRequest(query, aiMsgId) {
let fullText = '';
request.stream('/plant/chat/stream', { query }, {
onChunk: (res) => {
const text = this._decode(res.data);
// Detect non-SSE JSON error (e.g. quota exceeded returns {code:7, msg:"..."})
if (!text.startsWith('data: ')) {
try {
const json = JSON.parse(text);
if (json.code && json.code !== 200 && json.msg) {
this._updateAiMsg(aiMsgId, '⚠️ ' + json.msg);
this.setData({ isTyping: false });
this._fetchQuota();
return;
}
} catch (_) { /* not JSON, continue SSE parsing */ }
}
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const chunk = line.substring(6);
if (chunk === '[DONE]') {
this.setData({ isTyping: false });
return;
}
if (chunk.startsWith('[ERROR]')) {
fullText += '\n⚠️ ' + (chunk.substring(7) || '服务异常');
this._updateAiMsg(aiMsgId, fullText);
this.setData({ isTyping: false });
return;
}
fullText += chunk;
this._updateAiMsg(aiMsgId, fullText);
}
this.scrollToBottom();
},
onDone: () => {
this.setData({ isTyping: false });
this.scrollToBottom();
// Delay: backend saves history async (go func) after stream ends
setTimeout(() => this._fetchQuota(), 800);
},
onError: () => {
this._updateAiMsg(aiMsgId, '网络连接失败,请稍后重试');
this.setData({ isTyping: false });
},
});
},
_updateAiMsg(id, content) {
const idx = this.data.messages.findIndex(m => m.id === id);
if (idx !== -1) {
this.setData({ [`messages[${idx}].content`]: this._cleanMd(content) });
}
},
// Strip residual markdown symbols for clean display
_cleanMd(text) {
return text
.replace(/^#{1,6}\s*/gm, '') // ### headers
.replace(/\*\*(.+?)\*\*/g, '【$1】') // **bold** → 【bold】
.replace(/\*(.+?)\*/g, '$1') // *italic*
.replace(/^[\-\*]\s+/gm, '· ') // - list → · list
.replace(/^\d+\.\s+/gm, (m) => m) // keep numbered lists
.replace(/`([^`]+)`/g, '$1') // `code`
.replace(/^---+$/gm, '————') // --- → ————
.replace(/\n{3,}/g, '\n\n'); // collapse blank lines
},
_decode(buffer) {
try {
return new TextDecoder('utf-8').decode(new Uint8Array(buffer));
} catch (e) {
const bytes = new Uint8Array(buffer);
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
try { return decodeURIComponent(escape(s)); } catch (_) { return s; }
}
},
scrollToBottom() {
this.setData({ scrollAnchor: 'scroll-bottom' });
},
});
+9
View File
@@ -0,0 +1,9 @@
{
"navigationBarTitleText": "植物AI助手",
"navigationBarBackgroundColor": "#558B2F",
"navigationBarTextStyle": "white",
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}
+98
View File
@@ -0,0 +1,98 @@
<!--pages/wiki/chat/index.wxml-->
<view class="chat-page">
<!-- Messages Area -->
<scroll-view
class="chat-messages"
scroll-y
scroll-into-view="{{scrollAnchor}}"
enhanced
show-scrollbar="{{false}}"
scroll-with-animation
>
<!-- Floating History Button (always visible) -->
<view class="chat-history-float" bindtap="goToHistory" wx:if="{{messages.length > 0}}">
<t-icon name="time" size="28rpx" color="#558B2F" />
<text>历史记录</text>
</view>
<!-- Welcome -->
<view class="welcome" wx:if="{{messages.length === 0}}">
<view class="welcome-glow"></view>
<view class="welcome-icon anim-float">🌿</view>
<view class="welcome-title">植物AI百科</view>
<view class="welcome-sub">基于知识库的智能问答助手</view>
<view class="history-entry" bindtap="goToHistory">
<t-icon name="time" size="32rpx" color="#558B2F" />
<text>查看问答历史</text>
</view>
<view class="quick-grid">
<view class="quick-card" bindtap="onQuickAsk" data-q="龟背竹怎么养护?">
<text class="qc-emoji">🌱</text>
<text class="qc-text">龟背竹怎么养护?</text>
</view>
<view class="quick-card" bindtap="onQuickAsk" data-q="哪些植物适合室内?">
<text class="qc-emoji">🏠</text>
<text class="qc-text">哪些植物适合室内?</text>
</view>
<view class="quick-card" bindtap="onQuickAsk" data-q="多肉浇水注意什么?">
<text class="qc-emoji">💧</text>
<text class="qc-text">多肉浇水注意什么?</text>
</view>
<view class="quick-card" bindtap="onQuickAsk" data-q="植物叶子发黄怎么办?">
<text class="qc-emoji">🍂</text>
<text class="qc-text">叶子发黄怎么办?</text>
</view>
</view>
</view>
<!-- Chat Bubbles -->
<block wx:for="{{messages}}" wx:key="id">
<view id="msg-{{item.id}}" class="msg-row {{item.role}}">
<!-- AI avatar -->
<view wx:if="{{item.role === 'ai'}}" class="ai-avatar-wrap">
<text class="ai-avatar-emoji">🌱</text>
</view>
<view class="msg-bubble {{item.role}}">
<!-- AI: typing state -->
<view wx:if="{{item.role === 'ai' && !item.content}}" class="typing-wrap">
<view class="typing-dots">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
<text class="typing-label">思考中...</text>
</view>
<text wx:elif="{{item.role === 'ai'}}" class="ai-text" user-select>{{item.content}}</text>
<!-- User text -->
<text wx:else class="msg-text" user-select>{{item.content}}</text>
</view>
</view>
</block>
<view style="height: 32rpx;" id="scroll-bottom"></view>
</scroll-view>
<!-- Input -->
<view class="input-area">
<view class="input-row">
<input
class="chat-input"
placeholder="{{isTyping ? 'AI正在回答中...' : '问我任何植物相关的问题...'}}"
value="{{inputValue}}"
bindinput="onInput"
bindconfirm="onSend"
confirm-type="send"
disabled="{{isTyping}}"
adjust-position="{{true}}"
cursor-spacing="20"
/>
<view class="send-btn {{inputValue && !isTyping ? 'active' : ''}}" bindtap="onSend">
<t-icon name="arrow-up" size="36rpx" color="#fff" />
</view>
</view>
<view class="quota-bar" wx:if="{{quotaLimit > 0}}">
<text class="quota-text {{quotaRemaining <= 2 ? 'warn' : ''}}">今日剩余 {{quotaRemaining}}/{{quotaLimit}} 次</text>
</view>
<view class="safe-bottom"></view>
</view>
</view>
+299
View File
@@ -0,0 +1,299 @@
/** pages/wiki/chat/index.wxss **/
.chat-page {
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #EEF3E5 0%, #F4F6F0 35%, #F4F6F0 100%);
}
.chat-messages {
flex: 1;
padding: 20rpx 24rpx;
overflow-y: hidden;
}
.chat-history-float {
display: flex;
align-items: center;
gap: 8rpx;
justify-content: center;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
padding: 12rpx 28rpx;
border-radius: 32rpx;
font-size: 24rpx;
font-weight: 600;
color: #558B2F;
width: fit-content;
margin: 0 auto 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ── Welcome ── */
.welcome {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 20rpx 0;
position: relative;
}
.welcome-glow {
position: absolute;
top: -60rpx;
width: 480rpx;
height: 480rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(85,139,47,0.15) 0%, rgba(85,139,47,0.04) 50%, transparent 75%);
pointer-events: none;
animation: glowPulse 4s ease-in-out infinite;
}
@keyframes glowPulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.08); opacity: 1; }
}
.welcome-icon { font-size: 88rpx; margin-bottom: 12rpx; position: relative; }
.anim-float { animation: floatUp 3s ease-in-out infinite; }
@keyframes floatUp {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12rpx); }
}
.welcome-title {
font-size: 42rpx;
font-weight: 800;
color: #2E7D32;
margin-bottom: 8rpx;
letter-spacing: 2rpx;
}
.welcome-sub {
font-size: 24rpx;
color: #90A4AE;
margin-bottom: 28rpx;
}
.history-entry {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
border-radius: 32rpx;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(10px);
box-shadow: 0 2rpx 12rpx rgba(85,139,47,0.08);
font-size: 24rpx;
font-weight: 600;
color: #558B2F;
margin-bottom: 32rpx;
transition: all 0.15s;
}
.history-entry:active {
transform: scale(0.96);
background: #F0F7EB;
}
/* Quick Ask Grid */
.quick-grid {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
}
.quick-card {
background: rgba(255,255,255,0.9);
backdrop-filter: blur(10px);
border-radius: 24rpx;
padding: 24rpx 20rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
box-shadow: 0 2rpx 16rpx rgba(85,139,47,0.06);
border: 1rpx solid rgba(85,139,47,0.06);
transition: all 0.15s;
}
.quick-card:active {
transform: scale(0.96);
background: #F0F7EB;
border-color: rgba(85,139,47,0.15);
}
.qc-emoji { font-size: 40rpx; }
.qc-text {
font-size: 25rpx;
font-weight: 600;
color: #374151;
line-height: 1.45;
}
/* ── Message Row ── */
.msg-row {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
gap: 12rpx;
animation: fadeSlideIn 0.25s ease-out;
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(16rpx); }
to { opacity: 1; transform: translateY(0); }
}
.msg-row.user { flex-direction: row-reverse; }
.ai-avatar-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: linear-gradient(135deg, #E8F5E9, #C8E6C9);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(85,139,47,0.12);
}
.ai-avatar-emoji { font-size: 32rpx; }
/* ── Bubbles ── */
.msg-bubble {
max-width: 78%;
padding: 22rpx 26rpx;
border-radius: 24rpx;
word-break: break-word;
}
.msg-bubble.ai {
max-width: 88%;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
border-top-left-radius: 6rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.msg-bubble.user {
background: linear-gradient(135deg, #558B2F, #7CB342);
border-top-right-radius: 6rpx;
box-shadow: 0 4rpx 16rpx rgba(85,139,47,0.2);
}
.msg-text {
font-size: 29rpx;
line-height: 1.7;
color: #fff;
}
.ai-text {
font-size: 29rpx;
line-height: 1.85;
color: #1F2937;
white-space: pre-wrap;
}
/* ── Typing ── */
.typing-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.typing-dots {
display: flex;
gap: 8rpx;
align-items: center;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #A5D6A7;
animation: bounce 1.4s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.16s; }
.dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.5); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.typing-label {
font-size: 22rpx;
color: #90A4AE;
font-weight: 500;
}
/* ── Input ── */
.input-area {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(16px);
border-top: 1rpx solid rgba(0,0,0,0.03);
padding: 14rpx 24rpx 0;
}
.input-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.chat-input {
flex: 1;
height: 78rpx;
background: #F0F4E8;
border-radius: 40rpx;
padding: 0 28rpx;
font-size: 28rpx;
color: #1F2937;
}
.send-btn {
width: 74rpx;
height: 74rpx;
border-radius: 50%;
background: #D1D5DB;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.25s;
}
.send-btn.active {
background: linear-gradient(135deg, #558B2F, #7CB342);
box-shadow: 0 4rpx 20rpx rgba(85,139,47,0.35);
}
.send-btn:active { transform: scale(0.85); }
.safe-bottom {
height: calc(14rpx + env(safe-area-inset-bottom));
}
/* Quota Bar */
.quota-bar {
text-align: center;
padding: 6rpx 0 2rpx;
}
.quota-text {
font-size: 22rpx;
color: #90A4AE;
font-weight: 500;
}
.quota-text.warn {
color: #EF4444;
font-weight: 600;
}
+7
View File
@@ -142,5 +142,12 @@ Page({
title: `植物百科 - ${this.data.plant.name}`,
path: `/pages/wiki/detail/index?id=${this.data.plant.id}`
};
},
askAiAboutPlant() {
const name = this.data.plant ? this.data.plant.name : '';
wx.navigateTo({
url: `/pages/wiki/chat/index?prefillQuestion=${encodeURIComponent(name + '怎么养护?')}`
});
}
});
+6 -1
View File
@@ -195,9 +195,14 @@
</view>
</view>
<view style="height: 40rpx;"></view>
<view style="height: 120rpx;"></view>
</scroll-view>
<!-- Ask AI FAB -->
<view class="ask-ai-fab" bindtap="askAiAboutPlant">
<text class="fab-emoji">🤖</text>
<text>问 AI</text>
</view>
</view>
+24
View File
@@ -286,3 +286,27 @@ page {
transform: scale(0.9);
transition: transform 0.1s;
}
/* Ask AI FAB */
.ask-ai-fab {
position: fixed;
right: 32rpx;
bottom: 48rpx;
background: linear-gradient(135deg, rgba(21,101,192,0.92), rgba(25,118,210,0.92));
backdrop-filter: blur(12px);
color: #fff;
padding: 20rpx 32rpx;
border-radius: 48rpx;
display: flex;
align-items: center;
gap: 10rpx;
font-size: 26rpx;
font-weight: 700;
box-shadow: 0 8rpx 28rpx rgba(21,101,192,0.3);
z-index: 100;
transition: all 0.2s;
}
.ask-ai-fab:active { transform: scale(0.92); }
.fab-emoji { font-size: 32rpx; line-height: 1; }
+11 -9
View File
@@ -10,30 +10,32 @@
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
height: 100vh;
padding: 48rpx;
box-sizing: border-box;
padding-bottom: 20%; /* Push up visually */
}
.state-card {
background: #fff;
border-radius: 40rpx;
padding: 56rpx 48rpx;
border-radius: 48rpx;
padding: 64rpx 48rpx;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 32rpx;
box-shadow: 0 12rpx 40rpx rgba(85, 139, 47, 0.08);
gap: 40rpx;
box-shadow: 0 20rpx 60rpx rgba(85, 139, 47, 0.15);
}
/* ========== Loading State ========== */
.loading-image-wrap {
width: 280rpx;
height: 280rpx;
border-radius: 32rpx;
width: 440rpx;
height: 440rpx;
border-radius: 40rpx;
overflow: hidden;
position: relative;
box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.1);
box-shadow: 0 12rpx 40rpx rgba(0,0,0,0.15);
}
.loading-preview {
+16 -4
View File
@@ -21,7 +21,8 @@ Page({
scrollTop: 0,
// Modal State
showIdentifyModal: false
showIdentifyModal: false,
isRefreshing: false
},
onLoad() {
@@ -40,6 +41,13 @@ Page({
this.setData({ scrollTop: Math.random() * 0.01 });
},
onRefresh() {
this.setData({ isRefreshing: true });
this.fetchWikiList(true).finally(() => {
this.setData({ isRefreshing: false });
});
},
// Fetch categories from API
fetchCategories() {
request.get('/wiki-class/list').then(res => {
@@ -52,8 +60,8 @@ Page({
// Fetch wiki list from API
fetchWikiList(reset = false) {
if (this.data.isLoading) return;
if (!reset && !this.data.hasMore) return;
if (this.data.isLoading) return Promise.resolve();
if (!reset && !this.data.hasMore) return Promise.resolve();
const current = reset ? 1 : this.data.current;
@@ -75,7 +83,7 @@ Page({
params.classId = [this.data.activeCategory];
}
request.post('/wiki/page', params).then(res => {
return request.post('/wiki/page', params).then(res => {
const data = res || {};
const list = data.list || [];
const total = data.total || 0;
@@ -207,6 +215,10 @@ Page({
openIdentifyModal() { this.setData({ showIdentifyModal: true }); },
goToAiChat() {
wx.navigateTo({ url: '/pages/wiki/chat/index' });
},
onPopupVisibleChange(e) {
this.setData({
showIdentifyModal: e.detail.visible
+16 -5
View File
@@ -17,6 +17,9 @@
enhanced
show-scrollbar="{{false}}"
scroll-top="{{scrollTop}}"
refresher-enabled="{{true}}"
bindrefresherrefresh="onRefresh"
refresher-triggered="{{isRefreshing}}"
>
<view class="search-section">
<view class="search-box-card">
@@ -111,13 +114,9 @@
</view>
<!-- Spacer -->
<view style="height: 160rpx;"></view>
<view style="height: 200rpx;"></view>
</scroll-view>
<view class="floating-add-btn" bindtap="openIdentifyModal">
<t-icon name="scan" size="40rpx" color="#FFF" />
<text>植物识别</text>
</view>
<!-- Identify Popup -->
<t-popup visible="{{showIdentifyModal}}" bind:visible-change="onPopupVisibleChange" placement="bottom">
@@ -147,4 +146,16 @@
</view>
</view>
</t-popup>
<!-- Floating Buttons (must be after popup in DOM to stay on top) -->
<view class="floating-btns" wx:if="{{!showIdentifyModal}}">
<view class="floating-btn chat-btn" bindtap="goToAiChat">
<text class="btn-emoji">🤖</text>
<text>AI问答</text>
</view>
<view class="floating-btn scan-btn" bindtap="openIdentifyModal">
<t-icon name="scan" size="40rpx" color="#FFF" />
<text>植物识别</text>
</view>
</view>
</view>
+44 -22
View File
@@ -60,12 +60,12 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 36rpx;
padding: 0 28rpx;
height: 72rpx;
background: #fff;
border-radius: 36rpx;
margin-right: 24rpx;
font-size: 28rpx;
margin-right: 16rpx;
font-size: 26rpx;
color: #546E7A;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04);
@@ -79,11 +79,12 @@
}
.category-item.active {
background: #558B2F;
background: linear-gradient(135deg, #558B2F, #689F38);
color: #fff;
font-weight: 700;
box-shadow: 0 8rpx 20rpx rgba(85, 139, 47, 0.3);
box-shadow: 0 6rpx 20rpx rgba(85, 139, 47, 0.3);
border-color: #558B2F;
transform: scale(1.02);
}
.wiki-list {
@@ -179,28 +180,49 @@
font-size: 28rpx;
}
/* Floating Action Button */
.floating-add-btn {
/* Floating Action Buttons */
.floating-btns {
position: fixed;
right: 40rpx;
bottom: 60rpx;
background: #558B2F;
color: white;
padding: 24rpx 40rpx;
border-radius: 60rpx;
bottom: 48rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12rpx;
box-shadow: 0 12rpx 32rpx rgba(85, 139, 47, 0.4);
z-index: 1000;
font-size: 28rpx;
font-weight: 700;
transition: all 0.2s ease;
flex-direction: row;
gap: 24rpx;
z-index: 11600;
}
.floating-add-btn:active {
.floating-btn {
color: white;
padding: 18rpx 28rpx;
border-radius: 48rpx;
display: flex;
align-items: center;
gap: 8rpx;
font-size: 26rpx;
font-weight: 700;
white-space: nowrap;
transition: all 0.2s ease;
backdrop-filter: blur(12px);
}
.floating-btn:active {
transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(85, 139, 47, 0.2);
}
.scan-btn {
background: rgba(85, 139, 47, 0.92);
box-shadow: 0 8rpx 28rpx rgba(85, 139, 47, 0.35);
}
.chat-btn {
background: linear-gradient(135deg, rgba(21,101,192,0.92), rgba(25,118,210,0.92));
box-shadow: 0 8rpx 28rpx rgba(21, 101, 192, 0.3);
}
.btn-emoji {
font-size: 32rpx;
line-height: 1;
}
/* Popup Styles */
+53 -3
View File
@@ -63,7 +63,7 @@ class WxRequest {
if ((statusCode === 401 || data.code === 401) && !options.skipToken && !options._retry) {
const app = getApp();
if (app && app.forceRefreshLogin) {
console.log('401 detected, refreshing token...');
app.forceRefreshLogin().then(() => {
// Retry Original Request
this.request({ ...options, _retry: true })
@@ -266,12 +266,62 @@ class WxRequest {
post(url, data = {}, header = {}) {
return this.request({ url, method: 'POST', data, header });
}
/**
* SSE stream request with chunked transfer.
* Reuses baseUrl + token interceptor.
* @param {string} url API path, e.g. '/plant/chat/stream'
* @param {Object} data Query params (GET) or body (POST)
* @param {Object} callbacks { onChunk, onDone, onError }
* @returns {Object} wx.request task (call task.abort() to cancel)
*/
stream(url, data = {}, callbacks = {}) {
const fullUrl = url.startsWith('http')
? url
: this.baseUrl + url;
// Build query string for GET
const qs = Object.keys(data)
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(data[k])}`)
.join('&');
const reqUrl = qs ? `${fullUrl}?${qs}` : fullUrl;
// Resolve token via same logic as interceptor
let token = wx.getStorageSync('token');
const mergedHeader = {
...this.header,
'Accept': 'text/event-stream',
};
if (token) {
mergedHeader['Authorization'] = `Bearer ${token}`;
}
const task = wx.request({
url: reqUrl,
method: 'GET',
enableChunked: true,
header: mergedHeader,
success: () => {
if (callbacks.onDone) callbacks.onDone();
},
fail: (err) => {
if (callbacks.onError) callbacks.onError(err);
},
});
if (callbacks.onChunk) {
task.onChunkReceived(callbacks.onChunk);
}
return task;
}
}
// Initialize with default instance
const request = new WxRequest({
baseUrl: 'http://192.168.0.184:8889',
//baseUrl: 'https://go.sundynix.cn/api',
//baseUrl: 'http://192.168.0.184:8889',
baseUrl: 'https://go.sundynix.cn/api',
header: {
'Content-Type': 'application/json'
}
+59 -16
View File
@@ -1,38 +1,81 @@
/**
* Request WeChat Mini Program Subscription Message
* Template ID: R7fh3NDpuV8DYqI83HpEQvC8mLJy5xMWFl1qeGN9JIo
* Template ID: iG5GYMPQAgKxIE9zZNOgKS6tCURhM9p9AC8iZ3Uj3uA
*/
const TEMPLATE_ID = 'R7fh3NDpuV8DYqI83HpEQvC8mLJy5xMWFl1qeGN9JIo';
const DEFAULT_TEMPLATE_ID = 'iG5GYMPQAgKxIE9zZNOgKS6tCURhM9p9AC8iZ3Uj3uA';
const MIN_SUB_INTERVAL = 5000; // Minimum 5 seconds between real requests
let lastSubTime = 0;
export const requestSubscription = () => {
export const requestSubscription = (tmplIds = [DEFAULT_TEMPLATE_ID], silent = false) => {
return new Promise((resolve) => {
// Check if subscription capability is available (basic check)
const now = Date.now();
// Throttling for silent requests to avoid "frequent request" errors
if (silent && (now - lastSubTime < MIN_SUB_INTERVAL)) {
console.log('[Subscribe] Throttled: Skipping real request to avoid frequency errors');
resolve({ success: true, throttled: true });
return;
}
if (!wx.requestSubscribeMessage) {
console.warn('Current version does not support subscribe message');
if (!silent) console.warn('当前版本不支持订阅消息');
resolve({ success: false, errMsg: 'Not supported' });
return;
}
// Update lastSubTime before the call
lastSubTime = now;
wx.requestSubscribeMessage({
tmplIds: [TEMPLATE_ID],
tmplIds: tmplIds,
success(res) {
if (res[TEMPLATE_ID] === 'accept') {
console.log('Subscription accepted');
resolve({ success: true, status: 'accept' });
} else {
console.log('Subscription rejected or other status', res[TEMPLATE_ID]);
resolve({ success: false, status: res[TEMPLATE_ID] });
// Log detailed results for debugging
console.log('[Subscribe] Result:', res);
// Check if any requested IDs were accepted
const acceptedIds = tmplIds.filter(id => res[id] === 'accept');
const isAccepted = acceptedIds.length > 0;
// Identify if user has opted for "Always Reject"
const rejectedIds = tmplIds.filter(id => res[id] === 'reject');
if (rejectedIds.length > 0 && !silent) {
console.warn('[Subscribe] User rejected templates (possibly "Always Reject"):', rejectedIds);
}
resolve({
success: isAccepted,
res,
isAlwaysRejected: rejectedIds.length === tmplIds.length
});
},
fail(err) {
console.error('Subscription failed', err);
resolve({ success: false, errMsg: err.errMsg });
console.error('[Subscribe] Request Failed:', err);
// 20004: User closed main switch in settings
if (err.errCode === 20004) {
if (!silent) {
wx.showModal({
title: '提示',
content: '订阅通知主开关已关闭,请在设置中开启',
confirmText: '去开启',
success: (modalRes) => {
if (modalRes.confirm) {
wx.openSetting();
}
}
});
} else {
console.warn('[Subscribe] Main subscription switch is OFF');
}
}
resolve({ success: false, error: err, isMainSwitchOff: err.errCode === 20004 });
}
});
});
};
export const checkSubscriptionSettings = () => {
export const checkSubscriptionSettings = (tmplId = DEFAULT_TEMPLATE_ID) => {
return new Promise((resolve) => {
if (!wx.getSetting) {
resolve(undefined);
@@ -43,7 +86,7 @@ export const checkSubscriptionSettings = () => {
withSubscriptions: true,
success(res) {
const itemSettings = (res.subscriptionsSetting && res.subscriptionsSetting.itemSettings) || {};
resolve(itemSettings[TEMPLATE_ID]);
resolve(itemSettings[tmplId]);
},
fail() {
resolve(undefined);