Просмотр исходного кода

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

Sisyphus 1 день назад
Родитель
Сommit
6fdd0d2b40

+ 35 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -674,7 +674,41 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
                         .eq(FamilyMember::getUserId, userId)
                         .eq(FamilyMember::getUserId, userId)
                         .last("LIMIT 1"));
                         .last("LIMIT 1"));
         if (member == null) {
         if (member == null) {
-            throw new RuntimeException("未找到该用户的家庭成员记录");
+            // 兼容历史孤儿数据:users.family_id 指向旧家庭但 family_members 无该用户成员记录
+            // (早期创建家庭路径漏建创建者成员记录)。旧家庭无能量值资产(按 member_id 关联必然无),
+            // 健康报告按 user_id 关联可能残留,先尝试迁移;再在目标家庭补建成员记录,后续流程自然兼容。
+            try {
+                List<HealthReport> orphanReports = healthReportMapper.selectList(
+                        new LambdaQueryWrapper<HealthReport>()
+                                .eq(HealthReport::getFamilyId, oldFamilyId)
+                                .and(w -> w.eq(HealthReport::getUserId, userId)
+                                        .or().eq(HealthReport::getSubjectId, userId)));
+                for (HealthReport r : orphanReports) {
+                    r.setFamilyId(targetFamilyId);
+                    healthReportMapper.updateById(r);
+                }
+            } catch (Exception e) {
+                log.warn("孤儿成员健康报告迁移异常: userId={}, oldFamilyId={}", userId, oldFamilyId, e);
+            }
+
+            FamilyMember newMember = new FamilyMember();
+            newMember.setFamilyId(targetFamilyId);
+            newMember.setUserId(userId);
+            newMember.setNickname((user.getNickname() != null && !user.getNickname().isEmpty())
+                    ? user.getNickname() : ("用户" + userId));
+            newMember.setPhone(user.getPhone());
+            newMember.setAvatar(user.getAvatar());
+            newMember.setGender(user.getGender());
+            newMember.setGeneration(0);
+            newMember.setTotalPoints(0);
+            newMember.setSystemPoints(0);
+            newMember.setStatus("active");
+            newMember.setCreatedAt(new Date());
+            newMember.setUpdatedAt(new Date());
+            familyMemberMapper.insert(newMember);
+            member = newMember;
+            log.warn("成员记录缺失,已在目标家庭补建: userId={}, oldFamilyId={}, targetFamilyId={}, memberId={}",
+                    userId, oldFamilyId, targetFamilyId, member.getId());
         }
         }
 
 
         // CF 值清零丢弃(留在原家庭)
         // CF 值清零丢弃(留在原家庭)

+ 301 - 152
cfc-frontend/components/address-picker.vue

@@ -1,38 +1,66 @@
 <template>
 <template>
   <view class="address-picker">
   <view class="address-picker">
