Explorar o código

fix(mp-weixin): T27 修复模板未定义实例属性 + options 重复顶层键,新增语义级审计门禁

根因:Vue 2 模板引用未定义的实例属性时,微信小程序端只打
`Property or method "xxx" is not defined` 警告、不抛错,HBuilderX 编译也拦不住,
绑定静默失效。food-query.vue 的 familyMode 自 39605069f 引入模板后从未定义,
潜伏数月,mode-bar 恒不显示。

修复(16 文件 / 24 个未定义标识符):
- food-query.vue: 补 familyMode computed(by-family 返回过家庭聚合数据即家庭模式)
- member-{action,body,mind,wealth}-detail.vue: 补 goArticleDetail/goMoreArticles
- article-center/article-detail.vue: 补 resultIcon computed(按正确率派生)
- assessment/apply.vue: 补 assessmentTypes(initial/retest,对齐后端 appointment_type)
- body-detail/index.vue: 补 bodyPortrait computed(与 mindPortrait 同构,7 子维度)
- wealth/index.vue: 补 currentView data(DimensionMemberTabs 恒不渲染)
- dan-assessment/comparison.vue: 补 members/uploads data + deltas/first/secondUploadLabel
- health/indicator-quicklook.vue: 补 onMemberChange
- health/report-survey.vue: absAvoidInput/mustEatInput 从 survey.* 移到顶层 data
- promotion/team.vue: 补 curveDataReady
- purchase/renewal.vue: benefits 从 options 顶层移入 data()
- tasks/create-task.vue: 模板改用真实字段 prerequisiteTaskList/successorTaskList
- vendor/team-customers: 模板 isLoading 改真实字段 loading
- vendor/team-system: goToMembers 改为内联展开(无独立成员页)
- wisdom-detail/member-wisdom-detail.vue: 修正不存在的嵌套路由 article-detail/article-detail

修复(4 处 options 重复顶层键,后者静默覆盖前者、整块功能丢失):
- growth/diet-checkin.vue: 两个 mixins → 合并(fontScaleMixin 曾被 shareMixin 覆盖)
- activity/activity-detail: 两个 computed → 合并(contentTagStyle 曾被覆盖,模板收到 undefined)
- discover-detail/product-detail: 同上
- guide/register/index.vue: 两个 onLoad → 删被遮蔽的旧 onLoad(保留 b1ffb1df3 的 redirect)

新增门禁 scripts/audit-undefined-template-props.js:
对 .vue 做真实求值(ESM→CJS 沙箱 + mixins 展平 + @/ 别名 + .vue 递归求值),
用 vue-template-compiler 编译模板后经 Proxy has 陷阱采集 with(this) 标识符,
与 data/computed/methods/props/mixins ∪ 全局注入比对;另用 @babel/parser 检测
重复顶层键。含 --self-test 灵敏度自测(4 用例)。AGENTS.md 已补规范与用法。

门禁(均退出码 0):
- audit-undefined-template-props.js: 383 文件 / 求值失败 0 / 未定义 0 / 重复键 0
- audit-mp-antipatterns.js: ERROR 0
- verify-vue-compile.js: PASS
- audit-duplicate-api-calls.js: 0 error
zxyj-web hai 2 semanas
pai
achega
26746bb680

+ 18 - 0
cfc-frontend/AGENTS.md

@@ -168,6 +168,8 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 ## ANTI-PATTERNS
 
 - **DO NOT** 创建页面后忘记在 `pages.json` 注册
+- **DO NOT** 在模板里引用未在 `data`/`computed`/`methods`/`props`/`mixins` 定义的实例属性(Vue 2 只告警不抛错,绑定静默失效);`scripts/audit-undefined-template-props.js` 会拦
+- **DO NOT** 在 `export default` 的 options 里重复写同名顶层键(JS 对象字面量后者静默覆盖前者,整块功能丢失;`scripts/audit-undefined-template-props.js` 会拦)
 - **DO NOT** 硬编码 API 地址,使用 `config.baseUrl`
 - **DO NOT** 在前端存储敏感信息 (密码、密钥)
 - **NEVER** 跳过 JWT 认证直接调用需登录接口
@@ -256,6 +258,22 @@ node scripts/audit-mp-antipatterns.js --json     # 机器可读,便于脚本
 ERROR 级(必须修):模板 `?.`、`:key` 运算符表达式、`:key` 方法调用且作用域内事件/v-model 传条目变量(运行时 handler 恒收 `undefined`)、CSS 中文选择器、`uni.getSystemInfoSync()`、`transition: all`、`v-for` 缺 `:key`、日期用 `toLocale*String` 格式化。
 WARN 级(人工确认):`new Date(<非字面量>)`(应改 `parseDate()`)、`:class/:style` 内带参方法调用(uni-app 渲染函数下可用,但 WXML 原生表达式不支持,AGENTS.md 列为禁用项)。
 
+### 模板未定义实例属性 / 重复顶层键审计(语义级)
+
+```bash
+node scripts/audit-undefined-template-props.js            # 全站,ERROR 归零才算通过(退出码 0)
+node scripts/audit-undefined-template-props.js --self-test # 灵敏度自测
+node scripts/audit-undefined-template-props.js a.vue b.vue # 仅指定文件
+node scripts/audit-undefined-template-props.js --json      # 机器可读
+```
+
+两类 Vue 2 只告警不抛错、HBuilderX 编译也拦不住的问题,必须用本脚本检出:
+
+- **模板引用未定义实例属性**:模板里用了 `data`/`computed`/`methods`/`props`/`mixins`(含全局注入)都没有的标识符 → 控制台 `Property or method "xxx" is not defined`,该绑定静默失效。脚本对每个 `.vue` 做真实求值(ESM→CJS 沙箱 + mixins 展平 + `@/` 别名解析),用 `vue-template-compiler` 编译模板后经 Proxy `has` 陷阱采集 `with(this)` 内标识符,与定义名集合比对。
+- **`export default` options 重复顶层键**:JS 对象字面量同名键后者静默覆盖前者(如两个 `mixins:`、两个 `computed:`),整个块的功能丢失。脚本用 `@babel/parser` AST 检测 `ObjectProperty`/`ObjectMethod` 的重复键。
+
+ERROR 级(必须修)。新增/修改页面时若本脚本报命中,按「未定义属性 → 补定义;重复键 → 合并两块的键值」修复,禁止用 `// eslint-disable` 之类手段绕过。
+
 ### 修改 `remove-scoped.js` 的风险
 
 该文件在 `node_modules/@dcloudio/vue-cli-plugin-uni/` 内,每次 `npm install` 会通过 `postinstall-fix.js` 自动恢复补丁。如需手动执行:

+ 8 - 0
cfc-frontend/pages/action-detail/member-action-detail.vue

