Преглед на файлове

S-cfc-29: 新增历史完成任务页面 + 个人中心跳转调整

- 新增 cfc-frontend/pages/tasks/history.vue:展示当前成员已完成任务分页列表,支持 category 过滤、下拉加载更多、空态
- pages.json 注册 pages/tasks/history 路由,标题「历史完成任务」
- profile.vue: 新增 goToTaskHistory(),完成任务卡片由 goToTasks 改为 goToTaskHistory,任务列表入口保持不变
- 后端 /api/tasks/history 已就绪,前端 utils/api.js getTaskHistory 已封装
iwt преди 6 дни
родител
ревизия
ed8f1c347
променени са 3 файла, в които са добавени 496 реда и са изтрити 3 реда
  1. 7 2
      cfc-frontend/pages.json
  2. 4 1
      cfc-frontend/pages/profile-main/profile.vue
  3. 485 0
      cfc-frontend/pages/tasks/history.vue

+ 7 - 2
cfc-frontend/pages.json

@@ -285,8 +285,7 @@
           }
         }
       ]
-    },
-    {
+    },    {
       "root": "pages/tasks",
       "pages": [
         {
@@ -318,6 +317,12 @@
           "style": {
             "navigationBarTitleText": "外部商品打卡"
           }
+        },
+        {
+          "path": "history",
+          "style": {
+            "navigationBarTitleText": "历史完成任务"
+          }
         }
       ]
     },

+ 4 - 1
cfc-frontend/pages/profile-main/profile.vue

@@ -156,7 +156,7 @@
             <text class="ach-num">{{ streakDays }}</text>
             <text class="ach-label">连续打卡</text>
           </view>
-          <view class="ach-item" @click="goToTasks">
+          <view class="ach-item" @click="goToTaskHistory">
             <view class="ach-icon-wrap" style="background:rgba(99,102,241,0.12)">
               <text class="ach-icon">✅</text>
             </view>