-    <view class="picker-row" @click="showProvincePicker">
-      <text class="picker-label">省份:</text>
-      <text class="picker-value">{{ selectedProvince ? selectedProvince.province : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
+    <!-- 显示当前选中的地址 -->
+    <view class="current-address" @click="openPicker">
+      <text class="address-text">{{ currentAddress || '请选择地址' }}</text>
+      <text class="address-arrow">›</text>
     </view>
     </view>
-    
-    <view class="picker-row" @click="showCityPicker" v-if="selectedProvince">
-      <text class="picker-label">城市:</text>
-      <text class="picker-value">{{ selectedCity ? selectedCity.city : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
-    </view>
-    
-    <view class="picker-row" @click="showDistrictPicker" v-if="selectedCity">
-      <text class="picker-label">区县:</text>
-      <text class="picker-value">{{ selectedDistrict ? selectedDistrict.district : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
-    </view>
-    
-    <view class="picker-row" @click="showStreetPicker" v-if="selectedDistrict">
-      <text class="picker-label">街道:</text>
-      <text class="picker-value">{{ selectedStreet ? selectedStreet.street : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
-    </view>
-    
-    <view class="address-display" v-if="fullAddress">
-      <text class="address-label">完整地址:</text>
-      <text class="address-text">{{ fullAddress }}</text>
+
+    <!-- 地址选择面板 -->
+    <view class="picker-mask" v-if="showPicker" @click="closePicker">
+      <view class="picker-panel" @click.stop>
+        <!-- 面板头部 -->
+        <view class="picker-header">
+          <text class="picker-btn" @click="closePicker">取消</text>
+          <text class="picker-title">选择地址</text>
+          <text class="picker-btn picker-confirm" @click="confirmAddress">确定</text>
+        </view>
+
+        <!-- 面包屑导航 -->
+        <view class="picker-breadcrumb">
+          <text
+            :class="['crumb', currentLevel === 0 ? 'crumb-active' : '']"
+            @click="jumpToLevel(0)"
+          >{{ tempProvince || '省份' }}</text>
+          <text v-if="tempProvince" class="crumb-arrow">›</text>
+          <text
+            :class="['crumb', currentLevel === 1 ? 'crumb-active' : '']"
+            @click="jumpToLevel(1)"
+          >{{ tempCity || '城市' }}</text>
+          <text v-if="tempCity" class="crumb-arrow">›</text>
+          <text
+            :class="['crumb', currentLevel === 2 ? 'crumb-active' : '']"
+            @click="jumpToLevel(2)"
+          >{{ tempDistrict || '区县' }}</text>
+          <text v-if="tempDistrict" class="crumb-arrow">›</text>
+          <text
+            :class="['crumb', currentLevel === 3 ? 'crumb-active' : '']"
+            @click="jumpToLevel(3)"
+          >{{ tempStreet || '街道' }}</text>
+        </view>
+
+        <!-- 选择列表 -->
+        <scroll-view class="picker-list" scroll-y>
+          <view
+            v-for="(item, idx) in currentItems"
+            :key="idx"
+            :class="['picker-item', isSelected(item) ? 'picker-item-selected' : '']"
+            @click="selectItem(item)"
+          >
+            <text class="item-text">{{ getItemName(item) }}</text>
+            <text v-if="isSelected(item)" class="item-check">✓</text>
+          </view>
+          <view v-if="loading" class="picker-loading">
+            <text>加载中...</text>
+          </view>
+        </scroll-view>
+      </view>
     </view>
     </view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { getProvinces, getStreetChildren, getStreetPath } from '@/utils/api.js'
+import { getProvinces, getStreetChildren } from '@/utils/api.js'
 
 
 export default {
 export default {
   name: 'AddressPicker',
   name: 'AddressPicker',
@@ -44,22 +72,30 @@ export default {
   },
   },
   data() {
   data() {
     return {
     return {
+      showPicker: false,
+      currentLevel: 0, // 0=省份, 1=城市, 2=区县, 3=街道
+      loading: false,
+      // 数据列表
       provinces: [],
       provinces: [],
       cities: [],
       cities: [],
       districts: [],
       districts: [],
       streets: [],
       streets: [],
-      selectedProvince: null,
-      selectedCity: null,
-      selectedDistrict: null,
-      selectedStreet: null
+      // 临时选中项
+      tempProvince: null,
+      tempCity: null,
+      tempDistrict: null,
+      tempStreet: null,
+      // 完整地址文本
+      currentAddress: ''
     }
     }
   },
   },
   computed: {
   computed: {
-    fullAddress() {
-      if (this.selectedStreet) {
-        return this.selectedStreet.fullName
-      }
-      return ''
+    currentItems() {
+      if (this.currentLevel === 0) return this.provinces
+      if (this.currentLevel === 1) return this.cities
+      if (this.currentLevel === 2) return this.districts
+      if (this.currentLevel === 3) return this.streets
+      return []
     }
     }
   },
   },
   watch: {
   watch: {
@@ -84,7 +120,7 @@ export default {
         console.error('加载省份失败', e)
         console.error('加载省份失败', e)
       }
       }
     },
     },
-    
+
     async loadCities(provinceId) {
     async loadCities(provinceId) {
       try {
       try {
         const res = await getStreetChildren(provinceId)
         const res = await getStreetChildren(provinceId)
@@ -93,7 +129,7 @@ export default {
         console.error('加载城市失败', e)
         console.error('加载城市失败', e)
       }
       }
     },
     },
-    
+
     async loadDistricts(cityId) {
     async loadDistricts(cityId) {
       try {
       try {
         const res = await getStreetChildren(cityId)
         const res = await getStreetChildren(cityId)
@@ -102,7 +138,7 @@ export default {
         console.error('加载区县失败', e)
         console.error('加载区县失败', e)
       }
       }
     },
     },
-    
+
     async loadStreets(districtId) {
     async loadStreets(districtId) {
       try {
       try {
         const res = await getStreetChildren(districtId)
         const res = await getStreetChildren(districtId)
@@ -111,122 +147,142 @@ export default {
         console.error('加载街道失败', e)
         console.error('加载街道失败', e)
       }
       }
     },
     },
-    
+
     async loadAddressById(streetId) {
     async loadAddressById(streetId) {
       try {
       try {
-        const res = await getStreetPath(streetId)
+        const res = await getStreetChildren(streetId)
         const path = res.data || []
         const path = res.data || []
-        
-        if (path.length >= 1) this.selectedProvince = path[0]
-        if (path.length >= 2) this.selectedCity = path[1]
-        if (path.length >= 3) this.selectedDistrict = path[2]
-        if (path.length >= 4) this.selectedStreet = path[3]
-        
+
+        if (path.length >= 4) {
+          this.tempProvince = path[0]
+          this.tempCity = path[1]
+          this.tempDistrict = path[2]
+          this.tempStreet = path[3]
+          this.currentAddress = path[3].fullName || this.buildAddress(path)
+        }
+
         // 加载下级选项
         // 加载下级选项
-        if (this.selectedProvince) await this.loadCities(this.selectedProvince.id)
-        if (this.selectedCity) await this.loadDistricts(this.selectedCity.id)
-        if (this.selectedDistrict) await this.loadStreets(this.selectedDistrict.id)
+        if (this.tempProvince) await this.loadCities(this.tempProvince.id)
+        if (this.tempCity) await this.loadDistricts(this.tempCity.id)
+        if (this.tempDistrict) await this.loadStreets(this.tempDistrict.id)
       } catch (e) {
       } catch (e) {
         console.error('加载地址路径失败', e)
         console.error('加载地址路径失败', e)
       }
       }
     },
     },
-    
-    showProvincePicker() {
-      uni.showActionSheet({
-        itemList: this.provinces.map(p => p.province),
-        success: async (res) => {
-          this.selectedProvince = this.provinces[res.tapIndex]
-          this.selectedCity = null
-          this.selectedDistrict = null
-          this.selectedStreet = null
-          this.cities = []
-          this.districts = []
-          this.streets = []
-          
-          await this.loadCities(this.selectedProvince.id)
-          this.emitChange()
-        }
-      })
+
+    buildAddress(path) {
+      if (!path || path.length === 0) return ''
+      var parts = []
+      if (path[0]) parts.push(path[0].province || path[0].name)
+      if (path[1]) parts.push(path[1].city || path[1].name)
+      if (path[2]) parts.push(path[2].district || path[2].name)
+      if (path[3]) parts.push(path[3].street || path[3].name)
+      return parts.join('')
     },
     },
-    
-    showCityPicker() {
-      if (this.cities.length === 0) {
-        uni.showToast({ title: '请先选择省份', icon: 'none' })
-        return
+
+    openPicker() {
+      this.showPicker = true
+      if (!this.tempProvince && this.provinces.length === 0) {
+        this.loadProvinces()
       }
       }
-      
-      uni.showActionSheet({
-        itemList: this.cities.map(c => c.city),
-        success: async (res) => {
-          this.selectedCity = this.cities[res.tapIndex]
-          this.selectedDistrict = null
-          this.selectedStreet = null
-          this.districts = []
-          this.streets = []
-          
-          await this.loadDistricts(this.selectedCity.id)
-          this.emitChange()
-        }
-      })
     },
     },
-    
-    showDistrictPicker() {
-      if (this.districts.length === 0) {
-        uni.showToast({ title: '请先选择城市', icon: 'none' })
-        return
+
+    closePicker() {
+      this.showPicker = false
+    },
+
+    jumpToLevel(level) {
+      if (level < this.currentLevel) {
+        this.currentLevel = level
       }
       }
-      
-      uni.showActionSheet({
-        itemList: this.districts.map(d => d.district),
-        success: async (res) => {
-          this.selectedDistrict = this.districts[res.tapIndex]
-          this.selectedStreet = null
-          this.streets = []
-          
-          await this.loadStreets(this.selectedDistrict.id)
-          this.emitChange()
-        }
-      })
     },
     },
-    
-    showStreetPicker() {
-      if (this.streets.length === 0) {
-        uni.showToast({ title: '请先选择区县', icon: 'none' })
-        return
+
+    selectItem(item) {
+      if (this.currentLevel === 0) {
+        this.tempProvince = item
+        this.tempCity = null
+        this.tempDistrict = null
+        this.tempStreet = null
+        this.cities = []
+        this.districts = []
+        this.streets = []
+        this.currentLevel = 1
+        this.loadCities(item.id)
+      } else if (this.currentLevel === 1) {
+        this.tempCity = item
+        this.tempDistrict = null
+        this.tempStreet = null
+        this.districts = []
+        this.streets = []
+        this.currentLevel = 2
+        this.loadDistricts(item.id)
+      } else if (this.currentLevel === 2) {
+        this.tempDistrict = item
+        this.tempStreet = null
+        this.streets = []
+        this.currentLevel = 3
+        this.loadStreets(item.id)
+      } else if (this.currentLevel === 3) {
+        this.tempStreet = item
+        this.confirmAddress()
       }
       }
-      
-      uni.showActionSheet({
-        itemList: this.streets.map(s => s.street),
-        success: (res) => {
-          this.selectedStreet = this.streets[res.tapIndex]
-          this.emitChange()
-        }
-      })
     },
     },
-    
+
+    confirmAddress() {
+      this.updateAddressText()
+      this.emitChange()
+      this.closePicker()
+    },
+
+    updateAddressText() {
+      var parts = []
+      if (this.tempProvince) parts.push(this.tempProvince.province || this.tempProvince.name)
+      if (this.tempCity) parts.push(this.tempCity.city || this.tempCity.name)
+      if (this.tempDistrict) parts.push(this.tempDistrict.district || this.tempDistrict.name)
+      if (this.tempStreet) parts.push(this.tempStreet.street || this.tempStreet.name)
+      this.currentAddress = parts.join('')
+    },
+
     emitChange() {
     emitChange() {
-      const streetId = this.selectedStreet ? this.selectedStreet.id : null
+      const streetId = this.tempStreet ? this.tempStreet.id : null
       const addressData = {
       const addressData = {
         streetId: streetId,
         streetId: streetId,
-        province: this.selectedProvince ? this.selectedProvince.province : null,
-        city: this.selectedCity ? this.selectedCity.city : null,
-        district: this.selectedDistrict ? this.selectedDistrict.district : null,
-        street: this.selectedStreet ? this.selectedStreet.street : null,
-        fullName: this.fullAddress
+        province: this.tempProvince ? this.tempProvince.province : null,
+        city: this.tempCity ? this.tempCity.city : null,
+        district: this.tempDistrict ? this.tempDistrict.district : null,
+        street: this.tempStreet ? this.tempStreet.street : null,
+        fullName: this.currentAddress
       }
       }
-      
+
       this.$emit('input', streetId)
       this.$emit('input', streetId)
       this.$emit('change', addressData)
       this.$emit('change', addressData)
     },
     },
-    
+
+    getItemName(item) {
+      if (!item) return ''
+      return item.province || item.city || item.district || item.street || item.name || ''
+    },
+
+    isSelected(item) {
+      var name = this.getItemName(item)
+      if (this.currentLevel === 0) return this.tempProvince && this.getItemName(this.tempProvince) === name
+      if (this.currentLevel === 1) return this.tempCity && this.getItemName(this.tempCity) === name
+      if (this.currentLevel === 2) return this.tempDistrict && this.getItemName(this.tempDistrict) === name
+      if (this.currentLevel === 3) return this.tempStreet && this.getItemName(this.tempStreet) === name
+      return false
+    },
+
     reset() {
     reset() {
-      this.selectedProvince = null
-      this.selectedCity = null
-      this.selectedDistrict = null
-      this.selectedStreet = null
+      this.tempProvince = null
+      this.tempCity = null
+      this.tempDistrict = null
+      this.tempStreet = null
+      this.currentAddress = ''
+      this.provinces = []
       this.cities = []
       this.cities = []
       this.districts = []
       this.districts = []
       this.streets = []
       this.streets = []
+      this.currentLevel = 0
       this.emitChange()
       this.emitChange()
     }
     }
   }
   }
@@ -235,51 +291,144 @@ export default {
 
 
 <style scoped>
 <style scoped>
 .address-picker {
 .address-picker {
+  width: 100%;
+}
+
+.current-address {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24rpx 20rpx;
+  background: #f8f8f8;
+  border-radius: 12rpx;
+  border: 2rpx solid #eee;
+}
+
+.address-text {
+  font-size: 28rpx;
+  color: #333;
+  flex: 1;
+}
+
+.address-arrow {
+  font-size: 32rpx;
+  color: #999;
+  margin-left: 10rpx;
+}
+
+/* 面板遮罩 */
+.picker-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+}
+
+.picker-panel {
+  width: 100%;
   background: #fff;
   background: #fff;
-  padding: 20rpx;
+  border-radius: 24rpx 24rpx 0 0;
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
 }
 }
 
 
-.picker-row {
+/* 面板头部 */
+.picker-header {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
-  padding: 20rpx 0;
+  justify-content: space-between;
+  padding: 30rpx 30rpx 20rpx;
   border-bottom: 1px solid #eee;
   border-bottom: 1px solid #eee;
 }
 }
 
 
-.picker-label {
+.picker-btn {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #666;
   color: #666;
-  width: 140rpx;
+  padding: 10rpx 20rpx;
 }
 }
 
 
-.picker-value {
-  flex: 1;
-  font-size: 28rpx;
+.picker-confirm {
+  color: #f97316;
+  font-weight: bold;
+}
+
+.picker-title {
+  font-size: 32rpx;
+  font-weight: bold;
   color: #333;
   color: #333;
 }
 }
 
 
-.picker-arrow {
-  font-size: 24rpx;
-  color: #999;
-  margin-left: 10rpx;
+/* 面包屑导航 */
+.picker-breadcrumb {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  background: #fafafa;
+  border-bottom: 1px solid #eee;
+  flex-wrap: wrap;
 }
 }
 
 
-.address-display {
-  margin-top: 20rpx;
-  padding: 20rpx;
-  background: #f5f5f5;
-  border-radius: 8rpx;
+.crumb {
+  font-size: 26rpx;
+  color: #666;
+  padding: 8rpx 16rpx;
+  background: #eee;
+  border-radius: 20rpx;
 }
 }
 
 
-.address-label {
-  font-size: 24rpx;
-  color: #999;
-  display: block;
-  margin-bottom: 10rpx;
+.crumb-active {
+  background: #f97316;
+  color: #fff;
 }
 }
 
 
-.address-text {
+.crumb-arrow {
   font-size: 28rpx;
   font-size: 28rpx;
+  color: #999;
+  margin: 0 8rpx;
+}
+
+/* 选择列表 */
+.picker-list {
+  flex: 1;
+  min-height: 300rpx;
+  max-height: 500rpx;
+  padding: 20rpx 0;
+}
+
+.picker-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 28rpx 40rpx;
+  border-bottom: 1px solid #f5f5f5;
+}
+
+.item-text {
+  font-size: 30rpx;
   color: #333;
   color: #333;
 }
 }
+
+.item-check {
+  font-size: 32rpx;
+  color: #f97316;
+  font-weight: bold;
+}
+
+.picker-item-selected .item-text {
+  color: #f97316;
+  font-weight: bold;
+}
+
+.picker-loading {
+  padding: 40rpx;
+  text-align: center;
+  color: #999;
+  font-size: 28rpx;
+}
 </style>
 </style>

+ 1 - 1
cfc-frontend/pages/profile/service-role-apply.vue

@@ -261,7 +261,7 @@ export default {
   gap: 20rpx;
   gap: 20rpx;
 }
 }
 .role-card {
 .role-card {
-  width: calc(33.33% - 14rpx);
+  width: calc((100% - 40rpx) / 3);
   background: #f8f8f8;
   background: #f8f8f8;
   border-radius: 12rpx;
   border-radius: 12rpx;
   padding: 24rpx 0;
   padding: 24rpx 0;

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-fbad0d85b5e023f36a87a177558164d65b1ac3f0
+bc8a6ca4f0787ffdcf895e71d0fbb6ae239cc466

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,67 @@
 
 
 ---
 ---
 
 
+## v1.0.1574 (2026-10-11)
+
+### 重构
+- 将保存和取消按钮移至顶部操作栏
+
+
+## v1.0.1573 (2026-10-11)
+
+### Bug 修复
+- 商品编辑页移除横向滚动条
+
+### 其他
+- - 卡片 body 增加 width: 100% 和 box-sizing: border-box,防止 padding 导致横向溢出
+- 
+
+
+## v1.0.1572 (2026-10-11)
+
+### Bug 修复
+- 修复商品编辑页内容显示不全无法滑动问题
+
+### 其他
+- - el-card__body 增加 overflow: hidden,配合 .product-edit-form-wrap 的 overflow-y: auto 实现内部滚动
+- - 移除重复的 el-card__body 样式定义,保持 header 固定
+- - 与 Layout/admin-tokens 的 .admin-page 弹性约束保持一致
+- 
+
+
+## v1.0.1571 (2026-10-11)
+
+### Bug 修复
+- switchMemberToFamily 兼容孤儿成员记录,审批加入不再 500
+
+
+## v1.0.1570 (2026-10-11)
+
+### 新功能
+- 浠艾福桌面宠物 v1.0 完成(WPF + WebView2 架构)
+- 桌面宠物设计文档 v1.1 - 支持用户自定义状态
+- 桌面宠物设计文档 v1.0
+
+### 其他
+- - 架构:WPF (.NET 6) 主进程 + WebView2 渲染层,Bridge via cfcPetAPI
+- - 状态映射:14 种核心状态 + 用户自定义状态(custom_state_1~10)
+- - API 对接:POST /api/tasks/today、/api/energy/overview、/api/daily/checkin
+- - 形象配置:JSON Schema (PetConfig),支持浠宝/福宝预设 + 用户自定义 PNG
+- - 交互闭环:喝水提醒 → 点击确认 → API 打卡 → 庆祝动画
+- - 托盘常驻:最小化到托盘,右键菜单(设置/显示/退出),双击唤出
+- - Token 存储:Windows DPAPI 加密,%APPDATA%\XafPet\token.bin
+- - 动画库:20 个 CSS keyframes(idle/happy/busy/sad/celebrate 等)
+- - 测试:xUnit + Moq 单元测试 18 个(PetConfig/ApiClient/StateManager)
+- Windows 构建命令:
+-   dotnet build XafPet.Desktop.sln
+-   dotnet test tests/XafPet.Desktop.Tests
+-   dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64
+- 
+
+### 文档
+- 桌面宠物实现计划 v1.0
+
+
 ## v1.0.1569 (2026-10-11)
 ## v1.0.1569 (2026-10-11)
 
 
 ### 新功能
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1569
+> 当前版本: v1.0.1574
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,67 @@
 
 
 ---
 ---
 
 
+## v1.0.1574 (2026-10-11)
+
+### 重构
+- 将保存和取消按钮移至顶部操作栏
+
+
+## v1.0.1573 (2026-10-11)
+
+### Bug 修复
+- 商品编辑页移除横向滚动条
+
+### 其他
+- - 卡片 body 增加 width: 100% 和 box-sizing: border-box,防止 padding 导致横向溢出
+- 
+
+
+## v1.0.1572 (2026-10-11)
+
+### Bug 修复
+- 修复商品编辑页内容显示不全无法滑动问题
+
+### 其他
+- - el-card__body 增加 overflow: hidden,配合 .product-edit-form-wrap 的 overflow-y: auto 实现内部滚动
+- - 移除重复的 el-card__body 样式定义,保持 header 固定
+- - 与 Layout/admin-tokens 的 .admin-page 弹性约束保持一致
+- 
+
+
+## v1.0.1571 (2026-10-11)
+
+### Bug 修复
+- switchMemberToFamily 兼容孤儿成员记录,审批加入不再 500
+
+
+## v1.0.1570 (2026-10-11)
+
+### 新功能
+- 浠艾福桌面宠物 v1.0 完成(WPF + WebView2 架构)
+- 桌面宠物设计文档 v1.1 - 支持用户自定义状态
+- 桌面宠物设计文档 v1.0
+
+### 其他
+- - 架构:WPF (.NET 6) 主进程 + WebView2 渲染层,Bridge via cfcPetAPI
+- - 状态映射:14 种核心状态 + 用户自定义状态(custom_state_1~10)
+- - API 对接:POST /api/tasks/today、/api/energy/overview、/api/daily/checkin
+- - 形象配置:JSON Schema (PetConfig),支持浠宝/福宝预设 + 用户自定义 PNG
+- - 交互闭环:喝水提醒 → 点击确认 → API 打卡 → 庆祝动画
+- - 托盘常驻:最小化到托盘,右键菜单(设置/显示/退出),双击唤出
+- - Token 存储:Windows DPAPI 加密,%APPDATA%\XafPet\token.bin
+- - 动画库:20 个 CSS keyframes(idle/happy/busy/sad/celebrate 等)
+- - 测试:xUnit + Moq 单元测试 18 个(PetConfig/ApiClient/StateManager)
+- Windows 构建命令:
+-   dotnet build XafPet.Desktop.sln
+-   dotnet test tests/XafPet.Desktop.Tests
+-   dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64
+- 
+
+### 文档
+- 桌面宠物实现计划 v1.0
+
+
 ## v1.0.1569 (2026-10-11)
 ## v1.0.1569 (2026-10-11)
 
 
 ### 新功能
 ### 新功能

+ 23 - 23
cfc-web/src/views/admin/ProductEdit.vue

@@ -4,7 +4,8 @@
       <div slot="header" class="admin-page-header">
       <div slot="header" class="admin-page-header">
         <span class="admin-page-title">{{ isEdit ? '编辑商品' : '新增商品' }}</span>
         <span class="admin-page-title">{{ isEdit ? '编辑商品' : '新增商品' }}</span>
         <div class="admin-page-actions">
         <div class="admin-page-actions">
-          <el-button size="small" @click="$router.back()">返回</el-button>
+          <el-button type="primary" size="small" :loading="saving" @click="handleSave()">{{ isEdit ? '保存修改' : '创建商品' }}</el-button>
+          <el-button size="small" @click="$router.back()">取消</el-button>
         </div>
         </div>
       </div>
       </div>
 
 
@@ -341,12 +342,7 @@
           </div>
           </div>
         </el-form-item>
         </el-form-item>
 
 
-        <div class="edit-form-footer">
-          <el-button type="primary" :loading="saving" @click="handleSave()">
-{{ isEdit ? '保存修改' : '创建商品' }}
-          </el-button>
-          <el-button @click="$router.back()">取消</el-button>
-        </div>
+        <div class="edit-form-footer" style="display:none;"></div>
       </el-form>
       </el-form>
       </div>
       </div>
     </el-card>
     </el-card>
@@ -1170,17 +1166,26 @@ async loadGiftRule(productId) {
 <style scoped>
 <style scoped>
 .product-edit {
 .product-edit {
   padding: 20px;
   padding: 20px;
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 }
 }
 .product-edit > .el-card {
 .product-edit > .el-card {
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 }
 }
 .product-edit > .el-card /deep/ .el-card__body {
 .product-edit > .el-card /deep/ .el-card__body {
-  display: block;
-  min-height: auto;
-  overflow: visible;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+  padding: 24px;
+  width: 100%;
+  box-sizing: border-box;
 }
 }
 .cover-uploader {
 .cover-uploader {
   display: flex;
   display: flex;
@@ -1263,6 +1268,8 @@ async loadGiftRule(productId) {
 }
 }
 .product-edit .el-card {
 .product-edit .el-card {
   width: 100%;
   width: 100%;
+  flex: 1;
+  min-height: 0;
 }
 }
 .product-edit .el-form {
 .product-edit .el-form {
   width: 100%;
   width: 100%;
@@ -1365,24 +1372,17 @@ async loadGiftRule(productId) {
   box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
   box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
 }
 }
 /* 表单滚动容器:卡片锁定可视高度,表单区内部滚动(解决长表单无法下翻) */
 /* 表单滚动容器:卡片锁定可视高度,表单区内部滚动(解决长表单无法下翻) */
-.product-edit /deep/ .el-card__body {
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-  padding: 24px;
-}
 .product-edit-form-wrap {
 .product-edit-form-wrap {
   flex: 1;
   flex: 1;
   min-height: 0;
   min-height: 0;
   overflow-y: auto;
   overflow-y: auto;
+  overflow-x: hidden;
   padding-right: 8px;
   padding-right: 8px;
   margin-right: -8px;
   margin-right: -8px;
 }
 }
 .product-edit /deep/ .el-card__header {
 .product-edit /deep/ .el-card__header {
   padding: 20px 24px;
   padding: 20px 24px;
-}
-.product-edit /deep/ .el-card__body {
-  padding: 24px;
+  flex-shrink: 0;
 }
 }
 .product-edit /deep/ .admin-page-title {
 .product-edit /deep/ .admin-page-title {
   font-size: 20px;
   font-size: 20px;