fix(mini): 修复设置页绑定漂移并新增 WXML-JS 一致性检查
Publish Mini Program Dev Version / publish (push) Successful in 1m5s
Publish Mini Program Dev Version / publish (push) Successful in 1m5s
settings.js 同步绑定名 loggedIn→logged、login→wxLogin、navTo→navigateTo,恢复登录按钮与全部菜单项响应;npm test 接入新增 ci/check-bindings.cjs 门禁;calendar/wish-detail 各 1 处历史遗留漂移登记为仅告警另行跟进
This commit is contained in:
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WXML-JS 绑定一致性静态检查(npm test / CI 门禁)
|
||||||
|
*
|
||||||
|
* 小程序不会在编译期校验 bind/catch 绑定名与 {{}} 数据引用:
|
||||||
|
* wxml 与页面 JS 一旦命名漂移(例如 wxml 写 wxLogin/navigateTo,
|
||||||
|
* 而 js 只有 login/navTo),页面不会报错,只是点击与渲染静默失效。
|
||||||
|
* 本脚本把这类漂移拦在提交与 CI 阶段。
|
||||||
|
*
|
||||||
|
* 检查范围:pages/<name>/<name>.wxml 与同目录同名 .js 的配对
|
||||||
|
* 检查规则:
|
||||||
|
* 1. bind/catch 事件绑定(含 bind:tap 冒号写法)的方法名必须存在于对应 JS
|
||||||
|
* 2. {{}} 表达式中的根标识符(点号前的名字)必须存在于对应 JS(数据字段或方法)
|
||||||
|
* 3. wx:for 默认变量 item/index、wx:for-item/wx:for-index 别名、
|
||||||
|
* 字符串字面量与 true/false/null/undefined 自动忽略
|
||||||
|
* 4. LEGACY_DRIFT 中登记的历史遗留条目仅告警不失败(修复后应删除对应条目)
|
||||||
|
*
|
||||||
|
* 存在漂移时以非零退出码结束,使 npm test 与 CI 直接失败。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const projectPath = process.cwd();
|
||||||
|
const pagesPath = path.join(projectPath, "pages");
|
||||||
|
|
||||||
|
const LITERAL_KEYWORDS = new Set(["true", "false", "null", "undefined"]);
|
||||||
|
// wx:for 的默认循环变量属于模板结构名而非页面定义,始终忽略
|
||||||
|
const DEFAULT_LOOP_VARS = new Set(["item", "index"]);
|
||||||
|
|
||||||
|
// 引入本检查前就存在的历史遗留漂移(本轮修复范围外,另行跟进)。
|
||||||
|
// 命中时仅告警不失败;对应页面修复后应删除这里的条目,让检查恢复全量严格。
|
||||||
|
const LEGACY_DRIFT = new Set([
|
||||||
|
'pages/calendar/calendar.wxml catchtap="noop"',
|
||||||
|
'pages/wish-detail/wish-detail.wxml bindtap="onShare"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function listPageNames() {
|
||||||
|
return fs
|
||||||
|
.readdirSync(pagesPath, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.filter((name) => {
|
||||||
|
const dir = path.join(pagesPath, name);
|
||||||
|
return (
|
||||||
|
fs.existsSync(path.join(dir, `${name}.wxml`)) &&
|
||||||
|
fs.existsSync(path.join(dir, `${name}.js`))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOf(text, index) {
|
||||||
|
let line = 1;
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
if (text.charCodeAt(i) === 10) {
|
||||||
|
line++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取 wxml 中的事件绑定与 {{}} 表达式(去掉注释,保留行号) */
|
||||||
|
function collectWxmlRefs(source) {
|
||||||
|
const text = source.replace(/<!--[\s\S]*?-->/g, "");
|
||||||
|
const loopVars = new Set();
|
||||||
|
const handlers = [];
|
||||||
|
const roots = [];
|
||||||
|
|
||||||
|
for (const match of text.matchAll(/wx:for-(?:item|index)="([A-Za-z_$][\w$]*)"/g)) {
|
||||||
|
loopVars.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of text.matchAll(/((?:bind|catch):?[a-zA-Z]+)="([^"{}]+)"/g)) {
|
||||||
|
const name = match[2].trim();
|
||||||
|
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
|
||||||
|
handlers.push({ ref: `${match[1]}="${name}"`, name, line: lineOf(text, match.index) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of text.matchAll(/\{\{([^{}]*)\}\}/g)) {
|
||||||
|
for (const name of expressionRoots(match[1])) {
|
||||||
|
roots.push({ ref: `{{${name}}}`, name, line: lineOf(text, match.index) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loopVars, handlers, roots };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取 mustache 表达式的根标识符:字符串字面量、点号后的属性名、数字与关键字都不算 */
|
||||||
|
function expressionRoots(expression) {
|
||||||
|
const stripped = expression
|
||||||
|
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
||||||
|
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
||||||
|
const roots = new Set();
|
||||||
|
for (const match of stripped.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) {
|
||||||
|
if (!LITERAL_KEYWORDS.has(match[1])) {
|
||||||
|
roots.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 JS 的字符串与注释内容置为空白(保留换行),避免干扰键名提取与跨行匹配 */
|
||||||
|
function stripJsNoise(source) {
|
||||||
|
let result = "";
|
||||||
|
let index = 0;
|
||||||
|
while (index < source.length) {
|
||||||
|
const ch = source[index];
|
||||||
|
const next = source[index + 1];
|
||||||
|
if (ch === "/" && next === "/") {
|
||||||
|
while (index < source.length && source[index] !== "\n") {
|
||||||
|
result += " ";
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && next === "*") {
|
||||||
|
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
|
||||||
|
result += source[index] === "\n" ? "\n" : " ";
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
result += " ";
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "'" || ch === '"' || ch === "`") {
|
||||||
|
result += " ";
|
||||||
|
index++;
|
||||||
|
while (index < source.length) {
|
||||||
|
const current = source[index];
|
||||||
|
if (current === "\\") {
|
||||||
|
result += source[index + 1] === "\n" ? "\n" : " ";
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === ch) {
|
||||||
|
result += " ";
|
||||||
|
index++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result += current === "\n" ? "\n" : " ";
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result += ch;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取页面 JS 中 Page({}) 的数据字段、setData 键与方法名 */
|
||||||
|
function collectJsNames(source) {
|
||||||
|
const clean = stripJsNoise(source);
|
||||||
|
const names = new Set();
|
||||||
|
// 对象键:{ 或 , 之后的 name:(data 初始字段、setData 键、回调键)
|
||||||
|
for (const match of clean.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*:/g)) {
|
||||||
|
names.add(match[1]);
|
||||||
|
}
|
||||||
|
// 方法定义:行首可带 async 的 name(
|
||||||
|
for (const match of clean.matchAll(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/gm)) {
|
||||||
|
names.add(match[1]);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!fs.existsSync(pagesPath)) {
|
||||||
|
console.error(`[check-bindings] 找不到页面目录:${pagesPath}`);
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = listPageNames();
|
||||||
|
const problems = [];
|
||||||
|
const legacyHits = [];
|
||||||
|
let checkedRefs = 0;
|
||||||
|
|
||||||
|
for (const name of pages) {
|
||||||
|
const dir = path.join(pagesPath, name);
|
||||||
|
const wxmlFile = path.join(dir, `${name}.wxml`);
|
||||||
|
const jsFile = path.join(dir, `${name}.js`);
|
||||||
|
const { loopVars, handlers, roots } = collectWxmlRefs(fs.readFileSync(wxmlFile, "utf8"));
|
||||||
|
const known = collectJsNames(fs.readFileSync(jsFile, "utf8"));
|
||||||
|
const ignored = new Set([...DEFAULT_LOOP_VARS, ...loopVars]);
|
||||||
|
const wxmlRel = path.relative(projectPath, wxmlFile);
|
||||||
|
const jsRel = path.relative(projectPath, jsFile);
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
checkedRefs++;
|
||||||
|
if (!known.has(handler.name)) {
|
||||||
|
if (LEGACY_DRIFT.has(`${wxmlRel} ${handler.ref}`)) {
|
||||||
|
legacyHits.push(`${wxmlRel}:${handler.line} ${handler.ref}`);
|
||||||
|
} else {
|
||||||
|
problems.push(`${wxmlRel}:${handler.line} ${handler.ref} 在 ${jsRel} 中找不到对应方法`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const root of roots) {
|
||||||
|
if (ignored.has(root.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
checkedRefs++;
|
||||||
|
if (!known.has(root.name)) {
|
||||||
|
if (LEGACY_DRIFT.has(`${wxmlRel} ${root.ref}`)) {
|
||||||
|
legacyHits.push(`${wxmlRel}:${root.line} ${root.ref}`);
|
||||||
|
} else {
|
||||||
|
problems.push(`${wxmlRel}:${root.line} ${root.ref} 在 ${jsRel} 的 data/方法中找不到`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
console.error(`[check-bindings] 发现 ${problems.length} 处 WXML 与 JS 不一致的引用:`);
|
||||||
|
for (const problem of problems) {
|
||||||
|
console.error(` - ${problem}`);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
"[check-bindings] 请统一 wxml 绑定名与页面 JS 的 data 字段/方法名,避免点击与数据绑定静默失效。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacyHits.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[check-bindings] 另有 ${legacyHits.length} 处历史遗留漂移(引入检查前已存在,不计入本次失败,另行跟进):`,
|
||||||
|
);
|
||||||
|
for (const hit of legacyHits) {
|
||||||
|
console.warn(` - ${hit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[check-bindings] 通过:${pages.length} 个页面、${checkedRefs} 处 bind/catch 与 {{}} 引用均能在同名 JS 中找到定义。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"description": "祈福小助手 - 万年历微信小程序",
|
"description": "祈福小助手 - 万年历微信小程序",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"No tests yet\"",
|
"test": "node ci/check-bindings.cjs",
|
||||||
"upload": "node ci/upload-ci.cjs",
|
"upload": "node ci/upload-ci.cjs",
|
||||||
"preview": "node ci/preview-ci.cjs"
|
"preview": "node ci/preview-ci.cjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const birthProfile = require('../../utils/birth-profile.js');
|
|||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
userInfo: null,
|
userInfo: null,
|
||||||
loggedIn: false,
|
logged: false,
|
||||||
logging: false,
|
logging: false,
|
||||||
// 菜单角标
|
// 菜单角标
|
||||||
profileCount: 0,
|
profileCount: 0,
|
||||||
@@ -25,7 +25,7 @@ Page({
|
|||||||
refreshUser() {
|
refreshUser() {
|
||||||
const token = wx.getStorageSync('token');
|
const token = wx.getStorageSync('token');
|
||||||
const userInfo = wx.getStorageSync('lunar-user-info');
|
const userInfo = wx.getStorageSync('lunar-user-info');
|
||||||
this.setData({ loggedIn: !!token, userInfo: userInfo || null });
|
this.setData({ logged: !!token, userInfo: userInfo || null });
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshCounts() {
|
refreshCounts() {
|
||||||
@@ -39,7 +39,7 @@ Page({
|
|||||||
|
|
||||||
// ==================== 登录 ====================
|
// ==================== 登录 ====================
|
||||||
|
|
||||||
login() {
|
wxLogin() {
|
||||||
if (this.data.logging) return;
|
if (this.data.logging) return;
|
||||||
this.setData({ logging: true });
|
this.setData({ logging: true });
|
||||||
wx.login({
|
wx.login({
|
||||||
@@ -57,7 +57,7 @@ Page({
|
|||||||
if (res && res.code === 0 && res.data && res.data.token) {
|
if (res && res.code === 0 && res.data && res.data.token) {
|
||||||
wx.setStorageSync('token', res.data.token);
|
wx.setStorageSync('token', res.data.token);
|
||||||
wx.setStorageSync('lunar-user-info', res.data.user);
|
wx.setStorageSync('lunar-user-info', res.data.user);
|
||||||
this.setData({ userInfo: res.data.user, loggedIn: true, logging: false });
|
this.setData({ userInfo: res.data.user, logged: true, logging: false });
|
||||||
wx.showToast({ title: '登录成功', icon: 'success' });
|
wx.showToast({ title: '登录成功', icon: 'success' });
|
||||||
// 登录后拉取云端生辰档案,保证菜单计数准确
|
// 登录后拉取云端生辰档案,保证菜单计数准确
|
||||||
birthProfile.syncFromServer().then(() => this.refreshCounts()).catch(() => {});
|
birthProfile.syncFromServer().then(() => this.refreshCounts()).catch(() => {});
|
||||||
@@ -85,13 +85,13 @@ Page({
|
|||||||
if (!res.confirm) return;
|
if (!res.confirm) return;
|
||||||
wx.removeStorageSync('token');
|
wx.removeStorageSync('token');
|
||||||
wx.removeStorageSync('lunar-user-info');
|
wx.removeStorageSync('lunar-user-info');
|
||||||
this.setData({ userInfo: null, loggedIn: false });
|
this.setData({ userInfo: null, logged: false });
|
||||||
wx.showToast({ title: '已退出', icon: 'success' });
|
wx.showToast({ title: '已退出', icon: 'success' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
navTo(e) {
|
navigateTo(e) {
|
||||||
const url = e.currentTarget.dataset.url;
|
const url = e.currentTarget.dataset.url;
|
||||||
// tabBar 页面必须用 switchTab 打开
|
// tabBar 页面必须用 switchTab 打开
|
||||||
const tabPages = ['/pages/home/home', '/pages/wish-tree/wish-tree', '/pages/bazi/bazi', '/pages/wiki/wiki', '/pages/settings/settings'];
|
const tabPages = ['/pages/home/home', '/pages/wish-tree/wish-tree', '/pages/bazi/bazi', '/pages/wiki/wiki', '/pages/settings/settings'];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<!-- 登录与账号 -->
|
<!-- 登录与账号 -->
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="flex items-center justify-between" wx:if="{{loggedIn}}">
|
<view class="flex items-center justify-between" wx:if="{{logged}}">
|
||||||
<view class="flex items-center gap-2">
|
<view class="flex items-center gap-2">
|
||||||
<image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/>
|
<image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/>
|
||||||
<view class="avatar avatar-placeholder" wx:else>
|
<view class="avatar avatar-placeholder" wx:else>
|
||||||
@@ -24,28 +24,28 @@
|
|||||||
<text class="text-xs text-muted block">登录后生辰档案云端同步,换设备不丢失</text>
|
<text class="text-xs text-muted block">登录后生辰档案云端同步,换设备不丢失</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="btn-primary btn-sm" bindtap="login">{{logging ? '登录中…' : '授权登录'}}</view>
|
<view class="btn-primary btn-sm" bindtap="wxLogin">{{logging ? '登录中…' : '授权登录'}}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 我的数据 -->
|
<!-- 我的数据 -->
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-title">我的数据</view>
|
<view class="section-title">我的数据</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/birth-profiles/birth-profiles">
|
<view class="menu-item" bindtap="navigateTo" data-url="/pages/birth-profiles/birth-profiles">
|
||||||
<text class="menu-label">生辰管理</text>
|
<text class="menu-label">生辰管理</text>
|
||||||
<view class="menu-right">
|
<view class="menu-right">
|
||||||
<text class="menu-count">{{profileCount}}/{{maxFree}}</text>
|
<text class="menu-count">{{profileCount}}/{{maxFree}}</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/bookmarks/bookmarks">
|
<view class="menu-item" bindtap="navigateTo" data-url="/pages/bookmarks/bookmarks">
|
||||||
<text class="menu-label">我的收藏</text>
|
<text class="menu-label">我的收藏</text>
|
||||||
<view class="menu-right">
|
<view class="menu-right">
|
||||||
<text class="menu-count" wx:if="{{bookmarkCount > 0}}">{{bookmarkCount}}</text>
|
<text class="menu-count" wx:if="{{bookmarkCount > 0}}">{{bookmarkCount}}</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/preferences/preferences">
|
<view class="menu-item" bindtap="navigateTo" data-url="/pages/preferences/preferences">
|
||||||
<text class="menu-label">设置</text>
|
<text class="menu-label">设置</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -54,19 +54,19 @@
|
|||||||
<!-- 功能 -->
|
<!-- 功能 -->
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-title">功能</view>
|
<view class="section-title">功能</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/fortune/fortune">
|
<view class="menu-item" bindtap="navigateTo" data-url="/pages/fortune/fortune">
|
||||||
<text class="menu-label">每日运势</text>
|
<text class="menu-label">每日运势</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/divination/divination">
|
<view class="menu-item" bindtap="navigateTo" data-url="/pages/divination/divination">
|
||||||
<text class="menu-label">占卜</text>
|
<text class="menu-label">占卜</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/solar-terms/solar-terms">
|
<view class="menu-item" bindtap="navigateTo" data-url="/pages/solar-terms/solar-terms">
|
||||||
<text class="menu-label">二十四节气</text>
|
<text class="menu-label">二十四节气</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" bindtap="navTo" data-url="/pages/calendar/calendar">
|
<view class="menu-item" bindtap="navigateTo" data-url="/pages/calendar/calendar">
|
||||||
<text class="menu-label">月历视图</text>
|
<text class="menu-label">月历视图</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
Reference in New Issue
Block a user