Răsfoiți Sursa

refactor(beautify): D37 模板组件属性色值改绑 BrandColors + 新增全站编译自检

- scripts/verify-vue-compile.js:全量 .vue 模板(vue-template-compiler)/样式(dart-sass,
  自动注入 uni.scss)/脚本(node --check) 三段编译自检,383 文件 0 失败
  (跳过 components/mp-html 第三方渲染器)
- D37:24 处模板属性色值 attr=#hex 改为 :attr='xxxColor'(switch color、slider
  activeColor、progress activeColor、swiper indicator-active-color、RadarChart
  fillColor 等),颜色统一取自 BrandColors;18 文件补 import + data/computed 绑定
- SVG <stop stop-color> 属小程序内联 SVG 有限支持范围,仅补引号保持字面量
- 模板区编译期不可达的 hex 从 24 处降到 1 处(SVG stop)
- 硬编码色 485 -> 462;三项硬指标保持 0
- AGENTS.md 补「提交前全站编译自检」与两类新编译错误对照
liaoxg 5 zile în urmă
părinte
comite
78dee2a59b

+ 12 - 0
cfc-frontend/AGENTS.md

@@ -233,6 +233,18 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 | `Cannot find module 'node-sass'` | uni-app sass-loader 需要 | `npm install node-sass --save-dev` |
 | `getPreCompileOptions` DevTools 内部错误 | `dist/dev/mp-weixin/` 缺少 `project.config.json` | 先 `npm run build:mp-weixin` 生成 dist |
 | `Unexpected token` in `.vue` | JS 语法错误,常见于方法被截断/拼接错误 | 检查 v-on:click/方法调用是否正确闭合 |
+| 模板报 `Invalid expression` / `private name` | 模板表达式里写了未加引号的 `#hex`(如 `:style="{ color: #FFF }"`、`item.color \|\| #F97316`) | 加引号 `'#FFF'`,或把颜色移进 class / `BrandColors`(脚本侧) |
+| dart-sass `Undefined variable: $xxx` | 样式引用了 `uni.scss` 里不存在的令牌 | 在 `uni.scss` 补令牌,或改用已有等值令牌 |
+
+### 提交前全站编译自检
+
+```bash
+node scripts/verify-vue-compile.js            # 全量:模板 + 样式 + 脚本三段编译
+node scripts/verify-vue-compile.js a.vue b.vue  # 仅校验指定文件
+```
+
+判定标准:`模板通过 / 样式通过 / 脚本通过` 三项数量一致且输出 `PASS`(退出码 0)。
+它能拦住三类只在 HBuilderX 打包时才暴露、小程序侧表现为白板的问题:模板表达式里未加引号的 `#hex`、样式引用未定义 SCSS 令牌、脚本语法截断。`components/mp-html/node/node.vue` 属第三方渲染器,脚本内已跳过。
 
 ### 修改 `remove-scoped.js` 的风险
 

+ 3 - 2
cfc-frontend/pages/action-detail/index.vue

@@ -96,7 +96,7 @@
       v-if="isLoggedIn && actionSubDims.length > 0"
       title="🌲 关系六维"
       subtitle="家庭关系的六个方面"
-      accent-color=#10B981
+      :accent-color="successColor"
       :dims="actionSubDims" />
 
     <!-- 登录后:家庭关系(行维度核心) -->
@@ -194,7 +194,7 @@
         :scores="actionScores"
         :avg-scores="actionFamilyScores"
         :population-scores="actionPopulationScores"
-        fillColor=#10B981
+        :fillColor="successColor"
         gridColor="#D1FAE5"
         labelColor="#065F46"
         :width="580"
