Browse Source

fix(frontend): 新增小程序反模式审计 + 修正 9 处日期解析/格式化违规

- scripts/audit-mp-antipatterns.js:ERROR/WARN 分级审计 9 类反模式
  ERROR: 模板 ?.、:key 运算符、CSS 中文选择器、getSystemInfoSync、
  transition:all、v-for 缺 :key(跨行识别)、日期 toLocale*String
  WARN: new Date(非字面量)、:class/:style 带参方法调用
  当前 ERROR 0 / WARN 184(WARN 为存量提示项)
- teacher/chat.vue:消息时间 new Date().toLocaleString() -> formatDateTime()
  (toLocaleString 在 iOS/安卓输出不一致,违反时间显示规范)
- 8 处 new Date(<后端字符串>) 改 parseDate():FamilyChallengeCard、
  diet/record-detail、health/daily-checkin、health/water-index、
  profile/message-history(排序)、tasks(deadline 时间戳)、wealth-cf
- index-home:删除与 utils/format.js 重复的本地 parseDate(死代码,且 iOS 不安全)
- 9 文件模板/样式/脚本编译通过;AGENTS.md 补审计命令与分级说明
liaoxg 4 days ago
parent
commit
bcb937845b

+ 10 - 0
cfc-frontend/AGENTS.md

@@ -246,6 +246,16 @@ node scripts/verify-vue-compile.js a.vue b.vue  # 仅校验指定文件
 判定标准:`模板通过 / 样式通过 / 脚本通过` 三项数量一致且输出 `PASS`(退出码 0)。
 它能拦住三类只在 HBuilderX 打包时才暴露、小程序侧表现为白板的问题:模板表达式里未加引号的 `#hex`、样式引用未定义 SCSS 令牌、脚本语法截断。`components/mp-html/node/node.vue` 属第三方渲染器,脚本内已跳过。
 
+### 小程序反模式审计
+
+```bash
+node scripts/audit-mp-antipatterns.js            # ERROR 归零才算通过(退出码 0)
+node scripts/audit-mp-antipatterns.js --json     # 机器可读,便于脚本处理
+```
+
+ERROR 级(必须修):模板 `?.`、`:key` 运算符表达式、CSS 中文选择器、`uni.getSystemInfoSync()`、`transition: all`、`v-for` 缺 `:key`、日期用 `toLocale*String` 格式化。
+WARN 级(人工确认):`new Date(<非字面量>)`(应改 `parseDate()`)、`:class/:style` 内带参方法调用(uni-app 渲染函数下可用,但 WXML 原生表达式不支持,AGENTS.md 列为禁用项)。
+
 ### 修改 `remove-scoped.js` 的风险
 
 该文件在 `node_modules/@dcloudio/vue-cli-plugin-uni/` 内,每次 `npm install` 会通过 `postinstall-fix.js` 自动恢复补丁。如需手动执行:

+ 3 - 1
cfc-frontend/components/FamilyChallengeCard.vue

@@ -68,6 +68,7 @@
 </template>
 
 <script>