@@ -220,6 +220,14 @@ export default {
     goMoreProducts: function() {
       uni.navigateTo({ url: '/pages/shop/index/index' })
     },
+    goArticleDetail: function(articleOrId) {
+      var id = (articleOrId && articleOrId.id) || articleOrId
+      if (!id) return
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
+    },
+    goMoreArticles: function() {
+      uni.navigateTo({ url: '/pages/article-center/index' })
+    },
     goTasks: function() { uni.navigateTo({ url: '/pages/tasks/tasks' }) },
     goBack: function() { uni.navigateBack() }
   }

+ 5 - 6
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -336,12 +336,6 @@ import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 export default {
   mixins: [shareMixin],
   components: { MpHtml, ActivityPoster, RelatedContent },
-  computed: {
-    contentTagStyle() {
-      var scale = this.fontScale || 1
-      return buildContentTagStyle(32, scale)
-    }
-  },
   data() {
     return {
       fontScale: 1,
@@ -965,6 +959,11 @@ export default {
     }
   },
   computed: {
+    // 富文本标签样式(跟随系统字号缩放)
+    contentTagStyle() {
+      var scale = this.fontScale || 1
+      return buildContentTagStyle(32, scale)
+    },
     parsedSubmittedRatings: function() {
       if (!this.submittedFeedback || !this.submittedFeedback.ratings) return {}
       try {

+ 12 - 0
cfc-frontend/pages/article-center/article-detail.vue

@@ -143,6 +143,18 @@ export default {
       var fontSize = 32 * scale
       return { p: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', div: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', section: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', li: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;', blockquote: 'font-size:' + fontSize + 'rpx;color:#444;line-height:1.8;' }
     },
+    // 答题结果图标(按正确率派生,与文案「答对 x/y」一致)
+    resultIcon: function() {
+      var r = this.quizResult || {}
+      var total = Number(r.totalQuestions) || 0
+      var correct = Number(r.correctCount) || 0
+      if (!total) return '📚'
+      var rate = correct / total
+      if (rate >= 1) return '🎉'
+      if (rate >= 0.6) return '👏'
+      if (rate >= 0.3) return '💪'
+      return '🌱'
+    },
     // 当前展示的一题(一题一页)
     currentQuestion: function() {
       if (!this.quizQuestions || !this.quizQuestions.length) return null

+ 5 - 0
cfc-frontend/pages/assessment/apply.vue

@@ -150,6 +150,11 @@ export default {
       selectedGuideId: null,
       selectedGuideName: '',
       selectedType: 'initial',
+      // 测评类型选项(值与后端 appointment_type 一致:initial=初测 / retest=复测)
+      assessmentTypes: [
+        { value: 'initial', icon: '📋', name: '初次测评', desc: '首次全面测评,建立基线档案' },
+        { value: 'retest', icon: '📈', name: '复测', desc: '阶段性复测,对比成长变化' }
+      ],
       appointmentDate: '',
       appointmentTime: '',
       selectedChildId: null,

+ 58 - 0
cfc-frontend/pages/body-detail/index.vue

@@ -352,6 +352,64 @@ export default {
       }
       return result
     },
+    // 身维度画像:由 dimensionData 的各子维度得分派生(与心维度 mindPortrait 同构)
+    bodyPortrait: function() {
+      if (!this.isLoggedIn || !this.dimensionData) return null
+      var dims = this.dimensionData.dimensions || []
+      if (!dims.length) return null
+      var meta = {
+        growth: { icon: '📏', color: BrandColors.body, desc: '身高体重与生长发育水平' },
+        sleep: { icon: '😴', color: BrandColors.wisdom, desc: '睡眠时长与恢复质量' },
+        vision: { icon: '👀', color: BrandColors.infoSky, desc: '视力发育与用眼负担' },
+        immunity: { icon: '🛡️', color: BrandColors.success, desc: '免疫指标与抗病能力' },
+        nutrition: { icon: '🥦', color: BrandColors.wealth, desc: '营养摄入与膳食均衡' },
+        gut: { icon: '🦠', color: BrandColors.tealDeep, desc: '肠道菌群结构与消化状态' },
+        exercise: { icon: '🏃', color: BrandColors.primary, desc: '运动习惯与体能活力' }
+      }
+      var result = { overall: 0, tierLabel: '', summary: '', dims: [], tip: '' }
+      var total = 0
+      var dimList = []
+      for (var i = 0; i < dims.length; i++) {
+        var d = dims[i]
+        if (!d) continue
+        var key = d.dimension || d.code || ''
+        var score = Number(d.score)
+        if (isNaN(score)) score = 0
+        score = Math.max(0, Math.min(100, score))
+        total += score
+        var m = meta[key] || { icon: '•', color: BrandColors.body, desc: d.description || '' }
+        dimList.push({
+          code: key,
+          name: d.label || key,
+          score: Math.round(score),
+          color: m.color,
+          icon: m.icon,
+          desc: m.desc || d.description || ''
+        })
+      }
+      if (!dimList.length) return null
+      var o = Math.round(total / dimList.length)
+      result.overall = o
+      result.dims = dimList
+      if (o >= 80) {
+        result.tierLabel = '体魄达人'
+        result.summary = '您的身体状态非常出色,各项健康指标均衡且稳定。'
+        result.tip = '保持规律作息与运动习惯,也可分享经验帮助家人'
+      } else if (o >= 60) {
+        result.tierLabel = '体魄进阶者'
+        result.summary = '您的身体状态良好,主要健康指标处于健康区间。'
+        result.tip = '关注偏弱的子维度,针对性补充营养与运动,持续提升体能'
+      } else if (o >= 40) {
+        result.tierLabel = '体魄探索者'
+        result.summary = '您的身体状态正在建立基础,部分指标仍需改善。'
+        result.tip = '从规律作息与均衡饮食开始,逐步建立健康习惯'
+      } else {
+        result.tierLabel = '体魄守护者'
+        result.summary = '您可能存在多项健康指标异常,需要更多关注与干预。'
+        result.tip = '建议上传健康报告并咨询医生,优先从作息与饮食改善'
+      }
+      return result
+    },
     gutGrouped: function() {
       var groups = {}
       var order = ['core', 'probiotic', 'harmful', 'other']

+ 8 - 0
cfc-frontend/pages/body-detail/member-body-detail.vue

@@ -180,6 +180,14 @@ export default {
     goMoreProducts: function() {
       uni.navigateTo({ url: '/pages/shop/index/index' })
     },
+    goArticleDetail: function(articleOrId) {
+      var id = (articleOrId && articleOrId.id) || articleOrId
+      if (!id) return
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
+    },
+    goMoreArticles: function() {
+      uni.navigateTo({ url: '/pages/article-center/index' })
+    },
     goTasks: function() { uni.navigateTo({ url: '/pages/tasks/tasks' }) },
     goBack: function() { uni.navigateBack() }
   }

+ 30 - 1
cfc-frontend/pages/dan-assessment/comparison.vue

@@ -56,6 +56,7 @@
 
 <script>
 import { getFamilyMemberList, getDanReportList, compareDanReports } from '@/utils/api'
+import { formatDate } from '@/utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -64,7 +65,9 @@ export default {
   data() {
     return {
       memberId: null,
+      members: [],
       reportIds: [],
+      uploads: [],
       currentReportId: null,
       currentMemberName: '',
       currentReportIdLabel: '选择报告',
@@ -79,6 +82,18 @@ export default {
   computed: {
     memberNames() {
       return this.memberId ? this.members.map(m => m.name || m.nickname || '').filter(Boolean) : []
+    },
+    // 对比接口的差异明细(后端 /api/dan-report/compare 返回 deltas)
+    deltas() {
+      return (this.comparison && this.comparison.deltas) || []
+    },
+    // 首次上传标签(后端按 uploaded_at 升序对齐,firstUploadId 为较早那次)
+    firstUploadLabel() {
+      return this.uploadLabel(this.comparison && this.comparison.firstUploadId)
+    },
+    // 最近上传标签(secondUploadId 为较新那次)
+    secondUploadLabel() {
+      return this.uploadLabel(this.comparison && this.comparison.secondUploadId)
     }
   },
   onLoad(options) {
@@ -90,6 +105,18 @@ export default {
     self.loadMembers()
   },
   methods: {
+    // 上传记录展示标签:文件名 + 上传日期(找不到记录时返回空串)
+    uploadLabel(uploadId) {
+      if (!uploadId) return ''
+      for (var i = 0; i < this.uploads.length; i++) {
+        var u = this.uploads[i]
+        if (u && String(u.id) === String(uploadId)) {
+          var date = u.uploadedAt ? formatDate(u.uploadedAt) : ''
+          return date ? (u.fileName || ('上传 #' + u.id)) + ' · ' + date : (u.fileName || ('上传 #' + u.id))
+        }
+      }
+      return '上传 #' + uploadId
+    },
     loadMembers() {
       var self = this
       getFamilyMemberList({ visibleOnly: true }).then(function(res) {
@@ -109,7 +136,9 @@ export default {
         return
       }
       getDanReportList({ memberId: self.memberId }).then(function(res) {
-        self.reportIds = (res.data || []).map(function(u) { return String(u.id) })
+        var list = res.data || []
+        self.uploads = list
+        self.reportIds = list.map(function(u) { return String(u.id) })
         if (self.reportIds.length > 0) {
           self.currentReportId = self.reportIds[0]
           self.onReportIdChange(self.reportIds[0])

+ 6 - 0
cfc-frontend/pages/diet/food-query.vue

@@ -165,6 +165,12 @@ export default {
     }
   },
   computed: {
+    // 家庭模式:/api/health/foods/by-family 返回过家庭聚合数据(byMember 非空)即视为家庭模式
+    // 用于成员切换条与排序切换条(推荐值 ↓/↑)的展示条件;此前模板引用但从未定义,
+    // 导致 mode-bar 恒不显示、member-bar 只看 memberList.length(本页 Vue warn 根因)
+    familyMode: function() {
+      return this.familyMembers.length > 0
+    },
     filteredFoods: function() {
       var self = this
       var result = self.currentView === 'family'

+ 5 - 6
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -188,12 +188,6 @@ import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 export default {
   components: { MpHtml, ContentSharePoster, RelatedContent },
   mixins: [shareMixin],
-  computed: {
-    contentTagStyle() {
-      var scale = this.fontScale || 1
-      return buildContentTagStyle(32, scale)
-    }
-  },
   data() {
     return {
       primaryColor: BrandColors.primary,
@@ -221,6 +215,11 @@ export default {
     }
   },
   computed: {
+    // 富文本标签样式(跟随系统字号缩放)
+    contentTagStyle() {
+      var scale = this.fontScale || 1
+      return buildContentTagStyle(32, scale)
+    },
     apiBaseUrl: function() {
       return config.API_BASE_URL
     },

+ 1 - 2
cfc-frontend/pages/growth/diet-checkin.vue

@@ -169,9 +169,8 @@ import TagChip from '../../components/TagChip.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
-  mixins: [fontScaleMixin],
+  mixins: [shareMixin, fontScaleMixin],
   components: { TagChip },
-  mixins: [shareMixin],
   data() {
     return {
       memberId: null,

+ 0 - 7
cfc-frontend/pages/guide/register/index.vue

@@ -167,13 +167,6 @@ export default {
       submitting: false
     }
   },
-  onLoad(options) {
-    // 如果有邀请码参数,自动填充
-    if (options.inviteCode) {
-      this.formData.inviteCode = options.inviteCode
-      this.validateInviteCode()
-    }
-  },
   methods: {
     async validateInviteCode() {
       if (!this.formData.inviteCode) {

+ 8 - 0
cfc-frontend/pages/health/indicator-quicklook.vue

@@ -91,6 +91,14 @@ export default {
       })
     },
     reload: function() { this.page = 1; this.loadData() },
+    onMemberChange: function(e) {
+      var idx = parseInt(e.detail && e.detail.value || '0', 10)
+      var m = this.members[idx]
+      if (m && m.id !== this.currentMemberId) {
+        this.currentMemberId = m.id
+        this.reload()
+      }
+    },
     loadData: function() {
       var self = this
       if (!this.currentMemberId || this._loading) return

+ 3 - 2
cfc-frontend/pages/health/report-survey.vue

@@ -490,8 +490,6 @@ export default {
         },
         allergies: [],
         allergyDetail: '',
-        absAvoidInput: '',
-        mustEatInput: '',
         surgery: '',
         surgeryDetail: '',
         // 家族史
@@ -504,6 +502,9 @@ export default {
         // 健康目标
         goals: []
       },
+      // 忌口/必吃输入缓冲(瞬态 UI 状态,不入问卷提交体)
+      absAvoidInput: '',
+      mustEatInput: '',
       familyDiseaseOptions: [
         { value: 'none', label: '无' },
         { value: 'hypertension', label: '高血压' },

+ 8 - 0
cfc-frontend/pages/mind-detail/member-mind-detail.vue

@@ -180,6 +180,14 @@ export default {
     goMoreProducts: function() {
       uni.navigateTo({ url: '/pages/shop/index/index' })
     },
+    goArticleDetail: function(articleOrId) {
+      var id = (articleOrId && articleOrId.id) || articleOrId
+      if (!id) return
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
+    },
+    goMoreArticles: function() {
+      uni.navigateTo({ url: '/pages/article-center/index' })
+    },
     goTasks: function() { uni.navigateTo({ url: '/pages/tasks/tasks' }) },
     goBack: function() { uni.navigateBack() }
   }

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

@@ -227,7 +227,9 @@ export default {
       treeRows: [],
       treeCollapsedIds: [],
       treeLoaded: false,
-      treeRootHasChildren: false
+      treeRootHasChildren: false,
+      // 成长/消费曲线数据是否就绪(loadCurveData 成功后置 true)
+      curveDataReady: false
     }
   },
   onLoad() {

+ 9 - 9
cfc-frontend/pages/purchase/renewal.vue

@@ -71,7 +71,15 @@ export default {
       submitting: false,
       cfBalance: 0,
       discount: 0,
-      payAmount: 199900
+      payAmount: 199900,
+      benefits: [
+        { icon: '📋', text: '无限报告上传' },
+        { icon: '🤖', text: 'AI报告解析' },
+        { icon: '📝', text: '无限方案生成' },
+        { icon: '🏆', text: '全量闯关奖励' },
+        { icon: '👨‍👩‍👧', text: '最多5名成员' },
+        { icon: '🎯', text: '专属规划师' }
+      ]
     }
   },
   computed: {
@@ -82,14 +90,6 @@ export default {
       return this.cfBalance >= 19900 ? 100 : Math.round(this.discount / 19900 * 100)
     }
   },
-  benefits: [
-    { icon: '📋', text: '无限报告上传' },
-    { icon: '🤖', text: 'AI报告解析' },
-    { icon: '📝', text: '无限方案生成' },
-    { icon: '🏆', text: '全量闯关奖励' },
-    { icon: '👨‍👩‍👧', text: '最多5名成员' },
-    { icon: '🎯', text: '专属规划师' }
-  ],
   onLoad() {
     this.loadInfo()
   },

+ 2 - 2
cfc-frontend/pages/tasks/create-task.vue

@@ -110,7 +110,7 @@
   <text class="label label--pre">前置任务(可选,多选)</text>
   <text class="form-hint form-hint--tight">选择本任务执行前必须完成的任务</text>
   <view class="task-picker-scroll">
-    <view v-for="task in prerequisiteTasks" :key="task.id" class="task-checkbox-row" @click="togglePrerequisite(task.id)">
+    <view v-for="task in prerequisiteTaskList" :key="task.id" class="task-checkbox-row" @click="togglePrerequisite(task.id)">
       <view class="task-checkbox" :class="{ checked: form.prerequisiteTaskIds.indexOf(task.id) > -1 }">
         <text v-if="form.prerequisiteTaskIds.indexOf(task.id) > -1" class="check-icon">✓</text>
       </view>
@@ -124,7 +124,7 @@
   <text class="label label--post">后置任务(可选,多选)</text>
   <text class="form-hint form-hint--tight">选择本任务完成后自动解锁的任务(任意一个完成即可)</text>
   <view class="task-picker-scroll">
-    <view v-for="task in successorTasks" :key="task.id" class="task-checkbox-row" @click="toggleSuccessor(task.id)">
+    <view v-for="task in successorTaskList" :key="task.id" class="task-checkbox-row" @click="toggleSuccessor(task.id)">
       <view class="task-checkbox" :class="{ checked: form.successorTaskIds.indexOf(task.id) > -1 }">
         <text v-if="form.successorTaskIds.indexOf(task.id) > -1" class="check-icon">✓</text>
       </view>

+ 1 - 1
cfc-frontend/pages/vendor/team-customers/team-customers.vue

@@ -6,7 +6,7 @@
     </view>
 
     <view class="content-area">
-      <view v-if="isLoading" class="loading-indicator">
+      <view v-if="loading" class="loading-indicator">
         <image class="loading-avatar" src="/static/images/avatar_default.png" mode="aspectFill"></image>
         <text class="loading-text">加载中...</text>
       </view>

+ 10 - 3
cfc-frontend/pages/vendor/team-system/team-system.vue

@@ -78,10 +78,10 @@
     <view class="section">
       <view class="section-header">
         <text class="section-title">团队成员</text>
-        <text class="section-action" @click="goToMembers">查看全部</text>
+        <text class="section-action" @click="toggleShowAllMembers">{{ showAllMembers ? '收起' : '查看全部' }}</text>
       </view>
       <view class="member-list">
-        <view class="member-item" v-for="member in teamMembers.slice(0, 5)" :key="member.userId">
+        <view class="member-item" v-for="member in teamMembers.slice(0, showAllMembers ? teamMembers.length : 5)" :key="member.userId">
           <image v-if="member.avatar" class="member-avatar" :src="member.avatar" mode="aspectFill"></image>
           <text v-else class="member-avatar-placeholder">👤</text>
           <view class="member-info">
@@ -150,7 +150,9 @@ export default {
         amount: 0
       },
       teamMembers: [],
-      teamCustomers: []
+      teamCustomers: [],
+      // 团队成员列表是否展开全部(默认折叠,只显示前 5 条)
+      showAllMembers: false
     }
   },
 
@@ -250,6 +252,11 @@ export default {
       this.teamStats.customerCount = this.teamCustomers.length
     },
 
+    // 展开/收起团队成员列表(无独立成员页,直接就地展开全部)
+    toggleShowAllMembers() {
+      this.showAllMembers = !this.showAllMembers
+    },
+
     // 跳转管理页面
     goToManage() {
       uni.navigateTo({

+ 8 - 0
cfc-frontend/pages/wealth-detail/member-wealth-detail.vue

@@ -178,6 +178,14 @@ export default {
     goMoreProducts: function() {
       uni.navigateTo({ url: '/pages/shop/index/index' })
     },
+    goArticleDetail: function(articleOrId) {
+      var id = (articleOrId && articleOrId.id) || articleOrId
+      if (!id) return
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
+    },
+    goMoreArticles: function() {
+      uni.navigateTo({ url: '/pages/article-center/index' })
+    },
     goTasks: function() { uni.navigateTo({ url: '/pages/tasks/tasks' }) },
     goBack: function() { uni.navigateBack() }
   }

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

@@ -306,6 +306,7 @@ export default {
       showTabTransition: true,
       isLoggedIn: false,
       role: 'parent',
+      currentView: 'self', // 'self' | 'family'
       // 五维评分
       wealthScore: 0,
       // 子维度 - 利他创富四维(家长/孩子共用字段)

+ 1 - 1
cfc-frontend/pages/wisdom-detail/member-wisdom-detail.vue

@@ -141,7 +141,7 @@ export default {
     goArticleDetail: function(id) {
       var token = uni.getStorageSync('token')
       if (token) {
-        uni.navigateTo({ url: '/pages/article-center/article-detail/article-detail?id=' + id })
+        uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
       } else {
         this.openLoginSheet()
       }

+ 558 - 0
cfc-frontend/scripts/audit-undefined-template-props.js

@@ -0,0 +1,558 @@
+#!/usr/bin/env node
+/**
+ * 小程序模板「未定义实例属性」审计 + options 重复顶层键审计
+ *
+ * 背景:Vue 2 模板中引用未在 data/computed/methods/props/mixins 定义的实例属性时,
+ * 微信小程序端只会在控制台打 `Property or method "xxx" is not defined on the instance`
+ * 警告、不抛错,该绑定静默失效——这类 bug 无法靠 HBuilderX 编译拦截(编译通过、白板/不渲染),
+ * 只能靠「模板标识符 ↔ 实例定义名」语义级比对发现。本脚本对 `.vue` 文件做真实求值:
+ *
+ *   - 剥离注释 → ESM→CJS 转换(保留命名导出语义)→ 沙箱 `new Function` 求值 options
+ *     (含 mixins 展平、`@/` 别名、相对导入、`.vue` 导入递归求值、容错 this)
+ *   - `vue-template-compiler` 编译模板得 render → `new Function(render).call(proxy)`
+ *     用 Proxy `has` 陷阱采集 `with(this)` 内访问过的标识符
+ *   - 与 defined(data/computed/methods/props/filters ∪ mixins ∪ 全局注入 ∪ 全局对象)比对,
+ *     未命中的即为 ERROR
+ *
+ * 附带第二条规则:`export default` options 对象字面量「重复顶层键」——JS 对象字面量同名键
+ * 后者静默覆盖前者(如两个 `mixins:` / 两个 `computed:`),功能整体丢失。用 @babel/parser
+ * AST 检测(节点类型为 ObjectProperty/ObjectMethod)。
+ *
+ * 用法:
+ *   node scripts/audit-undefined-template-props.js            # 全站扫描
+ *   node scripts/audit-undefined-template-props.js a.vue b.vue  # 仅指定文件
+ *   node scripts/audit-undefined-template-props.js --json     # 机器可读
+ *   node scripts/audit-undefined-template-props.js --self-test # 灵敏度自测
+ *
+ * 退出码: 0 = 无 ERROR / 1 = 存在 ERROR / 2 = 基础设施缺失(无法扫描)
+ */
+const fs = require('fs')
+const path = require('path')
+
+const ROOT = path.resolve(__dirname, '..')
+const SKIP_DIRS = new Set(['node_modules', 'unpackage', 'dist', '.git', 'mp-html'])
+const SKIP_FILES = new Set(['components/mp-html/node/node.vue'])
+const JSON_OUT = process.argv.includes('--json')
+const SELF_TEST = process.argv.includes('--self-test')
+const ARGS = process.argv.slice(2).filter(a => !a.startsWith('--'))
+
+let compiler = null
+try { compiler = require(path.join(ROOT, 'node_modules/vue-template-compiler')) } catch (e) { compiler = null }
+
+// ---------------- 宽容桩 ----------------
+const DUMMY = (() => {
+  const fn = function () { return DUMMY }
+  return new Proxy(fn, {
+    get(t, k) {
+      if (k === Symbol.toPrimitive) return () => ''
+      if (k === Symbol.iterator) return undefined
+      if (k === 'length') return 0
+      if (k === 'toString' || k === 'valueOf') return () => ''
+      return DUMMY
+    },
+    apply: () => DUMMY, construct: () => DUMMY, has: () => true
+  })
+})()
+function permissiveThis(extra) {
+  const base = extra || {}
+  return new Proxy(base, {
+    has: () => true,
+    get(t, k) { return k in t ? t[k] : DUMMY }
+  })
+}
+
+// ---------------- 注释剥离 ----------------
+function stripComments(src) {
+  let out = ''
+  let i = 0
+  let mode = null
+  let prevSig = ''
+  let inClass = false
+  const buf = []
+  const flush = () => { if (buf.length) { out += ' '; buf.length = 0 } }
+  while (i < src.length) {
+    const c = src[i]
+    if (mode === 'line') {
+      if (c === '\n') { flush(); mode = null; out += '\n'; i++; continue }
+      buf.push(c); i++; continue
+    }
+    if (mode === 'block') {
+      if (c === '*' && src[i + 1] === '/') { buf.push('*/'); i += 2; flush(); mode = null; continue }
+      buf.push(c); i++; continue
+    }
+    if (mode === '"' || mode === "'" || mode === '`') {
+      out += c
+      if (c === '\\' && src[i + 1]) { out += src[i + 1]; i += 2; prevSig = '\\'; continue }
+      if (c === mode) mode = null
+      i++; continue
+    }
+    if (mode === 'reg') {
+      out += c
+      if (c === '\\' && src[i + 1]) { out += src[i + 1]; i += 2; continue }
+      if (c === '[') inClass = true
+      else if (c === ']') inClass = false
+      else if (c === '/' && !inClass) { mode = null; prevSig = '/' }
+      i++; continue
+    }
+    if (c === '/' && src[i + 1] === '/') { mode = 'line'; buf.push('//'); i += 2; continue }
+    if (c === '/' && src[i + 1] === '*') { mode = 'block'; buf.push('/*'); i += 2; continue }
+    if (c === '"' || c === "'" || c === '`') { mode = c; out += c; i++; continue }
+    if (c === '/') {
+      if (prevSig === '' || /[({[,:=+\-*/%<>!&|?^~;.]|\n/.test(prevSig)) {
+        mode = 'reg'; inClass = false; out += c; prevSig = c; i++; continue
+      }
+      out += c; prevSig = c; i++; continue
+    }
+    out += c
+    if (!/\s/.test(c)) prevSig = c
+    i++
+  }
+  flush()
+  return out
+}
+
+// ---------------- ESM -> CJS(保留命名导出) ----------------
+/* `import { a as b }` → 解构语法 `{ a: b }` */
+function normalizeNamed(named) {
+  return named.split(',').map(one => {
+    const t = one.trim()
+    if (!t) return ''
+    const m = t.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/)
+    return m ? `${m[1]}: ${m[2]}` : t
+  }).filter(Boolean).join(', ')
+}
+
+function transformESM(src) {
+  let s = stripComments(src)
+  const exportsList = [] // [{name, local}]
+  let hasDefault = false
+
+  // 1) import X, {a} from / import {a,b as c} from / import * as ns from / import X from / import 'x'
+  s = s.replace(/import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g,
+    (_m, def, named, p) => `const ${def} = __reqDefault(${JSON.stringify(p)}); const {${normalizeNamed(named)}} = __reqNamed(${JSON.stringify(p)});`)
+  s = s.replace(/import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g,
+    (_m, named, p) => `const {${normalizeNamed(named)}} = __reqNamed(${JSON.stringify(p)});`)
+  s = s.replace(/import\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]/g,
+    (_m, ns, p) => `const ${ns} = __reqAll(${JSON.stringify(p)});`)
+  s = s.replace(/import\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]/g,
+    (_m, def, p) => `const ${def} = __reqDefault(${JSON.stringify(p)});`)
+  s = s.replace(/^\s*import\s*['"][^'"]+['"]\s*$/gm, '')
+
+  // 2) export default 任意表达式(含对象/函数/箭头)
+  s = s.replace(/\bexport\s+default\b/g, '__default =')
+  hasDefault = /\b__default\s*=/.test(s)
+
+  // 3) export { a, b as c }(不带动词)
+  s = s.replace(/export\s*\{([^}]*)\}/g, (_m, body) => {
+    for (const item of body.split(',')) {
+      const t = item.trim()
+      if (!t) continue
+      if (/\s+as\s+/.test(t)) {
+        const [local, name] = t.split(/\s+as\s+/)
+        exportsList.push({ name: name.trim(), local: local.trim() })
+      } else exportsList.push({ name: t, local: t })
+    }
+    return ''
+  })
+
+  // 4) export function NAME / export class NAME
+  s = s.replace(/export\s+function\s+([A-Za-z_$][\w$]*)/g, (_m, n) => { exportsList.push({ name: n, local: n }); return 'function ' + n })
+  s = s.replace(/export\s+class\s+([A-Za-z_$][\w$]*)/g, (_m, n) => { exportsList.push({ name: n, local: n }); return 'class ' + n })
+
+  // 5) export const|let|var A = ..., B = ...
+  s = s.replace(/export\s+(const|let|var)\s+([\s\S]*?)(?=\n|;)/g, (_m, kw, decls) => {
+    const names = []
+    for (const seg of decls.split(/,(?=[A-Za-z_$])/)) {
+      const m = seg.match(/^\s*([A-Za-z_$][\w$]*)/)
+      if (m) names.push(m[1])
+    }
+    for (const n of names) exportsList.push({ name: n, local: n })
+    return `${kw} ${decls}`
+  })
+
+  // 6) export * from 'x' / export {a} from 'x'(本项目不存在,防御性移除)
+  s = s.replace(/export\s*\*\s*from\s*['"][^'"]+['"]/g, '')
+  s = s.replace(/export\s*\{[^}]*\}\s*from\s*['"][^'"]+['"]/g, '')
+
+  // 组装:把具名导出分配到 __exports(default 单独)
+  let assigns = ''
+  for (const e of exportsList) assigns += `\n__exports.${e.name} = ${e.local};`
+  if (hasDefault) assigns += '\n__exports.default = __default;'
+  const header = 'var __default; var __exports = {};'
+  return { code: header + '\n' + s + assigns, hasDefault }
+}
+
+// ---------------- 模块求值 ----------------
+const modCache = new Map()
+const UNI = new Proxy({
+  getStorageSync: () => DUMMY, setStorageSync: () => undefined,
+  getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }),
+  request: () => undefined
+}, { get(t, k) { return k in t ? t[k] : (() => DUMMY) } })
+const WX = new Proxy({}, { get: () => (...a) => DUMMY, apply: () => DUMMY })
+
+function moduleResultFor(spec, fromDir) {
+  if (spec === 'vue' || spec === 'vuex') {
+    try {
+      const m = require(path.join(ROOT, 'node_modules', spec))
+      return { __isReal: true, mod: m, default: m.default !== undefined ? m.default : m, named: m }
+    } catch (e) { return { mod: null } }
+  }
+  let p = null
+  if (spec.startsWith('@/')) p = path.resolve(ROOT, spec.slice(2))
+  else if (spec.startsWith('.')) p = path.resolve(fromDir, spec)
+  if (!p) return { mod: null }
+  if (!/\.[a-zA-Z0-9]+$/.test(p)) p = p + '.js'
+  if (!fs.existsSync(p)) return { mod: null }
+  if (p.endsWith('.vue')) return { mod: evalVueFileModule(p) }
+  return { mod: evalModuleFile(p) }
+}
+
+function evalVueFileModule(abs) {
+  if (modCache.has(abs)) return modCache.get(abs)
+  modCache.set(abs, {})
+  try {
+    const src = fs.readFileSync(abs, 'utf8')
+    const sm = src.match(/^<script([^>]*)>([\s\S]*?)^<\/script>/m)
+    if (!sm) { modCache.set(abs, null); return null }
+    const res = evalVueScript(sm[2], path.dirname(abs))
+    modCache.set(abs, res)
+    return res
+  } catch (e) { modCache.set(abs, null); return null }
+}
+
+function evalModuleFile(abs) {
+  if (modCache.has(abs)) return modCache.get(abs)
+  modCache.set(abs, {})
+  let t
+  try { t = transformESM(fs.readFileSync(abs, 'utf8')) } catch (e) { modCache.set(abs, null); return null }
+  const m = { exports: {} }
+  try {
+    const fn = new Function('module', 'exports', 'require', '__reqDefault', '__reqNamed', '__reqAll', 'uni', 'wx', 'console',
+      t.code + '\n;return __exports;')
+    const result = fn(m, m.exports, require,
+      (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}).default,
+      (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}),
+      (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}),
+      UNI, WX, console)
+    if (result !== undefined && result !== null) m.exports = result
+  } catch (e) {
+    if (process.env.DEBUG_EVAL) console.error('  [mod eval err]', abs, '->', e && e.message)
+    modCache.set(abs, null)
+    return null
+  }
+  modCache.set(abs, m.exports)
+  return m.exports
+}
+
+function evalVueScript(code, fileDir) {
+  let t
+  try { t = transformESM(code) } catch (e) { return null }
+  const m = { exports: {} }
+  let ret = null
+  try {
+    const fn = new Function('module', 'exports', 'require', '__reqDefault', '__reqNamed', '__reqAll', 'uni', 'wx', 'console',
+      t.code + '\n;return __exports;')
+    const result = fn(m, m.exports, require,
+      (spec) => (moduleResultFor(spec, fileDir).mod || {}).default,
+      (spec) => (moduleResultFor(spec, fileDir).mod || {}),
+      (spec) => (moduleResultFor(spec, fileDir).mod || {}),
+      UNI, WX, console)
+    ret = t.hasDefault ? result.default : result
+  } catch (e) {
+    if (process.env.DEBUG_EVAL) console.error('  [script eval err]', fileDir, '->', e && e.message)
+    return null
+  }
+  return ret
+}
+
+// ---------------- 组件定义名 ----------------
+function callData(dataFn, thisExtra) {
+  try {
+    const d = dataFn.call(permissiveThis(thisExtra))
+    return (d && typeof d === 'object') ? Object.keys(d) : []
+  } catch (e) { return [] }
+}
+
+function definedNames(options) {
+  const def = new Set()
+  const chain = [options]
+  for (let i = 0; i < chain.length; i++) {
+    const o = chain[i]
+    if (!o || typeof o !== 'object') continue
+    if (Array.isArray(o.mixins)) for (const mx of o.mixins) chain.push(mx)
+  }
+  for (const o of chain) {
+    if (!o || typeof o !== 'object') continue
+    if (typeof o.data === 'function') for (const k of callData(o.data)) def.add(k)
+    else if (o.data && typeof o.data === 'object') for (const k of Object.keys(o.data)) def.add(k)
+    if (o.computed) for (const k of Object.keys(o.computed)) def.add(k)
+    if (o.methods) for (const k of Object.keys(o.methods)) def.add(k)
+    if (o.props) for (const k of (Array.isArray(o.props) ? o.props : Object.keys(o.props))) def.add(k)
+    if (o.filters) for (const k of Object.keys(o.filters)) def.add(k)
+  }
+  return def
+}
+
+// ---------------- 全局注入 ----------------
+let GLOBAL_NAMES = null
+function globalNames() {
+  if (GLOBAL_NAMES) return GLOBAL_NAMES
+  const set = new Set()
+  const mainSrc = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8')
+  const imports = {}
+  const stripped = stripComments(mainSrc)
+  for (const m of stripped.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]/g)) imports[m[1]] = m[2]
+  for (const m of stripped.matchAll(/import\s*\{([^}]*)\}\s+from\s+['"]([^'"]+)['"]/g)) {
+    for (const one of m[1].split(',')) {
+      const nm = one.split(/\s+as\s+/).pop().trim()
+      if (nm) imports[nm] = m[2]
+    }
+  }
+  for (const m of stripped.matchAll(/Vue\.prototype\.([A-Za-z_$][\w$]*)\s*=/g)) set.add(m[1])
+  for (const m of stripped.matchAll(/Vue\.mixin\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) {
+    const id = m[1]
+    if (imports[id]) {
+      const spec = imports[id]
+      let p = spec.startsWith('@/') ? path.resolve(ROOT, spec.slice(2)) : path.resolve(ROOT, spec)
+      if (!/\.[a-zA-Z0-9]+$/.test(p)) p = p + '.js'
+      if (fs.existsSync(p)) {
+        const modRaw = evalModuleFile(p)
+        const mod = modRaw && (modRaw.default || modRaw)
+        if (mod) {
+          if (typeof mod.data === 'function') for (const k of callData(mod.data)) set.add(k)
+          if (mod.computed) for (const k of Object.keys(mod.computed)) set.add(k)
+          if (mod.methods) for (const k of Object.keys(mod.methods)) set.add(k)
+          if (mod.props) for (const k of (Array.isArray(mod.props) ? mod.props : Object.keys(mod.props))) set.add(k)
+        }
+      }
+    }
+  }
+  GLOBAL_NAMES = set
+  return set
+}
+
+// ---------------- 模板标识符采集 ----------------
+const GLOBALS = new Set([
+  'Math','JSON','Date','Object','Array','String','Number','Boolean','RegExp','Promise','Symbol','Map','Set','WeakMap','WeakSet',
+  'BigInt','Proxy','Reflect','Error','TypeError','RangeError','EvalError','SyntaxError','ReferenceError','URIError','Function',
+  'parseInt','parseFloat','isNaN','isFinite','encodeURI','decodeURI','encodeURIComponent','decodeURIComponent',
+  'escape','unescape','setTimeout','clearTimeout','setInterval','clearInterval','queueMicrotask',
+  'NaN','Infinity','undefined','globalThis','console','Intl','URL','URLSearchParams',
+  'Blob','File','FileReader','TextEncoder','TextDecoder','AbortController','AbortSignal',
+  'XMLHttpRequest','WebSocket','fetch','performance',
+  'getApp','getCurrentPages','requirePlugin','requireMiniProgram','requireNativePlugin',
+  'uni','wx','tt','swan','my'
+])
+const VUE_INTERNALS = new Set(['_c','_v','_s','_l','_t','_e','_m','_f','_k','_q','_i','_n','_o','_p','_u','_d','_b','_g','_vm'])
+const INSTANCE_PROPS = new Set(['$attrs','$slots','$scopedSlots','$options','$parent','$root','$refs','$el','$data','$props','$listeners','$children','$store','$router','$route','$event','$index','$value','$key','$emit','$set','$delete','$watch','$forceUpdate','$nextTick','$on','$off','$once','$createElement'])
+
+function collectTemplateIdentifiers(renderSrc) {
+  const accessed = new Set()
+  const target = {}
+  for (const k of VUE_INTERNALS) target[k] = DUMMY
+  for (const k of INSTANCE_PROPS) target[k] = DUMMY
+  const handler = {
+    has(t, key) {
+      if (typeof key === 'string') {
+        accessed.add(key)
+        if (key in t) return true
+        if (key in globalThis) return false
+        return true
+      }
+      return key in t
+    },
+    get(t, key) {
+      if (key === Symbol.unscopables) return undefined
+      if (key in t) return t[key]
+      if (typeof key === 'string' && key in globalThis) return globalThis[key]
+      return DUMMY
+    }
+  }
+  const proxy = new Proxy(target, handler)
+  try {
+    const fn = new Function(renderSrc)
+    fn.call(proxy)
+  } catch (e) { }
+  return accessed
+}
+
+// ---------------- options 重复顶层键(@babel/parser AST) ----------------
+let parser = null
+try { parser = require(path.join(ROOT, 'node_modules/@babel/parser')) } catch (e) { parser = null }
+
+function duplicateTopKeys(code) {
+  if (!parser) return null // 基础设施缺失,调用方自行降级
+  const findings = []
+  try {
+    const ast = parser.parse(code, { sourceType: 'module', plugins: [] })
+    let decl = null
+    for (const item of ast.program.body) {
+      if (item.type === 'ExportDefaultDeclaration') { decl = item; break }
+    }
+    if (!decl) return findings
+    const obj = decl.declaration
+    if (!obj || obj.type !== 'ObjectExpression') return findings
+    const seen = new Map()
+    for (const prop of obj.properties) {
+      if (!prop || !prop.key) continue
+      let name = null
+      if (prop.type === 'ObjectProperty' || prop.type === 'ObjectMethod') {
+        if (prop.key.type === 'Identifier') name = prop.key.name
+        else if (prop.key.type === 'StringLiteral' || prop.key.type === 'Literal') name = String(prop.key.value)
+      }
+      if (name) {
+        if (seen.has(name)) findings.push({ name, firstLine: seen.get(name), secondLine: prop.loc.start.line })
+        else seen.set(name, prop.loc.start.line)
+      }
+    }
+  } catch (e) { return findings }
+  return findings
+}
+
+// ---------------- 主流程 ----------------
+function collect(dir, out) {
+  out = out || []
+  let entries = []
+  try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return out }
+  for (const e of entries) {
+    const p = path.join(dir, e.name)
+    if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) collect(p, out) }
+    else if (e.name.endsWith('.vue')) out.push(p)
+  }
+  return out
+}
+
+/** 检查单个 .vue 文件,返回 { missing: [], dupKeys: [], evalFailed: bool } */
+function checkVueFile(abs) {
+  const src = fs.readFileSync(abs, 'utf8')
+  const tm = src.match(/^<template[^>]*>([\s\S]*?)^<\/template>/m)
+  const sm = src.match(/^<script([^>]*)>([\s\S]*?)^<\/script>/m)
+  if (!sm) return { missing: [], dupKeys: [], evalFailed: false }
+  if (/lang\s*=\s*['"]ts/.test(sm[1])) return { missing: [], dupKeys: [], evalFailed: false }
+
+  // 重复顶层键只依赖 script 块,无模板也能查
+  const dupKeys = duplicateTopKeys(sm[2]) || []
+
+  if (!tm) return { missing: [], dupKeys, evalFailed: false }
+
+  const options = evalVueScript(sm[2], path.dirname(abs))
+  if (options == null || typeof options !== 'object') return { missing: [], dupKeys, evalFailed: true }
+  if (!compiler) return { missing: [], dupKeys, evalFailed: false }
+
+  const comp = compiler.compile(tm[1])
+  if (comp.errors && comp.errors.length) return { missing: [], dupKeys, evalFailed: false }
+  const gSet = globalNames()
+  const def = definedNames(options)
+  for (const k of gSet) def.add(k)
+
+  const accessed = collectTemplateIdentifiers(comp.render)
+  const missing = []
+  for (const name of accessed) {
+    if (def.has(name)) continue
+    if (GLOBALS.has(name)) continue
+    if (VUE_INTERNALS.has(name) || INSTANCE_PROPS.has(name)) continue
+    if (name.startsWith('_') || name.startsWith('$')) continue
+    if (name === 'this') continue
+    if (name.length <= 1) continue
+    missing.push(name)
+  }
+  return { missing: [...new Set(missing)], dupKeys, evalFailed: false }
+}
+
+// ---------------- 灵敏度自测 ----------------
+function selfTest() {
+  let pass = true
+  const log = []
+  // 用例1:模板引用未定义属性,应命中
+  const tpl1 = '<view v-if="notDefinedProp">{{ ok }}</view>'
+  const script1 = 'export default { data() { return { ok: 1 } }, methods: { go() {} } }'
+  const comp1 = compiler ? compiler.compile(tpl1) : null
+  const def1 = definedNames(evalVueScript(script1, __dirname))
+  const acc1 = comp1 ? collectTemplateIdentifiers(comp1.render) : new Set()
+  const miss1 = [...acc1].filter(n => !def1.has(n) && !GLOBALS.has(n) && !VUE_INTERNALS.has(n) && !INSTANCE_PROPS.has(n) && !/^[_$]/.test(n) && n !== 'this' && n.length > 1)
+  const ok1 = miss1.includes('notDefinedProp')
+  log.push((ok1 ? 'PASS' : 'FAIL') + ' 模板未定义属性应命中 notDefinedProp(实际: ' + miss1.join(',') + ')')
+  if (!ok1) pass = false
+
+  // 用例2:全部已定义,应零命中
+  const tpl2 = '<view v-if="ok">{{ txt }}</view>'
+  const script2 = 'export default { data() { return { ok: true, txt: "a" } } }'
+  const comp2 = compiler ? compiler.compile(tpl2) : null
+  const def2 = definedNames(evalVueScript(script2, __dirname))
+  const acc2 = comp2 ? collectTemplateIdentifiers(comp2.render) : new Set()
+  const miss2 = [...acc2].filter(n => !def2.has(n) && !GLOBALS.has(n) && !VUE_INTERNALS.has(n) && !INSTANCE_PROPS.has(n) && !/^[_$]/.test(n) && n !== 'this' && n.length > 1)
+  log.push((miss2.length === 0 ? 'PASS' : 'FAIL') + ' 已定义属性应零命中(实际: ' + miss2.join(',') + ')')
+  if (miss2.length !== 0) pass = false
+
+  // 用例3:重复顶层键,应命中
+  const script3 = 'export default {\n  mixins: [a],\n  components: {},\n  mixins: [b]\n}'
+  const dup3 = duplicateTopKeys(script3) || []
+  const ok3 = dup3.some(d => d.name === 'mixins')
+  log.push((ok3 ? 'PASS' : 'FAIL') + ' 重复顶层键应命中 mixins(实际: ' + dup3.map(d => d.name).join(',') + ')')
+  if (!ok3) pass = false
+
+  // 用例4:无重复键,应零命中
+  const script4 = 'export default { data() { return {} }, methods: {} }'
+  const dup4 = duplicateTopKeys(script4) || []
+  log.push((dup4.length === 0 ? 'PASS' : 'FAIL') + ' 无重复键应零命中(实际: ' + dup4.map(d => d.name).join(',') + ')')
+  if (dup4.length !== 0) pass = false
+
+  console.log(log.join('\n'))
+  return pass ? 0 : 1
+}
+
+function main() {
+  if (SELF_TEST) { process.exit(selfTest()) }
+  if (!compiler) {
+    console.error('[FATAL] vue-template-compiler 不可用,无法扫描。请确认 node_modules 已安装。')
+    process.exit(2)
+  }
+
+  const files = ARGS.length
+    ? ARGS.map(a => path.isAbsolute(a) ? a : path.resolve(ROOT, a))
+    : collect(ROOT)
+
+  const missingFindings = []   // { file, missing: [] }
+  const dupFindings = []       // { file, name, firstLine, secondLine }
+  const evalFailed = []
+  for (const abs of files) {
+    const rel = path.relative(ROOT, abs).split(path.sep).join('/')
+    if (SKIP_FILES.has(rel)) continue
+    if (!fs.existsSync(abs)) { console.log('  [missing]', abs); continue }
+    const res = checkVueFile(abs)
+    for (const d of res.dupKeys) dupFindings.push({ file: rel, ...d })
+    if (res.evalFailed) { evalFailed.push(rel); continue }
+    if (res.missing.length) missingFindings.push({ file: rel, missing: res.missing })
+  }
+  const totalMiss = missingFindings.reduce((n, f) => n + f.missing.length, 0)
+  const out = []
+  out.push('扫描文件: ' + files.length + '  |  求值失败: ' + evalFailed.length + '  |  未定义属性命中: ' + totalMiss + '  |  重复顶层键命中: ' + dupFindings.length)
+  out.push('全局注入名: ' + [...globalNames()].join(', '))
+
+  if (evalFailed.length) {
+    out.push('\n[!] 脚本求值失败(未检查,需人工确认):')
+    for (const f of evalFailed) out.push('   ' + f)
+  }
+  if (missingFindings.length) {
+    out.push('\n=== 未定义实例属性(模板引用) ===')
+    for (const f of missingFindings) out.push('  ' + f.file + '  ->  ' + f.missing.join(', '))
+  }
+  if (dupFindings.length) {
+    out.push('\n=== options 重复顶层键(后者覆盖前者) ===')
+    for (const f of dupFindings) out.push('  ' + f.file + '  ->  ' + f.name + '  (L' + f.firstLine + ' / L' + f.secondLine + ')')
+  }
+
+  console.log(out.join('\n'))
+  if (JSON_OUT) {
+    console.log('\n---JSON---')
+    console.log(JSON.stringify({ missing: missingFindings, dupKeys: dupFindings, evalFailed }, null, 2))
+  }
+
+  const hasError = missingFindings.length > 0 || dupFindings.length > 0
+  // 求值失败 = 该文件未被检查,按 ERROR 对待(需人工确认),避免静默漏检
+  process.exit(hasError || evalFailed.length > 0 ? 1 : 0)
+}
+
+if (require.main === module) main()
+module.exports = { stripComments, transformESM, evalModuleFile, evalVueScript, definedNames, collectTemplateIdentifiers, globalNames, ROOT, compiler, modCache, DUMMY, duplicateTopKeys, checkVueFile }