Selaa lähdekoodia

feat(frontend): 实现服务角色申请、报告详情、成就页面,修复占位按钮

Sisyphus 1 kuukausi sitten
vanhempi
sitoutus
21938cbdf2

+ 6 - 0
cfc-frontend/pages.json

@@ -293,6 +293,12 @@
           "style": {
             "navigationBarTitleText": "勋章墙"
           }
+        },
+        {
+          "path": "achievement",
+          "style": {
+            "navigationBarTitleText": "我的成就"
+          }
         }
       ]
     },

+ 5 - 1
cfc-frontend/pages/article-center/index.vue

@@ -255,7 +255,11 @@ export default {
       }
     },
     goExplore() {
-      uni.showToast({ title: '书库即将上线', icon: 'none' })
+      // 筛选全部文章
+      this.currentTab = ''
+      this.page = 1
+      this.articles = []
+      this.loadArticles()
     },
     formatDate(dateStr) {
       if (!dateStr) return ''

+ 1 - 1
cfc-frontend/pages/home-pages/child-index.vue

@@ -678,7 +678,7 @@ export default {
       }
     },
     showAchievements() {
-      uni.showToast({ title: '成就页面开发中', icon: 'none' })
+      uni.navigateTo({ url: '/pages/achievement/index' })
     },
     switchMode() {
       uni.showModal({

+ 307 - 11
cfc-frontend/pages/member-detail/report-detail.vue

@@ -1,24 +1,320 @@
 <template>
   <view class="container">
-    <view class="page-header">
-      <text class="page-title">报告详情</text>
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-back" @click="goBack">‹ 返回</view>
+      <text class="nav-title">报告详情</text>
+      <view class="nav-placeholder"></view>
     </view>
-    <view class="empty-state">
-      <text class="empty-text">报告详情页面开发中</text>
+
+    <!-- 加载状态 -->
+    <view class="loading" v-if="loading">加载中...</view>
+
+    <!-- 报告内容 -->
+    <template v-else-if="report">
+      <!-- 报告头部 -->
+      <view class="report-header">
+        <view class="report-type-badge" :class="'badge-' + report.reportType">
+          <text>{{ typeLabel }}</text>
+        </view>
+        <text class="report-date">{{ formatDate(report.reportDate) }}</text>
+      </view>
+
+      <!-- 综合评分 -->
+      <view class="score-section" v-if="report.overallScore">
+        <view class="score-circle">
+          <text class="score-value">{{ report.overallScore }}</text>
+          <text class="score-unit">分</text>
+        </view>
+        <view class="score-level">
+          <text class="level-text">{{ scoreLevel }}</text>
+        </view>
+      </view>
+
+      <!-- 报告内容 -->
+      <view class="report-content">
+        <mp-html :content="report.content || ''" />
+      </view>
+
+      <!-- 报告附件 -->
+      <view class="report-attachment" v-if="report.fileUrl">
+        <view class="attachment-label">
+          <text>原始报告</text>
+          <text class="attachment-size">{{ report.fileSize || '' }}</text>
+        </view>
+        <button class="attachment-btn" @click="downloadFile">查看原文件</button>
+      </view>
+    </template>
+
+    <!-- 空状态 -->
+    <view v-else class="empty-state">
+      <text class="empty-icon">📋</text>
+      <text class="empty-title">报告不存在</text>
+      <text class="empty-desc">该报告可能已被删除</text>
+      <view class="empty-btn" @click="goBack">返回</view>
     </view>
   </view>
 </template>
 
 <script>
+import MpHtml from '@/components/mp-html/mp-html.vue'
+import { getReportDetail } from '@/utils/api'
+import { parseDate } from '@/utils/format'
+
 export default {
-  data() { return {} }
+  components: { MpHtml },
+  data() {
+    return {
+      reportId: null,
+      report: null,
+      loading: true
+    }
+  },
+  computed: {
+    typeLabel() {
+      const labels = {
+        'physical_exam': '体检报告',
+        'gut_flora': '肠道检测',
+        'tongue': '舌诊报告'
+      }
+      return labels[this.report.reportType] || '健康报告'
+    },
+    scoreLevel() {
+      const score = this.report.overallScore
+      if (!score) return '--'
+      if (score >= 90) return '优秀'
+      if (score >= 80) return '良好'
+      if (score >= 70) return '一般'
+      return '需关注'
+    }
+  },
+  onLoad(options) {
+    this.reportId = options.reportId
+    if (this.reportId) {
+      this.loadReport()
+    } else {
+      this.loading = false
+    }
+  },
+  methods: {
+    loadReport() {
+      this.loading = true
+      getReportDetail(this.reportId).then(res => {
+        if (res.code === 200 && res.data) {
+          this.report = res.data
+        }
+      }).catch(() => {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }).finally(() => {
+        this.loading = false
+      })
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return '--'
+      const d = parseDate(dateStr)
+      if (!d) return dateStr
+      const y = d.getFullYear()
+      const m = ('' + (d.getMonth() + 1)).padStart(2, '0')
+      const day = ('' + d.getDate()).padStart(2, '0')
+      return y + '-' + m + '-' + day
+    },
+    downloadFile() {
+      if (!this.report.fileUrl) return
+      uni.downloadFile({
+        url: this.report.fileUrl,
+        success: (res) => {
+          if (res.statusCode === 200) {
+            uni.openDocument({
+              filePath: res.tempFilePath,
+              fileType: this.getFileType(),
+              success: () => {},
+              fail: () => {
+                uni.showToast({ title: '打开失败', icon: 'none' })
+              }
+            })
+          }
+        },
+        fail: () => {
+          uni.showToast({ title: '下载失败', icon: 'none' })
+        }
+      })
+    },
+    getFileType() {
+      const url = this.report.fileUrl || ''
+      if (url.includes('.pdf')) return 'pdf'
+      if (url.includes('.jpg') || url.includes('.jpeg')) return 'jpg'
+      if (url.includes('.png')) return 'png'
+      return 'pdf'
+    },
+    goBack() {
+      uni.navigateBack()
+    }
+  }
 }
 </script>
 
-<style>
-.container { min-height: 100vh; background: #f5f7fa; padding: 30rpx; }
-.page-header { margin-bottom: 30rpx; }
-.page-title { font-size: 36rpx; font-weight: 700; color: #333; }
-.empty-state { text-align: center; padding: 120rpx 0; }
-.empty-text { color: #999; font-size: 28rpx; }
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+}
+.nav-bar {
+  display: flex;
+  align-items: center;
+  height: 88rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #eee;
+  padding: 0 30rpx;
+}
+.nav-back {
+  font-size: 32rpx;
+  color: #666;
+  padding: 0 20rpx;
+}
+.nav-title {
+  flex: 1;
+  text-align: center;
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+.nav-placeholder {
+  width: 60rpx;
+}
+.loading {
+  text-align: center;
+  padding: 100rpx;
+  color: #999;
+}
+.report-header {
+  background: #fff;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.report-type-badge {
+  padding: 8rpx 20rpx;
+  border-radius: 20rpx;
+  font-size: 24rpx;
+  font-weight: 600;
+}
+.badge-physical_exam {
+  background: #e6f7ff;
+  color: #1890ff;
+}
+.badge-gut_flora {
+  background: #f6ffed;
+  color: #52c41a;
+}
+.badge-tongue {
+  background: #fff7e6;
+  color: #fa8c16;
+}
+.report-date {
+  font-size: 26rpx;
+  color: #999;
+}
+.score-section {
+  background: #fff;
+  padding: 40rpx;
+  margin-bottom: 20rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.score-circle {
+  position: relative;
+  width: 160rpx;
+  height: 160rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.score-value {
+  font-size: 48rpx;
+  font-weight: 700;
+  color: #333;
+}
+.score-unit {
+  position: absolute;
+  bottom: 20rpx;
+  right: 30rpx;
+  font-size: 24rpx;
+  color: #999;
+}
+.score-level {
+  margin-top: 16rpx;
+}
+.level-text {
+  font-size: 28rpx;
+  color: #666;
+}
+.report-content {
+  background: #fff;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+  min-height: 200rpx;
+}
+.report-attachment {
+  background: #fff;
+  padding: 30rpx;
+  margin-bottom: 40rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.attachment-label {
+  display: flex;
+  flex-direction: column;
+}
+.attachment-label text:first-child {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+.attachment-size {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.attachment-btn {
+  background: #f97316;
+  color: #fff;
+  font-size: 24rpx;
+  padding: 12rpx 30rpx;
+  border-radius: 20rpx;
+  border: none;
+}
+.empty-state {
+  text-align: center;
+  padding: 120rpx 40rpx;
+}
+.empty-icon {
+  font-size: 80rpx;
+  display: block;
+  margin-bottom: 20rpx;
+}
+.empty-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 40rpx;
+}
+.empty-btn {
+  display: inline-block;
+  background: #f97316;
+  color: #fff;
+  font-size: 28rpx;
+  padding: 16rpx 60rpx;
+  border-radius: 40rpx;
+}
 </style>

+ 407 - 11
cfc-frontend/pages/member-detail/service-role-apply.vue

@@ -1,24 +1,420 @@
 <template>
   <view class="container">
-    <view class="page-header">
-      <text class="page-title">服务角色申请</text>
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-back" @click="goBack">‹ 返回</view>
+      <text class="nav-title">服务角色申请</text>
+      <view class="nav-placeholder"></view>
     </view>
-    <view class="empty-state">
-      <text class="empty-text">服务角色申请页面开发中</text>
+
+    <!-- 加载状态 -->
+    <view class="loading" v-if="loading">加载中...</view>
+
+    <template v-else-if="applyStatus === 'pending' || applyStatus === 'rejected'">
+      <!-- 已申请状态 -->
+      <view class="status-section">
+        <view class="status-icon">{{ applyStatus === 'pending' ? '⏳' : '❌' }}</view>
+        <text class="status-title">{{ applyStatus === 'pending' ? '申请审核中' : '申请被驳回' }}</text>
+        <text class="status-desc">{{ applyStatus === 'pending' ? '您的申请正在审核中,请耐心等待' : applyMessage || '请修改后重新申请' }}</text>
+        <view class="status-info" v-if="applyData">
+          <text class="info-text">申请时间:{{ formatTime(applyData.createdAt) }}</text>
+        </view>
+        <view class="btn-group">
+          <button class="btn-primary" v-if="applyStatus === 'rejected'" @click="editApplication">重新申请</button>
+          <button class="btn-secondary" @click="goBack">返回首页</button>
+        </view>
+      </view>
+    </template>
+
+    <!-- 申请表单 -->
+    <view v-else class="form-section">
+      <view class="form-card">
+        <view class="form-title">基本信息</view>
+        
+        <view class="form-item">
+          <text class="label">姓名 <text class="required">*</text></text>
+          <input class="input" v-model="form.name" placeholder="请输入真实姓名" />
+        </view>
+
+        <view class="form-item">
+          <text class="label">手机号 <text class="required">*</text></text>
+          <input class="input" type="number" v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
+        </view>
+
+        <view class="form-item">
+          <text class="label">选择角色 <text class="required">*</text></text>
+          <picker :range="roleOptions" range-key="label" @change="onRoleChange">
+            <view class="picker">
+              <text>{{ form.roleLabel || '请选择角色' }}</text>
+              <text class="picker-arrow">›</text>
+            </view>
+          </picker>
+        </view>
+
+        <view class="form-item">
+          <text class="label">身份证照片 <text class="required">*</text></text>
+          <view class="upload-area" @click="uploadIdCard">
+            <image v-if="form.idCardUrl" :src="form.idCardUrl" mode="aspectFit" class="upload-preview" />
+            <text v-else class="upload-placeholder">+ 上传身份证</text>
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="label">资质证书</text>
+          <view class="upload-area" @click="uploadCertificate">
+            <image v-if="form.certificateUrl" :src="form.certificateUrl" mode="aspectFit" class="upload-preview" />
+            <text v-else class="upload-placeholder">+ 上传资质证书(选填)</text>
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="label">申请说明</text>
+          <textarea class="textarea" v-model="form.remark" placeholder="请简述您的资质和经验(选填)" maxlength="200" />
+        </view>
+      </view>
+
+      <view class="tips-card">
+        <text class="tips-title">申请须知</text>
+        <text class="tips-text">• 请确保填写信息真实有效</text>
+        <text class="tips-text">• 审核时间一般为1-3个工作日</text>
+        <text class="tips-text">• 审核通过后将获得相应角色权限</text>
+      </view>
+
+      <button class="submit-btn" :disabled="submitting" @click="submitApplication">
+        {{ submitting ? '提交中...' : '提交申请' }}
+      </button>
     </view>
   </view>
 </template>
 
 <script>
+import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage } from '@/utils/api'
+
 export default {
-  data() { return {} }
+  data() {
+    return {
+      loading: false,
+      submitting: false,
+      applyStatus: null,
+      applyData: null,
+      applyMessage: '',
+      form: {
+        name: '',
+        phone: '',
+        role: '',
+        roleLabel: '',
+        idCardUrl: '',
+        certificateUrl: '',
+        remark: ''
+      },
+      roleOptions: [
+        { label: '成长规划师', value: 'teacher' },
+        { label: '营养师', value: 'nutritionist' },
+        { label: '健康管家', value: 'butler' }
+      ]
+    }
+  },
+  onLoad() {
+    this.loadApplication()
+  },
+  methods: {
+    loadApplication() {
+      this.loading = true
+      getMyServiceRoleApply().then(res => {
+        if (res.code === 200 && res.data) {
+          this.applyStatus = res.data.status
+          this.applyData = res.data
+          if (res.data.status === 'rejected') {
+            this.applyMessage = res.data.rejectReason || '申请未通过,请修改后重新申请'
+          }
+        } else if (res.code === 404) {
+          this.applyStatus = null
+        }
+      }).catch(() => {
+        this.applyStatus = null
+      }).finally(() => {
+        this.loading = false
+      })
+    },
+    onRoleChange(e) {
+      const idx = e.detail.value
+      this.form.role = this.roleOptions[idx].value
+      this.form.roleLabel = this.roleOptions[idx].label
+    },
+    uploadIdCard() {
+      this.uploadImage('idCard')
+    },
+    uploadCertificate() {
+      this.uploadImage('certificate')
+    },
+    uploadImage(field) {
+      uni.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: (res) => {
+          const tempFilePath = res.tempFilePaths[0]
+          uni.showLoading({ title: '上传中...' })
+          uploadServiceRoleImage(tempFilePath).then(uploadRes => {
+            uni.hideLoading()
+            if (uploadRes.code === 200) {
+              this.form[field + 'Url'] = uploadRes.data.url
+            }
+          }).catch(() => {
+            uni.hideLoading()
+            uni.showToast({ title: '上传失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    submitApplication() {
+      if (!this.form.name) {
+        return uni.showToast({ title: '请输入姓名', icon: 'none' })
+      }
+      if (!this.form.phone || this.form.phone.length !== 11) {
+        return uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
+      }
+      if (!this.form.role) {
+        return uni.showToast({ title: '请选择角色', icon: 'none' })
+      }
+      if (!this.form.idCardUrl) {
+        return uni.showToast({ title: '请上传身份证照片', icon: 'none' })
+      }
+
+      this.submitting = true
+      submitServiceRoleApply({
+        name: this.form.name,
+        phone: this.form.phone,
+        role: this.form.role,
+        idCardUrl: this.form.idCardUrl,
+        certificateUrl: this.form.certificateUrl,
+        remark: this.form.remark
+      }).then(res => {
+        this.submitting = false
+        if (res.code === 200) {
+          uni.showToast({ title: '申请已提交', icon: 'success' })
+          setTimeout(() => {
+            this.loadApplication()
+          }, 1500)
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      }).catch(() => {
+        this.submitting = false
+        uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+      })
+    },
+    editApplication() {
+      this.applyStatus = null
+      this.applyMessage = ''
+    },
+    goBack() {
+      uni.navigateBack()
+    },
+    formatTime(time) {
+      if (!time) return ''
+      return time.replace('T', ' ').substring(0, 16)
+    }
+  }
 }
 </script>
 
-<style>
-.container { min-height: 100vh; background: #f5f7fa; padding: 30rpx; }
-.page-header { margin-bottom: 30rpx; }
-.page-title { font-size: 36rpx; font-weight: 700; color: #333; }
-.empty-state { text-align: center; padding: 120rpx 0; }
-.empty-text { color: #999; font-size: 28rpx; }
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+}
+.nav-bar {
+  display: flex;
+  align-items: center;
+  height: 88rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #eee;
+  padding: 0 30rpx;
+}
+.nav-back {
+  font-size: 32rpx;
+  color: #666;
+  padding: 0 20rpx;
+}
+.nav-title {
+  flex: 1;
+  text-align: center;
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+.nav-placeholder {
+  width: 60rpx;
+}
+.loading {
+  text-align: center;
+  padding: 100rpx;
+  color: #999;
+}
+.status-section {
+  background: #fff;
+  margin: 30rpx;
+  border-radius: 20rpx;
+  padding: 60rpx 40rpx;
+  text-align: center;
+}
+.status-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+.status-title {
+  display: block;
+  font-size: 36rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 16rpx;
+}
+.status-desc {
+  display: block;
+  font-size: 28rpx;
+  color: #666;
+  margin-bottom: 30rpx;
+}
+.status-info {
+  margin-bottom: 40rpx;
+}
+.info-text {
+  font-size: 24rpx;
+  color: #999;
+}
+.btn-group {
+  display: flex;
+  gap: 20rpx;
+  justify-content: center;
+}
+.btn-primary {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  border: none;
+  padding: 20rpx 60rpx;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+}
+.btn-secondary {
+  background: #f5f7fa;
+  color: #666;
+  border: none;
+  padding: 20rpx 60rpx;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+}
+.form-section {
+  padding: 30rpx;
+}
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 30rpx;
+}
+.form-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 30rpx;
+  padding-bottom: 20rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.form-item {
+  margin-bottom: 30rpx;
+}
+.label {
+  display: block;
+  font-size: 28rpx;
+  color: #333;
+  margin-bottom: 16rpx;
+}
+.required {
+  color: #ff4d4f;
+}
+.input {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #e8e8e8;
+  border-radius: 12rpx;
+  padding: 0 24rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.picker {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #e8e8e8;
+  border-radius: 12rpx;
+  padding: 0 24rpx;
+  font-size: 28rpx;
+  color: #333;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.picker-arrow {
+  color: #999;
+}
+.upload-area {
+  width: 200rpx;
+  height: 200rpx;
+  border: 2rpx dashed #d9d9d9;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+.upload-preview {
+  width: 100%;
+  height: 100%;
+}
+.upload-placeholder {
+  font-size: 24rpx;
+  color: #999;
+  text-align: center;
+}
+.textarea {
+  width: 100%;
+  height: 160rpx;
+  border: 1rpx solid #e8e8e8;
+  border-radius: 12rpx;
+  padding: 20rpx 24rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.tips-card {
+  background: #fffbe6;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  margin-bottom: 30rpx;
+}
+.tips-title {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #fa8c16;
+  margin-bottom: 16rpx;
+}
+.tips-text {
+  display: block;
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.8;
+}
+.submit-btn {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  border: none;
+  border-radius: 40rpx;
+  padding: 28rpx;
+  font-size: 32rpx;
+  font-weight: 600;
+  margin-bottom: 60rpx;
+}
+.submit-btn[disabled] {
+  opacity: 0.6;
+}
 </style>

+ 1 - 1
cfc-frontend/pages/profile/components/ProfileBadges.vue

@@ -105,7 +105,7 @@ export default {
       this.badgesLoaded = true
     },
     goAllBadges() {
-      uni.showToast({ title: '即将上线', icon: 'none' })
+      uni.navigateTo({ url: '/pages/rewards/badge' })
     }
   }
 }

+ 10 - 2
cfc-frontend/pages/promotion/material.vue

@@ -184,9 +184,17 @@ export default {
     },
     shareItem(item) {
       uni.showShareMenu({
-        withShareTicket: true
+        withShareTicket: true,
+        menus: ['shareAppMessage', 'shareTimeline']
+      })
+      // 触发系统分享面板
+      uni.shareAppMessage({
+        title: item.title || '推荐内容',
+        imageUrl: item.coverImage || '',
+        imageUrl() {
+          return ''
+        }
       })
-      uni.showToast({ title: '分享功能开发中', icon: 'none' })
     },
     downloadItem(item) {
       if (!item.fileUrl) {