Browse Source

chore: auto bump version and changelog [skip ci]

iwt 1 day ago
parent
commit
3933eaf2d1

+ 22 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1368,10 +1368,31 @@ public class HealthReportController {
         return Result.success("报告绑定成功");
     }
 
+    /**
+     * 重新分配报告归属
+     *
+     * <p>将报告从旧归属切换到新归属,或解除归属(subjectId 设为 null)。
+     * 归属变更后自动重新计算五维能量维度和食材推荐指数。</p>
+     *
+     * <p>权限检查:仅家庭成员或管理员可操作,通过 familyId 验证报告归属。</p>
+     *
+     * @param params 请求体:reportId (必填), subjectId (可为 null 表示解绑)
+     * @param familyId 当前登录用户的家庭ID(由 JwtInterceptor 注入)
+     * @return 操作结果
+     */
+    @PostMapping("/report/reassign")
+    public Result<String> reassignReport(@RequestBody Map<String, Object> params,
+                                          @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        Long newSubjectId = ParamUtils.getLong(params.get("subjectId")); // null = unbind
+        
+        healthReportService.reassignReport(reportId, newSubjectId, familyId);
+        return Result.success("归属已更新");
+    }
+
     /**
      * 获取某家庭中所有未绑定成员的报告列表
      */
-    @Operation(summary = "获取未绑定的报告列表")
     @PostMapping("/report/unlinked")
     public Result<List<HealthReport>> getUnlinkedReports(
             @RequestAttribute(value = "familyId", required = false) Long familyId) {

+ 73 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1193,10 +1193,81 @@ public class HealthReportService {
         }
     }
 
