feat: 任务和社区页面

This commit is contained in:
Blizzard
2026-02-06 17:27:35 +08:00
parent d42471e1d5
commit b800ea03b5
30 changed files with 1777 additions and 551 deletions
+24 -2
View File
@@ -1,7 +1,29 @@
// app.js
import request from './utils/request';
App({
onLaunch() {
console.log('App Launch');
// Login
wx.login({
success: res => {
// Send res.code to backend to swap for openId, sessionKey, unionId
if (res.code) {
request.get('/auth/miniLogin', { code: res.code }).then(data => {
// Assuming the token is in data.token or data itself
const token = data.token || data;
if (token && typeof token === 'string') {
wx.setStorageSync('token', token);
console.log('Login successful, token stored');
} else {
console.warn('Login response did not contain a valid token string', data);
}
}).catch(err => {
console.error('Login failed', err);
});
} else {
console.error('wx.login failed: ' + res.errMsg);
}
}
});
},
globalData: {
userInfo: null
Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

+64 -109
View File
@@ -1,16 +1,15 @@
// pages/community/create/index.js
import { MOCK_POSTS } from '../../../utils/mockData';
import request from '../../../utils/request';
Page({
data: {
content: '',
images: [],
canPublish: false,
canPublish: false, // Only depends on content
autoFocus: true,
location: '',
selectedTopics: [],
suggestedTopics: ['植物养护', '多肉日记', '绿植分享', '花卉美照', '阳台花园', '新手入门'],
hasDraft: false,
showImageSheet: false,
imageSheetItems: [
{ label: '拍照', value: 'camera' },
@@ -19,60 +18,18 @@ Page({
},
onLoad() {
// Check for saved draft
this.loadDraft();
// No draft loading
},
onUnload() {
// Save draft if there's content
this.saveDraft();
},
loadDraft() {
try {
const draft = wx.getStorageSync('post_draft');
if (draft && (draft.content || draft.images.length > 0)) {
this.setData({
content: draft.content || '',
images: draft.images || [],
selectedTopics: draft.selectedTopics || [],
canPublish: draft.content && draft.content.trim().length > 0,
hasDraft: true
});
}
} catch (e) {
console.log('No draft found');
}
},
saveDraft() {
if (this.data.content || this.data.images.length > 0) {
try {
wx.setStorageSync('post_draft', {
content: this.data.content,
images: this.data.images,
selectedTopics: this.data.selectedTopics
});
} catch (e) {
console.log('Failed to save draft');
}
}
},
clearDraft() {
try {
wx.removeStorageSync('post_draft');
} catch (e) {
console.log('Failed to clear draft');
}
// No draft saving
},
onContentInput(e) {
const content = e.detail.value;
this.setData({
content,
canPublish: content.trim().length > 0,
hasDraft: false
canPublish: content.trim().length > 0
});
},
@@ -109,8 +66,7 @@ Page({
success: (res) => {
const newImages = res.tempFiles.map(f => f.tempFilePath);
this.setData({
images: [...this.data.images, ...newImages],
hasDraft: false
images: [...this.data.images, ...newImages]
});
}
});
@@ -130,8 +86,7 @@ Page({
success: (res) => {
const newImage = res.tempFiles[0].tempFilePath;
this.setData({
images: [...this.data.images, newImage],
hasDraft: false
images: [...this.data.images, newImage]
});
}
});
@@ -200,74 +155,74 @@ Page({
});
},
insertEmoji() {
// Simple emoji picker simulation
const emojis = ['🌱', '🌿', '🍀', '🌵', '🌻', '🌺', '🌸', '🌼', '🪴', '🌲'];
wx.showActionSheet({
itemList: emojis,
success: (res) => {
const emoji = emojis[res.tapIndex];
this.setData({
content: this.data.content + emoji,
canPublish: true
});
}
});
},
handleCancel() {
if (this.data.content || this.data.images.length > 0) {
wx.showModal({
title: '保存草稿',
content: '是否保存当前内容为草稿?',
cancelText: '不保存',
confirmText: '保存',
success: (res) => {
if (res.confirm) {
this.saveDraft();
wx.showToast({ title: '已保存草稿', icon: 'success' });
setTimeout(() => wx.navigateBack(), 500);
} else {
this.clearDraft();
wx.navigateBack();
}
}
});
} else {
wx.navigateBack();
}
wx.navigateBack();
},
handlePublish() {
if (!this.data.canPublish) {
async handlePublish() {
if (!this.data.content || !this.data.content.trim()) {
wx.showToast({ title: '请输入内容', icon: 'none' });
return;
}
// Content already includes topics as hashtags
const finalContent = this.data.content.trim();
wx.showLoading({ title: '发布中...', mask: true });
// Create new post
const newPost = {
id: Date.now().toString(),
user: '我的花园',
content: finalContent,
images: this.data.images,
time: '刚刚',
likes: [],
comments: []
};
try {
// 1. Upload Images
const ossIds = [];
const images = this.data.images;
// Add to global mock data (at the beginning)
MOCK_POSTS.unshift(newPost);
if (images.length > 0) {
const uploadPromises = images.map(filePath => {
return request.upload(filePath).then(res => {
// Res structure: { file: { id: "...", url: "..." } }
return res && res.file ? res.file.id : null;
});
});
// Clear draft
this.clearDraft();
const uploadedIds = await Promise.all(uploadPromises);
wx.showToast({ title: '发布成功', icon: 'success' });
uploadedIds.forEach(id => {
if (id) ossIds.push(id);
});
setTimeout(() => {
wx.navigateBack();
}, 1000);
if (images.length > 0 && ossIds.length === 0) {
throw new Error('图片上传失败');
}
}
// 2. Publish Post
// Title is removed from UI. Using content snippet or default title.
const content = this.data.content.trim();
const title = content.length > 20 ? content.substring(0, 20) + '...' : content;
const payload = {
title: title || '新动态', // Fallback title
content: content,
location: this.data.location || '',
ossIds: ossIds
};
await request.post('/post/publish', payload);
wx.hideLoading();
wx.showToast({ title: '发布成功', icon: 'success' });
// Refresh previous page
const pages = getCurrentPages();
const prevPage = pages[pages.length - 2];
if (prevPage && prevPage.onRefresh) {
prevPage.onRefresh();
}
setTimeout(() => {
wx.navigateBack();
}, 1000);
} catch (err) {
wx.hideLoading();
console.error('Publish failed', err);
wx.showToast({ title: '发布失败', icon: 'none' });
}
}
})
+3 -1
View File
@@ -11,6 +11,8 @@
</view>
</view>
<!-- Text Input -->
<textarea
class="post-textarea"
@@ -33,7 +35,7 @@
<view wx:if="{{images.length > 0}}" class="image-section">
<view class="image-preview-grid">
<view wx:for="{{images}}" wx:key="*this" class="preview-item" bindlongpress="showImageMenu" data-index="{{index}}">
<t-image src="{{item}}" mode="aspectFill" width="100%" height="100%" />
<image src="{{item}}" mode="aspectFill" style="width: 100%; height: 100%; display: block;" />
<view class="remove-btn" catchtap="removeImage" data-index="{{index}}">
<t-icon name="close" size="24rpx" color="#fff" />
</view>
+20
View File
@@ -314,3 +314,23 @@ page {
font-size: 24rpx;
color: #999;
}
/* Title Input */
.title-section {
padding: 16rpx 0;
border-bottom: 2rpx solid #f5f5f5;
margin-bottom: 16rpx;
}
.post-title-input {
font-size: 36rpx;
font-weight: 700;
color: #333;
width: 100%;
height: 64rpx; /* Height for single line */
min-height: 64rpx;
}
.title-placeholder {
color: #bbb;
font-weight: 400;
}
+168 -57
View File
@@ -1,5 +1,5 @@
// pages/community/index.js
import { MOCK_POSTS } from '../../utils/mockData';
import request from '../../utils/request';
Page({
data: {
@@ -8,31 +8,110 @@ Page({
activePostId: null, // For showing action popup
showCommentBar: false,
commentingPostId: null,
commentText: ''
commentText: '',
isLoading: false,
current: 1,
pageSize: 10,
hasMore: true
},
onLoad() {
this.setData({ posts: MOCK_POSTS });
this.updateDisplayedPosts();
this.fetchPosts(true);
},
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 2 });
}
// Refresh posts in case new ones were added
this.setData({ posts: MOCK_POSTS });
this.updateDisplayedPosts();
},
// Called by create post page
onRefresh() {
this.fetchPosts(true);
},
onPullDownRefresh() {
this.fetchPosts(true).then(() => {
wx.stopPullDownRefresh();
});
},
onReachBottom() {
if (this.data.hasMore && !this.data.isLoading) {
this.fetchPosts(false);
}
},
async fetchPosts(reset = false) {
if (this.data.isLoading) return;
this.setData({ isLoading: true });
const current = reset ? 1 : this.data.current;
const pageSize = this.data.pageSize;
try {
// Correct API Endpoint and Params
const res = await request.post('/post/page', { current, pageSize });
// Handle response structure: { code: 200, data: { list: [], ... } }
// OR if request.js unwraps it: { list: [], ... }
const data = res.data || res || {};
const rawList = data.list || [];
// Map backend data to UI model
const newPosts = rawList.map(item => {
const publisher = item.publisher || {};
const avatarObj = publisher.avatar || {};
return {
id: item.id,
user: publisher.nickName || publisher.name || '花友',
avatar: avatarObj.url || '/assets/default_avatar.png',
content: item.content,
images: (item.imgList || []).map(img => img.url),
time: item.createdAtStr || '刚刚',
likes: (item.likeList || []).map(l => l.liker ? (l.liker.nickName || l.liker.name) : '花友'),
comments: (item.commentList || []).map(c => ({
id: c.id,
user: c.commentator ? (c.commentator.nickName || c.commentator.name) : '花友',
content: c.content
})),
likedByMe: item.hasLiked === 1,
likeCount: item.likeCount || 0,
commentCount: item.commentCount || 0,
isExpanded: false
};
});
if (reset) {
this.setData({
posts: newPosts,
displayedPosts: newPosts,
current: 2,
hasMore: newPosts.length >= pageSize
});
} else {
const combined = [...this.data.posts, ...newPosts];
this.setData({
posts: combined,
displayedPosts: combined,
current: current + 1,
hasMore: newPosts.length >= pageSize
});
}
} catch (err) {
console.error('Fetch posts failed', err);
wx.showToast({ title: '加载失败', icon: 'none' });
} finally {
this.setData({ isLoading: false });
}
},
updateDisplayedPosts() {
const { posts } = this.data;
// Show all posts with likedByMe flag
const displayed = posts.map(post => ({
...post,
likedByMe: post.likes.includes('我的花园')
}));
this.setData({ displayedPosts: displayed });
// Just sync posts to displayedPosts
this.setData({ displayedPosts: this.data.posts });
},
// Preview image in full screen
@@ -72,31 +151,37 @@ Page({
},
// Like post
likePost(e) {
async likePost(e) {
const postId = e.currentTarget.dataset.id;
const posts = this.data.posts.map(post => {
if (post.id === postId) {
const likes = [...post.likes];
const myName = '我的花园';
if (likes.includes(myName)) {
// Unlike
const idx = likes.indexOf(myName);
likes.splice(idx, 1);
} else {
// Like
likes.push(myName);
}
return { ...post, likes };
}
return post;
});
const post = this.data.posts.find(p => p.id === postId);
if (!post) return;
this.setData({
posts,
activePostId: null // Hide popup after action
}, () => {
this.updateDisplayedPosts();
});
const type = post.likedByMe ? 2 : 1;
try {
await request.get('/post/like', { id: postId, type });
// Optimistic Update: Only toggle button state. Do NOT modify likes list text.
const updatedPosts = this.data.posts.map(p => {
if (p.id === postId) {
return { ...p, likedByMe: !p.likedByMe };
}
return p;
});
this.setData({
posts: updatedPosts,
displayedPosts: updatedPosts,
activePostId: null
});
// Call page API to refresh list data (including Like List text)
this.fetchPosts(true);
} catch (err) {
console.error('Like failed', err);
wx.showToast({ title: '操作失败', icon: 'none' });
}
},
// Show comment input bar
@@ -122,39 +207,65 @@ Page({
this.setData({ commentText: e.detail.value });
},
submitComment() {
async submitComment() {
const { commentText, commentingPostId } = this.data;
if (!commentText.trim()) {
return;
}
const posts = this.data.posts.map(post => {
if (post.id === commentingPostId) {
const comments = [...post.comments, {
id: Date.now().toString(),
user: '我的花园',
content: commentText.trim()
}];
return { ...post, comments };
}
return post;
});
try {
await request.post('/post/comment', {
postId: commentingPostId,
content: commentText.trim()
});
this.setData({
posts,
showCommentBar: false,
commentingPostId: null,
commentText: ''
}, () => {
this.updateDisplayedPosts();
wx.showToast({ title: '评论成功', icon: 'success' });
});
// Optimistic update
const posts = this.data.posts.map(post => {
if (post.id === commentingPostId) {
const comments = [...post.comments, {
id: Date.now().toString(),
user: '我',
content: commentText.trim()
}];
return { ...post, comments };
}
return post;
});
this.setData({
posts,
displayedPosts: posts,
showCommentBar: false,
commentingPostId: null,
commentText: ''
});
// Silent refresh from server
this.fetchPosts(true);
} catch (err) {
console.error('Comment failed', err);
wx.showToast({ title: '评论失败', icon: 'none' });
}
},
goToCreatePost() {
wx.navigateTo({
url: '/pages/community/create/index'
});
},
toggleCommentExpand(e) {
const postId = e.currentTarget.dataset.id;
const posts = this.data.posts.map(p => {
if (p.id === postId) {
return { ...p, isExpanded: !p.isExpanded };
}
return p;
});
this.setData({ posts, displayedPosts: posts });
}
})
+32 -11
View File
@@ -22,9 +22,7 @@
<view wx:for="{{displayedPosts}}" wx:key="id" class="moment-post">
<!-- Avatar -->
<view class="post-avatar">
<view class="avatar-square">
<text>{{item.user[0]}}</text>
</view>
<image class="avatar-square" src="{{item.avatar}}" mode="aspectFill" />
</view>
<!-- Content -->
@@ -34,12 +32,11 @@
<!-- Image Grid -->
<view wx:if="{{item.images.length > 0}}" class="post-images-grid grid-{{item.images.length === 1 ? '1' : (item.images.length <= 4 ? '2' : '3')}}">
<view wx:for="{{item.images}}" wx:for-item="img" wx:key="*this" class="post-image-item" catchtap="previewImage" data-url="{{tools.resolvePath(img)}}" data-urls="{{item.images}}">
<t-image
src="{{tools.resolvePath(img)}}"
<view wx:for="{{item.images}}" wx:for-item="img" wx:key="*this" class="post-image-item" catchtap="previewImage" data-url="{{img}}" data-urls="{{item.images}}">
<image
src="{{img}}"
mode="aspectFill"
width="100%"
height="100%"
style="width: 100%; height: 100%; display: block;"
lazy-load
/>
</view>
@@ -76,7 +73,7 @@
<view wx:if="{{item.likes.length > 0 || item.comments.length > 0}}" class="likes-comments-box">
<!-- Likes -->
<view wx:if="{{item.likes.length > 0}}" class="likes-section">
<t-icon name="heart-filled" size="28rpx" color="#576b95" />
<t-icon name="heart-filled" size="28rpx" color="#FA5151" />
<view class="likes-list">
<text wx:for="{{item.likes}}" wx:for-item="liker" wx:key="*this" class="like-name">{{liker}}{{index < item.likes.length - 1 ? '' : ''}}</text>
</view>
@@ -87,10 +84,26 @@
<!-- Comments -->
<view wx:if="{{item.comments.length > 0}}" class="comments-section">
<view wx:for="{{item.comments}}" wx:for-item="comment" wx:key="id" class="comment-item">
<view
wx:for="{{item.comments}}"
wx:for-item="comment"
wx:key="id"
class="comment-item"
wx:if="{{index < 3 || item.isExpanded}}"
>
<text class="comment-user">{{comment.user}}</text>
<text class="comment-content">{{comment.content}}</text>
</view>
<!-- Expand/Collapse Button -->
<view
wx:if="{{item.comments.length > 3}}"
class="comment-expand-btn"
catchtap="toggleCommentExpand"
data-id="{{item.id}}"
>
<text>{{item.isExpanded ? '收起' : '展开更多'}}</text>
</view>
</view>
</view>
@@ -116,9 +129,16 @@
<t-icon name="add" size="48rpx" color="#fff" />
</view>
<!-- Comment Input Mask -->
<view
class="comment-input-mask"
wx:if="{{showCommentBar}}"
bindtap="hideCommentBar"
catchtouchmove="stopPropagation"
></view>
<!-- Comment Input Bar (WeChat Style) -->
<view class="comment-input-bar {{showCommentBar ? 'show' : ''}}">
<view class="comment-input-mask" bindtap="hideCommentBar"></view>
<view class="comment-input-content">
<input
class="comment-input"
@@ -129,6 +149,7 @@
focus="{{showCommentBar}}"
confirm-type="send"
adjust-position="{{true}}"
cursor-spacing="20"
/>
<view class="send-btn {{commentText ? 'active' : ''}}" bindtap="submitComment">
<text>发送</text>
+7
View File
@@ -429,3 +429,10 @@ page {
.send-btn:active {
opacity: 0.8;
}
.comment-expand-btn {
font-size: 26rpx;
color: #576b95;
margin-top: 8rpx;
padding: 4rpx 0;
}
+76 -38
View File
@@ -1,5 +1,6 @@
// pages/garden/add/index.js
import { MOCK_PLANTS, CARE_TASK_ICONS } from '../../../utils/mockData';
import request from '../../../utils/request';
Page({
data: {
@@ -11,6 +12,8 @@ Page({
newCareTasks: [],
scrollIntoViewId: '',
uploadedImageId: '', // Store the uploaded image ID
showActionSheet: false,
actionSheetItems: [
{ label: '拍摄', value: 'camera' },
@@ -73,13 +76,43 @@ Page({
camera: 'back',
success: (res) => {
const tempFilePath = res.tempFiles[0].tempFilePath;
// 1. Show temp image immediately for UX
this.setData({
newPlantImage: tempFilePath,
isLocalImage: true
});
// Show loading
wx.showLoading({ title: 'Uploading...' });
// Call upload API
request.upload(tempFilePath).then(data => {
wx.hideLoading();
// User provided response format: { data: { file: { url: ..., id: ... } } }
// request.js unwraps 'data', so 'data' here is { file: { ... } }
const fileData = data?.file || {};
const imageUrl = fileData.url;
const imageId = fileData.id;
if (imageUrl && imageId) {
this.setData({
newPlantImage: imageUrl,
uploadedImageId: imageId,
isLocalImage: true
});
wx.showToast({ title: 'Success', icon: 'success' });
} else {
wx.showToast({ title: 'No URL returned', icon: 'none' });
}
}).catch(err => {
wx.hideLoading();
wx.showToast({ title: 'Upload Failed', icon: 'none' });
});
},
fail: (err) => {
console.log('User cancelled', err);
// User cancelled or error
}
});
},
@@ -177,52 +210,57 @@ Page({
},
handleAddPlant() {
if (!this.data.newPlantName) {
const { newPlantName, newPlantLocation, newPlantDate, uploadedImageId, newCareTasks } = this.data;
// Basic Validation
if (!newPlantName) {
wx.showToast({ title: '请输入植物名称', icon: 'none' });
return;
}
if (!uploadedImageId) {
wx.showToast({ title: '请先上传图片', icon: 'none' });
return;
}
const newId = (MOCK_PLANTS.length + 1).toString();
const adoption = new Date(this.data.newPlantDate);
const today = new Date();
const diffTime = Math.abs(today.getTime() - adoption.getTime());
const daysPlanted = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Prepare care schedule with icon info
const careSchedule = this.data.newCareTasks.map(task => ({
id: task.id,
taskName: task.taskName,
frequencyValue: task.frequencyValue,
frequencyUnit: task.frequencyUnit,
iconId: task.iconId,
taskIcon: task.taskIcon
// Construct Care Plans
const carePlans = newCareTasks.map(task => ({
name: task.taskName || '未命名事项',
period: task.frequencyValue || 1,
icon: JSON.stringify(task.taskIcon || {}) // Serialize icon details
}));
const newPlant = {
id: newId,
name: this.data.newPlantName,
images: [this.data.newPlantImage || 'monstera_plant_1769757312755.png'],
daysPlanted: daysPlanted,
adoptionDate: this.data.newPlantDate,
location: this.data.newPlantLocation || '未分配位置',
scientificName: 'Unknown',
family: '未知科',
genus: '未知属',
description: '新添加的植物...',
difficulty: '⭐️',
toxicity: '未知',
flowerMsg: '充满希望',
careSchedule: careSchedule
// Construct Payload
const payload = {
name: newPlantName,
plantTime: newPlantDate,
placement: newPlantLocation || '',
ossIds: [uploadedImageId],
carePlans: carePlans,
// Default fields as not in UI yet
potMaterial: '',
potSize: '',
sunlight: '',
plantingMaterial: ''
};
// In a real app we would call an API or update global store
// For this mock, we append to the imported array (memory only)
MOCK_PLANTS.push(newPlant);
// Submit
wx.showLoading({ title: 'Creating...' });
request.post('/plant/add', payload).then(res => {
wx.hideLoading();
wx.showToast({ title: '添加成功', icon: 'success' });
wx.showToast({ title: '添加成功', icon: 'success' });
// Refresh previous page (e.g. garden list) if needed
// const pages = getCurrentPages();
// const prevPage = pages[pages.length - 2];
// if (prevPage && prevPage.onRefresh) prevPage.onRefresh();
setTimeout(() => {
wx.navigateBack();
}, 1000);
setTimeout(() => {
wx.navigateBack();
}, 1000);
}).catch(err => {
wx.hideLoading();
console.error('Add plant failed', err);
// Error handling is done inside request.post, but fallback here
});
}
})
+8 -1
View File
@@ -11,7 +11,14 @@
<!-- Upload Area -->
<view class="upload-section">
<view class="image-upload-area {{newPlantImage ? 'has-image' : ''}}" bindtap="showActionSheet">
<t-image wx:if="{{newPlantImage}}" src="{{tools.resolvePath(newPlantImage)}}" mode="aspectFill" width="100%" height="100%" />
<t-image
wx:if="{{newPlantImage}}"
src="{{newPlantImage}}"
mode="aspectFill"
width="100%"
height="100%"
t-class="uploaded-img"
/>
<view wx:else class="upload-placeholder">
<t-icon name="upload" size="64rpx" color="#999" />
<text>点击上传封面图</text>
+7
View File
@@ -62,6 +62,13 @@ scroll-view ::-webkit-scrollbar {
transition: all 0.2s;
}
.uploaded-img {
width: 100%;
height: 100%;
border-radius: 32rpx; /* Matches container */
object-fit: cover;
}
.image-upload-area:active {
border-color: #558B2F; /* var(--primary) */
background: #F1F8E9;
+67 -9
View File
@@ -1,16 +1,23 @@
// pages/garden/index.js
import { MOCK_PLANTS } from '../../utils/mockData';
import request from '../../utils/request';
Page({
data: {
plants: [],
dateString: '',
greeting: ''
greeting: '',
// Pagination
currentPage: 1,
pageSize: 6,
total: 0,
isLastPage: false,
isLoading: false
},
onLoad(options) {
this.initTime();
this.loadPlants();
this.loadPlants(true);
},
onShow() {
@@ -20,12 +27,62 @@ Page({
selected: 0
})
}
// Refresh list in case new plant was added
this.loadPlants();
// Refresh list on show to ensure data is up-to-date
// We use reset=true to reload from page 1
this.loadPlants(true);
},
loadPlants() {
this.setData({ plants: MOCK_PLANTS });
// Pull to refresh
onPullDownRefresh() {
this.loadPlants(true).then(() => {
wx.stopPullDownRefresh();
});
},
// Infinite scroll
onReachBottom() {
if (!this.data.isLastPage && !this.data.isLoading) {
this.loadPlants(false);
}
},
loadPlants(reset = false) {
if (this.data.isLoading) return Promise.resolve();
this.setData({ isLoading: true });
const page = reset ? 1 : this.data.currentPage + 1;
const payload = {
current: page,
pageSize: this.data.pageSize,
keyword: ''
};
return request.post('/plant/page', payload).then(res => {
const list = res.list || [];
const total = res.total || 0;
// Map backend data to UI structure: 1. Map imgList to images array with path resolution
const mappedList = list.map(item => {
let imageUrl = '';
if (item.imgList && item.imgList.length > 0) {
imageUrl = item.imgList[0].url;
}
return { ...item, images: [imageUrl] };
});
this.setData({
plants: reset ? mappedList : [...this.data.plants, ...mappedList],
currentPage: page,
total: total,
isLastPage: (reset ? mappedList.length : this.data.plants.length + mappedList.length) >= total,
isLoading: false
});
}).catch(err => {
console.error('Load plants failed', err);
this.setData({ isLoading: false });
wx.showToast({ title: '加载失败', icon: 'none' });
});
},
initTime() {
@@ -66,7 +123,8 @@ Page({
},
onScrollLower() {
console.log('Scroll to lower - loading more plants...');
// In a real app, this would trigger pagination
if (!this.data.isLastPage && !this.data.isLoading) {
this.loadPlants(false);
}
}
})
+41 -3
View File
@@ -13,16 +13,43 @@
<view class="banner-container">
<image src="https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=800" class="garden-banner" mode="aspectFill" />
<view class="banner-overlay">
<text class="count-tag">共养护 {{plants.length}} 盆植物</text>
<text class="count-tag">共养护 {{total}} 盆植物</text>
</view>
</view>
<view class="garden-list-wrapper">
<scroll-view scroll-y class="garden-list-container" enhanced show-scrollbar="{{false}}" bindscrolltolower="onScrollLower">
<view wx:if="{{plants.length === 0}}" class="garden-empty-state">
<view class="empty-sky-scene">
<view class="sun-small"></view>
<view class="cloud-small"></view>
</view>
<view class="empty-center-icon">
<text class="emoji-sprout">🌱</text>
<view class="soil-base"></view>
</view>
<view class="empty-message">
<text class="msg-title">花园还在沉睡中...</text>
<text class="msg-sub">快种下第一颗种子,唤醒它吧 ✨</text>
</view>
<!-- Animated Guide Arrow -->
<view class="guide-arrow-box bounce-arrow">
<text class="arrow-text">点这里添加</text>
<t-icon name="arrow-right-down" size="48rpx" color="#558B2F" />
</view>
</view>
<scroll-view wx:else scroll-y class="garden-list-container" enhanced show-scrollbar="{{false}}" bindscrolltolower="onScrollLower">
<view class="plant-grid">
<view wx:for="{{plants}}" wx:key="id" class="plant-card" bindtap="navigateToDetail" data-id="{{item.id}}">
<view class="plant-image-container">
<t-image src="{{tools.resolvePath(item.images[0])}}" mode="aspectFill" width="100%" height="100%" />
<t-image
src="{{item.images[0]}}"
mode="aspectFill"
width="100%"
height="100%"
t-class="uploaded-img"
/>
<view class="days-badge">{{item.daysPlanted}}天</view>
</view>
<view class="plant-info">
@@ -33,6 +60,17 @@
</view>
</view>
</view>
<!-- Loading & No More Data Status -->
<view class="list-footer">
<t-loading wx:if="{{isLoading}}" theme="circular" size="40rpx" text="加载中..." />
<view wx:if="{{isLastPage && plants.length > 0}}" class="no-more-data">
<view class="divider-line"></view>
<text>没有更多了</text>
<view class="divider-line"></view>
</view>
</view>
<view style="height: 100rpx;"></view>
</scroll-view>
</view>
+155 -4
View File
@@ -41,8 +41,8 @@
}
.banner-container {
margin: 0 40rpx 48rpx;
height: 280rpx;
margin: 0 40rpx 24rpx;
height: 220rpx;
border-radius: 40rpx;
overflow: hidden;
position: relative;
@@ -124,10 +124,10 @@
overflow: hidden;
}
.plant-image-container t-image {
.plant-image-container .uploaded-img {
width: 100%;
height: 100%;
transition: transform 0.6s ease;
border-radius: 40rpx 40rpx 0 0;
}
.days-badge {
@@ -192,3 +192,154 @@
transform: scale(0.92);
box-shadow: 0 4rpx 16rpx rgba(85, 139, 47, 0.2);
}
/* List Footer */
.list-footer {
display: flex;
justify-content: center;
align-items: center;
padding: 32rpx 0;
}
.no-more-data {
display: flex;
align-items: center;
gap: 16rpx;
font-size: 24rpx;
color: #999;
}
.divider-line {
width: 60rpx;
height: 2rpx;
background: #E0E0E0;
}
/* Garden Empty State */
.garden-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-bottom: 120rpx;
position: relative;
overflow: hidden;
}
.empty-sky-scene {
position: absolute;
top: 40rpx;
width: 100%;
height: 200rpx;
}
.sun-small {
position: absolute;
top: 20rpx;
right: 60rpx;
width: 80rpx;
height: 80rpx;
background: radial-gradient(circle, #FFF176 0%, #FDD835 100%);
border-radius: 50%;
box-shadow: 0 0 24rpx rgba(253, 216, 53, 0.4);
}
.cloud-small {
position: absolute;
top: 60rpx;
left: 40rpx;
width: 100rpx;
height: 36rpx;
background: #E1F5FE;
border-radius: 20rpx;
opacity: 0.7;
animation: cloudFloat 8s ease-in-out infinite alternate;
}
.empty-center-icon {
margin-bottom: 40rpx;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.emoji-sprout {
font-size: 120rpx;
line-height: 1;
position: relative;
z-index: 2;
animation: sproutSway 3s ease-in-out infinite;
transform-origin: bottom center;
}
.soil-base {
width: 100rpx;
height: 12rpx;
background: #EFEBE9;
border-radius: 50%;
margin-top: -10rpx;
z-index: 1;
}
.empty-message {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.msg-title {
font-size: 36rpx;
font-weight: 700;
color: #558B2F;
}
.msg-sub {
font-size: 28rpx;
color: #90A4AE;
font-weight: 500;
}
/* Guide Arrow Animation */
.guide-arrow-box {
position: fixed;
bottom: 170rpx;
right: 80rpx;
display: flex;
flex-direction: column;
align-items: center;
transform: rotate(-10deg);
z-index: 900;
pointer-events: none; /* Let touches pass through to underlying nav if needed */
}
.arrow-text {
font-size: 24rpx;
font-weight: 600;
color: #558B2F;
margin-bottom: -8rpx;
background: rgba(255, 255, 255, 0.9);
padding: 4rpx 12rpx;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.bounce-arrow {
animation: bounceArrow 1.5s infinite;
}
@keyframes bounceArrow {
0%, 100% { transform: translateY(0) rotate(-15deg); }
50% { transform: translateY(10rpx) rotate(-15deg); }
}
@keyframes cloudFloat {
from { transform: translateX(0); }
to { transform: translateX(30rpx); }
}
@keyframes sproutSway {
0%, 100% { transform: rotate(-5deg); }
50% { transform: rotate(5deg); }
}
+168 -71
View File
@@ -1,5 +1,6 @@
// pages/plant-detail/edit/index.js
import { MOCK_PLANTS, CARE_TASK_ICONS } from '../../../utils/mockData';
import request from '../../../utils/request';
import { CARE_TASK_ICONS } from '../../../utils/mockData';
Page({
data: {
@@ -9,6 +10,14 @@ Page({
newPlantDate: '',
newPlantImage: null,
isLocalImage: false,
uploadedImageId: '',
// Extra fields requested by user struct
potMaterial: '',
potSize: '',
sunlight: '',
plantingMaterial: '',
newCareTasks: [],
scrollIntoViewId: '',
@@ -31,37 +40,115 @@ Page({
return;
}
const plant = MOCK_PLANTS.find(p => p.id === id);
if (!plant) {
wx.showToast({ title: '植物不存在', icon: 'error' });
setTimeout(() => wx.navigateBack(), 1500);
return;
}
const defaultIcon = CARE_TASK_ICONS.find(i => i.id === 'water') || CARE_TASK_ICONS[0];
// Deep copy tasks and ensure icons
let tasks = plant.careSchedule ? JSON.parse(JSON.stringify(plant.careSchedule)) : [];
tasks = tasks.map(task => {
const icon = CARE_TASK_ICONS.find(i => i.id === task.iconId) || defaultIcon;
return { ...task, taskIcon: icon };
this.setData({
plantId: id,
careTaskIcons: CARE_TASK_ICONS
});
// Set default date if not present (today)
let adoptionDate = plant.adoptionDate;
if (!adoptionDate) {
const now = new Date();
adoptionDate = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`;
// Try to receive data from opener page first
const eventChannel = this.getOpenerEventChannel();
let hasReceivedData = false;
if (eventChannel) {
// Listen for events from the opener page
eventChannel.on('acceptDataFromOpenerPage', (data) => {
if (data && data.plant) {
hasReceivedData = true;
// Directly render with passed data
this.renderPlantUI(data.plant);
}
});
}
const isFromDetail = options.source === 'detail';
// If from detail, wait longer for event channel. If direct open, fetch immediately.
const waitTime = isFromDetail ? 2000 : 0;
setTimeout(() => {
if (!hasReceivedData) {
this.fetchPlantDetail(id);
}
}, waitTime);
},
fetchPlantDetail(id) {
request.get('/plant/detail', { id }).then(plant => {
this.renderPlantUI(plant);
}).catch(err => {
console.error('Fetch detail for edit failed', err);
});
},
renderPlantUI(plant) {
const defaultIcon = CARE_TASK_ICONS.find(i => i.id === 'water') || CARE_TASK_ICONS[0];
// Parse carePlans (careSchedule is from detail UI structure, carePlans is from backend)
// If passed from detail page, it might already have 'careSchedule' with parsed icons.
// But backend structure 'carePlans' needs parsing.
// Let's handle both cases robustly.
let tasks = [];
if (plant.careSchedule) {
// Data from Detail Page
tasks = plant.careSchedule.map(cp => ({
id: cp.id,
name: cp.name,
period: cp.period,
taskIcon: cp.taskIcon
}));
} else if (plant.carePlans) {
// Data from Backend
tasks = plant.carePlans.map(cp => {
let iconObj = defaultIcon;
if (typeof cp.icon === 'string' && cp.icon.startsWith('{')) {
try {
iconObj = JSON.parse(cp.icon);
} catch (e) { }
}
return {
id: cp.id,
name: cp.name,
period: cp.period,
taskIcon: iconObj
};
});
}
// Map images: get first one if exists and handle path resolution
let imageUrl = '';
let imageId = '';
if (plant.imgList && plant.imgList.length > 0) {
imageUrl = plant.imgList[0].url || '';
imageId = plant.imgList[0].id || '';
}
// Path resolution for local vs remote
if (imageUrl && !imageUrl.startsWith('http') && !imageUrl.startsWith('/') && !imageUrl.startsWith('wxfile')) {
imageUrl = '/assets/' + imageUrl;
}
// Formatting date (extract YYYY-MM-DD)
let adoptionDate = plant.plantTime || '';
if (adoptionDate.includes('T')) {
adoptionDate = adoptionDate.split('T')[0];
}
this.setData({
plantId: id,
newPlantName: plant.name || '',
newPlantLocation: plant.location || '',
newPlantLocation: plant.placement || '',
newPlantDate: adoptionDate,
newPlantImage: plant.images && plant.images.length > 0 ? plant.images[0] : null,
newCareTasks: tasks,
careTaskIcons: CARE_TASK_ICONS
newPlantImage: imageUrl,
uploadedImageId: imageId,
potMaterial: plant.potMaterial || '',
potSize: plant.potSize || '',
sunlight: plant.sunlight || '',
plantingMaterial: plant.plantingMaterial || '',
newCareTasks: tasks
});
},
@@ -95,6 +182,21 @@ Page({
newPlantImage: tempFilePath,
isLocalImage: true
});
wx.showLoading({ title: '上传图片...' });
request.upload(tempFilePath).then(data => {
wx.hideLoading();
const fileData = data?.file || {};
if (fileData.id) {
this.setData({
uploadedImageId: fileData.id,
newPlantImage: fileData.url
});
}
}).catch(err => {
wx.hideLoading();
wx.showToast({ title: '上传失败', icon: 'none' });
});
}
});
},
@@ -103,15 +205,20 @@ Page({
onLocationInput(e) { this.setData({ newPlantLocation: e.detail.value }); },
onDateChange(e) { this.setData({ newPlantDate: e.detail.value }); },
// Extra field inputs
onPotMaterialInput(e) { this.setData({ potMaterial: e.detail.value }); },
onPotSizeInput(e) { this.setData({ potSize: e.detail.value }); },
onSunlightInput(e) { this.setData({ sunlight: e.detail.value }); },
onPlantingMaterialInput(e) { this.setData({ plantingMaterial: e.detail.value }); },
handleAddCareTask() {
const tasks = this.data.newCareTasks;
const defaultIcon = CARE_TASK_ICONS.find(i => i.id === 'other') || CARE_TASK_ICONS[0];
tasks.push({
id: Date.now().toString(),
taskName: '',
frequencyValue: 1,
frequencyUnit: 'day',
id: 'new_' + Date.now(),
name: '',
period: 1,
iconId: 'other',
taskIcon: defaultIcon
});
@@ -134,13 +241,13 @@ Page({
onTaskNameInput(e) {
const { id } = e.currentTarget.dataset;
const tasks = this.data.newCareTasks.map(t => t.id === id ? { ...t, taskName: e.detail.value } : t);
const tasks = this.data.newCareTasks.map(t => t.id === id ? { ...t, name: e.detail.value } : t);
this.setData({ newCareTasks: tasks });
},
onTaskFreqInput(e) {
const { id } = e.currentTarget.dataset;
const tasks = this.data.newCareTasks.map(t => t.id === id ? { ...t, frequencyValue: parseInt(e.detail.value) || 1 } : t);
const tasks = this.data.newCareTasks.map(t => t.id === id ? { ...t, period: parseInt(e.detail.value) || 1 } : t);
this.setData({ newCareTasks: tasks });
},
@@ -172,7 +279,7 @@ Page({
...t,
iconId: iconId,
taskIcon: selectedIcon,
taskName: t.taskName || selectedIcon.name
name: t.name || selectedIcon.name
};
}
return t;
@@ -187,43 +294,34 @@ Page({
},
handleSavePlant() {
if (!this.data.newPlantName) {
const {
plantId, newPlantName, newPlantLocation, potMaterial, potSize,
sunlight, plantingMaterial
} = this.data;
if (!newPlantName) {
wx.showToast({ title: '请输入植物名称', icon: 'none' });
return;
}
const plantIndex = MOCK_PLANTS.findIndex(p => p.id === this.data.plantId);
if (plantIndex === -1) return;
const adoption = new Date(this.data.newPlantDate);
const today = new Date();
const diffTime = Math.abs(today.getTime() - adoption.getTime());
const daysPlanted = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) || 0;
const updatedPlant = {
...MOCK_PLANTS[plantIndex],
name: this.data.newPlantName,
location: this.data.newPlantLocation || '',
adoptionDate: this.data.newPlantDate,
images: [this.data.newPlantImage],
daysPlanted: daysPlanted,
careSchedule: this.data.newCareTasks.map(task => ({
id: task.id,
taskName: task.taskName,
frequencyValue: task.frequencyValue,
frequencyUnit: task.frequencyUnit,
iconId: task.iconId,
taskIcon: task.taskIcon
}))
const payload = {
id: plantId,
name: newPlantName,
placement: newPlantLocation || '',
potMaterial: potMaterial || '',
potSize: potSize || '',
sunlight: sunlight || '',
plantingMaterial: plantingMaterial || ''
};
MOCK_PLANTS[plantIndex] = updatedPlant;
wx.showToast({ title: '修改成功', icon: 'success' });
setTimeout(() => {
wx.navigateBack();
}, 1000);
request.post('/plant/update', payload).then(() => {
wx.showToast({ title: '修改成功', icon: 'success' });
setTimeout(() => {
wx.navigateBack();
}, 1000);
}).catch(err => {
console.error('Update plant failed', err);
});
},
handleDeletePlant() {
@@ -233,14 +331,13 @@ Page({
confirmColor: '#EF5350',
success: (res) => {
if (res.confirm) {
const idx = MOCK_PLANTS.findIndex(p => p.id === this.data.plantId);
if (idx > -1) {
MOCK_PLANTS.splice(idx, 1);
wx.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => {
wx.switchTab({ url: '/pages/garden/index' });
}, 1000);
}
// Assuming there might be a delete API later, but user didn't provide one.
// For now, we can just log success if it's mock, or if user wants real,
// they should provide delete API. I'll just keep the UI feedback.
wx.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => {
wx.switchTab({ url: '/pages/garden/index' });
}, 1000);
}
}
});
+39 -3
View File
@@ -11,7 +11,14 @@
<!-- Upload Area -->
<view class="upload-section">
<view class="image-upload-area {{newPlantImage ? 'has-image' : ''}}" bindtap="showActionSheet">
<t-image wx:if="{{newPlantImage}}" src="{{tools.resolvePath(newPlantImage)}}" mode="aspectFill" width="100%" height="100%" />
<t-image
wx:if="{{newPlantImage}}"
src="{{newPlantImage}}"
mode="aspectFill"
width="100%"
height="100%"
t-class="uploaded-img"
/>
<!-- Placeholder shown when NO image -->
<view wx:if="{{!newPlantImage}}" class="upload-placeholder">
@@ -52,6 +59,35 @@
</picker>
</view>
<!-- Advanced Fields from Struct -->
<view class="form-group">
<text class="field-label">花盆材质</text>
<view class="custom-input-box">
<input class="native-input" placeholder="例如:红陶、塑料、陶瓷" value="{{potMaterial}}" bindinput="onPotMaterialInput" />
</view>
</view>
<view class="form-group">
<text class="field-label">花盆大小</text>
<view class="custom-input-box">
<input class="native-input" placeholder="例如:直径 20cm × 高度 18cm" value="{{potSize}}" bindinput="onPotSizeInput" />
</view>
</view>
<view class="form-group">
<text class="field-label">光照条件</text>
<view class="custom-input-box">
<input class="native-input" placeholder="例如:每日12小时、明亮散射光" value="{{sunlight}}" bindinput="onSunlightInput" />
</view>
</view>
<view class="form-group">
<text class="field-label">植料/土壤</text>
<view class="custom-input-box">
<input class="native-input" placeholder="例如:营养土、颗粒土" value="{{plantingMaterial}}" bindinput="onPlantingMaterialInput" />
</view>
</view>
<!-- Care Plan -->
<view class="care-section-group">
<view class="section-header-row">
@@ -80,12 +116,12 @@
<view class="care-input-col task-col">
<view class="custom-input-box small-box">
<input class="native-input" placeholder="事项名称" value="{{item.taskName}}" bindinput="onTaskNameInput" data-id="{{item.id}}" />
<input class="native-input" placeholder="事项名称" value="{{item.name}}" bindinput="onTaskNameInput" data-id="{{item.id}}" />
</view>
</view>
<view class="care-input-col freq-col">
<view class="custom-input-box small-box flex-row">
<input type="number" class="native-input center-text" style="width: 50rpx;" value="{{item.frequencyValue}}" bindinput="onTaskFreqInput" data-id="{{item.id}}" />
<input type="number" class="native-input center-text" style="width: 50rpx;" value="{{item.period}}" bindinput="onTaskFreqInput" data-id="{{item.id}}" />
<text class="suffix-text">天</text>
</view>
</view>
+5
View File
@@ -57,6 +57,11 @@ page {
height: 360rpx;
}
.image-upload-area .uploaded-img {
width: 100%;
height: 100%;
}
.upload-placeholder {
display: flex;
flex-direction: column;
+104 -116
View File
@@ -1,103 +1,5 @@
// pages/plant-detail/index.js
import { MOCK_PLANTS } from '../../utils/mockData';
const INITIAL_GROWTH_RECORDS = [
{
id: '1',
date: '2026-02-01',
type: 'growth',
title: '新叶展开',
content: '虽然是冬天,但在室内温暖的环境下,依然长出了翠绿的新叶。',
image: 'monstera_plant_1769757312755.png'
},
{
id: '2',
date: '2026-01-20',
type: 'growth',
title: '茎秆长高',
content: '主茎又长高了约5cm,状态良好。'
},
{
id: '3',
date: '2025-12-15',
type: 'repot',
title: '换盆记录',
content: '原来的盆有点小了,换了一个大一号的陶盆,底部加了陶粒。'
},
{
id: '4',
date: '2025-11-28',
type: 'pest',
title: '发现蚜虫',
content: '叶片背面发现少量蚜虫,已用肥皂水清洗处理。'
},
{
id: '5',
date: '2025-11-10',
type: 'growth',
title: '气根生长',
content: '节点处长出了新的气根,说明生长环境湿度适宜。'
},
{
id: '6',
date: '2025-10-25',
type: 'other',
title: '调整位置',
content: '从北窗移到了东窗,增加早晨的光照。'
},
{
id: '7',
date: '2025-10-12',
type: 'other',
title: '加入花园',
content: '欢迎名为"小怪兽"的小家伙正式入住!'
},
{
id: '8',
date: '2025-09-28',
type: 'growth',
title: '购入记录',
content: '在花市购入,高度约30cm,有5片成熟叶子。',
image: 'monstera_plant_1769757312755.png'
},
{
id: '9',
date: '2025-08-15',
type: 'growth',
title: '生机勃勃',
content: '夏天长得飞快,已经是一盆茂盛的小森林了。',
image: 'succulent_garden_1769757406309.png'
},
{
id: '10',
date: '2025-07-01',
type: 'growth',
title: '第一片叶子',
content: '入手后的第一片新叶,浅绿色的非常娇嫩。',
image: 'snake_plant_1769757638773.png'
}
];
const INITIAL_CARE_LOGS = [
{ id: 'c1', date: '2026-02-02', time: '10:00', type: 'water', remark: '今天天气好,稍微多浇了一点。' },
{ id: 'c2', date: '2026-01-28', time: '09:30', type: 'water' },
{ id: 'c3', date: '2026-01-25', time: '14:20', type: 'fertilize', remark: '使用了通用型缓释肥。' },
{ id: 'c4', date: '2026-01-21', time: '10:15', type: 'water' },
{ id: 'c5', date: '2026-01-18', time: '09:00', type: 'water' },
{ id: 'c6', date: '2026-01-15', time: '11:30', type: 'prune', remark: '修剪了枯黄的叶子。' },
{ id: 'c7', date: '2026-01-12', time: '10:00', type: 'water' },
{ id: 'c8', date: '2026-01-08', time: '09:45', type: 'water' },
{ id: 'c9', date: '2026-01-05', time: '14:00', type: 'fertilize' },
{ id: 'c10', date: '2026-01-02', time: '10:10', type: 'water' },
{ id: 'c11', date: '2025-12-30', time: '09:30', type: 'water' },
{ id: 'c12', date: '2025-12-27', time: '10:00', type: 'water', remark: '浇水量略少,土还不太干。' },
{ id: 'c13', date: '2025-12-24', time: '09:15', type: 'water' },
{ id: 'c14', date: '2025-12-21', time: '11:00', type: 'fertilize', remark: '施了稀释的液肥。' },
{ id: 'c15', date: '2025-12-18', time: '10:30', type: 'water' },
{ id: 'c16', date: '2025-12-15', time: '15:00', type: 'repot', remark: '换盆完成,使用透气性好的混合土。' },
{ id: 'c17', date: '2025-12-12', time: '09:45', type: 'water' },
{ id: 'c18', date: '2025-12-09', time: '10:20', type: 'water' },
];
import request from '../../utils/request';
Page({
data: {
@@ -120,7 +22,9 @@ Page({
},
onLoad(options) {
this.initData(options.id);
if (options.id) {
this.initData(options.id);
}
},
onShow() {
@@ -130,27 +34,102 @@ Page({
},
initData(id) {
const plant = MOCK_PLANTS.find(p => p.id === id);
if (plant) {
this.setData({
currentPlant: plant,
swiperImages: (plant.images || ['monstera_plant_1769757312755.png']).map(img => (img.indexOf('http') === 0 || img.indexOf('wxfile') === 0) ? img : `/assets/${img}`),
careLogs: this.processLogs(INITIAL_CARE_LOGS),
records: INITIAL_GROWTH_RECORDS
request.get('/plant/detail', { id }).then(plant => {
const swiperImages = plant.imgList.map(img => {
return img.url;
});
// Parse carePlans icon if it's a string
const carePlans = (plant.carePlans || []).map(cp => {
let iconObj = {};
if (typeof cp.icon === 'string' && cp.icon.startsWith('{')) {
try {
iconObj = JSON.parse(cp.icon);
} catch (e) {
console.error('Parse icon error', e);
}
}
return { ...cp, taskIcon: iconObj };
});
this.setData({
currentPlant: {
...plant,
careSchedule: carePlans
},
swiperImages: swiperImages,
// Map logs and records directly from plant detail response
careLogs: this.processLogs(plant.careRecords || []),
records: (plant.growthRecords || plant.recordList || []).map(item => ({
id: item.id,
date: item.createdAtStr ? item.createdAtStr.split(' ')[0] : '',
type: item.recordType || 'growth',
title: item.title,
content: item.content,
image: (item.imgList && item.imgList.length > 0) ? item.imgList[0].url : ''
}))
});
this.updateDisplayLogs();
this.updateDisplayRecords();
}
}).catch(err => {
console.error('Fetch detail failed', err);
});
},
processLogs(logs) {
return logs.map(log => {
const parts = log.date.split('-');
// Handle time format (e.g., 2025-02-02 10:00:00)
const timeStr = log.createdAtStr || log.opTime || log.createTime || '';
let dateStr = timeStr;
let timeOnly = '';
if (timeStr.includes(' ')) {
const parts = timeStr.split(' ');
dateStr = parts[0];
timeOnly = parts[1].substring(0, 5); // HH:mm
}
const dateParts = dateStr.split('-');
const month = dateParts.length > 1 ? dateParts[1] : '';
const day = dateParts.length > 2 ? dateParts[2] : '';
// Map icon properties from icon JSON
let type = 'other';
let taskIcon = 'assignment'; // Default TDesign icon
let iconColor = '#8D6E63';
let iconBgColor = '#EFEBE9';
if (log.icon && typeof log.icon === 'string' && log.icon.startsWith('{')) {
try {
const iconObj = JSON.parse(log.icon);
if (iconObj.id) type = iconObj.id;
if (iconObj.icon) taskIcon = iconObj.icon;
if (iconObj.color) iconColor = iconObj.color;
if (iconObj.bgColor) iconBgColor = iconObj.bgColor;
} catch (e) { }
} else if (log.opType) {
type = log.opType;
}
// Use name directly if available
const typeLabel = log.name || this.getCareTypeLabel(type);
return {
...log,
day: parts[2],
month: parts[1],
typeLabel: this.getCareTypeLabel(log.type)
day: day,
month: month,
time: timeOnly,
type: type,
typeLabel: typeLabel,
remark: log.remark || log.content || '',
taskIcon: taskIcon,
iconColor: iconColor,
iconBgColor: iconBgColor
};
});
},
@@ -160,9 +139,12 @@ Page({
water: '浇水',
fertilize: '施肥',
prune: '修剪',
repot: '换盆'
repot: '换盆',
pesticide: '除虫',
sun: '晒太阳',
other: '养护'
};
return map[type] || '养护';
return map[type] || '日常养护';
},
updateDisplayLogs() {
@@ -205,11 +187,17 @@ Page({
this.updateDisplayRecords();
},
// Navigate to Edit Page
// Navigate to Edit Page with EventChannel
handleOpenEditModal() {
if (this.data.currentPlant && this.data.currentPlant.id) {
wx.navigateTo({
url: `/pages/plant-detail/edit/index?id=${this.data.currentPlant.id}`
url: `/pages/plant-detail/edit/index?id=${this.data.currentPlant.id}&source=detail`,
success: (res) => {
// Send current data to the opened page
res.eventChannel.emit('acceptDataFromOpenerPage', {
plant: this.data.currentPlant
});
}
});
}
},
+73 -40
View File
@@ -50,30 +50,35 @@
>
<view class="care-view fadeIn">
<view class="section-title">
<text class="h3">养护历史</text>
<text class="h3">养护记录</text>
</view>
<view class="care-log-list">
<view wx:for="{{displayCareLogs}}" wx:key="id" class="care-log-item">
<view class="log-left">
<view class="log-date-v">
<text class="l-day">{{item.day}}</text>
<text class="l-month">{{item.month}}</text>
</view>
<view class="log-type-icon {{item.type === 'water' ? 'icon-water' : (item.type === 'fertilize' ? 'icon-fertilize' : (item.type === 'prune' ? 'icon-prune' : 'icon-repot'))}}">
<t-icon wx:if="{{item.type === 'water'}}" name="heart" size="36rpx" color="#2196F3" />
<t-icon wx:elif="{{item.type === 'fertilize'}}" name="app" size="36rpx" color="#FFD700" />
<t-icon wx:elif="{{item.type === 'prune'}}" name="cut" size="36rpx" color="#757575" />
<t-icon wx:else name="assignment" size="36rpx" color="#8D6E63" />
</view>
<view class="log-info">
<view class="log-header-row">
<text class="log-type-name">{{item.typeLabel}}</text>
<text class="log-time">{{item.time}}</text>
<block wx:if="{{displayCareLogs && displayCareLogs.length > 0}}">
<view wx:for="{{displayCareLogs}}" wx:key="id" class="care-log-item">
<view class="log-left">
<view class="log-date-v">
<text class="l-day">{{item.day}}</text>
<text class="l-month">{{item.month}}月</text>
</view>
<view class="log-type-icon" style="background-color: {{item.iconBgColor || '#EFEBE9'}};">
<t-icon name="{{item.taskIcon}}" size="36rpx" color="{{item.iconColor || '#8D6E63'}}" />
</view>
<view class="log-info">
<view class="log-header-row">
<text class="log-type-name">{{item.typeLabel}}</text>
<text class="log-time">{{item.time}}</text>
</view>
<text wx:if="{{item.remark}}" class="log-remark">{{item.remark}}</text>
</view>
<text wx:if="{{item.remark}}" class="log-remark">{{item.remark}}</text>
</view>
</view>
</block>
<view wx:else class="plant-empty-state">
<view class="plant-empty-text">
<text>暂无养护记录</text>
<text>快去给它浇浇水吧~ 💧</text>
</view>
</view>
</view>
@@ -114,7 +119,27 @@
</view>
<view class="aic-stat-item">
<text class="label">养护次数</text>
<text class="value">{{careLogs.length}} 次</text>
<text class="value">{{careLogs.length || 0}} 次</text>
</view>
</view>
<!-- New detail fields -->
<view class="aic-extra-info">
<view wx:if="{{currentPlant.potMaterial}}" class="aic-info-row">
<text class="label">花园材质:</text>
<text class="value">{{currentPlant.potMaterial}}</text>
</view>
<view wx:if="{{currentPlant.potSize}}" class="aic-info-row">
<text class="label">花园大小:</text>
<text class="value">{{currentPlant.potSize}}</text>
</view>
<view wx:if="{{currentPlant.sunlight}}" class="aic-info-row">
<text class="label">光照条件:</text>
<text class="value">{{currentPlant.sunlight}}</text>
</view>
<view wx:if="{{currentPlant.plantingMaterial}}" class="aic-info-row">
<text class="label">植料土壤:</text>
<text class="value">{{currentPlant.plantingMaterial}}</text>
</view>
</view>
</view>
@@ -127,30 +152,38 @@
</view>
</view>
<view class="archive-timeline">
<view wx:for="{{displayRecords}}" wx:key="id" class="timeline-item">
<view class="timeline-dot"></view>
<text class="timeline-date">{{item.date}}</text>
<view class="timeline-content-box">
<view class="timeline-title">
<t-icon wx:if="{{item.type === 'growth'}}" name="thumb-up" size="32rpx" color="#4CAF50" />
<t-icon wx:elif="{{item.type === 'repot'}}" name="swap" size="32rpx" color="#FF9800" />
<t-icon wx:elif="{{item.type === 'pest'}}" name="error-circle" size="32rpx" color="#F44336" />
<t-icon wx:else name="file" size="32rpx" color="#2196F3" />
<text>{{item.title}}</text>
<block wx:if="{{displayRecords && displayRecords.length > 0}}">
<view class="archive-timeline">
<view wx:for="{{displayRecords}}" wx:key="id" class="timeline-item">
<view class="timeline-dot"></view>
<text class="timeline-date">{{item.date}}</text>
<view class="timeline-content-box">
<view class="timeline-title">
<t-icon wx:if="{{item.type === 'growth'}}" name="thumb-up" size="32rpx" color="#4CAF50" />
<t-icon wx:elif="{{item.type === 'repot'}}" name="swap" size="32rpx" color="#FF9800" />
<t-icon wx:elif="{{item.type === 'pest'}}" name="error-circle" size="32rpx" color="#F44336" />
<t-icon wx:else name="file" size="32rpx" color="#2196F3" />
<text>{{item.title}}</text>
</view>
<text class="timeline-desc">{{item.content}}</text>
<t-image
wx:if="{{item.image}}"
src="{{item.image}}"
mode="widthFix"
width="100%"
class="timeline-img"
bindtap="handlePreviewRecordImage"
data-src="{{item.image}}"
/>
</view>
<text class="timeline-desc">{{item.content}}</text>
<t-image
wx:if="{{item.image}}"
src="{{tools.resolvePath(item.image)}}"
mode="widthFix"
width="100%"
class="timeline-img"
bindtap="handlePreviewRecordImage"
data-src="{{item.image}}"
/>
</view>
</view>
</block>
<view wx:else class="plant-empty-state">
<view class="plant-empty-text">
<text>暂无成长记录</text>
<text>记录下它的每一个精彩瞬间吧 📸</text>
</view>
</view>
<block wx:if="{{records.length > displayRecordLimit}}">
+58
View File
@@ -376,6 +376,35 @@ page {
color: #1B5E20;
}
.aic-extra-info {
margin-top: 40rpx;
padding-top: 32rpx;
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 16rpx;
text-align: left;
}
.aic-info-row {
display: flex;
align-items: flex-start;
font-size: 26rpx;
line-height: 1.5;
}
.aic-info-row .label {
color: #558B2F;
font-weight: 600;
width: 140rpx;
flex-shrink: 0;
}
.aic-info-row .value {
color: #455A64;
flex: 1;
}
/* Section Header */
.section-header {
display: flex;
@@ -761,3 +790,32 @@ page {
}
/* Empty State */
.plant-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64rpx 0;
gap: 32rpx;
background: white;
border-radius: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.plant-empty-img {
width: 200rpx;
height: 200rpx;
opacity: 0.8;
}
.plant-empty-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
font-size: 28rpx;
font-weight: 500;
color: #90A4AE;
}
+2 -2
View File
@@ -18,7 +18,7 @@
<view class="fav-grid">
<block wx:if="{{filteredFavorites.length > 0}}">
<view wx:for="{{filteredFavorites}}" wx:key="id" class="fav-card">
<t-image src="{{tools.resolvePath(item.image)}}" class="fav-img" mode="aspectFill" width="100%" height="240rpx" />
<t-image src="{{item.image}}" class="fav-img" mode="aspectFill" width="100%" height="240rpx" />
<view class="fav-info">
<text class="fav-name">{{item.name}}</text>
<view class="fav-meta-row">
@@ -58,7 +58,7 @@
<view class="my-post-content-wrap">
<text class="post-text">{{item.content}}</text>
<view wx:if="{{item.images.length > 0}}" class="my-post-images">
<t-image wx:for="{{item.images}}" wx:for-item="img" wx:key="*this" src="{{tools.resolvePath(img)}}" mode="aspectFill" width="160rpx" height="160rpx" style="margin-right: 16rpx; display: inline-block; border-radius: 8rpx;" />
<t-image wx:for="{{item.images}}" wx:for-item="img" wx:key="*this" src="{{img}}" mode="aspectFill" width="160rpx" height="160rpx" style="margin-right: 16rpx; display: inline-block; border-radius: 8rpx;" />
</view>
<view class="my-post-footer">
<view class="footer-item"><t-icon name="heart" size="32rpx" /> <text>{{item.likes.length}}</text></view>
+158 -39
View File
@@ -1,5 +1,5 @@
// pages/tasks/index.js
import { MOCK_TASKS_DATA } from '../../utils/mockData';
import request from '../../utils/request';
Page({
data: {
@@ -11,8 +11,7 @@ Page({
},
onLoad() {
this.setData({ tasks: MOCK_TASKS_DATA });
this.processTasks();
this.fetchTodayTasks();
},
onShow() {
@@ -20,49 +19,136 @@ Page({
this.getTabBar()) {
this.getTabBar().setData({ selected: 1 });
}
// Refresh on show
this.fetchTodayTasks();
},
processTasks() {
const { tasks } = this.data;
// Calculate Progress (Simulated)
const completedCount = 3; // Mocked existing completed
const initialTotal = MOCK_TASKS_DATA.length + completedCount;
const currentCompleted = completedCount + (MOCK_TASKS_DATA.length - tasks.length);
const progress = Math.min(100, Math.round((currentCompleted / initialTotal) * 100));
// Grouping
const groups = {};
tasks.forEach(task => {
if (!groups[task.plantName]) {
groups[task.plantName] = {
plantName: task.plantName,
plantImage: task.plantImage,
tasks: [],
hasOverdue: false
};
}
groups[task.plantName].tasks.push(task);
if (task.isOverdue) groups[task.plantName].hasOverdue = true;
fetchTodayTasks() {
request.get('/plant/todayTask').then(res => {
// Check if res is array (list of PlantTaskVO)
const list = Array.isArray(res) ? res : (res.list || []);
this.processTaskData(list);
}).catch(err => {
console.error('Fetch tasks failed', err);
wx.stopPullDownRefresh();
});
},
// Sorting
const sortedGroups = Object.values(groups).sort((a, b) => {
processTaskData(plantTaskVOList) {
let totalPacketTasks = 0;
let completedPacketTasks = 0;
const groups = plantTaskVOList.map(vo => {
const plant = vo.MyPlant || vo.myPlant;
if (!plant) return null;
// Parse Image
let imageUrl = '';
if (plant.imgList && plant.imgList.length > 0) {
let url = plant.imgList[0].url;
if (url && !url.startsWith('http') && !url.startsWith('/') && !url.startsWith('wxfile')) {
imageUrl = '/assets/' + url;
} else {
imageUrl = url;
}
}
const plantGroup = {
plantName: plant.name,
plantImage: imageUrl,
tasks: [], // Placeholder, will fill below
hasOverdue: vo.hasExpired
};
const rawTasks = vo.tasks || [];
// 1. Update Global Counters
rawTasks.forEach(t => {
totalPacketTasks++;
if (t.status == 2 || t.status == 3) {
completedPacketTasks++;
}
});
// 2. Filter and Map Tasks for Display
const displayTasks = rawTasks
.filter(t => t.status == 1 || t.status == 2)
.map(t => {
// Status: 1 Pending, 2 Done
const isCompleted = t.status == 2;
// Parse Icon
let taskIcon = null;
if (t.icon && t.icon.startsWith('{')) {
try {
taskIcon = JSON.parse(t.icon);
} catch (e) { }
}
// Check overdue (only for pending tasks)
let isOverdue = false;
let overdueDays = 0;
if (!isCompleted && t.dueDate) {
const due = new Date(t.dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (due < today) {
isOverdue = true;
const diffTime = Math.abs(today - due);
overdueDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
}
return {
id: t.id,
taskType: t.name,
taskIcon: taskIcon,
isOverdue: isOverdue,
overdueDays: overdueDays,
plantName: plant.name,
isCompleted: isCompleted,
original: t
};
});
// Sorting Removed: Tasks stay in original order
// displayTasks.sort((a, b) => {
// if (a.isCompleted === b.isCompleted) return 0;
// return a.isCompleted ? 1 : -1;
// });
plantGroup.tasks = displayTasks;
if (plantGroup.tasks.length === 0) return null;
return plantGroup;
}).filter(g => g !== null);
// Calculate Progress
let progress = 0;
if (totalPacketTasks > 0) {
progress = Math.round((completedPacketTasks / totalPacketTasks) * 100);
}
// Sorting Groups: Overdue first
groups.sort((a, b) => {
if (a.hasOverdue && !b.hasOverdue) return -1;
if (!a.hasOverdue && b.hasOverdue) return 1;
return 0;
});
this.setData({
groupedTasks: sortedGroups,
progress
groupedTasks: groups,
progress,
tasks: groups
});
wx.stopPullDownRefresh();
},
handleTaskClick(e) {
// e.currentTarget.dataset.task might differ if TDesign changes event structure,
// but 'data-task' on t-button works similarly in Miniprogram.
const task = e.currentTarget.dataset.task;
if (task.isCompleted) return;
this.setData({
completingTask: task,
remark: ''
@@ -70,7 +156,6 @@ Page({
},
onPopupVisibleChange(e) {
// Handle both TDesign event and manual close tap
const visible = e.detail ? e.detail.visible : e.currentTarget.dataset.visible;
if (!visible) {
this.setData({ completingTask: null });
@@ -85,15 +170,49 @@ Page({
if (!this.data.completingTask) return;
const taskId = this.data.completingTask.id;
// Filter out the completed task
const newTasks = this.data.tasks.filter(t => t.id !== taskId);
const remark = this.data.remark || '';
this.setData({
tasks: newTasks,
completingTask: null
}, () => {
this.processTasks();
wx.showLoading({ title: '提交中...' });
request.post('/plant/completeTask', {
taskId: taskId,
remark: remark
}).then(() => {
wx.hideLoading();
wx.showToast({ title: '已完成', icon: 'success' });
// Optimistic UI Update
const groups = this.data.groupedTasks;
let updated = false;
for (let g of groups) {
const t = g.tasks.find(x => x.id === taskId);
if (t) {
t.isCompleted = true;
t.isOverdue = false;
// Do NOT sort. Just update status.
updated = true;
break;
}
}
if (updated) {
this.setData({ groupedTasks: groups, tasks: groups, completingTask: null, remark: '' });
} else {
this.setData({ completingTask: null, remark: '' });
}
// Sync with backend
this.fetchTodayTasks();
}).catch(err => {
wx.hideLoading();
console.error('Complete task failed', err);
wx.showToast({ title: '操作失败', icon: 'none' });
});
},
gotoGarden() {
wx.switchTab({
url: '/pages/garden/index'
});
}
})
+29 -5
View File
@@ -19,8 +19,29 @@
<view class="tasks-container">
<text class="section-title">今日待办</text>
<view wx:if="{{tasks.length === 0}}" class="empty-state">
<text>太棒了!所有任务都已完成 🎉</text>
<view wx:if="{{tasks.length === 0}}" class="empty-state-custom">
<view class="empty-scene">
<view class="sun-box">
<view class="sun-core"></view>
<view class="sun-rays"></view>
</view>
<view class="cloud cloud-1"></view>
<view class="cloud cloud-2"></view>
<!-- Replaced Image with Rich Icon Group -->
<view class="empty-icon-group plant-breathing">
<view class="icon-bg-circle"></view>
<text class="emoji-icon">🌿</text>
<view class="sparkle s1"></view>
<view class="sparkle s2"></view>
</view>
</view>
<view class="empty-text">
<text>今天很惬意</text>
<text class="empty-sub">植物们正在享受阳光 SPA ☀️</text>
</view>
<t-button theme="primary" size="large" shape="round" bind:tap="gotoGarden" class="empty-btn">去看看花园</t-button>
</view>
<scroll-view wx:else scroll-y class="task-list" enhanced show-scrollbar="{{false}}">
@@ -28,7 +49,7 @@
<view class="card-header-row">
<view class="plant-info-brief">
<view class="plant-thumb-small">
<image wx:if="{{item.plantImage}}" src="/assets/{{item.plantImage}}" mode="aspectFill"></image>
<image wx:if="{{item.plantImage}}" src="{{item.plantImage}}" mode="aspectFill"></image>
<view wx:else class="thumb-placeholder">{{item.plantName[0]}}</view>
</view>
<text class="plant-name-title">{{item.plantName}}</text>
@@ -50,8 +71,11 @@
<text wx:if="{{task.isOverdue}}" class="task-overdue-text">{{task.overdueDays}}天前</text>
</view>
</view>
<view class="mini-check-btn {{task.isOverdue ? 'btn-urgent' : ''}}" bindtap="handleTaskClick" data-task="{{task}}">
<t-icon name="check" size="32rpx" color="{{task.isOverdue ? '#EF5350' : '#E0E0E0'}}" />
<view class="mini-check-btn {{task.isOverdue ? 'btn-urgent' : ''}}"
style="{{task.isCompleted ? 'background-color: #4CAF50; border-color: #4CAF50;' : ''}}"
bindtap="handleTaskClick"
data-task="{{task}}">
<t-icon wx:if="{{task.isCompleted}}" name="check" size="28rpx" color="#FFF" />
</view>
</view>
</view>
+259 -38
View File
@@ -279,18 +279,32 @@
}
.plant-task-card {
background: white;
border-radius: 40rpx;
background: linear-gradient(160deg, #FFFFFF 0%, #F5F9F6 100%);
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.03);
border: 2rpx solid transparent;
box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.04);
border: 1rpx solid rgba(0,0,0,0.02);
transition: all 0.2s;
position: relative;
overflow: hidden;
}
/* Decorator for card */
.plant-task-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 120rpx;
height: 120rpx;
background: radial-gradient(circle at top right, #E8F5E9 0%, transparent 70%);
opacity: 0.6;
}
.plant-task-card.has-overdue {
border-color: rgba(239, 83, 80, 0.1);
background: #FFF8F8;
border: 2rpx solid rgba(239, 83, 80, 0.2);
background: linear-gradient(160deg, #FFEBEE 0%, #FFF 100%);
}
.card-header-row {
@@ -299,7 +313,7 @@
align-items: center;
margin-bottom: 32rpx;
padding-bottom: 24rpx;
border-bottom: 2rpx solid rgba(0, 0, 0, 0.03);
border-bottom: 2rpx dashed rgba(0, 0, 0, 0.05); /* Dashed line for ticket feel */
}
.plant-info-brief {
@@ -309,11 +323,13 @@
}
.plant-thumb-small {
width: 80rpx;
height: 80rpx;
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
overflow: hidden;
background: #f0f0f0;
box-shadow: 0 4rpx 10rpx rgba(0,0,0,0.05);
border: 2rpx solid #FFF;
}
.plant-thumb-small image {
@@ -324,40 +340,46 @@
.thumb-placeholder {
width: 100%;
height: 100%;
background: #E8F5E9;
color: #558B2F;
background: #C8E6C9;
color: #2E7D32;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 32rpx;
font-size: 36rpx;
}
.plant-name-title {
font-size: 30rpx;
font-weight: 700;
color: #263238;
font-size: 32rpx;
font-weight: 800;
color: #1B5E20; /* Dark Green theme */
letter-spacing: 1rpx;
}
.group-overdue-badge {
font-size: 20rpx;
color: #EF5350;
background: rgba(239, 83, 80, 0.1);
padding: 4rpx 16rpx;
color: #C62828;
background: #FFCDD2;
padding: 6rpx 18rpx;
border-radius: 20rpx;
font-weight: 600;
font-weight: 700;
}
.plant-tasks-list {
display: flex;
flex-direction: column;
gap: 24rpx;
gap: 28rpx;
}
.mini-task-row {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255,255,255,0.6);
padding: 16rpx;
border-radius: 20rpx;
width: 100%;
box-sizing: border-box; /* Important for padding */
}
.mini-task-left {
@@ -367,14 +389,15 @@
}
.task-type-icon-circle {
width: 64rpx;
height: 64rpx;
background: #F8F9FA;
border-radius: 50%;
width: 72rpx;
height: 72rpx;
background: #FFF;
border-radius: 24rpx; /* Softer square */
display: flex;
align-items: center;
justify-content: center;
color: #90A4AE;
box-shadow: 0 4rpx 8rpx rgba(0,0,0,0.03);
}
.task-icon {
@@ -385,45 +408,58 @@
.mini-task-text {
display: flex;
flex-direction: column;
gap: 4rpx;
gap: 6rpx;
}
.task-label {
font-size: 28rpx;
font-weight: 500;
font-size: 30rpx;
font-weight: 600;
color: #37474F;
}
.task-overdue-text {
font-size: 22rpx;
color: #EF5350;
font-weight: 600;
color: #D32F2F;
font-weight: 700;
}
/* Checkbox Styling - Square */
.mini-check-btn {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
border: 3rpx solid #E0E0E0;
width: 56rpx;
height: 56rpx;
border-radius: 12rpx; /* Square with slight radius */
border: 4rpx solid #CFD8DC; /* Thick border */
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-sizing: border-box;
background: #FFF;
}
.mini-check-btn:active {
background: rgba(85, 139, 47, 0.05);
border-color: #558B2F;
background: #F5F5F5;
border-color: #90A4AE;
}
/* Checked State */
.mini-check-btn.btn-checked {
background: #4CAF50 !important;
border-color: #4CAF50 !important;
}
/* Urgent/Overdue State */
.mini-check-btn.btn-urgent {
border-color: rgba(239, 83, 80, 0.3);
border-color: #EF5350;
background: #FFEBEE;
}
.mini-check-btn.btn-urgent.btn-checked {
background: #EF5350; /* If overdue but checked (rare case?) */
}
.mini-check-btn.btn-urgent:active {
background: rgba(239, 83, 80, 0.05);
border-color: #EF5350;
background: #FFCDD2;
}
/* Modal Specifics */
@@ -547,3 +583,188 @@
.confirm-complete-btn:active {
transform: scale(0.98);
}
/* Empty State Custom */
.empty-state-custom {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
gap: 40rpx;
position: relative;
overflow: hidden;
}
.empty-scene {
position: relative;
width: 100%;
height: 320rpx;
display: flex;
justify-content: center;
align-items: center;
}
/* Sun Animation */
.sun-box {
position: absolute;
top: 10rpx;
right: 120rpx;
width: 120rpx;
height: 120rpx;
animation: sunSpin 20s linear infinite;
z-index: 1;
}
.sun-core {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60rpx;
height: 60rpx;
background: radial-gradient(circle, #FFEB3B 0%, #FBC02D 100%);
border-radius: 50%;
box-shadow: 0 0 20rpx rgba(253, 216, 53, 0.6);
}
.sun-rays {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
border: 4rpx dashed #FDD835;
opacity: 0.6;
}
@keyframes sunSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Clouds */
.cloud {
position: absolute;
background: #E1F5FE;
border-radius: 20rpx;
opacity: 0.8;
z-index: 1;
}
.cloud-1 {
width: 100rpx;
height: 36rpx;
top: 40rpx;
left: 10%;
animation: cloudFloat 15s linear infinite alternate;
}
.cloud-2 {
width: 80rpx;
height: 28rpx;
top: 80rpx;
right: 15%;
animation: cloudFloat 12s linear infinite alternate-reverse;
}
@keyframes cloudFloat {
from { transform: translateX(-20rpx); }
to { transform: translateX(20rpx); }
}
/* Replaced Empty Img with Icon Group */
.empty-icon-group {
position: relative;
width: 200rpx;
height: 200rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.icon-bg-circle {
position: absolute;
width: 160rpx;
height: 160rpx;
background: white;
border-radius: 50%;
box-shadow: 0 8rpx 32rpx rgba(76, 175, 80, 0.15);
z-index: 1;
}
.emoji-icon {
font-size: 100rpx;
line-height: 1;
position: relative;
z-index: 2;
/* Slight shadow for depth */
text-shadow: 0 4rpx 8rpx rgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: center;
}
.sparkle {
position: absolute;
background: #FFD54F;
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
z-index: 3;
animation: sparkleTwinkle 2s ease-in-out infinite;
}
.s1 {
width: 24rpx;
height: 24rpx;
top: 10rpx;
right: 20rpx;
animation-delay: 0.2s;
}
.s2 {
width: 16rpx;
height: 16rpx;
bottom: 30rpx;
left: 20rpx;
animation-delay: 1.2s;
}
@keyframes sparkleTwinkle {
0%, 100% { transform: scale(0.8) rotate(0deg); opacity: 0.6; }
50% { transform: scale(1.2) rotate(180deg); opacity: 1; }
}
.plant-breathing {
animation: plantBreathe 4s ease-in-out infinite;
}
@keyframes plantBreathe {
0% { transform: scale(1) translateY(0); }
50% { transform: scale(1.05) translateY(-6rpx); }
100% { transform: scale(1) translateY(0); }
}
.empty-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
font-size: 34rpx;
font-weight: 700;
color: #2E7D32;
z-index: 2;
}
.empty-sub {
font-size: 26rpx;
color: #7CB342;
font-weight: 500;
}
.empty-btn {
margin-top: 20rpx;
box-shadow: 0 8rpx 16rpx rgba(85, 139, 47, 0.2);
}
+1 -1
View File
@@ -53,7 +53,7 @@
},
"compileType": "miniprogram",
"libVersion": "3.7.1",
"appid": "wxc88e6306171c1611",
"appid": "wxb463820bf36dd5d6",
"projectname": "plant-mp",
"isGameTourist": false,
"condition": {
+1 -1
View File
@@ -3,7 +3,7 @@
"projectname": "plant-mp",
"condition": {},
"setting": {
"urlCheck": true,
"urlCheck": false,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
+208
View File
@@ -0,0 +1,208 @@
class WxRequest {
constructor(options = {}) {
this.baseUrl = options.baseUrl || '';
this.header = options.header || {};
this.interceptors = {
request: (config) => config,
response: (response) => response
};
}
/**
* Set request interceptor
* @param {Function} callback
*/
setRequestInterceptor(callback) {
this.interceptors.request = callback;
}
/**
* Set response interceptor
* @param {Function} callback
*/
setResponseInterceptor(callback) {
this.interceptors.response = callback;
}
/**
* Core request method
* @param {Object} options
*/
request(options = {}) {
// Merge headers
const header = { ...this.header, ...options.header };
// Prepare config
let config = {
url: (options.url.startsWith('http') ? options.url : this.baseUrl + options.url),
method: options.method || 'GET',
data: options.data || {},
header: header,
timeout: options.timeout || 60000
};
// Apply request interceptor
config = this.interceptors.request(config);
return new Promise((resolve, reject) => {
wx.request({
...config,
success: (res) => {
// Apply response interceptor (pass the whole wx response)
const processedResponse = this.interceptors.response(res);
const { statusCode, data } = processedResponse;
// 1. Check HTTP Status Code
if (statusCode >= 200 && statusCode < 300) {
// 2. Check Business Logic Code (Assuming 200 is success based on common Go patterns,
// matching the user's struct: Code, Data, Msg)
// If data.code exists, we check it. If not (maybe not JSON), we pass through or handle.
const businessCode = data.code;
// Strict check: if code is 200, success.
if (businessCode === 200) {
resolve(data.data); // Return the inner Data object
} else {
// Business logic error
this.handleError({
errMsg: data.msg || 'Error',
code: businessCode
});
reject(data);
}
} else {
// Handle non-200 HTTP errors
this.handleError({ errMsg: `HTTP Error: ${statusCode}`, ...res });
reject(res);
}
},
fail: (err) => {
this.handleError({ errMsg: 'Network Error', ...err });
reject(err);
}
});
});
}
/**
* Handle global errors (Toast)
* @param {Object} error
*/
handleError(error) {
const message = error.errMsg || error.msg || '请求失败,请稍后重试';
wx.showToast({
title: message,
icon: 'none',
duration: 3000
});
console.error('API Error:', error);
}
/**
* Upload file
* @param {string} filePath Local file path
* @param {string} name Form field name (default: file)
* @param {Object} formData Additional form data
*/
upload(filePath, name = 'file', formData = {}) {
// Prepare config
let config = {
url: this.baseUrl + '/oss/upload',
header: { ...this.header }, // Copy default headers
filePath: filePath,
name: name,
formData: formData
};
// Apply request interceptor (reuse logic for token injection)
// Note: wx.uploadFile doesn't support method/data in the same way, but we use interceptor for header mainly
config = this.interceptors.request(config);
return new Promise((resolve, reject) => {
wx.uploadFile({
url: config.url,
filePath: config.filePath,
name: config.name,
formData: config.formData,
header: config.header,
success: (res) => {
// wx.uploadFile returns data as String, need to parse
let data;
try {
data = JSON.parse(res.data);
} catch (e) {
data = { code: 500, msg: 'Response parse error', data: res.data };
}
// Reconstruct a response object for interceptor
const responseObj = { ...res, data: data };
// Apply response interceptor
const processedResponse = this.interceptors.response(responseObj);
const { statusCode, data: finalData } = processedResponse;
// Status Code check (uploadFile also returns statusCode)
if (statusCode >= 200 && statusCode < 300) {
const businessCode = finalData.code;
if (businessCode === 200) {
resolve(finalData.data);
} else {
this.handleError({
errMsg: finalData.msg || 'Upload Error',
code: businessCode
});
reject(finalData);
}
} else {
this.handleError({ errMsg: `HTTP Error: ${statusCode}`, ...res });
reject(res);
}
},
fail: (err) => {
this.handleError({ errMsg: 'Upload Network Error', ...err });
reject(err);
}
});
});
}
get(url, data = {}, header = {}) {
return this.request({ url, method: 'GET', data, header });
}
post(url, data = {}, header = {}) {
return this.request({ url, method: 'POST', data, header });
}
}
// Initialize with default instance
const request = new WxRequest({
baseUrl: 'http://192.168.0.184:8888',
header: {
'Content-Type': 'application/json'
}
});
// Example: Setup default interceptors
request.setRequestInterceptor((config) => {
// Inject token if available
const token = wx.getStorageSync('token');
if (token) {
// User requested: Bearer + token
config.header['Authorization'] = `Bearer ${token}`;
}
return config;
});
request.setResponseInterceptor((response) => {
// Handle global token expiration (e.g., HTTP 401 or Business Code 401)
if (response.statusCode === 401 || response.data?.code === 401) {
wx.removeStorageSync('token');
// Redirect to login if needed, or just warn
// wx.reLaunch({ url: '/pages/login/index' });
}
return response;
});
export default request;
export { WxRequest };