+import { parseDate } from '../../utils/format.js'
 export default {
   props: {
     challenge: { type: Object, default: null },
@@ -131,7 +132,8 @@ export default {
     },
     deadlineText: function() {
       if (!this.challenge || !this.challenge.respondDeadline) return ''
-      var deadline = new Date(this.challenge.respondDeadline)
+      var deadline = parseDate(this.challenge.respondDeadline)
+      if (!deadline) return ''
       var now = new Date()
       var diffDays = Math.round((deadline - now) / (1000 * 60 * 60 * 24))
       if (diffDays < 0) return '(已超时)'

+ 3 - 2
cfc-frontend/pages/diet/record-detail.vue

@@ -74,6 +74,7 @@
 
 <script>
 import { getDietDailyRecords } from '@/utils/api.js'
+import { parseDate } from '@/utils/format.js'
 import shareMixin from '@/components/share-mixin.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
@@ -130,8 +131,8 @@ export default {
     },
     formatTime: function(timeStr) {
       if (!timeStr) return ''
-      var d = new Date(timeStr)
-      if (isNaN(d.getTime())) return timeStr
+      var d = parseDate(timeStr)
+      if (!d) return timeStr
       var h = d.getHours().toString().padStart(2, '0')
       var m = d.getMinutes().toString().padStart(2, '0')
       return h + ':' + m

+ 3 - 1
cfc-frontend/pages/health/daily-checkin.vue

@@ -92,6 +92,7 @@
 
 <script>
 import { BrandColors } from '@/utils/brand-colors.js'
+import { parseDate } from '@/utils/format.js'
 import config from '@/config.js'
 
 export default {
@@ -125,7 +126,8 @@ export default {
     },
     formatDate: function(dateStr) {
       if (!dateStr) return ''
-      var d = new Date(dateStr)
+      var d = parseDate(dateStr)
+      if (!d) return ''
       var m = (d.getMonth() + 1).toString().padStart(2, '0')
       var day = d.getDate().toString().padStart(2, '0')
       return m + '-' + day

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

@@ -101,6 +101,7 @@
 
 <script>
 import config from '@/config.js'
+import { parseDate } from '@/utils/format.js'
 
 export default {
   data() {
@@ -139,7 +140,8 @@ export default {
     },
     formatDate: function(dateStr) {
       if (!dateStr) return ''
-      var d = new Date(dateStr)
+      var d = parseDate(dateStr)
+      if (!d) return ''
       var m = (d.getMonth() + 1).toString().padStart(2, '0')
       var day = d.getDate().toString().padStart(2, '0')
       return m + '-' + day

+ 0 - 9
cfc-frontend/pages/index-home/index.vue

@@ -1481,15 +1481,6 @@ title: '添加家庭成员',
       }).catch(function() {})
     },
     
-    // 辅助方法:解析日期
-    parseDate: function(dateStr) {
-      if (!dateStr) return null
-      // 统一日期格式处理
-      var d = new Date(dateStr)
-      if (isNaN(d)) return null
-      return d
-    },
-    
     isToday: function(date) {
       var today = new Date()
       return date.getDate() === today.getDate() &&

+ 1 - 1
cfc-frontend/pages/profile/message-history.vue

@@ -212,7 +212,7 @@ export default {
             ...this.cfMessages.map(m => ({...m, type: 'cf'})),
             ...this.systemMessages.map(m => ({...m, type: 'system'}))
           ]
-          return all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+          return all.sort((a, b) => (parseDate(b.createdAt) || 0) - (parseDate(a.createdAt) || 0))
       }
     }
   },

+ 1 - 1
cfc-frontend/pages/tasks/tasks.vue

@@ -602,7 +602,7 @@ export default {
           description: this.newTask.description,
           category: this.newTask.category,
           points: parseInt(this.newTask.points) || 2,
-          deadline: this.newTask.deadline ? new Date(this.newTask.deadline).getTime() : null,
+          deadline: parseDate(this.newTask.deadline) ? parseDate(this.newTask.deadline).getTime() : null,
           executorType: 'member',
           memberIds: this.newTask.memberIds.map(id => Number(id))
         })

+ 2 - 1
cfc-frontend/pages/teacher/chat.vue

@@ -33,6 +33,7 @@
 
 <script>
 import { getGuideMessages } from '../../utils/api.js'
+import { formatDateTime } from '../../utils/format.js'
 import config from '@/config.js'
 
 export default {
@@ -81,7 +82,7 @@ export default {
 
       const newMessage = {
         content: this.inputText,
-        time: new Date().toLocaleString(),
+        time: formatDateTime(new Date()),
         isMine: true
       }
       this.messages.push(newMessage)

+ 3 - 1
cfc-frontend/pages/wealth-cf/index.vue

@@ -97,6 +97,7 @@
 
 <script>
 import { getFamilyPlatformBalance, getFamilyPlatformLogs, exchangeCouponByCf } from '@/utils/api.js'
+import { parseDate } from '@/utils/format.js'
 
 export default {
   data() {
@@ -229,7 +230,8 @@ export default {
     },
     formatTime(dateStr) {
       if (!dateStr) return ''
-      const d = new Date(dateStr)
+      const d = parseDate(dateStr)
+      if (!d) return ''
       const m = d.getMonth() + 1
       const day = d.getDate()
       const h = d.getHours()

+ 124 - 0
cfc-frontend/scripts/audit-mp-antipatterns.js

@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+/**
+ * 小程序模板/脚本反模式审计(AGENTS.md「ANTI-PATTERNS」中可机器判定的部分)
+ *
+ * 分级:
+ *   ERROR — 确定会在微信小程序端出错/违反硬规范,必须修
+ *     1) 模板可选链 `?.`
+ *     2) `:key` 使用运算符表达式
+ *     3) CSS 选择器含中文
+ *     4) `uni.getSystemInfoSync()`
+ *     5) `transition: all`
+ *     6) `v-for` 缺 `:key`(支持跨行)
+ *     7) 日期用 `toLocaleString` / `toLocaleDateString` / `toLocaleTimeString` 格式化
+ *   WARN — 需人工确认
+ *     8) `new Date(<非字面量>)`:iOS 解析空格分隔/Java 格式会失败,应改 `parseDate()`
+ *        (`new Date()` 与 `new Date(Date 对象)` 不计入)
+ *     9) `:class` / `:style` 内带参方法调用:uni-app 渲染函数下可用,
+ *        但 AGENTS.md 列为禁用项,仅提示(WXML 原生表达式不支持)
+ *
+ * 用法: node scripts/audit-mp-antipatterns.js [--json] [--error-only]
+ * 退出码: 0 无 ERROR / 1 存在 ERROR
+ */
+const fs = require('fs')
+const path = require('path')
+
+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'])
+const JSON_OUT = process.argv.includes('--json')
+const ERROR_ONLY = process.argv.includes('--error-only')
+
+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
+}
+
+const findings = []
+function add(level, kind, file, line, text) {
+  findings.push({ level, kind, file, line, text: String(text).trim().slice(0, 140) })
+}
+
+for (const abs of collect(ROOT)) {
+  const rel = path.relative(ROOT, abs).split(path.sep).join('/')
+  if (SKIP_FILES.has(rel)) continue
+  const src = fs.readFileSync(abs, 'utf8')
+  const lines = src.split(/\r?\n/)
+
+  const tplMatch = src.match(/^<template[^>]*>/m)
+  const tplStart = tplMatch ? src.indexOf(tplMatch[0]) : -1
+  const tplEnd = tplStart === -1 ? -1 : src.indexOf('\n</template>', tplStart)
+
+  let offset = 0
+  for (let i = 0; i < lines.length; i++) {
+    const l = lines[i]
+    const pos = offset
+    const inTpl = tplStart !== -1 && pos > tplStart && pos < tplEnd
+    offset += l.length + 1
+
+    if (inTpl && /\?\./.test(l)) add('ERROR', '模板可选链 ?.', rel, i + 1, l)
+    if (inTpl && /:key="[^"]*(\|\||&&|\+|\?)/.test(l)) add('ERROR', ':key 运算符表达式', rel, i + 1, l)
+    if (/uni\.getSystemInfoSync\s*\(/.test(l)) add('ERROR', 'getSystemInfoSync 弃用', rel, i + 1, l)
+    if (/transition\s*:\s*all\b/.test(l)) add('ERROR', 'transition: all', rel, i + 1, l)
+    // toLocale* 仅针对日期;数字千分位(toLocaleString)不算违规
+    const localeHit = l.match(/(new\s+Date\s*\([^)]*\)|[A-Za-z_$][\w$.]*)\s*\.\s*toLocale(Date|Time)?String\s*\(/)
+    if (localeHit) {
+      const receiver = localeHit[1]
+      if (/Date|date|time|Time|day|Day|created|updated/.test(receiver)) {
+        add('ERROR', 'toLocale* 日期格式化', rel, i + 1, l)
+      }
+    }
+    if (inTpl && /:(class|style)="[^"]*\w+\([^)]*\)/.test(l)) add('WARN', ':class/:style 方法调用', rel, i + 1, l)
+
+    // new Date(<非字面量>):排除无参、数字字面量与多参数字构造(多参为合法且 iOS 安全)
+    const dm = l.match(/new\s+Date\s*\(\s*([^)]*?)\s*\)/)
+    if (dm && dm[1] && !dm[1].includes(',') && !/^['"`\d]/.test(dm[1]) && !/^[A-Za-z_$][\w$.]*\s*[-+*/]/.test(dm[1])) {
+      add('WARN', 'new Date(非字面量)', rel, i + 1, l)
+    }
+  }
+
+  // v-for 缺 :key(允许属性跨行,检查标签范围)
+  for (const tag of src.matchAll(/<[a-zA-Z][\w-]*(\s[^>]*?)?>/g)) {
+    const body = tag[0]
+    if (!/\bv-for=/.test(body)) continue
+    if (/:key=|v-bind:key=/.test(body)) continue
+    const line = src.slice(0, tag.index).split('\n').length
+    add('ERROR', 'v-for 缺 :key', rel, line, body.replace(/\s+/g, ' '))
+  }
+
+  // CSS 选择器含中文(排除注释行)
+  for (const m of src.matchAll(/^<style[^>]*>([\s\S]*?)^<\/style>/gm)) {
+    const base = src.slice(0, m.index).split('\n').length
+    m[1].split(/\r?\n/).forEach((l, i) => {
+      if (/^\s*(\/\/|\/\*|\*)/.test(l)) return
+      if (/^[^{}\n]*[\u4e00-\u9fa5][^{}\n]*\{/.test(l)) add('ERROR', 'CSS 选择器含中文', rel, base + i, l)
+    })
+  }
+}
+
+const shown = ERROR_ONLY ? findings.filter(f => f.level === 'ERROR') : findings
+
+if (JSON_OUT) {
+  console.log(JSON.stringify(shown, null, 2))
+} else {
+  const kinds = [...new Set(findings.map(f => f.kind))]
+  for (const kind of kinds) {
+    const list = findings.filter(f => f.kind === kind)
+    const level = list[0].level
+    if (ERROR_ONLY && level !== 'ERROR') continue
+    console.log('[' + level + '] ' + kind + ': ' + list.length)
+    for (const f of list.slice(0, 6)) console.log('    ' + f.file + ':' + f.line + '  ' + f.text)
+    if (list.length > 6) console.log('    ... 另有 ' + (list.length - 6) + ' 处')
+  }
+  const errors = findings.filter(f => f.level === 'ERROR').length
+  const warns = findings.length - errors
+  console.log('')
+  console.log('ERROR ' + errors + ' 处 / WARN ' + warns + ' 处')
+}
+process.exit(findings.some(f => f.level === 'ERROR') ? 1 : 0)