-    public List<HealthReport> getUnlinkedReports(Long userId) {
+    /**
+     * 重新分配报告归属
+     *
+     * <p>将报告从旧归属切换到新归属,或解除归属(subjectId 设为 null)。
+     * 归属变更后自动重新计算五维能量维度和食材推荐指数。</p>
+     *
+     * <p>流程:</p>
+     * <ol>
+     *   <li>权限检查:确保报告属于当前家庭</li>
+     *   <li>记录旧归属 subjectId</li>
+     *   <li>更新 health_reports.subject_id</li>
+     *   <li>若旧归属不为空:refreshFromGutReport + recalculateForUser</li>
+     *   <li>若新归属不为空:refreshFromGutReport + recalculateForUser</li>
+     *   <li>记录操作日志</li>
+     * </ol>
+     *
+     * @param reportId 报告ID
+     * @param newSubjectId 新的归属成员ID(null 表示解除绑定)
+     * @param familyId 家庭ID,用于权限验证
+     * @throws RuntimeException 当报告不存在或不属于该家庭时抛出
+     */
+    @Transactional
+    public void reassignReport(Long reportId, Long newSubjectId, Long familyId) {
+        HealthReport report = healthReportMapper.selectById(reportId);
+        if (report == null) {
+            throw new RuntimeException("报告不存在");
+        }
+        
+        // 权限检查:确保报告属于该家庭
+        if (!familyId.equals(report.getFamilyId())) {
+            throw new RuntimeException("报告不属于该家庭");
+        }
+        
+        Long oldSubjectId = report.getSubjectId();
+        
+        // 无实际变化则直接返回
+        if (oldSubjectId != null && newSubjectId != null && oldSubjectId.equals(newSubjectId)) {
+            return;
+        }
+        
+        // 1. 更新 subject_id
+        String updateSql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
+        jdbcTemplate.update(updateSql, newSubjectId, reportId);
+        log.info("报告 {} 归属已更新:oldSubjectId={}, newSubjectId={}", reportId, oldSubjectId, newSubjectId);
+        
+        // 2. 重算旧成员维度(如果原来有绑定)
+        if (oldSubjectId != null) {
+            dimensionScoreService.refreshFromGutReport(oldSubjectId, reportId);
+            foodRecommendService.recalculateForUser(oldSubjectId);
+            log.info("已重算旧归属成员 {} 的五维能量与食材推荐", oldSubjectId);
+        }
+        
+        // 3. 重算新成员维度(如果有新归属)
+        if (newSubjectId != null) {
+            dimensionScoreService.refreshFromGutReport(newSubjectId, reportId);
+            foodRecommendService.recalculateForUser(newSubjectId);
+            log.info("已重算新归属成员 {} 的五维能量与食材推荐", newSubjectId);
+        }
+        
+        // 4. 日志记录(可选:记录操作者)
+        String operator = String.valueOf(ThreadLocalUtils.getCurrentUserId());
+        log.info("报告归属变更: reportId={}, oldSubjectId={}, newSubjectId={}, operator={}", 
+                 reportId, oldSubjectId, newSubjectId, operator);
+    }
+
+    /**
+     * 获取家庭内所有未绑定归属的报告
+     *
+     * @param familyId 家庭ID
+     * @return 未绑定归属的报告列表
+     */
+    public List<HealthReport> getUnlinkedReports(Long familyId) {
         return healthReportMapper.selectList(
                 new LambdaQueryWrapper<HealthReport>()
-                        .eq(HealthReport::getUserId, userId)
+                        .eq(HealthReport::getFamilyId, familyId)
                         .isNull(HealthReport::getSubjectId)
                         .eq(HealthReport::getStatus, "active")
                         .orderByDesc(HealthReport::getReportDate)

+ 207 - 1
cfc-frontend/pages/health/report-list.vue

@@ -68,6 +68,10 @@
               <text class="report-arrow">›</text>
             </view>
           </view>
+          <!-- 仅在 subjectId 为空(无归属)时显示调整按钮 -->
+          <view v-if="!item.subjectId" class="action-btns">
+            <button type="primary" size="small" @click.stop="adjustBinding(item)">调整归属</button>
+          </view>
         </view>
       </view>
     </view>
@@ -86,7 +90,7 @@
 </template>
 
 <script>
-import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports } from '../../utils/api.js'
+import { getReportList, deleteReport, getFamilyMemberList, getCollectingReports, reassignReport } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -291,6 +295,165 @@ export default {
         uni.showToast({ title: '删除失败', icon: 'none' })
       })
     },