@@ -826,6 +826,9 @@ export default {
     goToTasks: function() {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
+    goToTaskHistory: function() {
+      uni.navigateTo({ url: '/pages/tasks/history' })
+    },
     goToWishes: function() {
       uni.navigateTo({ url: '/pages/wishes/index' })
     },

+ 485 - 0
cfc-frontend/pages/tasks/history.vue

@@ -0,0 +1,485 @@
+<template>
+  <view class="container" :style="{ '--font-scale': fontScale }">
+    <!-- 页头:当前账号 + 成员选择 -->
+    <view class="page-header">
+      <view class="header-account">
+        <text class="account-label">当前账号</text>
+        <text class="account-name">{{ selfMember ? selfMember.nickname : '我' }}</text>
+      </view>
+      <view class="header-tip">查看已完成的历史任务记录</view>
+      <!-- 成员选择 -->
+      <MemberSelector
+        variant="minimal"
+        embedded
+        :members="familyMembers"
+        :selected-member-id="selectedMemberId"
+        :show-self="true"
+        self-label="本人"
+        :self-active="!selectedMemberId"
+        @select="onMemberSelect" />
+    </view>
+
+    <!-- 分类筛选 -->
+    <view class="category-filter" v-if="categories.length > 0">
+      <view class="category-scroll" scroll-x show-scrollbar="false">
+        <view class="category-inner">
+          <view
+            class="category-chip"
+            :class="{ active: !currentCategory }"
+            @click="selectCategory('')"
+          >
+            <text class="chip-text">全部</text>
+          </view>
+          <view
+            v-for="cat in categories"
+            :key="cat"
+            class="category-chip"
+            :class="{ active: currentCategory === cat }"
+            @click="selectCategory(cat)"
+          >
+            <text class="chip-text">{{ cat }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 历史任务列表 -->
+    <view class="task-list">
+      <view class="task-card" v-for="task in taskList" :key="task.id">
+        <view class="card-accent-line"></view>
+        <view class="card-body">
+          <view class="card-left">
+            <view class="card-top-row">
+              <text class="card-type-icon" v-if="task.category === '小游戏类'">🎮</text>
+              <text class="card-type-icon" v-else-if="task.actionType === 'cart' || task.actionType === 'order'">🛒</text>
+              <text class="card-type-icon" v-else-if="task.actionType === 'focus'">⏱️</text>
+              <text class="card-type-icon" v-else-if="task.actionType === 'survey'">📝</text>
+              <text class="card-type-icon" v-else-if="task.actionType === 'article'">📖</text>
+              <text class="card-type-icon" v-else-if="task.actionType === 'activity'">🎯</text>
+              <text class="card-type-icon" v-else-if="task.requireInput === 1">✍️</text>
+              <text class="card-title">{{ task.title }}</text>
+              <text class="member-pill" v-if="task.memberOnly === 1">会员</text>
+            </view>
+            <view class="card-meta">
+              <text class="points-pill">+{{ task.points }} CF</text>
+              <text class="meta-dot">·</text>
+              <text class="card-category" v-if="task.category">{{ task.category }}</text>
+              <text class="meta-dot" v-if="task.category">·</text>
+              <text class="card-completed-time">完成于 {{ formatCompletedTime(task.completedAt || task.updatedAt) }}</text>
+            </view>
+          </view>
+          <view class="card-right">
+            <view class="badge badge-completed">已完成</view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空态 -->
+      <view class="empty-state" v-if="!loading && taskList.length === 0">
+        <view class="empty-icon">📋</view>
+        <text class="empty-main">暂无已完成任务</text>
+        <text class="empty-sub">完成任务后会自动记录到这里</text>
+      </view>
+    </view>
+
+    <!-- 加载更多 -->
+    <view class="load-more" v-if="loading">
+      <view class="spinner"></view>
+      <text class="load-more-text">加载中...</text>
+    </view>
+    <view class="load-more" v-if="!loading && hasMore">
+      <text class="load-more-text" @click="loadMore">点击加载更多</text>
+    </view>
+    <view class="load-more" v-if="!loading && !hasMore && taskList.length > 0">
+      <text class="load-more-text end-text">已加载全部</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import MemberSelector from '@/components/MemberSelector.vue'
+import { getFamilyMemberList, getTaskHistory } from '../../utils/api.js'
+import { formatDateTime } from '../../utils/format.js'
+import fontScaleMixin from '@/mixins/font-scale.js'
+
+export default {
+  mixins: [fontScaleMixin],
+  components: { MemberSelector },
+  data() {
+    return {
+      familyMembers: [],
+      selfMember: null,
+      selectedMemberId: null,
+      currentCategory: '',
+      categories: [],
+      taskList: [],
+      loading: false,
+      hasMore: true,
+      page: 1,
+      pageSize: 20,
+      currentMemberId: null,
+    }
+  },
+  onLoad() {
+    this.currentMemberId = uni.getStorageSync('currentMemberId') || uni.getStorageSync('currentChildId') || uni.getStorageSync('userId')
+    this.loadFamilyMembers()
+  },
+  onShow() {
+    // 页面返回时刷新
+    if (this._loaded) {
+      this.loadHistory(true)
+    }
+    this._loaded = true
+  },
+  methods: {
+    loadFamilyMembers() {
+      const self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var data = res && res.data ? res.data : []
+        self.familyMembers = Array.isArray(data) ? data : []
+        // 找到当前登录成员作为默认
+        var currentId = self.currentMemberId
+        if (currentId) {
+          var found = self.familyMembers.find(function(m) { return m.id == currentId })
+          if (found) {
+            self.selfMember = found
+          }
+        }
+        if (!self.selfMember && self.familyMembers.length > 0) {
+          self.selfMember = self.familyMembers[0]
+        }
+        self.loadHistory(true)
+      }).catch(function() {
+        self.familyMembers = []
+        self.loadHistory(true)
+      })
+    },
+    onMemberSelect(member) {
+      this.selectedMemberId = member ? member.id : null
+      this.currentMemberId = member ? member.id : (this.currentMemberId || uni.getStorageSync('userId'))
+      this.selfMember = member || this.familyMembers[0] || null
+      this.loadHistory(true)
+    },
+    selectCategory(category) {
+      if (this.currentCategory === category) return
+      this.currentCategory = category
+      this.loadHistory(true)
+    },
+    loadHistory(reset) {
+      if (this.loading) return
+      if (reset) {
+        this.page = 1
+        this.taskList = []
+        this.hasMore = true
+      }
+      this.loading = true
+
+      var memberId = this.selectedMemberId || this.currentMemberId || uni.getStorageSync('userId')
+      if (!memberId) {
+        this.loading = false
+        this.hasMore = false
+        return
+      }
+
+      const self = this
+      getTaskHistory(memberId, this.page, this.pageSize, this.currentCategory || null).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          var pageData = res.data
+          var records = pageData.records || pageData.list || []
+          // 提取分类用于筛选
+          if (self.page === 1) {
+            var cats = []
+            records.forEach(function(t) {
+              if (t.category && cats.indexOf(t.category) === -1) {
+                cats.push(t.category)
+              }
+            })
+            self.categories = cats
+          }
+          if (reset) {
+            self.taskList = records
+          } else {
+            self.taskList = self.taskList.concat(records)
+          }
+          self.hasMore = records.length >= self.pageSize
+          if (self.hasMore) {
+            self.page++
+          }
+        } else {
+          self.hasMore = false
+        }
+      }).catch(function(err) {
+        self.loading = false
+        self.hasMore = false
+        console.error('加载历史任务失败', err)
+      })
+    },
+    loadMore() {
+      if (!this.loading && this.hasMore) {
+        this.loadHistory(false)
+      }
+    },
+    formatCompletedTime(timeStr) {
+      if (!timeStr) return ''
+      var d = new Date(timeStr.replace(/-/g, '/'))
+      if (isNaN(d.getTime())) return timeStr
+      var y = d.getFullYear()
+      var m = ('0' + (d.getMonth() + 1)).slice(-2)
+      var day = ('0' + d.getDate()).slice(-2)
+      var h = ('0' + d.getHours()).slice(-2)
+      var min = ('0' + d.getMinutes()).slice(-2)
+      return y + '-' + m + '-' + day + ' ' + h + ':' + min
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+$c-text-gray-400:   $facade-text-gray-400;
+$c-fill-2b:         #F2F2F2;
+
+.container {
+  min-height: 100vh;
+  background: $facade-fill-warm-gray;
+  padding-bottom: 40rpx;
+}
+
+/* 页头 */
+.page-header {
+  margin: 0 $uni-space-lg 16rpx;
+  background: $app-surface;
+  border-radius: 24rpx;
+  padding: 20rpx 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+
+.header-account {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-bottom: 8rpx;
+}
+
+.account-label {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+
+.account-name {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+}
+
+.header-tip {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+}
+
+/* 分类筛选 */
+.category-filter {
+  margin: 0 $uni-space-lg 16rpx;
+}
+
+.category-scroll {
+  white-space: nowrap;
+}
+
+.category-inner {
+  display: flex;
+  gap: 16rpx;
+  padding: 8rpx 8rpx 16rpx;
+}
+
+.category-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 8rpx 20rpx;
+  border-radius: 999rpx;
+  background: $facade-fill;
+  border: 1rpx solid $app-border-subtle;
+  flex-shrink: 0;
+}
+
+.category-chip.active {
+  background: $brand-primary;
+  border-color: $brand-primary;
+}
+
+.category-chip.active .chip-text {
+  color: $app-surface;
+}
+
+.chip-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+}
+
+/* 任务列表 */
+.task-list {
+  padding: 0 $uni-space-lg;
+}
+
+.task-card {
+  background: $app-surface;
+  border-radius: 20rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+  overflow: hidden;
+}
+
+.card-accent-line {
+  width: 6rpx;
+  height: 100%;
+  background: linear-gradient(180deg, $brand-primary, $brand-primary-light);
+}
+
+.card-body {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 24rpx;
+  gap: 16rpx;
+}
+
+.card-left {
+  flex: 1;
+  min-width: 0;
+}
+
+.card-top-row {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+  margin-bottom: 8rpx;
+  flex-wrap: wrap;
+}
+
+.card-type-icon {
+  font-size: calc(28rpx * var(--font-scale, 1));
+}
+
+.card-title {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+  flex: 1;
+  min-width: 0;
+}
+
+.member-pill {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  background: $dim-wealth-tint;
+  color: $dim-wealth-deep;
+  padding: 2rpx 10rpx;
+  border-radius: 999rpx;
+  font-weight: 500;
+}
+
+.card-meta {
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+  flex-wrap: wrap;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+  line-height: 1.5;
+}
+
+.points-pill {
+  background: $dim-action-tint;
+  color: $dim-action-deep;
+  padding: 2rpx 10rpx;
+  border-radius: 999rpx;
+  font-weight: 600;
+}
+
+.meta-dot {
+  color: $facade-text-ghost;
+}
+
+.card-category {
+  color: $brand-primary;
+  font-weight: 500;
+}
+
+.card-completed-time {
+  color: $facade-text-muted;
+}
+
+.card-right {
+  flex-shrink: 0;
+}
+
+.badge {
+  display: inline-flex;
+  align-items: center;
+  padding: 6rpx 16rpx;
+  border-radius: 999rpx;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  font-weight: 500;
+}
+
+.badge-completed {
+  background: $dim-body-tint;
+  color: $dim-body-deep;
+}
+
+/* 空态 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 24rpx 40rpx;
+}
+
+.empty-icon {
+  font-size: calc(80rpx * var(--font-scale, 1));
+  margin-bottom: 16rpx;
+}
+
+.empty-main {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+  margin-bottom: 8rpx;
+}
+
+.empty-sub {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+
+/* 加载更多 */
+.load-more {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 32rpx 0;
+}
+
+.spinner {
+  width: 32rpx;
+  height: 32rpx;
+  border: 3rpx solid $facade-fill;
+  border-top-color: $brand-primary;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.load-more-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-left: 12rpx;
+}
+
+.end-text {
+  color: $facade-text-ghost;
+}
+</style>