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') { if (token && typeof token === 'string') {
wx.setStorageSync('token', token); wx.setStorageSync('token', token);
console.log('Login successful');
if (this._resolveLogin) this._resolveLogin(token); if (this._resolveLogin) this._resolveLogin(token);
// Background Profile Update // Background Profile Update
+14 -2
View File
@@ -12,6 +12,8 @@
"pages/plant-detail/growth-record/index", "pages/plant-detail/growth-record/index",
"pages/wiki/detail/index", "pages/wiki/detail/index",
"pages/wiki/identify/index", "pages/wiki/identify/index",
"pages/wiki/chat/index",
"pages/wiki/chat/history/index",
"pages/profile/identify-history/index", "pages/profile/identify-history/index",
"pages/profile/badges/index", "pages/profile/badges/index",
"pages/profile/badges/level-detail/index", "pages/profile/badges/level-detail/index",
@@ -19,7 +21,8 @@
"pages/profile/favorites/index", "pages/profile/favorites/index",
"pages/profile/posts/index", "pages/profile/posts/index",
"pages/profile/about/index", "pages/profile/about/index",
"pages/profile/exchange/index" "pages/profile/exchange/index",
"pages/profile/exchange/orders/index"
], ],
"window": { "window": {
"backgroundTextStyle": "light", "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) { formatLocation(address, name) {
if (!address) return name || ''; if (!address && !name) return '';
// Municipalities const source = address || '';
// 直辖市
const munis = ['北京', '上海', '天津', '重庆']; const munis = ['北京', '上海', '天津', '重庆'];
for (let m of munis) { for (let m of munis) {
if (address.startsWith(m)) { if (source.startsWith(m + '市') || source.startsWith(m)) {
return m; return m;
} }
} }
// Standard: Prov + City (Simplify names) // 特别行政区
const match = address.match(/^(.+?)(?:省|自治区)(.+?)(?:市|自治州|地区|盟)/); if (source.startsWith('香港') || source.startsWith('澳门')) {
if (match) { return source.substring(0, 2);
return `${match[1]}.${match[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) { toggleTopic(e) {
@@ -236,11 +262,11 @@ Page({
wx.hideLoading(); wx.hideLoading();
wx.showToast({ title: '发布成功', icon: 'success' }); wx.showToast({ title: '发布成功', icon: 'success' });
// Refresh previous page // 直接通知上一页(社区列表页)刷新数据
const pages = getCurrentPages(); const pages = getCurrentPages();
const prevPage = pages[pages.length - 2]; const communityPage = pages[pages.length - 2];
if (prevPage && prevPage.onRefresh) { if (communityPage && communityPage.onRefresh) {
prevPage.onRefresh(); communityPage.onRefresh();
} }
setTimeout(() => { setTimeout(() => {
+14 -2
View File
@@ -81,6 +81,15 @@ Page({
const publisher = item.publisher || {}; const publisher = item.publisher || {};
const avatarObj = publisher.avatar || {}; 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 { return {
id: item.id, id: item.id,
user: publisher.nickName || publisher.name || '花友', user: publisher.nickName || publisher.name || '花友',
@@ -89,7 +98,7 @@ Page({
images: (item.imgList || []).map(img => img.url), images: (item.imgList || []).map(img => img.url),
location: item.location || '', location: item.location || '',
time: item.createdAtStr || '刚刚', time: item.createdAtStr || '刚刚',
likes: item.hasLiked === 1 ? ['我'] : [], likes: likeNames,
comments: (item.commentList || []).map(c => ({ comments: (item.commentList || []).map(c => ({
id: c.id, id: c.id,
user: c.commentator ? (c.commentator.nickName || c.commentator.name) : '花友', user: c.commentator ? (c.commentator.nickName || c.commentator.name) : '花友',
@@ -180,10 +189,13 @@ Page({
const updatedPosts = this.data.posts.map(p => { const updatedPosts = this.data.posts.map(p => {
if (p.id === postId) { if (p.id === postId) {
const liked = !p.likedByMe; const liked = !p.likedByMe;
const updatedLikes = liked
? (p.likes.includes('我') ? p.likes : [...p.likes, '我'])
: p.likes.filter(n => n !== '我');
return { return {
...p, ...p,
likedByMe: liked, likedByMe: liked,
likes: liked ? ['我'] : [] likes: updatedLikes
}; };
} }
return p; return p;
+8 -3
View File
@@ -135,11 +135,16 @@
<!-- Empty State --> <!-- Empty State -->
<view wx:else class="empty-feed"> <view wx:else class="empty-feed">
<view class="empty-icon"> <view class="empty-scene">
<t-icon name="chat" size="80rpx" color="#ccc" /> <view class="empty-glow"></view>
<view class="empty-emoji anim-breathe">💬</view>
</view> </view>
<text class="empty-text">暂无相关动态</text> <text class="empty-title">暂无相关动态</text>
<text class="empty-hint">快来发布第一条动态吧</text> <text class="empty-hint">快来发布第一条动态吧</text>
<view class="empty-cta" bindtap="goToCreatePost">
<t-icon name="add" size="28rpx" color="#fff" />
<text>发布动态</text>
</view>
</view> </view>
<!-- Bottom Spacer --> <!-- Bottom Spacer -->
+63 -19
View File
@@ -276,31 +276,75 @@ page {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 120rpx 40rpx; padding: 100rpx 40rpx 60rpx;
color: #999;
} }
.empty-icon { .empty-scene {
width: 160rpx; position: relative;
height: 160rpx;
background: #f5f5f5;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 32rpx; 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-size: 32rpx;
font-weight: 600; font-weight: 700;
color: #666; color: #558B2F;
margin-bottom: 12rpx; margin-bottom: 8rpx;
} }
.empty-hint { .empty-hint {
font-size: 26rpx; 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 */ /* Floating Action Button */
@@ -308,23 +352,23 @@ page {
position: fixed; position: fixed;
right: 40rpx; right: 40rpx;
bottom: 60rpx; bottom: 60rpx;
background: #558B2F; background: rgba(85, 139, 47, 0.92);
backdrop-filter: blur(12px);
color: white; color: white;
padding: 24rpx 40rpx; padding: 20rpx 36rpx;
border-radius: 60rpx; border-radius: 48rpx;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12rpx; gap: 10rpx;
box-shadow: 0 12rpx 32rpx rgba(85, 139, 47, 0.4); box-shadow: 0 8rpx 28rpx rgba(85, 139, 47, 0.35);
z-index: 100; z-index: 100;
font-size: 28rpx; font-size: 26rpx;
font-weight: 700; font-weight: 700;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.floating-add-btn:active { .floating-add-btn:active {
transform: scale(0.92); transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(85, 139, 47, 0.2);
} }
/* WeChat Style Action Container */ /* WeChat Style Action Container */
+31 -3
View File
@@ -7,6 +7,8 @@ Page({
plants: [], plants: [],
dateString: '', dateString: '',
greeting: '', greeting: '',
bannerList: [],
currentBanner: 0,
// Pagination // Pagination
currentPage: 1, currentPage: 1,
@@ -14,12 +16,14 @@ Page({
total: 0, total: 0,
isLastPage: false, isLastPage: false,
isLoading: false, isLoading: false,
isRefreshing: false,
scrollTop: 0 scrollTop: 0
}, },
onLoad(options) { onLoad(options) {
this.initTime(); this.initTime();
this.loadPlants(true); this.loadPlants(true);
this.loadBanners();
}, },
onShow() { onShow() {
@@ -34,13 +38,23 @@ Page({
this.loadPlants(true); this.loadPlants(true);
}, },
// Pull to refresh // Pull to refresh (page-level)
onPullDownRefresh() { onPullDownRefresh() {
this.loadPlants(true).then(() => { this.loadPlants(true).then(() => {
wx.stopPullDownRefresh(); 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 // Infinite scroll
onReachBottom() { onReachBottom() {
if (!this.data.isLastPage && !this.data.isLoading) { 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() { initTime() {
const updateTime = () => { const updateTime = () => {
const now = new Date(); const now = new Date();
@@ -115,6 +139,10 @@ Page({
updateTime(); updateTime();
}, },
onBannerChange(e) {
this.setData({ currentBanner: e.detail.current });
},
navigateToDetail(e) { navigateToDetail(e) {
const { id } = e.currentTarget.dataset; const { id } = e.currentTarget.dataset;
wx.navigateTo({ wx.navigateTo({
@@ -136,14 +164,14 @@ Page({
onShareAppMessage() { onShareAppMessage() {
return { return {
title: '我的植物花园 - Sundynix Plant', title: '我的私人花园 - 植趣',
path: '/pages/garden/index' path: '/pages/garden/index'
}; };
}, },
onShareTimeline() { onShareTimeline() {
return { return {
title: '我的植物花园 - Sundynix Plant' title: '我的私人花园 - 植趣'
}; };
}, },
+27 -3
View File
@@ -17,7 +17,27 @@
</view> </view>
<view class="banner-container"> <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"> <view class="banner-overlay">
<text class="count-tag">共养护 {{total}} 盆植物</text> <text class="count-tag">共养护 {{total}} 盆植物</text>
</view> </view>
@@ -45,7 +65,7 @@
</view> </view>
</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 class="plant-grid">
<view wx:for="{{plants}}" wx:key="id" class="plant-card" bindtap="navigateToDetail" data-id="{{item.id}}"> <view wx:for="{{plants}}" wx:key="id" class="plant-card" bindtap="navigateToDetail" data-id="{{item.id}}">
<view class="plant-image-container"> <view class="plant-image-container">
@@ -55,13 +75,17 @@
width="100%" width="100%"
height="100%" height="100%"
t-class="uploaded-img" t-class="uploaded-img"
lazy
/> />
<view class="days-badge">{{item.daysPlanted}}天</view> <view class="days-badge">{{item.daysPlanted}}天</view>
</view> </view>
<view class="plant-info"> <view class="plant-info">
<text class="plant-name">{{item.name}}</text> <text class="plant-name">{{item.name}}</text>
<view class="status-wrap"> <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> </view>
</view> </view>
+55 -8
View File
@@ -83,11 +83,41 @@
flex-shrink: 0; flex-shrink: 0;
} }
.garden-banner { .banner-swiper {
width: 100%; width: 100%;
height: 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 { .banner-overlay {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -191,6 +221,9 @@
.status-wrap { .status-wrap {
display: flex; display: flex;
align-items: center;
gap: 8rpx;
flex-wrap: wrap;
} }
.status { .status {
@@ -200,6 +233,20 @@
padding: 4rpx 16rpx; padding: 4rpx 16rpx;
border-radius: 12rpx; border-radius: 12rpx;
font-weight: 600; 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 */ /* Custom Floating Button */
@@ -207,23 +254,23 @@
position: fixed; position: fixed;
right: 40rpx; right: 40rpx;
bottom: 60rpx; bottom: 60rpx;
background: #558B2F; background: rgba(85, 139, 47, 0.92);
backdrop-filter: blur(12px);
color: white; color: white;
padding: 24rpx 40rpx; padding: 20rpx 36rpx;
border-radius: 60rpx; border-radius: 48rpx;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12rpx; gap: 10rpx;
box-shadow: 0 12rpx 32rpx rgba(85, 139, 47, 0.4); box-shadow: 0 8rpx 28rpx rgba(85, 139, 47, 0.35);
z-index: 1000; z-index: 1000;
font-size: 28rpx; font-size: 26rpx;
font-weight: 700; font-weight: 700;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.floating-add-btn:active { .floating-add-btn:active {
transform: scale(0.92); transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(85, 139, 47, 0.2);
} }
/* List Footer */ /* List Footer */
.list-footer { .list-footer {
+6 -1
View File
@@ -72,7 +72,12 @@ Page({
} }
// 2. Prepare payload // 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 title = mapTitle[this.data.recordType] || '日常记录';
const payload = { const payload = {
@@ -8,14 +8,38 @@
<t-icon name="thumb-up" size="32rpx" /> <t-icon name="thumb-up" size="32rpx" />
<text>生长</text> <text>生长</text>
</view> </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"> <view class="chip {{recordType === 'repot' ? 'active' : ''}}" bindtap="setRecordType" data-type="repot">
<t-icon name="swap" size="32rpx" /> <t-icon name="swap" size="32rpx" />
<text>换盆</text> <text>换盆</text>
</view> </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"> <view class="chip {{recordType === 'pest' ? 'active' : ''}}" bindtap="setRecordType" data-type="pest">
<t-icon name="error-circle" size="32rpx" /> <t-icon name="error-circle" size="32rpx" />
<text>病虫害</text> <text>病虫害</text>
</view> </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"> <view class="chip {{recordType === 'other' ? 'active' : ''}}" bindtap="setRecordType" data-type="other">
<t-icon name="file" size="32rpx" /> <t-icon name="file" size="32rpx" />
<text>其他</text> <text>其他</text>
+21 -2
View File
@@ -79,13 +79,32 @@ Page({
imageUrl = item.imgList[0].url; 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 { return {
id: item.id, id: item.id,
date: item.createdAtStr ? item.createdAtStr.split(' ')[0] : '', date: item.createdAtStr ? item.createdAtStr.split(' ')[0] : '',
type: item.tag || 'growth', type: tag,
title: item.name || '成长记录', title: item.name || '成长记录',
content: item.content || item.desc || '', content: item.content || item.desc || '',
image: imageUrl image: imageUrl,
iconName: cfg.icon,
iconColor: cfg.color,
accentColor: cfg.accent,
}; };
}) })
}); });
+19 -14
View File
@@ -7,14 +7,20 @@
</view> </view>
<view class="header-gallery"> <view class="header-gallery">
<t-swiper <block wx:if="{{swiperImages.length > 0}}">
current="{{activeImageIndex}}" <t-swiper
autoplay="{{false}}" current="{{activeImageIndex}}"
navigation="{{ { type: '' } }}" autoplay="{{false}}"
list="{{swiperImages}}" navigation="{{ { type: '' } }}"
bind:change="onSwiperChange" list="{{swiperImages}}"
height="500rpx" 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> <view class="header-gradient"></view>
<!-- Image Counter --> <!-- Image Counter -->
@@ -177,12 +183,11 @@
<view wx:for="{{displayRecords}}" wx:key="id" class="timeline-item"> <view wx:for="{{displayRecords}}" wx:key="id" class="timeline-item">
<view class="timeline-dot"></view> <view class="timeline-dot"></view>
<text class="timeline-date">{{item.date}}</text> <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"> <view class="timeline-title">
<t-icon wx:if="{{item.type === 'growth'}}" name="thumb-up" size="32rpx" color="#4CAF50" /> <view class="timeline-type-badge" style="background: {{item.accentColor || '#E8F5E9'}};">
<t-icon wx:elif="{{item.type === 'repot'}}" name="swap" size="32rpx" color="#FF9800" /> <t-icon name="{{item.iconName || 'file'}}" size="28rpx" color="{{item.iconColor || '#4CAF50'}}" />
<t-icon wx:elif="{{item.type === 'pest'}}" name="error-circle" size="32rpx" color="#F44336" /> </view>
<t-icon wx:else name="file" size="32rpx" color="#2196F3" />
<text>{{item.title}}</text> <text>{{item.title}}</text>
</view> </view>
<text class="timeline-desc">{{item.content}}</text> <text class="timeline-desc">{{item.content}}</text>
@@ -190,7 +195,7 @@
wx:if="{{item.image}}" wx:if="{{item.image}}"
src="{{item.image}}" src="{{item.image}}"
mode="aspectFill" 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" class="timeline-img"
bindtap="handlePreviewRecordImage" bindtap="handlePreviewRecordImage"
data-src="{{item.image}}" data-src="{{item.image}}"
+128 -19
View File
@@ -110,34 +110,39 @@ page {
border-bottom: 2rpx solid rgba(0, 0, 0, 0.05); border-bottom: 2rpx solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
flex-shrink: 0; flex-shrink: 0;
gap: 8rpx;
} }
.pd-tab-btn { .pd-tab-btn {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 32rpx 48rpx; padding: 24rpx 32rpx;
font-size: 30rpx; font-size: 28rpx;
font-weight: 500; font-weight: 500;
color: #6B7280; color: #6B7280;
background: none; background: none;
border: none; border: none;
position: relative; position: relative;
border-radius: 20rpx 20rpx 0 0;
transition: all 0.3s ease;
} }
.pd-tab-btn.active { .pd-tab-btn.active {
color: #558B2F; color: #2E7D32;
font-weight: 600; font-weight: 700;
background: rgba(85, 139, 47, 0.06);
} }
.pd-tab-btn.active::after { .pd-tab-btn.active::after {
content: ''; content: '';
position: absolute; position: absolute;
bottom: -2rpx; bottom: -2rpx;
left: 25%; left: 20%;
right: 25%; right: 20%;
height: 6rpx; height: 6rpx;
background: #558B2F; background: linear-gradient(90deg, #689F38, #33691E);
border-radius: 4rpx; border-radius: 4rpx;
transition: all 0.3s ease;
} }
/* Content Area */ /* Content Area */
@@ -145,7 +150,7 @@ page {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 48rpx; padding: 48rpx;
padding-bottom: 160rpx; padding-bottom: 48rpx;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -182,12 +187,25 @@ page {
color: #263238; color: #263238;
} }
/* Care Log List - Matching Prototype */ /* Care Log List - Timeline Style */
.care-log-list { .care-log-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24rpx; gap: 0;
margin-bottom: 40rpx; 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 { .care-log-item {
@@ -195,6 +213,27 @@ page {
border-radius: 32rpx; border-radius: 32rpx;
padding: 32rpx; padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04); 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 { .log-left {
@@ -309,8 +348,19 @@ page {
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 12rpx; height: 10rpx;
background: linear-gradient(90deg, #AED581, #2E7D32); 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 { .aic-header {
@@ -420,7 +470,8 @@ page {
.archive-timeline { .archive-timeline {
position: relative; position: relative;
padding-left: 48rpx; 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-left: 24rpx;
margin-bottom: 80rpx; margin-bottom: 80rpx;
} }
@@ -441,25 +492,43 @@ page {
width: 24rpx; width: 24rpx;
height: 24rpx; height: 24rpx;
background: white; background: white;
border: 6rpx solid #558B2F; border: 6rpx solid #81C784;
border-radius: 50%; border-radius: 50%;
z-index: 1; z-index: 1;
box-shadow: 0 0 0 8rpx #F4F6F0; 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 { .timeline-date {
font-size: 24rpx; font-size: 22rpx;
color: #9E9E9E; color: #81C784;
margin-bottom: 16rpx; 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 { .timeline-content-box {
background: white; background: white;
border-radius: 32rpx; border-radius: 28rpx;
padding: 32rpx; padding: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04); box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
border: 2rpx solid rgba(0, 0, 0, 0.02); border: 2rpx solid rgba(0, 0, 0, 0.02);
border-left: 6rpx solid #81C784;
position: relative;
overflow: hidden;
} }
.timeline-content-box:active { .timeline-content-box:active {
@@ -467,7 +536,7 @@ page {
} }
.timeline-title { .timeline-title {
font-size: 32rpx; font-size: 30rpx;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
margin-bottom: 16rpx; margin-bottom: 16rpx;
@@ -476,6 +545,16 @@ page {
gap: 16rpx; 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 { .timeline-desc {
font-size: 28rpx; font-size: 28rpx;
color: #546E7A; color: #546E7A;
@@ -840,3 +919,33 @@ page {
color: #EF5350; color: #EF5350;
font-weight: 500; 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 }); this.setData({ isLoading: true });
wx.showLoading({ title: '加载中...' }); wx.showLoading({ title: '加载中...' });
try { try {
// Fetch Config Tree // Parallel Fetch: Config Tree & User Badges
const treeRes = await request.get('/config/badge/tree'); const [treeRes, userBadgesRes] = await Promise.all([
request.get('/config/badge/tree'),
request.get('/profile/badge')
]);
const list = Array.isArray(treeRes) ? treeRes : (treeRes.data || []); const list = Array.isArray(treeRes) ? treeRes : (treeRes.data || []);
// DEBUG: Force Unlock All // 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 || [];
}
// Populate Achieved Map using Badge ID (not Record ID)
let achievedMap = {}; let achievedMap = {};
list.forEach(dim => { userBadgeList.forEach(b => {
if (dim.groups) { const badgeId = b.badgeId || (b.badge ? b.badge.id : null);
dim.groups.forEach(grp => { if (badgeId) {
if (grp.badges) { achievedMap[badgeId] = b;
grp.badges.forEach(b => {
achievedMap[b.id] = true;
});
}
});
} }
}); });
// 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;
});
}
} catch (e) {
// Silent fail
}
*/
this.setData({ this.setData({
dimensions: list, dimensions: list,
achievedMap achievedMap
}); });
} catch (e) { } catch (e) {
console.error('Fetch badge tree failed', e); console.error('Fetch badge data failed', e);
wx.showToast({ title: '加载失败', icon: 'none' }); wx.showToast({ title: '加载失败', icon: 'none' });
} finally { } finally {
this.setData({ isLoading: false }); this.setData({ isLoading: false });
+4 -4
View File
@@ -26,7 +26,7 @@ Page({
try { try {
// Fetch levels // Fetch levels
const levelRes = await request.get('/config/level/list'); const levelRes = await request.get('/config/level/list');
console.log('Level Detail - API Response:', levelRes);
let list = []; let list = [];
if (levelRes) { if (levelRes) {
@@ -41,14 +41,14 @@ Page({
} }
} }
console.log('Level Detail - Parsed List:', list);
list.sort((a, b) => a.minSunlight - b.minSunlight); list.sort((a, b) => a.minSunlight - b.minSunlight);
// Fetch profile if sunlight not passed // Fetch profile if sunlight not passed
let currentSunlight = passedSunlight; let currentSunlight = passedSunlight;
if (currentSunlight === undefined) { if (currentSunlight === undefined) {
const profileRes = await request.get('/profile/detail'); const profileRes = await request.get('/profile/detail');
console.log('Level Detail - Profile:', profileRes);
currentSunlight = profileRes.totalSunlight || 0; currentSunlight = profileRes.totalSunlight || 0;
this.setData({ currentSunlight }); this.setData({ currentSunlight });
} }
@@ -63,7 +63,7 @@ Page({
} }
} }
console.log('Level Detail - Calculated Level:', currentLevel);
this.setData({ this.setData({
levels: list, 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": "兑换中心", "navigationBarTitleText": "兑换中心",
"disableScroll": true,
"usingComponents": { "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"> <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> </view>
+443 -5
View File
@@ -1,9 +1,447 @@
page { page {
background: #F4F6F0; background: #F4F6F0;
} }
.exchange-page { .exchange-page {
display: flex; height: 100vh;
align-items: center; display: flex;
justify-content: center; flex-direction: column;
height: 100vh; 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;
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": "我的收藏", "navigationBarTitleText": "我的收藏",
"disableScroll": true,
"usingComponents": { "usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon", "t-icon": "tdesign-miniprogram/icon/icon",
"t-image": "tdesign-miniprogram/image/image", "t-image": "tdesign-miniprogram/image/image",
+1 -1
View File
@@ -130,7 +130,7 @@ Page({
// Open WeChat notification settings // Open WeChat notification settings
wx.openSetting({ wx.openSetting({
success: (res) => { success: (res) => {
console.log('Settings opened', res);
} }
}); });
}, },
+13 -7
View File
@@ -29,10 +29,9 @@
<t-icon name="setting" size="40rpx" color="#666" /> <t-icon name="setting" size="40rpx" color="#666" />
</view> </view>
</view> </view>
<!-- Stats Card (Fixed) --> <!-- Stats Card (Fixed) -->
<scroll-view scroll-y class="profile-content" enhanced show-scrollbar="{{false}}" scroll-top="{{scrollTop}}"> <scroll-view scroll-y class="profile-content" enhanced show-scrollbar="{{false}}" scroll-top="{{scrollTop}}">
<!-- Menu --> <!-- Menu -->
<view class="profile-menu"> <view class="profile-menu">
@@ -46,7 +45,6 @@
<text class="menu-text">兑换中心</text> <text class="menu-text">兑换中心</text>
</view> </view>
<view class="menu-right-info"> <view class="menu-right-info">
<text class="menu-badge-text">开发中</text>
<t-icon name="chevron-right" size="36rpx" color="#ccc" /> <t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view> </view>
</view> </view>
@@ -70,7 +68,9 @@
</view> </view>
<text class="menu-text">我的收藏</text> <text class="menu-text">我的收藏</text>
</view> </view>
<t-icon name="chevron-right" size="36rpx" color="#ccc" /> <view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view> </view>
<view class="menu-item" bindtap="goToPosts"> <view class="menu-item" bindtap="goToPosts">
@@ -80,7 +80,9 @@
</view> </view>
<text class="menu-text">我的发布</text> <text class="menu-text">我的发布</text>
</view> </view>
<t-icon name="chevron-right" size="36rpx" color="#ccc" /> <view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view> </view>
<view class="menu-item" bindtap="goToIdentifyHistory"> <view class="menu-item" bindtap="goToIdentifyHistory">
@@ -90,7 +92,9 @@
</view> </view>
<text class="menu-text">识别记录</text> <text class="menu-text">识别记录</text>
</view> </view>
<t-icon name="chevron-right" size="36rpx" color="#ccc" /> <view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view> </view>
<view class="menu-group-title" style="margin-top: 32rpx;">更多服务</view> <view class="menu-group-title" style="margin-top: 32rpx;">更多服务</view>
@@ -102,7 +106,9 @@
</view> </view>
<text class="menu-text">帮助与关于</text> <text class="menu-text">帮助与关于</text>
</view> </view>
<t-icon name="chevron-right" size="36rpx" color="#ccc" /> <view class="menu-right-info">
<t-icon name="chevron-right" size="36rpx" color="#ccc" />
</view>
</view> </view>
</view> </view>
+71 -57
View File
@@ -1,5 +1,6 @@
// pages/tasks/index.js // pages/tasks/index.js
import request from '../../utils/request'; import request from '../../utils/request';
import { requestSubscription } from '../../utils/subscribe';
Page({ Page({
data: { data: {
@@ -239,71 +240,84 @@ Page({
const taskId = this.data.completingTask.id; const taskId = this.data.completingTask.id;
const remark = this.data.remark || ''; const remark = this.data.remark || '';
wx.showLoading({ title: '提交中...', mask: true }); // Attempt to subscribe (silent mode avoids error popups if disabled)
// This encourages "Always Allow" behavior for seamless experience
request.post('/plant/completeTask', { requestSubscription(undefined, true).then((subResult) => {
taskId: taskId, // Debug failure feedback
remark: remark if (!subResult.success) {
}).then(res => { if (subResult.isMainSwitchOff) {
wx.hideLoading(); wx.showToast({ title: '提醒总开关已关闭', icon: 'none' });
} else {
// Handle Rewards console.log('[Task] Subscription quota not increased:', subResult.res || subResult.error);
const queue = []; }
// Check if res has level up or badge data
// Note: res is already data.data from request.js
if (res && res.isLevelUp && res.currentLevel) {
queue.push({ type: 'level', data: res.currentLevel });
} }
// Check for Badge using IsGetBadge flag (allowing for casing variance) wx.showLoading({ title: '提交中...', mask: true });
if (res && (res.IsGetBadge === true || res.isGetBadge === true) && res.newBadge) {
queue.push({ type: 'badge', data: res.newBadge });
}
this._popupQueue = queue; request.post('/plant/completeTask', {
taskId: taskId,
remark: remark
}).then(res => {
wx.hideLoading();
// Optimistic UI Update Logic // Handle Rewards
const groups = this.data.groupedTasks; const queue = [];
let updated = false; // Check if res has level up or badge data
// Need to deep clone possibly, but here we modify and set back // Note: res is already data.data from request.js
for (let g of groups) { if (res && res.isLevelUp && res.currentLevel) {
// g is an object. groupedTasks is array of objects. queue.push({ type: 'level', data: res.currentLevel });
if (g.tasks) { }
const t = g.tasks.find(x => x && x.id === taskId);
if (t) { // Check for Badge using IsGetBadge flag (allowing for casing variance)
t.isCompleted = true; if (res && (res.IsGetBadge === true || res.isGetBadge === true) && res.newBadge) {
t.isOverdue = false; queue.push({ type: 'badge', data: res.newBadge });
updated = true; }
break;
this._popupQueue = queue;
// Optimistic UI Update Logic
const groups = this.data.groupedTasks;
let updated = false;
// Need to deep clone possibly, but here we modify and set back
for (let g of groups) {
// g is an object. groupedTasks is array of objects.
if (g.tasks) {
const t = g.tasks.find(x => x && x.id === taskId);
if (t) {
t.isCompleted = true;
t.isOverdue = false;
updated = true;
break;
}
} }
} }
}
// Trigger Animation and Close Modal // Trigger Animation and Close Modal
this.setData({ this.setData({
completingTask: null, completingTask: null,
remark: '', remark: '',
groupedTasks: groups, // Update UI groupedTasks: groups, // Update UI
tasks: groups, tasks: groups,
showSunshine: true showSunshine: true
});
// Hide Animation after duration and Start showing modals
setTimeout(() => {
this.setData({ showSunshine: false });
// Show rewards after sunshine animation
if (this._popupQueue.length > 0) {
this.processPopupQueue();
}
}, 1000); // 1.0s delay usually covers animation
// Sync with backend silently
this.fetchTodayTasks();
}).catch(err => {
wx.hideLoading();
console.error('Complete task failed', err);
wx.showToast({ title: '操作失败', icon: 'none' });
}); });
// Hide Animation after duration and Start showing modals
setTimeout(() => {
this.setData({ showSunshine: false });
// Show rewards after sunshine animation
if (this._popupQueue.length > 0) {
this.processPopupQueue();
}
}, 1000); // 1.0s delay usually covers animation
// Sync with backend silently
this.fetchTodayTasks();
}).catch(err => {
wx.hideLoading();
console.error('Complete task failed', err);
wx.showToast({ title: '操作失败', icon: 'none' });
}); });
}, },
+7
View File
@@ -459,6 +459,13 @@
.mini-check-btn.btn-checked { .mini-check-btn.btn-checked {
background: #4CAF50 !important; background: #4CAF50 !important;
border-color: #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 */ /* 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}`, title: `植物百科 - ${this.data.plant.name}`,
path: `/pages/wiki/detail/index?id=${this.data.plant.id}` 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> </view>
<view style="height: 40rpx;"></view> <view style="height: 120rpx;"></view>
</scroll-view> </scroll-view>
<!-- Ask AI FAB -->
<view class="ask-ai-fab" bindtap="askAiAboutPlant">
<text class="fab-emoji">🤖</text>
<text>问 AI</text>
</view>
</view> </view>
+24
View File
@@ -286,3 +286,27 @@ page {
transform: scale(0.9); transform: scale(0.9);
transition: transform 0.1s; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; height: 100vh;
padding: 48rpx; padding: 48rpx;
box-sizing: border-box;
padding-bottom: 20%; /* Push up visually */
} }
.state-card { .state-card {
background: #fff; background: #fff;
border-radius: 40rpx; border-radius: 48rpx;
padding: 56rpx 48rpx; padding: 64rpx 48rpx;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 32rpx; gap: 40rpx;
box-shadow: 0 12rpx 40rpx rgba(85, 139, 47, 0.08); box-shadow: 0 20rpx 60rpx rgba(85, 139, 47, 0.15);
} }
/* ========== Loading State ========== */ /* ========== Loading State ========== */
.loading-image-wrap { .loading-image-wrap {
width: 280rpx; width: 440rpx;
height: 280rpx; height: 440rpx;
border-radius: 32rpx; border-radius: 40rpx;
overflow: hidden; overflow: hidden;
position: relative; 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 { .loading-preview {
+16 -4
View File
@@ -21,7 +21,8 @@ Page({
scrollTop: 0, scrollTop: 0,
// Modal State // Modal State
showIdentifyModal: false showIdentifyModal: false,
isRefreshing: false
}, },
onLoad() { onLoad() {
@@ -40,6 +41,13 @@ Page({
this.setData({ scrollTop: Math.random() * 0.01 }); this.setData({ scrollTop: Math.random() * 0.01 });
}, },
onRefresh() {
this.setData({ isRefreshing: true });
this.fetchWikiList(true).finally(() => {
this.setData({ isRefreshing: false });
});
},
// Fetch categories from API // Fetch categories from API
fetchCategories() { fetchCategories() {
request.get('/wiki-class/list').then(res => { request.get('/wiki-class/list').then(res => {
@@ -52,8 +60,8 @@ Page({
// Fetch wiki list from API // Fetch wiki list from API
fetchWikiList(reset = false) { fetchWikiList(reset = false) {
if (this.data.isLoading) return; if (this.data.isLoading) return Promise.resolve();
if (!reset && !this.data.hasMore) return; if (!reset && !this.data.hasMore) return Promise.resolve();
const current = reset ? 1 : this.data.current; const current = reset ? 1 : this.data.current;
@@ -75,7 +83,7 @@ Page({
params.classId = [this.data.activeCategory]; params.classId = [this.data.activeCategory];
} }
request.post('/wiki/page', params).then(res => { return request.post('/wiki/page', params).then(res => {
const data = res || {}; const data = res || {};
const list = data.list || []; const list = data.list || [];
const total = data.total || 0; const total = data.total || 0;
@@ -207,6 +215,10 @@ Page({
openIdentifyModal() { this.setData({ showIdentifyModal: true }); }, openIdentifyModal() { this.setData({ showIdentifyModal: true }); },
goToAiChat() {
wx.navigateTo({ url: '/pages/wiki/chat/index' });
},
onPopupVisibleChange(e) { onPopupVisibleChange(e) {
this.setData({ this.setData({
showIdentifyModal: e.detail.visible showIdentifyModal: e.detail.visible
+16 -5
View File
@@ -17,6 +17,9 @@
enhanced enhanced
show-scrollbar="{{false}}" show-scrollbar="{{false}}"
scroll-top="{{scrollTop}}" scroll-top="{{scrollTop}}"
refresher-enabled="{{true}}"
bindrefresherrefresh="onRefresh"
refresher-triggered="{{isRefreshing}}"
> >
<view class="search-section"> <view class="search-section">
<view class="search-box-card"> <view class="search-box-card">
@@ -111,13 +114,9 @@
</view> </view>
<!-- Spacer --> <!-- Spacer -->
<view style="height: 160rpx;"></view> <view style="height: 200rpx;"></view>
</scroll-view> </scroll-view>
<view class="floating-add-btn" bindtap="openIdentifyModal">
<t-icon name="scan" size="40rpx" color="#FFF" />
<text>植物识别</text>
</view>
<!-- Identify Popup --> <!-- Identify Popup -->
<t-popup visible="{{showIdentifyModal}}" bind:visible-change="onPopupVisibleChange" placement="bottom"> <t-popup visible="{{showIdentifyModal}}" bind:visible-change="onPopupVisibleChange" placement="bottom">
@@ -147,4 +146,16 @@
</view> </view>
</view> </view>
</t-popup> </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> </view>
+44 -22
View File
@@ -60,12 +60,12 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 36rpx; padding: 0 28rpx;
height: 72rpx; height: 72rpx;
background: #fff; background: #fff;
border-radius: 36rpx; border-radius: 36rpx;
margin-right: 24rpx; margin-right: 16rpx;
font-size: 28rpx; font-size: 26rpx;
color: #546E7A; color: #546E7A;
font-weight: 600; font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04); box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04);
@@ -79,11 +79,12 @@
} }
.category-item.active { .category-item.active {
background: #558B2F; background: linear-gradient(135deg, #558B2F, #689F38);
color: #fff; color: #fff;
font-weight: 700; 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; border-color: #558B2F;
transform: scale(1.02);
} }
.wiki-list { .wiki-list {
@@ -179,28 +180,49 @@
font-size: 28rpx; font-size: 28rpx;
} }
/* Floating Action Button */ /* Floating Action Buttons */
.floating-add-btn { .floating-btns {
position: fixed; position: fixed;
right: 40rpx; bottom: 48rpx;
bottom: 60rpx; left: 50%;
background: #558B2F; transform: translateX(-50%);
color: white;
padding: 24rpx 40rpx;
border-radius: 60rpx;
display: flex; display: flex;
align-items: center; flex-direction: row;
gap: 12rpx; gap: 24rpx;
box-shadow: 0 12rpx 32rpx rgba(85, 139, 47, 0.4); z-index: 11600;
z-index: 1000;
font-size: 28rpx;
font-weight: 700;
transition: all 0.2s ease;
} }
.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); 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 */ /* Popup Styles */
+53 -3
View File
@@ -63,7 +63,7 @@ class WxRequest {
if ((statusCode === 401 || data.code === 401) && !options.skipToken && !options._retry) { if ((statusCode === 401 || data.code === 401) && !options.skipToken && !options._retry) {
const app = getApp(); const app = getApp();
if (app && app.forceRefreshLogin) { if (app && app.forceRefreshLogin) {
console.log('401 detected, refreshing token...');
app.forceRefreshLogin().then(() => { app.forceRefreshLogin().then(() => {
// Retry Original Request // Retry Original Request
this.request({ ...options, _retry: true }) this.request({ ...options, _retry: true })
@@ -266,12 +266,62 @@ class WxRequest {
post(url, data = {}, header = {}) { post(url, data = {}, header = {}) {
return this.request({ url, method: 'POST', 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 // Initialize with default instance
const request = new WxRequest({ const request = new WxRequest({
baseUrl: 'http://192.168.0.184:8889', //baseUrl: 'http://192.168.0.184:8889',
//baseUrl: 'https://go.sundynix.cn/api', baseUrl: 'https://go.sundynix.cn/api',
header: { header: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
+59 -16
View File
@@ -1,38 +1,81 @@
/** /**
* Request WeChat Mini Program Subscription Message * 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) => { 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) { if (!wx.requestSubscribeMessage) {
console.warn('Current version does not support subscribe message'); if (!silent) console.warn('当前版本不支持订阅消息');
resolve({ success: false, errMsg: 'Not supported' }); resolve({ success: false, errMsg: 'Not supported' });
return; return;
} }
// Update lastSubTime before the call
lastSubTime = now;
wx.requestSubscribeMessage({ wx.requestSubscribeMessage({
tmplIds: [TEMPLATE_ID], tmplIds: tmplIds,
success(res) { success(res) {
if (res[TEMPLATE_ID] === 'accept') { // Log detailed results for debugging
console.log('Subscription accepted'); console.log('[Subscribe] Result:', res);
resolve({ success: true, status: 'accept' });
} else { // Check if any requested IDs were accepted
console.log('Subscription rejected or other status', res[TEMPLATE_ID]); const acceptedIds = tmplIds.filter(id => res[id] === 'accept');
resolve({ success: false, status: res[TEMPLATE_ID] }); 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) { fail(err) {
console.error('Subscription failed', err); console.error('[Subscribe] Request Failed:', err);
resolve({ success: false, errMsg: err.errMsg });
// 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) => { return new Promise((resolve) => {
if (!wx.getSetting) { if (!wx.getSetting) {
resolve(undefined); resolve(undefined);
@@ -43,7 +86,7 @@ export const checkSubscriptionSettings = () => {
withSubscriptions: true, withSubscriptions: true,
success(res) { success(res) {
const itemSettings = (res.subscriptionsSetting && res.subscriptionsSetting.itemSettings) || {}; const itemSettings = (res.subscriptionsSetting && res.subscriptionsSetting.itemSettings) || {};
resolve(itemSettings[TEMPLATE_ID]); resolve(itemSettings[tmplId]);
}, },
fail() { fail() {
resolve(undefined); resolve(undefined);