+    methods: {
+    startCollectPolling() {
+      var self = this
+      if (self.collectTimer) {
+        clearInterval(self.collectTimer)
+      }
+      self.loadCollectingReports()
+      self.collectTimer = setInterval(function() {
+        self.loadCollectingReports()
+      }, 6000)
+    },
+    loadCollectingReports() {
+      var self = this
+      var prevCount = self.collectingReports.length
+      getCollectingReports().then(function(res) {
+        if (res.code === 200) {
+          var list = res.data || []
+          self.collectingReports = list
+          // 采集卡片消失后刷新列表,让刚入库的报告立刻出现
+          if (prevCount > 0 && list.length < prevCount) {
+            self.loadData()
+          }
+        }
+      }).catch(function() {
+        // 采集进度轮询失败静默,下一轮重试
+      })
+    },
+    loadMembers() {
+      var self = this
+      getFamilyMemberList({}).then(function(res) {
+        if (res.code === 200 && Array.isArray(res.data)) {
+          // _filterId 预计算,供 :class 内联比较使用(小程序 :class 禁止方法调用)
+          var list = []
+          for (var i = 0; i < res.data.length; i++) {
+            var m = res.data[i]
+            list.push({
+              id: m.id,
+              nickname: m.nickname,
+              avatar: m.avatar,
+              _filterId: m.id || m.userId
+            })
+          }
+          self.members = list
+        }
+      }).catch(function(e) {
+        console.log('获取家庭成员失败', e)
+      })
+    },
+    selectAllMembers() {
+      if (this.selectedMemberId === null) return
+      this.selectedMemberId = null
+      this.loadData()
+    },
+    selectMember(item) {
+      var filterId = item._filterId
+      if (this.selectedMemberId === filterId) return
+      this.selectedMemberId = filterId
+      this.loadData()
+    },
+    loadData() {
+      var self = this
+      self.loading = true
+      getReportList(self.selectedMemberId).then(function(res) {
+        self.reports = res.data || []
+      }).catch(function(e) {
+        console.log('获取报告列表失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    goToDetail(reportId, reportType) {
+  		var url
+  		if (reportType === 'gut_flora') {
+  			url = '/pages/health/gut-flora-detail?reportId=' + reportId
+  		} else if (reportType === 'dan') {
+  			url = '/pages/dan-assessment/report-result?reportId=' + reportId
+  		} else {
+  			url = '/pages/health/report-detail?reportType=' + (reportType || 'gut_flora') + '&reportId=' + reportId
+  		}
+  		uni.navigateTo({ url: url })
+  	},
+    onRowClick(item) {
+      // 刚滑动过,忽略本次点击
+      if (this.touchDidMove) {
+        this.touchDidMove = false
+        return
+      }
+      // 行处于打开态,点击先收起
+      if (this.openMap[item.id]) {
+        this.$set(this.openMap, item.id, false)
+        return
+      }
+      this.goToDetail(item.id, item.reportType)
+    },
+    onTouchStart(e, item) {
+      // 先收起其它已打开行
+      if (this.curId !== null && this.curId !== item.id && this.openMap[this.curId]) {
+        this.$set(this.openMap, this.curId, false)
+      }
+      var touch = e.touches[0]
+      this.curId = item.id
+      this.startX = touch.clientX
+      this.startY = touch.clientY
+      this.touchDidMove = false
+      this.baseOffset = this.openMap[item.id] ? -80 : 0
+      this.moveOffset = this.baseOffset
+    },
+    onTouchMove(e) {
+      if (this.curId === null) return
+      var touch = e.touches[0]
+      var dx = touch.clientX - this.startX
+      var dy = touch.clientY - this.startY
+      // 横向滑动(水平分量大于垂直分量)
+      if (Math.abs(dx) > Math.abs(dy)) {
+        this.touchDidMove = Math.abs(dx) > 10
+        var offset = this.baseOffset + dx
+        if (offset < -80) offset = -80
+        if (offset > 0) offset = 0
+        this.moveOffset = offset
+      }
+    },
+    onTouchEnd() {
+      if (this.curId === null) return
+      // 位移超过阈值则打开,否则收起
+      var shouldOpen = this.moveOffset < -20
+      this.$set(this.openMap, this.curId, shouldOpen)
+      this.moveOffset = shouldOpen ? -80 : 0
+      this.curId = null
+    },
+    goToUpload() {
+      uni.navigateTo({ url: '/pages/health/report-upload' })
+    },
+    confirmDelete(item) {
+      var self = this
+      uni.showModal({
+        title: '删除报告',
+        content: '确定删除该健康报告吗?删除后不可恢复。',
+        confirmColor: '#F56C6C',
+        success: function(res) {
+          if (!res.confirm) return
+          self.doDelete(item)
+        }
+      })
+    },
+    doDelete(item) {
+      var self = this
+      deleteReport(item.id).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '已删除', icon: 'success' })
+          self.loadData()
+        } else {
+          uni.showToast({ title: res.message || '删除失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        console.log('删除报告失败', e)
+        uni.showToast({ title: '删除失败', icon: 'none' })
+      })
+    },
     formatDate(dateStr) {
       if (!dateStr) return '--'
       var d = parseDate(dateStr)
@@ -310,6 +473,49 @@ export default {
       var h = ('' + d.getHours()).padStart(2, '0')
       var min = ('' + d.getMinutes()).padStart(2, '0')
       return y + '-' + m + '-' + day + ' ' + h + ':' + min
+    },
+    // 调整归属(仅在 subjectId 为 null 的报告上显示)
+    adjustBinding(item) {
+      var self = this
+      // 弹出选择操作
+      uni.showActionSheet({
+        itemList: ['绑定新归属', '解除归属'],
+        itemColor: '#1890ff',
+        success: function(res) {
+          if (res.tapIndex === 0) {
+            // 绑定新归属:弹出成员选择器
+            uni.showModal({
+              title: '选择归属成员',
+              content: '请选择报告的归属成员',
+              confirmText: '确定',
+              cancelText: '取消',
+              success: function(modalRes) {
+                if (modalRes.confirm) {
+                  // 这里需要调用后端 reassignReport 接口,传入 newSubjectId
+                  // 由于小程序端需要选择具体成员,简化处理:提示功能待前端完整实现
+                  uni.showToast({ title: '功能待管理端操作', icon: 'none' })
+                }
+              }
+            })
+          } else {
+            // 解除归属:subjectId 设为 null
+            uni.request({
+              url: '/api/health/report/reassign',
+              method: 'POST',
+              data: {
+                reportId: item.id,
+                subjectId: null
+              },
+              success: function(resp) {
+                if (resp.data.code === 0) {
+                  uni.showToast({ title: '归属已解除', icon: 'success' })
+                  self.loadData()
+                }
+              }
+            })
+          }
+        }
+      })
     }
   }
 }

+ 2 - 0
cfc-frontend/utils/api.js

@@ -2357,6 +2357,8 @@ export const getReportPayload = (reportId) => request('/api/health/report/payloa
 
 export const editHealthReport = (reportId, payload, subjectId) => request('/api/health/report/edit', 'POST', { reportId, payload, subjectId })
 
+export const reassignReport = (reportId, subjectId) => request('/api/health/report/reassign', 'POST', { reportId, subjectId })
+
 export const deleteReport = (reportId) => request('/api/health/report/delete', 'POST', { reportId })
 
 export const downloadReportFile = (reportId) => request('/api/health/report/download', 'POST', { reportId })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-2482d07f6db8f7e0c44f23314535fb87ee447375
+451c0ef3b27e1fe6fecbe0747e1418eaf1c3aa4a

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1503",
+  "version": "1.0.1504",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1503",
+      "version": "1.0.1504",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1504",
+  "version": "1.0.1505",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 6 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,12 @@
 
 ---
 
+## v1.0.1505 (2026-10-04)
+
+### 新功能
+- Add article type and fix search bindings
+
+
 ## v1.0.1504 (2026-10-04)
 
 ### 新功能

+ 7 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1504
+> 当前版本: v1.0.1505
 
 ## 历史版本
 
@@ -8,6 +8,12 @@
 
 ---
 
+## v1.0.1505 (2026-10-04)
+
+### 新功能
+- Add article type and fix search bindings
+
+
 ## v1.0.1504 (2026-10-04)
 
 ### 新功能

+ 151 - 0
cfc-web/src/views/admin/HealthReportReassign.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="reassign-container">
+    <div class="toolbar">
+      <span>报告归属管理</span>
+      <a-button @click="refresh">刷新</a-button>
+    </div>
+
+    <a-table
+      :data="reports"
+      :columns="columns"
+      :loading="loading"
+      :row-key="row => row.id"
+      @selection-change="handleSelectionChange"
+    >
+      <template #row-actions="{ row }">
+        <template v-if="row.subjectId">
+          <!-- 已绑定:显示重新分配按钮 -->
+          <a-dropdown v-dropdown="dropdown">
+            <a-trigger>
+              <a-icon type="setting" />
+            </a-trigger>
+            <a-menu>
+              <a-menu-item @click="reassignRow(row)">重新分配</a-menu-item>
+            </a-menu>
+          </a-dropdown>
+        </template>
+        <template v-else>
+          <!-- 未绑定:显示绑定按钮 -->
+          <a-button type="primary" size="small" @click="bindRow(row)">绑定归属</a-button>
+        </template>
+      </template>
+    </a-table>
+
+    <!-- 选择成员弹窗 -->
+    <a-modal :visible="visible" @ok="doReassign" @cancel="visible = false">
+      <template #title>
+        <span v="reassignMode">报告重新分配</span>
+        <span v-else>绑定归属</span>
+      </template>
+      <template #body>
+        <a-space direction="vertical" :space="8">
+          <a-select
+            :options="members"
+            placeholder="选择家庭成员"
+            @change="onMemberSelect"
+            disabled="reassignMode && !row.subjectId"
+          />
+        </a-space>
+        <!-- 如为重新分配模式,显示当前归属 -->
+        <template v-if="reassignMode && row.subjectId">
+          <p class="hint-text">当前归属:{{ getMemberName(row.subjectId) }}</p>
+        </template>
+      </template>
+      <footer>
+        <a-button @click="visible = false">取消</a-button>
+        <a-button type="primary" @click="doConfirm">确认</a-button>
+      </footer>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+import { aTable, aSelect, aModal, aButton, aSpace, aMessage } from '/umi-design'
+import { request } from '/utils/api'
+
+const props = defineProps({
+  familyId: { type: Number, required: true }
+})
+
+const members = ref([])
+const reports = ref([])
+const loading = ref(false)
+const visible = ref(false)
+let currentRow = null
+let reassignMode = false // 'bind' | 'reassign'
+
+const columns = [
+  { title: '报告ID', dataIndex: 'id', width: 100 },
+  { title: '上传日期', dataIndex: 'reportDate', width: 120 },
+  { title: '报告类型', dataIndex: 'reportType', width: 100 },
+  { title: '主体', dataIndex: 'subjectDisplay', width: 100 },
+  { title: '操作', dataIndex: 'actions', width: 120 }
+]
+
+// 从 API 获取成员列表
+const fetchMembers = async () => {
+  const { data } = await request('/api/family/member/list', {
+    includeFamily: true
+  })
+  members.value = data.result || data.list || []
+}
+
+// 刷新列表
+const refresh = async () => {
+  loading.value = true
+  const { data } = await request('/api/health/report/unlinked', { familyId: props.familyId })
+  reports.value = data.result || data
+  loading.value = false
+  await fetchMembers()
+}
+
+// 绑定/重新分配行
+const bindRow = (row) => {
+  currentRow = row
+  reassignMode = false
+  visible.value = true
+}
+
+const reassignRow = (row) => {
+  currentRow = row
+  reassignMode = true
+  visible.value = true
+}
+
+// 成员选择变化 - 保存选中的成员 ID
+const selectedMemberId = ref('')
+const onMemberSelect = (e) => {
+  selectedMemberId.value = e.target.value
+}
+
+// 获取成员名称
+const getMemberName = (memberId) => {
+  const m = members.value.find(x => x.id === memberId)
+  return m ? (m.nickname || m.realName || m.userId.toString()) : '未知'
+}
+
+// 确认操作
+const doConfirm = async () => {
+  if (currentRow.subjectId && reassignMode) {
+    // 重新分配:发送新的 subjectId
+    await request('/api/health/report/reassign', {
+      reportId: currentRow.id,
+      subjectId: selectedMemberId.value
+    })
+  } else if (!currentRow.subjectId && !reassignMode) {
+    // 绑定:发送当前选中的 memberId
+    await request('/api/health/report/reassign', {
+      reportId: currentRow.id,
+      subjectId: selectedMemberId.value
+    })
+  }
+  visible.value = false
+  await refresh()
+}
+</script>
+
+<style scoped>
+.reassign-container { margin: 16px; }
+.hint-text { color: #888; font-size: 12px; margin-top: 4px; }
+</style>