@@ -280,6 +280,7 @@ export default {
   components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, DimensionVisitorCard, FloatingAvatar, RadarChart, DimensionSubDims, DimensionPageSkeleton },
   data() {
     return {
+      successColor: BrandColors.success,
     pageLoading: false,
       isLoggedIn: false,
       role: null,

+ 3 - 1
cfc-frontend/pages/body-detail/index.vue

@@ -89,7 +89,7 @@
         :scores="radarScores"
         :avg-scores="radarFamilyScores"
         :population-scores="radarPopulationScores"
-        fillColor=#FF8C42
+        :fillColor="bodyColor"
         gridColor="#FED7AA"
         labelColor="#9A3412"
         :width="580"
@@ -242,6 +242,7 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
@@ -271,6 +272,7 @@ export default {
   components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, DimensionMemberTabs, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
+      bodyColor: BrandColors.body,
     memberScoreMap: {},
     pageLoading: false,
       isLoggedIn: false,

+ 3 - 1
cfc-frontend/pages/child/rewards.vue

@@ -10,7 +10,7 @@
         <view class="reward-info">
           <view class="reward-title">{{ reward.title }}</view>
           <view class="reward-progress">
-            <progress :percent="reward.pointsRequired > 0 ? (currentPoints / reward.pointsRequired) * 100 : 0" stroke-width="6" activeColor=#F97316 backgroundColor="#eee" />
+            <progress :percent="reward.pointsRequired > 0 ? (currentPoints / reward.pointsRequired) * 100 : 0" stroke-width="6" :activeColor="primaryColor" backgroundColor="#eee" />
             <text class="progress-text">{{ currentPoints }}/{{ reward.pointsRequired }}积分</text>
           </view>
         </view>
@@ -43,11 +43,13 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import { getWishlist, createReward as createRewardApi, getPointsBalance, getChildren, switchBackToParent, verifyPassword } from '../../utils/api.js'
 
 export default {
   data() {
     return {
+      primaryColor: BrandColors.primary,
       rewards: [],
       currentPoints: 0,
       currentChildId: '',

+ 3 - 1
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -6,7 +6,7 @@
       <text class="loading-text">加载中...</text>
     </view>
     <scroll-view v-else-if="product.id" scroll-y class="content" @scrolltolower="onScrollToBottom">
-      <swiper class="cover-swiper" indicator-dots autoplay circular indicator-active-color=#F97316>
+      <swiper class="cover-swiper" indicator-dots autoplay circular :indicator-active-color="primaryColor">
         <swiper-item v-for="(img, idx) in displayImages" :key="idx">
           <image class="swiper-image" :src="getImageUrl(img)" mode="widthFix" @error="onSwiperImageError(idx)" />
         </swiper-item>
@@ -151,6 +151,7 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import config from '@/config.js'
 import { productDetail, productOrderCreate, productOrderPay, cartAdd, getShareQrCode, productSpecMap, getMyMembership } from '@/utils/api.js'
 import MpHtml from '@/components/mp-html/mp-html.vue'
@@ -170,6 +171,7 @@ export default {
   },
   data() {
     return {
+      primaryColor: BrandColors.primary,
       fontScale: 1,
       product: {},
       loading: false,

+ 3 - 1
cfc-frontend/pages/discover/create-circle.vue

@@ -48,7 +48,7 @@
         <view class="form-item">
           <view class="switch-row">
             <text class="form-label">公开可见</text>
-            <switch :checked="form.isPublic === 1" color=#FF8C42 @change="form.isPublic = $event.detail.value ? 1 : 0" />
+            <switch :checked="form.isPublic === 1" :color="bodyColor" @change="form.isPublic = $event.detail.value ? 1 : 0" />
           </view>
           <text class="switch-desc">其他用户可以在发现页看到此圈子</text>
         </view>
@@ -64,11 +64,13 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import { createGeneralCircle } from '@/utils/api.js'
 
 export default {
   data: function() {
     return {
+      bodyColor: BrandColors.body,
       form: {
         name: '',
         type: 'topic',

+ 3 - 1
cfc-frontend/pages/family/add-member.vue

@@ -86,7 +86,7 @@
 <view class="form-item" v-if="isEdit && operatorAdmin">
 <view class="admin-row">
 <text class="admin-row-text">家庭管理员</text>
-<switch :checked="form.isAdmin === 1" color=#F97316 @change="onAdminChange" />
+<switch :checked="form.isAdmin === 1" :color="primaryColor" @change="onAdminChange" />
 </view>
 <text class="hint">{{ form.isAdmin === 1 ? '管理员可查看/管理全家庭报告与成员' : '仅可查看本人报告' }}</text>
 </view>
@@ -118,6 +118,7 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import { addFamilyMember, updateFamilyMember, recordConsent, checkPhone } from '../../utils/api.js'
 import GenerationPicker from '@/components/GenerationPicker.vue'
 import { parseDate } from '../../utils/format.js'
@@ -126,6 +127,7 @@ export default {
 components: { GenerationPicker },
 data() {
 return {
+  primaryColor: BrandColors.primary,
 submitting: false,
 isEdit: false,
 editMemberId: null,

+ 4 - 2
cfc-frontend/pages/health-main/index.vue

@@ -33,12 +33,12 @@
         </view>
       </view>
       
-        <RadarChart :dimensions="demoBodyDims" :scores="demoBodyScores" fillColor=#FF8C42 childName="示例" width="560" height="460" />
+        <RadarChart :dimensions="demoBodyDims" :scores="demoBodyScores" :fillColor="bodyColor" childName="示例" width="560" height="460" />
         <view class="demo-chart-title-row">
           <text class="demo-chart-name">🧠 认知雷达</text>
           <text class="demo-chart-tag">示例</text>
         </view>
-        <RadarChart :dimensions="demoCogDims" :scores="demoCogScores" fillColor=#6366F1 childName="示例" width="560" height="460" />
+        <RadarChart :dimensions="demoCogDims" :scores="demoCogScores" :fillColor="wisdomColor" childName="示例" width="560" height="460" />
       <!-- 五维健康体系 -->
       <view class="wuxing-section">
         <view class="wuxing-title">身·心·智·行·富 · 看得见的健康闭环</view>
@@ -247,6 +247,8 @@ export default {
   components: { LoginGuideCard, RadarChart, ArticleBookshelf, ReportUploadCard },
   data() {
     return {
+      bodyColor: BrandColors.body,
+      wisdomColor: BrandColors.wisdom,
       isLoggedIn: !!uni.getStorageSync('token'),
       currentTab: 'body',
       selectedMemberName: '',

+ 3 - 1
cfc-frontend/pages/health/components/ComprehensiveSurvey.vue

@@ -244,7 +244,7 @@
         <view class="card">
           <view class="card-head"><text class="card-title">辣度</text><text class="card-hint">{{ spiceLabels[innerDiet.spiceLevel || 0] }}</text></view>
           <view class="slider-row">
-            <slider :value="innerDiet.spiceLevel || 0" min="0" max="5" step="1" activeColor=#FF8C42
+            <slider :value="innerDiet.spiceLevel || 0" min="0" max="5" step="1" :activeColor="bodyColor"
                     @changing="onSpiceChanging" @change="onSpiceChange" />
           </view>
         </view>
@@ -479,6 +479,7 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import { BOWEL_FORM_OPTIONS, normalizeBowelForm } from '@/utils/bowel-form.js'
 // ===== 预设常量(与旧子组件 / preferences.vue 保持一致) =====
 var PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
@@ -597,6 +598,7 @@ export default {
   },
   data: function() {
     return {
+      bodyColor: BrandColors.body,
       currentStep: 1,
       innerForm: {},
       innerDiet: {},

+ 7 - 5
cfc-frontend/pages/health/daily-checkin.vue

@@ -10,7 +10,7 @@
             <text class="score-label">饮食评分</text>
             <view class="score-row">
               <text class="score-min">差</text>
-              <slider min="1" max="10" :value="form.dietScore" @change="function(e){form.dietScore=e.detail.value}" block-size="20" activeColor=#FF8C42 />
+              <slider min="1" max="10" :value="form.dietScore" @change="function(e){form.dietScore=e.detail.value}" block-size="20" :activeColor="bodyColor" />
               <text class="score-val">{{ form.dietScore }}</text>
             </view>
           </view>
@@ -19,7 +19,7 @@
             <text class="score-label">运动评分</text>
             <view class="score-row">
               <text class="score-min">差</text>
-              <slider min="1" max="10" :value="form.exerciseScore" @change="function(e){form.exerciseScore=e.detail.value}" block-size="20" activeColor=#FF8C42 />
+              <slider min="1" max="10" :value="form.exerciseScore" @change="function(e){form.exerciseScore=e.detail.value}" block-size="20" :activeColor="bodyColor" />
               <text class="score-val">{{ form.exerciseScore }}</text>
             </view>
           </view>
@@ -28,7 +28,7 @@
             <text class="score-label">睡眠评分</text>
             <view class="score-row">
               <text class="score-min">差</text>
-              <slider min="1" max="10" :value="form.sleepScore" @change="function(e){form.sleepScore=e.detail.value}" block-size="20" activeColor=#FF8C42 />
+              <slider min="1" max="10" :value="form.sleepScore" @change="function(e){form.sleepScore=e.detail.value}" block-size="20" :activeColor="bodyColor" />
               <text class="score-val">{{ form.sleepScore }}</text>
             </view>
           </view>
@@ -37,7 +37,7 @@
             <text class="score-label">饮水评分</text>
             <view class="score-row">
               <text class="score-min">差</text>
-              <slider min="1" max="10" :value="form.waterScore" @change="function(e){form.waterScore=e.detail.value}" block-size="20" activeColor=#FF8C42 />
+              <slider min="1" max="10" :value="form.waterScore" @change="function(e){form.waterScore=e.detail.value}" block-size="20" :activeColor="bodyColor" />
               <text class="score-val">{{ form.waterScore }}</text>
             </view>
           </view>
@@ -46,7 +46,7 @@
             <text class="score-label">心情评分</text>
             <view class="score-row">
               <text class="score-min">差</text>
-              <slider min="1" max="10" :value="form.moodScore" @change="function(e){form.moodScore=e.detail.value}" block-size="20" activeColor=#FF8C42 />
+              <slider min="1" max="10" :value="form.moodScore" @change="function(e){form.moodScore=e.detail.value}" block-size="20" :activeColor="bodyColor" />
               <text class="score-val">{{ form.moodScore }}</text>
             </view>
           </view>
@@ -91,11 +91,13 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import config from '@/config.js'
 
 export default {
   data() {
     return {
+      bodyColor: BrandColors.body,
       memberId: null,
       submitting: false,
       history: [],

+ 3 - 1
cfc-frontend/pages/health/sleep-index.vue

@@ -88,7 +88,7 @@
             <text class="form-label">睡眠质量(1-10)</text>
             <view class="slider-row">
               <text class="slider-min">差</text>
-              <slider min="1" max="10" :value="form.qualityScore" @change="function(e){form.qualityScore=e.detail.value}" block-size="20" activeColor=#FF8C42 />
+              <slider min="1" max="10" :value="form.qualityScore" @change="function(e){form.qualityScore=e.detail.value}" block-size="20" :activeColor="bodyColor" />
               <text class="slider-val">{{ form.qualityScore }}</text>
             </view>
           </view>
@@ -131,12 +131,14 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
   data() {
     return {
+      bodyColor: BrandColors.body,
       memberId: null,
       submitting: false,
       history: [],

+ 3 - 1
cfc-frontend/pages/member-detail/components/member-detail-sections/body-section.vue

@@ -28,7 +28,7 @@
       <RadarChart
         :dimensions="dimensionOverview.dimensions.map(function(d) { return { label: d.label, key: d.dimension } })"
         :scores="dimensionOverview.dimensions.map(function(d) { return d.score })"
-        fillColor=#FF8C42
+        :fillColor="bodyColor"
         gridColor="#FED7AA"
         labelColor="#9A3412"
         :width="580"
@@ -59,6 +59,7 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import RadarChart from '@/components/RadarChart.vue'
 import { getHealthAnalysis, getDimensionOverview, getCheckinList } from '@/utils/api.js'
 import { parseDate } from '@/utils/format.js'
@@ -74,6 +75,7 @@ export default {
   },
   data: function() {
     return {
+      bodyColor: BrandColors.body,
       loading: true,
       healthReport: null,
       dimensionOverview: null,

+ 2 - 1
cfc-frontend/pages/mind-detail/index.vue

@@ -239,7 +239,7 @@
           :scores="getMindGroupScores(group)"
           :avg-scores="getMindGroupFamilyScores(group)"
           :population-scores="getGroupPopulationScores(group)"
-          fillColor=#FF6B9D
+          :fillColor="mindColor"
           gridColor="#FCE7F3"
           labelColor="#9D174D"
           :width="540"
@@ -278,6 +278,7 @@ export default {
 components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, DimensionMemberTabs, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
+      mindColor: BrandColors.mind,
     memberScoreMap: {},
     pageLoading: false,
       isLoggedIn: false,

+ 3 - 1
cfc-frontend/pages/profile/components/ProfileMenu.vue

@@ -81,7 +81,7 @@
         </view>
         <view class="menu-item menu-item-switch" v-if="role === 'parent'">
           <text>👁 对家庭成员可见</text>
-          <switch :checked="showToFamily === 1" @change="onShowToFamilyChange" color=#F97316 />
+          <switch :checked="showToFamily === 1" @change="onShowToFamilyChange" :color="primaryColor" />
         </view>
         <view class="menu-item" @click="logout">
           <text>🚪 退出登录</text>
@@ -139,6 +139,7 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import { setPassword, verifyPassword, updateMemberVisibility, getFamilyMemberList, vendorStatus, ensureFamily, getReferralCode, extractReferralCode } from '@/utils/api.js'
 import { getUserFontScale, setUserFontScale } from '@/utils/fontScale.js'
 
@@ -156,6 +157,7 @@ export default {
   },
   data() {
     return {
+      primaryColor: BrandColors.primary,
       hasPassword: false,
       showPasswordModal: false,
       oldPassword: '',

+ 3 - 1
cfc-frontend/pages/promotion/index.vue

@@ -63,7 +63,7 @@
             <text class="member-amount available">={{ cfVal(m.availableAmount) }}</text>
           </view>
           <!-- 管理员可切换成员池权限 -->
-          <switch v-if="isAdmin && !m.isAdmin" class="member-switch" :checked="m.canUseFamilyBalance" @change="toggleMemberAccess(m)" color=#F97316></switch>
+          <switch v-if="isAdmin && !m.isAdmin" class="member-switch" :checked="m.canUseFamilyBalance" @change="toggleMemberAccess(m)" :color="primaryColor"></switch>
         </view>
       </view>
     </view>
@@ -187,11 +187,13 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import { getReferralSummary, getCfSummary, getFamilyEarningsSummary, getFamilyEarningsMembers, getFamilyEarningsRecords, distributeToMember, toggleMemberBalance, withdrawFamilyEarnings, getReferralCode, extractReferralCode } from '../../utils/api.js'
 
 export default {
   data() {
     return {
+      primaryColor: BrandColors.primary,
       totalEarnings: '0.00',
       availableAmount: '0.00',
       summary: null,

+ 3 - 1
cfc-frontend/pages/rewards/rewards.vue

@@ -8,7 +8,7 @@
             <progress 
               :percent="(currentPoints / reward.pointsRequired) * 100" 
               stroke-width="6" 
-              activeColor=#F97316
+              :activeColor="primaryColor"
               backgroundColor="#eee"
             />
             <text class="progress-text">{{ currentPoints }}/{{ reward.pointsRequired }}积分</text>
@@ -63,6 +63,7 @@
 </template>
 
 <script>
+import { BrandColors } from '@/utils/brand-colors.js'
 import config from '@/config.js'
 import { getWishlist, exchangeReward as exchangeRewardApi, createReward as createRewardApi, getPointsBalance, getChildren, getParentWishlist, createParentWishlist, exchangeParentWishlist, reportAppEnter
 } from '../../utils/api.js'
@@ -70,6 +71,7 @@ import { getWishlist, exchangeReward as exchangeRewardApi, createReward as creat
 export default {
   data() {
     return {
+      primaryColor: BrandColors.primary,
       rewards: [],
       currentPoints: 0,
       currentChildId: '',

+ 1 - 1
cfc-frontend/pages/wealth/components/WealthEnergyRing.vue

@@ -13,7 +13,7 @@
           :stroke-dasharray="circumference" :stroke-dashoffset="progressOffset" />
         <defs>
           <linearGradient id="ringGradient" x1="0%" y1="0%" x2="100%" y2="0%">
-            <stop offset="0%" stop-color=#F59E0B />
+            <stop offset="0%" stop-color="#F59E0B" />
             <stop offset="100%" stop-color="#FBBF24" />
           </linearGradient>
         </defs>

+ 2 - 1
cfc-frontend/pages/wealth/index.vue

@@ -122,7 +122,7 @@
         v-if="isLoggedIn && wealthSubDims.length > 0"
         title="💧 财富维度构成"
         subtitle="家庭财富能量的各个方面"
-        accent-color=#F59E0B
+        :accent-color="wealthColor"
         :dims="wealthSubDims" />
 
       <!-- 财富强项与提升 -->
@@ -300,6 +300,7 @@ export default {
   components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, DimensionMemberTabs, FamilyEnergyBar, DimensionIntroCard, DimensionVisitorCard, DimensionSubDims, DimensionPageSkeleton },
   data() {
     return {
+      wealthColor: BrandColors.wealth,
     memberScoreMap: {},
     pageLoading: false,
       showTabTransition: true,

+ 2 - 1
cfc-frontend/pages/wisdom-detail/index.vue

@@ -28,7 +28,7 @@
         :avg-scores="cognitiveFamilyScores"
         :population-scores="cognitivePopulationScores"
         :childName="activeChildName"
-        fillColor=#6366F1
+        :fillColor="wisdomColor"
         gridColor="#E0E7FF"
         labelColor="#3730A3" />
     </view>
@@ -256,6 +256,7 @@ export default {
   components: { TabTransition, RadarChart, FamilyEnergyBar, FloatingAvatar, DimensionMemberTabs, DimensionIntroCard, DimensionVisitorCard, DimensionPageSkeleton },
   data() {
     return {
+      wisdomColor: BrandColors.wisdom,
     memberScoreMap: {},
     pageLoading: false,
       isLoggedIn: false,

+ 132 - 0
cfc-frontend/scripts/d37-template-color-bind.js

@@ -0,0 +1,132 @@
+#!/usr/bin/env node
+/**
+ * D37: 模板区组件属性色值(switch color / slider activeColor / 图表 fillColor 等)
+ * 绑定到 BrandColors,消除模板里编译期不可达的硬编码 hex。
+ *
+ * 做法:
+ *   1) 模板属性 `attr=#HEX` → `:attr="<key>Color"`
+ *   2) script 内补 `import { BrandColors } from '@/utils/brand-colors.js'`(已存在则跳过)
+ *   3) 在 data() 的 return 对象首部(无 data 则 computed,再否则新建 computed)注入
+ *      `<key>Color: BrandColors.<key>,`
+ *
+ * 用法: node scripts/d37-template-color-bind.js [--dry]
+ */
+const fs = require('fs');
+
+const HEXT_TO_KEY = {
+  '#FF8C42': 'body',
+  '#F97316': 'primary',
+  '#10B981': 'success',
+  '#6366F1': 'wisdom',
+  '#F59E0B': 'wealth',
+  '#FF6B9D': 'mind',
+};
+
+const FILES = [
+  'pages/action-detail/index.vue',
+  'pages/body-detail/index.vue',
+  'pages/child/rewards.vue',
+  'pages/discover/create-circle.vue',
+  'pages/discover-detail/product-detail/product-detail.vue',
+  'pages/family/add-member.vue',
+  'pages/health/components/ComprehensiveSurvey.vue',
+  'pages/health/daily-checkin.vue',
+  'pages/health/sleep-index.vue',
+  'pages/health-main/index.vue',
+  'pages/member-detail/components/member-detail-sections/body-section.vue',
+  'pages/mind-detail/index.vue',
+  'pages/profile/components/ProfileMenu.vue',
+  'pages/promotion/index.vue',
+  'pages/rewards/rewards.vue',
+  'pages/wealth/components/WealthEnergyRing.vue',
+  'pages/wealth/index.vue',
+  'pages/wisdom-detail/index.vue',
+];
+
+const DRY = process.argv.includes('--dry');
+const IMPORT_LINE = "import { BrandColors } from '@/utils/brand-colors.js'";
+
+let totalBind = 0;
+for (const f of FILES) {
+  let src = fs.readFileSync(f, 'utf8');
+  const eol = src.includes('\r\n') ? '\r\n' : '\n';
+
+  // 只在模板区(第一个行首 <script 之前)替换属性色值
+  const scriptStart = src.search(/^<script/m);
+  if (scriptStart === -1) { console.log('SKIP(no script): ' + f); continue; }
+  let head = src.slice(0, scriptStart);
+  const tail = src.slice(scriptStart);
+
+  const keys = new Set();
+  let changes = 0;
+  head = head.replace(/([A-Za-z-]+)=(#[0-9a-fA-F]{3,8})\b/g, (m, attr, hex) => {
+    const key = HEXT_TO_KEY[hex.toUpperCase()];
+    if (!key) return m;
+    changes++;
+    // SVG 内部属性(<stop stop-color>):小程序对内联 SVG 支持有限,
+    // 不做响应式绑定,仅补引号保持合法字面量
+    if (attr === 'stop-color') return attr + '="' + hex + '"';
+    keys.add(key);
+    totalBind++;
+    return ':' + attr + '="' + key + 'Color"';
+  });
+
+  if (!changes) { console.log('SKIP(no binding): ' + f); continue; }
+
+  let body = tail;
+  // 1) import(仅有响应式绑定才需要引入)
+  if (keys.size && !/brand-colors/.test(body)) {
+    body = body.replace(/^<script[^>]*>\r?\n/, m => m + IMPORT_LINE + eol);
+  }
+
+  // 2) 注入 data/computed 属性
+  let injected = false;
+
+  const dataPatterns = [
+    /^([ \t]*)data\s*(?:\([^)]*\)|:\s*function\s*\([^)]*\))\s*\{\r?\n([ \t]*)return\s*\{\r?\n/m,
+    /^([ \t]*)data\s*:\s*\(\)\s*=>\s*\(\{\r?\n([ \t]*)/m,
+  ];
+  for (const re of dataPatterns) {
+    const m = body.match(re);
+    if (!m) continue;
+    const idx = body.indexOf(m[0]) + m[0].length;
+    const indent = m[2] + '  ';
+    const block = [...keys].map(k => indent + k + 'Color: BrandColors.' + k + ',').join(eol) + eol;
+    body = body.slice(0, idx) + block + body.slice(idx);
+    injected = true;
+    break;
+  }
+
+  if (!injected && keys.size) {
+    const computedMatch = body.match(/^([ \t]*)computed\s*:\s*\{\r?\n([ \t]*)/m);
+    if (computedMatch) {
+      const idx = body.indexOf(computedMatch[0]) + computedMatch[0].length;
+      const indent = computedMatch[2] || (computedMatch[1] + '  ');
+      const block = [...keys].map(k => indent + k + 'Color() { return BrandColors.' + k + ' },').join(eol) + eol;
+      // computedMatch 已吞掉下一行缩进,需补回,否则首个既有属性会丢缩进
+      body = body.slice(0, idx) + block + indent + body.slice(idx);
+      injected = true;
+    }
+  }
+
+  if (!injected && keys.size) {
+    const exp = body.match(/^([ \t]*)export\s+default\s*\{\r?\n/m);
+    if (exp) {
+      const idx = body.indexOf(exp[0]) + exp[0].length;
+      const indent = exp[1] + '  ';
+      const block = indent + 'computed: {' + eol +
+        [...keys].map(k => indent + '  ' + k + 'Color() { return BrandColors.' + k + ' },').join(eol) + eol +
+        indent + '},' + eol;
+      body = body.slice(0, idx) + block + body.slice(idx);
+      injected = true;
+    }
+  }
+
+  if (!injected && keys.size) { console.log('FAIL(inject): ' + f); continue; }
+
+  const out = head + body;
+  if (!DRY) fs.writeFileSync(f, out, 'utf8');
+  console.log((DRY ? 'DRY ' : 'OK  ') + f + '  bindings=' + keys.size);
+}
+
+console.log('模板属性绑定总数: ' + totalBind);

+ 134 - 0
cfc-frontend/scripts/verify-vue-compile.js

@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+/**
+ * 全站 .vue 三段编译校验(模板 / 样式 / 脚本)
+ *
+ * 覆盖三类真实会打断 HBuilderX 打包的错误:
+ *   1) 模板:表达式里未加引号的 `#hex`、可选链 `?.`、`:class` 带参方法调用等 →
+ *      vue-template-compiler 编译即失败(小程序侧表现为白板)
+ *   2) 样式:引用了 uni.scss 里不存在的 `$var` → dart-sass 报 Undefined variable
+ *   3) 脚本:语法错误 / 截断 → node --check 失败
+ *
+ * 用法:
+ *   node scripts/verify-vue-compile.js                 # 全量
+ *   node scripts/verify-vue-compile.js a.vue b.vue     # 指定文件
+ * 退出码: 0 全通过 / 1 有失败项
+ */
+const fs = require('fs')
+const path = require('path')
+const cp = require('child_process')
+const os = require('os')
+
+const ROOT = path.resolve(__dirname, '..')
+const SKIP_DIRS = new Set(['node_modules', 'dist', 'unpackage', '.git', 'dist_bak_1792213156', '.tmp_equiv'])
+const SKIP_FILES = new Set(['components/mp-html/node/node.vue']) // 第三方渲染器:自定义标签 + 双 script 结构
+
+let sass = null
+try { sass = require(path.join(ROOT, 'node_modules/sass')) } catch (e) { sass = null }
+let compiler = null
+try { compiler = require(path.join(ROOT, 'node_modules/vue-template-compiler')) } catch (e) { compiler = null }
+
+const uniScss = fs.readFileSync(path.join(ROOT, 'uni.scss'), 'utf8')
+
+function collect(dir, out = []) {
+  for (const name of fs.readdirSync(dir)) {
+    if (SKIP_DIRS.has(name)) continue
+    const p = path.join(dir, name)
+    const st = fs.statSync(p)
+    if (st.isDirectory()) collect(p, out)
+    else if (name.endsWith('.vue')) out.push(p)
+  }
+  return out
+}
+
+function blocks(src) {
+  // SFC 顶层块:标签必须位于行首(避免误命中脚本里的 /<style[\s\S]*?<\/style>/ 正则字面量)
+  const out = { template: null, styles: [], scripts: [] }
+  const tpl = src.match(/^<template[^>]*>([\s\S]*?)^<\/template>/m)
+  if (tpl) out.template = tpl[1]
+  for (const m of src.matchAll(/^<style([^>]*)>([\s\S]*?)^<\/style>/gm)) out.styles.push({ attrs: m[1] || '', code: m[2] || '' })
+  for (const m of src.matchAll(/^<script([^>]*)>([\s\S]*?)^<\/script>/gm)) out.scripts.push({ attrs: m[1] || '', code: m[2] || '' })
+  return out
+}
+
+const args = process.argv.slice(2)
+const files = args.length ? args.map(a => path.resolve(ROOT, a)) : collect(ROOT)
+
+const failures = []
+function fail(file, kind, msg) {
+  failures.push({ file, kind, msg: String(msg).replace(/\s+/g, ' ').slice(0, 220) })
+}
+
+let tplOk = 0, scssOk = 0, jsOk = 0
+
+for (const abs of files) {
+  const rel = path.relative(ROOT, abs).split(path.sep).join('/')
+  if (SKIP_FILES.has(rel)) continue
+  const src = fs.readFileSync(abs, 'utf8')
+  const sfc = blocks(src)
+
+  // 1) 模板
+  if (compiler) {
+    const tpl = sfc.template || ''
+    if (tpl) {
+      const res = compiler.compile(tpl)
+      if (res.errors && res.errors.length) fail(rel, 'template', res.errors.join(' | '))
+      else tplOk++
+    } else tplOk++
+  }
+
+  // 2) 样式(SCSS 优先把 uni.scss 变量注入)
+  if (sass) {
+    const styleBlocks = sfc.styles
+    let styleFailed = false
+    for (const b of styleBlocks) {
+      const attrs = b.attrs || ''
+      const code = b.code || ''
+      if (!/lang\s*=\s*["']scss["']/.test(attrs) && !/\$[a-zA-Z_-]/.test(code)) continue
+      if (!/lang\s*=\s*["']scss["']/.test(attrs) && !/\$[a-zA-Z_-]/.test(code)) continue
+      const attempts = [uniScss + '\n' + code, code]
+      let ok = false
+      let lastErr = null
+      for (const candidate of attempts) {
+        try {
+          sass.compileString(candidate, { loadPaths: [ROOT], quietDeps: true, logger: { warn() {} } })
+          ok = true
+          break
+        } catch (e) { lastErr = e }
+      }
+      if (!ok) {
+        styleFailed = true
+        fail(rel, 'style', lastErr && lastErr.message ? lastErr.message.split('\n')[0] : 'compile failed')
+      }
+    }
+    if (!styleFailed) scssOk++
+  }
+
+  // 3) 脚本
+  const scriptBlocks = sfc.scripts
+  let jsFailed = false
+  for (const b of scriptBlocks) {
+    const tmp = path.join(os.tmpdir(), 'verify_' + Math.random().toString(36).slice(2) + '.js')
+    fs.writeFileSync(tmp, b.code, 'utf8')
+    try {
+      cp.execFileSync(process.execPath, ['--check', tmp], { stdio: 'pipe' })
+    } catch (e) {
+      jsFailed = true
+      fail(rel, 'script', String(e.stderr || '').split('\n').slice(0, 3).join(' '))
+    } finally {
+      try { fs.unlinkSync(tmp) } catch (e) {}
+    }
+  }
+  if (!jsFailed) jsOk++
+}
+
+console.log('校验文件数: ' + files.length)
+console.log('  模板通过: ' + tplOk + '   样式通过: ' + scssOk + '   脚本通过: ' + jsOk)
+if (compiler === null) console.log('  !! 未找到 vue-template-compiler,跳过模板校验')
+if (sass === null) console.log('  !! 未找到 sass,跳过样式校验')
+
+if (failures.length) {
+  console.log('\n失败 ' + failures.length + ' 项:')
+  for (const f of failures) console.log('  [' + f.kind + '] ' + f.file + '\n      ' + f.msg)
+  process.exit(1)
+}
+console.log('PASS')