fix(miniapp): 小程序界面全量中文化,消除英文/非中文枚举码上屏

- 新增 miniapp/constants/profileLabels.js 作为「枚举码→中文简体」唯一字典
  (婚姻/子女/学历/职业/吸烟饮酒/择偶子女/红娘状态),覆盖表单值与种子
  实际值两套并集,保证详情页无论后端返回哪种码都显示中文
- 修复「择偶条件-子女」下拉英文选项(any/accept/reject→不限/可接受/不接受),
  改用 value/label 数组 + range-key="label"
- 修复 recommend-card / user-card / profile-detail / profile-confirm 直接渲染
  后端枚举码(educationCode/maritalStatus/occupationCode…)的问题,改由
  observers 派生中文 label 上屏
- 清理残留英文文案:Required 条件→必须符合的条件、喜欢TA/向Ta→喜欢对方/向对方、
  U51 服务预约→服务预约、U64 公益服务记录→公益服务记录
- 修复 7 个英文窗口标题与 3 个英文占位页正文
- matchmaker 历史服务状态消除原始码兜底,统一走字典
- 加固 scripts/audit-miniapp-language.mjs(A 整行先剥离 mustache、B 排除
  bind:action 处理器、F 窗口标题、JPG/PNG 入白名单)
- 新增 ui-tests/miniapp-language-contracts.mjs 语种守卫(复用审计脚本,断言 0 命中),
  UI 契约门禁 MIN_GUARDS 21→22;已做负向注入验证判别力
This commit is contained in:
WorkBuddy
2026-09-23 11:19:17 +08:00
parent 1fc34f1bcd
commit c0298c27a5
31 changed files with 585 additions and 71 deletions
+25 -1
View File
@@ -8,6 +8,8 @@
* 不变量(AGENTS.md 第 2 条):用户已勾选"必须符合"的字段不可被前端自动放宽为
* "最好符合",UI 必须显式提示用户此操作将影响匹配精度。
*/
const { ACCEPT_CHILDREN_OPTIONS } = require('../../constants')
Component({
options: { addGlobalClass: true },
properties: {
@@ -19,6 +21,24 @@ Component({
required: '必须符合',
preferred: '最好符合',
},
// 子女(是否接受有孩)下拉:选项均为中文,value 为后端枚举码
childrenOptions: ACCEPT_CHILDREN_OPTIONS,
childrenLabelMap: {
any: '不限',
accept: '可接受',
reject: '不接受',
},
childrenIndex: -1,
},
observers: {
preference(pref) {
if (!pref) {
this.setData({ childrenIndex: -1 })
return
}
const idx = this.data.childrenOptions.findIndex((o) => o.value === pref.acceptChildren)
this.setData({ childrenIndex: idx })
},
},
methods: {
// 切换某字段的 importance:required ↔ preferred
@@ -54,9 +74,13 @@ Component({
},
onAcceptChildrenChange(e) {
if (this.data.readonly) return;
const idx = Number(e.detail.value)
const opt = this.data.childrenOptions[idx]
if (!opt) return
this.setData({ childrenIndex: idx })
this.triggerEvent('change', {
field: 'acceptChildren',
value: e.detail.value,
value: opt.value,
});
},
},
@@ -111,13 +111,13 @@
<view class="ui-pref__label">子女</view>
<picker
class="ui-pref__picker"
value="{{preference.acceptChildren}}"
range="{{['any', 'accept', 'reject']}}"
range-key=""
value="{{childrenIndex < 0 ? 0 : childrenIndex}}"
range="{{childrenOptions}}"
range-key="label"
bindchange="onAcceptChildrenChange"
>
<view class="ui-pref__picker-value">
{{preference.acceptChildren === 'any' ? '不限' : preference.acceptChildren === 'accept' ? '可接受' : '不接受'}}
{{childrenLabelMap[preference.acceptChildren] || '不限'}}
</view>
</picker>
</view>
+16 -1
View File
@@ -7,7 +7,7 @@
* 不暴露"放宽了 X 条件才得到这个推荐"的字样,前端只显示后端返回的 reasons。
* - 第 5 条:红娘不可替用户操作——本卡片仅由当前用户操作,不接受 matchmakerId 参数。
*/
const { STATUS_BADGE } = require('../../constants');
const { STATUS_BADGE, profileLabelOf } = require('../../constants');
Component({
options: { addGlobalClass: true },
@@ -29,6 +29,21 @@ Component({
medium: 'info',
normal: 'warning',
},
// 由后端枚举码派生的中文展示值(界面不得出现英文码)
educationLabel: '',
occupationLabel: '',
},
observers: {
recommendation(rec) {
if (!rec) {
this.setData({ educationLabel: '', occupationLabel: '' })
return
}
this.setData({
educationLabel: profileLabelOf('educationCode', rec.educationCode),
occupationLabel: profileLabelOf('occupationCode', rec.occupationCode),
})
},
},
methods: {
onViewDetail() {
+2 -2
View File
@@ -2,8 +2,8 @@
<ui-avatar src="{{recommendation.avatarUrl || '/assets/brand/default-avatar.png'}}" size="lg" verified="{{recommendation.verified}}" reviewed="{{recommendation.reviewed}}" default-text="{{recommendation.displayName}}" />
<view class="ui-rec__info">
<view class="ui-rec__name-row"><text class="ui-rec__name">{{recommendation.displayName || '善缘用户'}}</text><text wx:if="{{recommendation.age}}" class="ui-rec__age">{{recommendation.age}}岁</text><text wx:if="{{recommendation.online}}" class="ui-rec__online">在线</text></view>
<view class="ui-rec__meta"><text wx:if="{{recommendation.regionName}}">{{recommendation.regionName}}</text><text wx:if="{{recommendation.heightCm}}"> · {{recommendation.heightCm}}cm</text><text wx:if="{{recommendation.educationCode}}"> · {{recommendation.educationCode}}</text></view>
<view wx:if="{{recommendation.occupationCode}}" class="ui-rec__meta">{{recommendation.occupationCode}}</view>
<view class="ui-rec__meta"><text wx:if="{{recommendation.regionName}}">{{recommendation.regionName}}</text><text wx:if="{{recommendation.heightCm}}"> · {{recommendation.heightCm}}cm</text><text wx:if="{{educationLabel}}"> · {{educationLabel}}</text></view>
<view wx:if="{{occupationLabel}}" class="ui-rec__meta">{{occupationLabel}}</view>
<view wx:if="{{recommendation.recommendationReasons && recommendation.recommendationReasons.length}}" class="ui-rec__reasons"><text wx:for="{{recommendation.recommendationReasons}}" wx:key="*this" class="ui-rec__reason">{{item}}</text></view>
</view>
<view wx:if="{{showActions}}" class="ui-rec__actions">
+21
View File
@@ -7,6 +7,8 @@
* <view slot="actions">…自定义按钮…</view>
* </ui-user-card>
*/
const { profileLabelOf } = require('../../constants')
Component({
options: { addGlobalClass: true, multipleSlots: true },
properties: {
@@ -15,6 +17,25 @@ Component({
selectable: { type: Boolean, value: false }, // 是否显示选择框
selected: { type: Boolean, value: false },
},
data: {
// 由后端枚举码派生的中文展示值(界面不得出现英文码)
educationLabel: '',
occupationLabel: '',
maritalLabel: '',
},
observers: {
user(u) {
if (!u) {
this.setData({ educationLabel: '', occupationLabel: '', maritalLabel: '' })
return
}
this.setData({
educationLabel: profileLabelOf('educationCode', u.educationCode),
occupationLabel: profileLabelOf('occupationCode', u.occupationCode),
maritalLabel: profileLabelOf('maritalStatus', u.maritalStatus),
})
},
},
methods: {
onTap() {
if (!this.data.user) return;
+5 -5
View File
@@ -19,16 +19,16 @@
<view class="ui-user-card__meta">
<text wx:if="{{user.heightCm}}">{{user.heightCm}}cm · </text>
<text>{{user.regionName}}</text>
<text wx:if="{{user.educationCode}}"> · {{user.educationCode}}</text>
<text wx:if="{{educationLabel}}"> · {{educationLabel}}</text>
</view>
<view wx:if="{{user.occupationCode}}" class="ui-user-card__meta">
{{user.occupationCode}}
<view wx:if="{{occupationLabel}}" class="ui-user-card__meta">
{{occupationLabel}}
</view>
<view wx:if="{{user.recommendationReasons && user.recommendationReasons.length}}" class="ui-user-card__tags">
<text wx:for="{{user.recommendationReasons}}" wx:key="*this" class="ui-user-card__tag">{{item}}</text>
</view>
<view wx:if="{{user.maritalStatus || user.hasChildren !== undefined}}" class="ui-user-card__meta">
<text wx:if="{{user.maritalStatus}}">{{user.maritalStatus}}</text>
<view wx:if="{{maritalLabel || user.hasChildren !== undefined}}" class="ui-user-card__meta">
<text wx:if="{{maritalLabel}}">{{maritalLabel}}</text>
<text wx:if="{{user.hasChildren}}"> · 有孩</text>
</view>
</view>
+12
View File
@@ -10,6 +10,7 @@ const statusEnums = require('./statusEnums');
const permissionCodes = require('./permissionCodes');
const storageKeys = require('./storageKeys');
const businessLimits = require('./businessLimits');
const profileLabels = require('./profileLabels');
module.exports = {
// 路由
@@ -60,4 +61,15 @@ module.exports = {
// 业务限制
BUSINESS_LIMITS: businessLimits.BUSINESS_LIMITS,
mergeAppConfig: businessLimits.mergeAppConfig,
// 资料/择偶枚举码 → 中文简体字典(界面不得出现英文枚举码)
PROFILE_LABELS: profileLabels,
profileLabelOf: profileLabels.labelOf,
yesNoLabel: profileLabels.yesNoLabel,
futureDanyangLabel: profileLabels.futureDanyangLabel,
MATCHMAKER_SERVICE_STATUS_LABEL: profileLabels.MATCHMAKER_SERVICE_STATUS_LABEL,
ACCEPT_CHILDREN_OPTIONS: profileLabels.ACCEPT_CHILDREN_OPTIONS,
MARITAL_OPTIONS: profileLabels.MARITAL_OPTIONS,
EDUCATION_OPTIONS: profileLabels.EDUCATION_OPTIONS,
OCCUPATION_OPTIONS: profileLabels.OCCUPATION_OPTIONS,
};
+189
View File
@@ -0,0 +1,189 @@
/**
* 资料 / 择偶条件枚举码 → 中文简体 唯一字典
* ============================================================
* 铁律:界面上不允许出现任何英文枚举码(如 never_married / bachelor /
* government / any / accept / reject)。所有会展示给用户的状态码、选项码
* 都必须在本文件里有中文 label。
*
* 为什么要集中:
* - 小程序里最典型的缺陷是「后端枚举码直接当展示值」上屏(例如某卡片
* 直接渲染 educationCode=bachelor)。静态类型检查 / WXSS 编译 / 结构
* 守卫都发现不了,只有人眼在真机才看得见。
* - 后端 demo 种子与实际表单存的码存在历史不一致(表单用 occasional,
* 种子用 sometimes;表单用 yes/no,种子用 是/否)。labelOf 统一覆盖
* 两套并集,保证详情页无论后端返回哪种都显示中文。
*
* labelOf(group, code):
* - 空值返回 ''
* - 已知码返回中文
* - 未知码返回原值(便于排查;生产路径不应触发,否则说明字典需补)
*/
// ============ 婚姻状况 ============
const MARITAL = {
never_married: '未婚',
divorced: '离异',
widowed: '丧偶',
single: '未婚', // profile-confirm 旧别名
}
// ============ 子女情况 ============
const CHILDREN_STATUS = {
none: '无子女',
has: '有子女',
}
// ============ 学历 ============
// 覆盖表单值(high_school) 与种子实际值(senior_high/junior_high) 及别名
const EDUCATION = {
high_school: '高中/中专',
senior_high: '高中/中专',
junior_high: '初中',
college: '大专',
bachelor: '本科',
master: '硕士',
phd: '博士',
doctor: '博士', // profile-confirm 旧别名
}
// ============ 职业 ============
// 表单类目(生产路径)+ demo 种子实际值(自由英文词)
const OCCUPATION = {
government: '机关事业单位',
education: '教育',
medical: '医疗',
enterprise: '企业职员',
technical: '技术人员',
self_employed: '个体经营',
freelance: '自由职业',
service: '服务行业',
agriculture: '农业',
other: '其他',
// demo 种子实际值
clerical: '行政文职',
designer: '设计',
factory: '工厂生产',
nurse: '医护',
engineer: '工程师',
programmer: '程序员',
sales: '销售',
doctor: '医生',
researcher: '科研',
teacher: '教师',
}
// ============ 择偶条件:是否接受有孩(preference-editor 子女下拉) ============
const ACCEPT_CHILDREN = {
any: '不限',
accept: '可接受',
reject: '不接受',
}
// ============ 吸烟 / 饮酒 ============
// 覆盖表单值(occasional) 与种子实际值(sometimes)
const SMOKING = {
never: '不吸烟',
occasional: '偶尔吸烟',
sometimes: '偶尔吸烟',
often: '经常吸烟',
}
const DRINKING = {
never: '不喝酒',
occasional: '偶尔喝酒',
sometimes: '偶尔喝酒',
often: '经常喝酒',
}
// ============ 红娘服务状态(matchmaker 历史服务) ============
const MATCHMAKER_SERVICE_STATUS_LABEL = {
submitted: '已提交',
assigned: '已分配',
in_service: '服务中',
waiting_user: '待您确认',
completed: '已完成',
cancelled: '已取消',
}
const GROUPS = {
maritalStatus: MARITAL,
childrenStatus: CHILDREN_STATUS,
educationCode: EDUCATION,
occupationCode: OCCUPATION,
acceptChildren: ACCEPT_CHILDREN,
smokingStatus: SMOKING,
drinkingStatus: DRINKING,
}
/**
* 通用映射:已知码→中文,未知→原值
* @param {string} group GROUPS 的键
* @param {*} code 后端返回的原始码
* @returns {string}
*/
function labelOf(group, code) {
if (code === undefined || code === null || code === '') return ''
const map = GROUPS[group]
if (!map) return String(code)
return map[code] !== undefined ? map[code] : String(code)
}
/** 是/否类(长期居住丹阳等):兼容布尔、yes/no、是/否 三种来源 */
function yesNoLabel(v) {
if (v === true) return '是'
if (v === false) return '否'
if (v === 'yes') return '是'
if (v === 'no') return '否'
if (v === '是' || v === '否') return v
return v === undefined || v === null || v === '' ? '' : String(v)
}
/** 未来规划(未来是否愿意继续在丹阳):willing/uncertain/unwilling 或 是/否 */
function futureDanyangLabel(v) {
if (v === 'willing' || v === '是') return '愿意'
if (v === 'uncertain') return '不确定'
if (v === 'unwilling' || v === '否') return '不愿意'
return v === undefined || v === null || v === '' ? '' : String(v)
}
// ============ picker 选项数组(value/label 形式,供 wxml range + range-key 使用) ============
const ACCEPT_CHILDREN_OPTIONS = [
{ value: 'any', label: '不限' },
{ value: 'accept', label: '可接受' },
{ value: 'reject', label: '不接受' },
]
const MARITAL_OPTIONS = [
{ value: 'never_married', label: '未婚' },
{ value: 'divorced', label: '离异' },
{ value: 'widowed', label: '丧偶' },
]
const EDUCATION_OPTIONS = [
{ value: 'senior_high', label: '高中/中专' },
{ value: 'junior_high', label: '初中' },
{ value: 'college', label: '大专' },
{ value: 'bachelor', label: '本科' },
{ value: 'master', label: '硕士' },
{ value: 'phd', label: '博士' },
]
const OCCUPATION_OPTIONS = Object.keys(OCCUPATION).map((v) => ({ value: v, label: OCCUPATION[v] }))
module.exports = {
MARITAL,
CHILDREN_STATUS,
EDUCATION,
OCCUPATION,
ACCEPT_CHILDREN,
SMOKING,
DRINKING,
MATCHMAKER_SERVICE_STATUS_LABEL,
GROUPS,
labelOf,
yesNoLabel,
futureDanyangLabel,
ACCEPT_CHILDREN_OPTIONS,
MARITAL_OPTIONS,
EDUCATION_OPTIONS,
OCCUPATION_OPTIONS,
}
+1 -1
View File
@@ -1 +1 @@
{"navigationBarTitleText":"about","usingComponents":{}}
{"navigationBarTitleText":"关于我们","usingComponents":{}}
@@ -20,7 +20,7 @@
<view class="status-section" wx:else>
<view class="pending-icon">💬</view>
<text class="pending-title">向Ta表达兴趣</text>
<text class="pending-title">向对方表达兴趣</text>
<text class="pending-hint">双方确认后才能开启线上沟通</text>
<button class="confirm-btn" bindtap="onConfirm">确认开启沟通</button>
</view>
@@ -1 +1 @@
{"navigationBarTitleText":"activity-register","usingComponents":{}}
{"navigationBarTitleText":"活动报名","usingComponents":{}}
@@ -1 +1 @@
<view class="page"><text>页面: activity-register</text></view>
<view class="page"><text>活动报名(建设中)</text></view>
+1 -1
View File
@@ -1 +1 @@
{"navigationBarTitleText":"bind-phone","usingComponents":{}}
{"navigationBarTitleText":"绑定手机","usingComponents":{}}
+1 -1
View File
@@ -1 +1 @@
<view class="page"><text>bind-phone</text></view>
<view class="page"><text>手机号绑定(建设中)</text></view>
+1 -1
View File
@@ -242,7 +242,7 @@ Page({
const { otherUserId } = this.data;
if (!otherUserId) { appStore.toast('暂无更多信息'); return; }
wx.showActionSheet({
itemList: ['查看 Ta 的资料', '举报 Ta', '拉黑 Ta'],
itemList: ['查看对方的资料', '举报对方', '拉黑对方'],
success: (res) => {
if (res.tapIndex === 0) wx.navigateTo({ url: to.detail(otherUserId) });
else if (res.tapIndex === 1) wx.navigateTo({ url: to.report(otherUserId) });
+1 -1
View File
@@ -107,7 +107,7 @@
<view wx:elif="{{!loading}}" class="discover-empty">
<image src="/assets/brand/logo-mark.png" mode="aspectFit" />
<text class="empty-title">暂无符合条件的人选</text>
<text class="empty-desc">Required 条件不会自动放宽,可以调整筛选后再看看</text>
<text class="empty-desc">必须符合的条件不会自动放宽,可以调整筛选后再看看</text>
<view class="empty-action" bindtap="onResetFilter">清空筛选</view>
</view>
</view>
+1 -1
View File
@@ -85,7 +85,7 @@
catchtap="onLikeBack"
data-user-id="{{item.userId}}"
disabled="{{actionLoadingUserId === item.userId}}"
>喜欢TA</button>
>喜欢对方</button>
</view>
</ui-user-card>
</view>
+1 -1
View File
@@ -1 +1 @@
{"navigationBarTitleText":"help-center","usingComponents":{}}
{"navigationBarTitleText":"帮助中心","usingComponents":{}}
+1 -1
View File
@@ -72,7 +72,7 @@
</view>
<view wx:else class="recommend-empty">
<image class="empty-logo" src="/assets/brand/logo-mark.png" mode="aspectFit" />
<view class="empty-copy"><text class="empty-title">暂时没有完全符合双方条件的人</text><text class="empty-desc">我们坚持 Required 条件,不会为了数量降低筛选标准</text></view>
<view class="empty-copy"><text class="empty-title">暂时没有完全符合双方条件的人</text><text class="empty-desc">我们坚持必须符合的条件,不会为了数量降低筛选标准</text></view>
<view class="empty-actions"><view class="empty-btn secondary" bindtap="onEmptyAction" data-event="preference">调整条件</view><view class="empty-btn primary" bindtap="onEmptyAction" data-event="matchmaker">公益红娘</view></view>
</view>
</view>
+1 -1
View File
@@ -1 +1 @@
{"navigationBarTitleText":"login","usingComponents":{}}
{"navigationBarTitleText":"登录","usingComponents":{}}
+11 -4
View File
@@ -8,7 +8,7 @@
* - 推荐:红娘推荐列表跳 fate-center 页(AGENTS 第 5 条:红娘不可替用户操作)
*/
import { matchmakerStore, appStore } from '../../stores';
import { ROUTE, to, buildPath, MATCHMAKER_SERVICE_STATUS } from '../../constants';
import { ROUTE, to, buildPath, MATCHMAKER_SERVICE_STATUS, MATCHMAKER_SERVICE_STATUS_LABEL } from '../../constants';
Page({
data: {
@@ -59,10 +59,17 @@ Page({
activeService.status === MATCHMAKER_SERVICE_STATUS.IN_SERVICE ||
activeService.status === MATCHMAKER_SERVICE_STATUS.WAITING_USER
);
const historyServices = activeService
const withStatusText = (s: any) => ({
...s,
statusText: MATCHMAKER_SERVICE_STATUS_LABEL[s.status] || s.statusText || s.status || '',
})
const historyServices = (activeService
? myServices.filter((s: any) => s.serviceId !== activeService.serviceId)
: myServices;
this.setData({ loading: false, firstLoaded: true, activeService, historyServices, showRecommendations });
: myServices).map(withStatusText)
const activeServiceWithText = activeService
? { ...activeService, statusText: MATCHMAKER_SERVICE_STATUS_LABEL[activeService.status] || activeService.statusText || activeService.status || '' }
: null
this.setData({ loading: false, firstLoaded: true, activeService: activeServiceWithText, historyServices, showRecommendations });
} catch (err: any) {
this.setData({ loading: false, firstLoaded: true });
appStore.toastError(err, '加载红娘服务失败');
@@ -1 +1 @@
{"navigationBarTitleText":"my-activities","usingComponents":{}}
{"navigationBarTitleText":"我的活动","usingComponents":{}}
@@ -1 +1 @@
<view class="page"><text>页面: my-activities</text></view>
<view class="page"><text>我的活动(建设中)</text></view>
@@ -12,7 +12,7 @@
*/
const { userStore, appStore } = require('../../stores');
const { profileService } = require('../../services');
const { ROUTE, STORAGE_KEYS, PROFILE_REVIEW } = require('../../constants');
const { ROUTE, STORAGE_KEYS, PROFILE_REVIEW, profileLabelOf } = require('../../constants');
// 审核状态 → StatusBadge kind 映射
const REVIEW_BADGE = {
@@ -23,28 +23,6 @@ const REVIEW_BADGE = {
[PROFILE_REVIEW.REJECTED]: { label: '未通过', kind: 'danger' },
};
// 婚姻状况字典(前端展示用,权威值仍由服务端 Profile.maritalStatus 给出)
const MARITAL_LABEL = {
single: '未婚',
divorced: '离异',
widowed: '丧偶',
};
// 学历字典
const EDUCATION_LABEL = {
high_school: '高中/中专',
college: '大专',
bachelor: '本科',
master: '硕士',
doctor: '博士',
};
// 子女情况字典
const CHILDREN_LABEL = {
none: '无子女',
has: '有子女',
};
/**
* 计算资料完整度(客户端派生展示)
*
@@ -165,9 +143,9 @@ Page({
resubmitAllowed,
completion,
reviewBadge: REVIEW_BADGE[reviewStatus] || REVIEW_BADGE[PROFILE_REVIEW.NOT_SUBMITTED],
maritalLabel: MARITAL_LABEL[profile && profile.maritalStatus] || (profile && profile.maritalStatus) || '-',
educationLabel: EDUCATION_LABEL[profile && profile.educationCode] || (profile && profile.educationCode) || '-',
childrenLabel: CHILDREN_LABEL[profile && (profile.hasChildren ? 'has' : 'none')] || '-',
maritalLabel: profileLabelOf('maritalStatus', profile && profile.maritalStatus) || '-',
educationLabel: profileLabelOf('educationCode', profile && profile.educationCode) || '-',
childrenLabel: profile && (profile.hasChildren ? '有子女' : '无子女'),
});
} catch (err) {
appStore.toastError(err, '加载失败');
+19 -11
View File
@@ -11,7 +11,7 @@
*/
const { matchStore, appStore, blockUser } = require('../../stores');
const { recommendationService } = require('../../services');
const { to, DANGER_ACTION, RELATION_STATUS, BUSINESS_ERROR_CODE } = require('../../constants');
const { to, DANGER_ACTION, RELATION_STATUS, BUSINESS_ERROR_CODE, profileLabelOf, yesNoLabel, futureDanyangLabel } = require('../../constants');
Page({
data: {
@@ -96,22 +96,30 @@ Page({
const metaParts = [];
if (user.heightCm) metaParts.push(`${user.heightCm}cm`);
if (user.regionName) metaParts.push(user.regionName);
if (user.educationCode) metaParts.push(user.educationCode);
if (user.occupationCode) metaParts.push(user.occupationCode);
const eduLabel = profileLabelOf('educationCode', user.educationCode);
if (eduLabel) metaParts.push(eduLabel);
const occLabel = profileLabelOf('occupationCode', user.occupationCode);
if (occLabel) metaParts.push(occLabel);
const basicInfo = [];
if (user.maritalStatus) basicInfo.push({ label: '婚姻状况', value: user.maritalStatus });
const maritalLabel = profileLabelOf('maritalStatus', user.maritalStatus);
if (maritalLabel) basicInfo.push({ label: '婚姻状况', value: maritalLabel });
if (user.childrenStatus || user.hasChildren !== undefined) {
basicInfo.push({ label: '子女情况', value: user.childrenStatus || (user.hasChildren ? '有孩' : '无孩') });
basicInfo.push({ label: '子女情况', value: user.childrenStatus ? profileLabelOf('childrenStatus', user.childrenStatus) : (user.hasChildren ? '有孩' : '无孩') });
}
if (user.educationCode) basicInfo.push({ label: '学历', value: user.educationCode });
if (user.occupationCode) basicInfo.push({ label: '职业', value: user.occupationCode });
if (user.smokingStatus) basicInfo.push({ label: '吸烟', value: user.smokingStatus });
if (user.drinkingStatus) basicInfo.push({ label: '饮酒', value: user.drinkingStatus });
if (user.longTermDanyang) basicInfo.push({ label: '定居意愿', value: user.longTermDanyang, full: true });
if (user.futureDanyang) basicInfo.push({ label: '未来规划', value: user.futureDanyang, full: true });
if (eduLabel) basicInfo.push({ label: '学历', value: eduLabel });
if (occLabel) basicInfo.push({ label: '职业', value: occLabel });
const smokingLabel = profileLabelOf('smokingStatus', user.smokingStatus);
if (smokingLabel) basicInfo.push({ label: '吸烟', value: smokingLabel });
const drinkingLabel = profileLabelOf('drinkingStatus', user.drinkingStatus);
if (drinkingLabel) basicInfo.push({ label: '饮酒', value: drinkingLabel });
const longTermLabel = yesNoLabel(user.longTermDanyang);
if (longTermLabel) basicInfo.push({ label: '定居意愿', value: longTermLabel, full: true });
const futureLabel = futureDanyangLabel(user.futureDanyang);
if (futureLabel) basicInfo.push({ label: '未来规划', value: futureLabel, full: true });
const attitudeInfo = [];
// relationshipGoal / marriagePlan 由后端返回中文文案(结婚 / 恋爱 等),未知码原样透传
if (user.relationshipGoal) attitudeInfo.push({ label: '寻找', value: user.relationshipGoal, full: true });
if (user.marriagePlan) attitudeInfo.push({ label: '结婚计划', value: user.marriagePlan, full: true });
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">U51 服务预约</text><text class="hero-title">预约到站服务</text><text class="hero-desc">选择合适的时间和服务项目,提交后等待工作人员确认。</text></view><view class="content"><view class="card"><view class="field" wx:if="{{serviceLocationId}}"><text class="label">服务站点</text><view class="select-box is-readonly">{{serviceLocationName || '已选服务站点'}}</view></view><view class="field"><text class="label">服务项目</text><picker range="{{services}}" bindchange="chooseService"><view class="select-box">{{service}}<text class="arrow" style="margin-left:auto">›</text></view></picker></view><view class="field"><text class="label">预约日期</text><picker mode="date" bindchange="chooseDate"><view class="select-box">{{date || '请选择日期'}}</view></picker></view><view class="field"><text class="label">希望沟通的事项(选填)</text><textarea class="textarea" value="{{note}}" bindinput="onNoteInput" placeholder="请简要说明,避免填写身份证号等敏感信息" maxlength="200"/></view><view class="notice">提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。</view></view></view><view class="fixed-action"><view class="primary-btn {{submitting ? 'is-disabled' : ''}}" bindtap="submit">{{submitting ? '提交中…' : '提交预约'}}</view></view></view>
<view class="service-page"><view class="hero"><text class="eyebrow">服务预约</text><text class="hero-title">预约到站服务</text><text class="hero-desc">选择合适的时间和服务项目,提交后等待工作人员确认。</text></view><view class="content"><view class="card"><view class="field" wx:if="{{serviceLocationId}}"><text class="label">服务站点</text><view class="select-box is-readonly">{{serviceLocationName || '已选服务站点'}}</view></view><view class="field"><text class="label">服务项目</text><picker range="{{services}}" bindchange="chooseService"><view class="select-box">{{service}}<text class="arrow" style="margin-left:auto">›</text></view></picker></view><view class="field"><text class="label">预约日期</text><picker mode="date" bindchange="chooseDate"><view class="select-box">{{date || '请选择日期'}}</view></picker></view><view class="field"><text class="label">希望沟通的事项(选填)</text><textarea class="textarea" value="{{note}}" bindinput="onNoteInput" placeholder="请简要说明,避免填写身份证号等敏感信息" maxlength="200"/></view><view class="notice">提交预约不代表已确认。工作人员确认后,预约状态会更新为“已确认”。</view></view></view><view class="fixed-action"><view class="primary-btn {{submitting ? 'is-disabled' : ''}}" bindtap="submit">{{submitting ? '提交中…' : '提交预约'}}</view></view></view>
@@ -1 +1 @@
<view class="service-page"><view class="hero"><text class="eyebrow">U64 公益服务记录</text><text class="hero-title">每一次服务,都清楚可查</text><text class="hero-desc">这里只展示与你有关的公益服务事实,不展示咨询正文。</text></view><view class="content"><view wx:if="{{loading}}" class="card empty-panel"><view class="empty-symbol">…</view><text class="empty-title">正在加载</text></view><view wx:elif="{{error}}" class="card empty-panel"><view class="empty-symbol">!</view><text class="empty-title">{{error}}</text></view><view wx:elif="{{records.length}}" class="block"><view class="card"><view wx:for="{{records}}" wx:key="key" class="record-row"><view class="feature-icon">{{item.icon}}</view><view class="feature-copy"><text class="feature-title">{{item.title}}</text><text class="feature-desc">{{item.date}}</text></view><text class="tag">{{item.status}}</text></view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">记</view><text class="empty-title">还没有公益服务记录</text><text class="meta">预约到站服务或申请公益咨询后,会在这里留下时间线。</text></view></view><view class="notice">咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。</view></view>
<view class="service-page"><view class="hero"><text class="eyebrow">公益服务记录</text><text class="hero-title">每一次服务,都清楚可查</text><text class="hero-desc">这里只展示与你有关的公益服务事实,不展示咨询正文。</text></view><view class="content"><view wx:if="{{loading}}" class="card empty-panel"><view class="empty-symbol">…</view><text class="empty-title">正在加载</text></view><view wx:elif="{{error}}" class="card empty-panel"><view class="empty-symbol">!</view><text class="empty-title">{{error}}</text></view><view wx:elif="{{records.length}}" class="block"><view class="card"><view wx:for="{{records}}" wx:key="key" class="record-row"><view class="feature-icon">{{item.icon}}</view><view class="feature-copy"><text class="feature-title">{{item.title}}</text><text class="feature-desc">{{item.date}}</text></view><text class="tag">{{item.status}}</text></view></view></view><view wx:else class="card empty-panel"><view class="empty-symbol">记</view><text class="empty-title">还没有公益服务记录</text><text class="meta">预约到站服务或申请公益咨询后,会在这里留下时间线。</text></view></view><view class="notice">咨询与公益红娘记录彼此分离;工作人员只能查看职责范围内的信息。</view></view>
+1 -1
View File
@@ -1 +1 @@
{"navigationBarTitleText":"settings","usingComponents":{}}
{"navigationBarTitleText":"设置","usingComponents":{}}
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* 小程序文案语种审计:找出「会显示给用户、但不是中文简体」的文本。
*
* 为什么需要它:小程序里最典型的一类缺陷是**后端枚举码直接当展示值**——
* 例如某个 <picker range="{{['any','accept','reject']}}"> 的下拉里出现
* any / accept / reject,而同一行的选中值却是「不限」。静态类型检查、
* WXSS/WXML 编译、结构守卫全都不会报,只有人眼在真机上才发现。
*
* 检查项:
* A 文本节点里的 ASCII 单词(剥掉注释与 {{ }} 表达式后仍有英文)
* B 中文属性(placeholder/title/label/subtitle/action/desc…)里的 ASCII 单词
* C <picker range="{{[...]}}"> 字面量选项里出现纯英文
* D JS 里 label: 'English' 这类未汉化的文案字段
* E 繁体字(与简体异形的常用字表)——「其他非中文简体」里的常见一类
* F 页面 .json 的 navigationBarTitleText 为英文
*
* 用法:node scripts/audit-miniapp-language.mjs [--json] [--out=file]
* 退出码:有命中 → 1;干净 → 0。(可直接当守卫用)
*/
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
const STACK_FILE = 'E:/develop/公益婚恋服务平台/.workbuddy/audit_stack.txt'
try {
main()
} catch (e) {
try { writeFileSync(STACK_FILE, 'AUDIT_CRASH:\n' + ((e && e.stack) || String(e)), 'utf8') } catch (_) {}
console.error((e && e.stack) || e)
process.exit(3)
}
function main() {
const root = resolve(import.meta.dirname, '..')
const MINIAPP = join(root, 'miniapp')
const AS_JSON = process.argv.includes('--json')
const outArg = process.argv.find((a) => a.startsWith('--out='))
/** 允许出现在界面上的非中文片段(单位、专名缩写等),按需增补并写清理由 */
const ASCII_ALLOW = new Set([
'cm', 'kg', 'ml', 'km', 'm', // 计量单位
'ID', 'id', // 证件/编号类专名
'APP', 'App', // 平台专名
'VIP',
'AI',
'OK',
'Lv', 'LV',
'MB', 'GB', 'KB', // 体积
'TEL',
'URL',
'el', // 个别组件前缀(如 el-tag 为历史遗留 Web 组件,不在小程序渲染路径)
// 图片/文档格式专名(与 cm/kg 同理,属技术缩写而非「其他语言」)
'JPG', 'PNG', 'GIF', 'PDF', 'WEBP',
])
/**
* 与简体**异形**的常用繁体字。只收录「简体写法不同」的字,
* 简繁同形的字(如 趣/域/婚/子/女/定/案 等)绝不能放进来,否则全是误报。
*/
const TRADITIONAL_CHARS = new Set(
'個們這來時說對開關聯繫預約資標準確認興學歷職業區狀況選擇設檔訊報幫隱權註銷帳號審過實證齡體車產戶現電話碼驗動參簽紅薦發務條詳細臺灣門網絡軟機腦錄簡無線藍員會構團隊義誌願評價滿歡適檢醫療護飲當還讓給與從麼樣為長愛見覺點種問間東風雲視圖書課師圓場園館廳營記軍農極變層屬歲廣慶應處養雙頁頂項順顧題額類頻飛飯飽餓鮮藥衛親眾優傳偉傷兒'
)
/** 需要审计的用户可见属性 */
const TEXT_ATTRS = [
'placeholder', 'title', 'subtitle', 'action', 'label', 'desc', 'description',
'content', 'text', 'tip', 'tips', 'confirm-text', 'cancel-text', 'empty-text',
'hint', 'notice', 'caption', 'unit',
]
const findings = []
const add = (level, file, line, kind, detail) => {
findings.push({ level, file: relative(root, file).replace(/\\/g, '/'), line, kind, detail })
}
function walk(dir, filter, out = []) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === '.git' || name === 'dist') continue
const p = join(dir, name)
const st = statSync(p)
if (st.isDirectory()) walk(p, filter, out)
else if (filter(name)) out.push(p)
}
return out
}
/** 把 <!-- --> 注释替换成等长空白,保持行号不变 */
function stripWxmlComments(src) {
return src.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '))
}
/** 剥离 {{ }} 表达式(表达式里出现英文是正常的:变量名/枚举判断) */
function stripMustache(src) {
return src.replace(/\{\{[\s\S]*?\}\}/g, (m) => ' '.repeat(m.length))
}
function linesOf(src) {
return src.split(/\r?\n/)
}
function asciiWords(text) {
const words = text.match(/[A-Za-z][A-Za-z0-9_'-]*/g) || []
return words.filter((w) => !ASCII_ALLOW.has(w))
}
/* ---------------- A/B/C:WXML ---------------- */
for (const file of walk(join(MINIAPP, 'pages'), (n) => n.endsWith('.wxml'))
.concat(walk(join(MINIAPP, 'components'), (n) => n.endsWith('.wxml')))) {
const src = stripWxmlComments(readFileSync(file, 'utf8'))
const lines = linesOf(src)
// A 文本节点
lines.forEach((line, i) => {
// 先在整行上剥离 {{ }},否则 mustache 内的 > / <(如 minlength > 0 && x < y)
// 会破坏「>文本<」的提取,把比较运算符误判为文本节点。
const strippedLine = stripMustache(line)
const texts = [...strippedLine.matchAll(/>([^<>]*)</g)].map((m) => m[1])
for (const raw of texts) {
const text = stripMustache(raw).trim()
if (!text) continue
const words = asciiWords(text)
if (words.length) add('error', file, i + 1, 'A-文本节点', `"${text.trim()}" → ${words.join(', ')}`)
}
})
// B 中文属性
// 负向环视:排除 bind:action / catch:action / data-action 等事件绑定——
// 它们的取值是处理器标识(onXxx),不是界面可见文案。
const attrRe = new RegExp(`(?<![:\\w-])(${TEXT_ATTRS.join('|')})="([^"]*)"`, 'g')
lines.forEach((line, i) => {
for (const m of line.matchAll(attrRe)) {
const value = stripMustache(m[2])
if (!value.trim() || value.includes('{{')) continue
const words = asciiWords(value)
if (words.length) add('error', file, i + 1, 'B-属性', `${m[1]}="${value}" → ${words.join(', ')}`)
}
})
// C picker 字面量选项
lines.forEach((line, i) => {
for (const m of line.matchAll(/range="\{\{\s*\[([^\]]*)\]\s*\}\}"/g)) {
const items = m[1].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
const bad = items.filter((s) => s && !/[\u4e00-\u9fa5]/.test(s))
if (bad.length) add('error', file, i + 1, 'C-picker 选项', `纯英文/非中文选项: ${bad.join(', ')}`)
}
})
}
/* ---------------- D:JS/TS 未汉化文案字段 ---------------- */
const LABEL_KEYS = '(label|title|subtitle|text|placeholder|desc|description|name|tip|notice)'
for (const file of walk(MINIAPP, (n) => /\.(js|ts)$/.test(n))) {
const src = readFileSync(file, 'utf8')
const lines = linesOf(src)
lines.forEach((line, i) => {
// 跳过注释行
if (/^\s*(\/\/|\*|\/\*)/.test(line)) return
const re = new RegExp(`${LABEL_KEYS}\\s*:\\s*(['"])([A-Za-z][A-Za-z0-9 _'-]*)\\1`, 'g')
for (const m of line.matchAll(re)) {
if (ASCII_ALLOW.has(m[3])) continue
add('error', file, i + 1, 'D-文案字段', `${m[1]}: '${m[3]}'`)
}
})
}
/* ---------------- E:繁体字 ---------------- */
for (const file of walk(MINIAPP, (n) => /\.(wxml|js|ts|json)$/.test(n))) {
const src = file.endsWith('.wxml') ? stripWxmlComments(readFileSync(file, 'utf8')) : readFileSync(file, 'utf8')
linesOf(src).forEach((line, i) => {
const hits = [...new Set([...line].filter((ch) => TRADITIONAL_CHARS.has(ch)))]
if (hits.length) add('error', file, i + 1, 'E-繁体字', hits.join(''))
})
}
/* ---------------- F:页面 .json navigationBarTitleText 英文 ---------------- */
for (const file of walk(join(MINIAPP, 'pages'), (n) => n.endsWith('.json'))) {
let json
try { json = JSON.parse(readFileSync(file, 'utf8')) } catch (_) { continue }
const t = json && json.navigationBarTitleText
if (typeof t === 'string' && t.trim() && /[A-Za-z]/.test(t) && !/[\u4e00-\u9fa5]/.test(t)) {
add('error', file, 1, 'F-窗口标题', `navigationBarTitleText="${t}"`)
}
}
/* ---------------- 输出 ---------------- */
if (outArg) {
writeFileSync(outArg.slice('--out='.length), JSON.stringify(findings, null, 2), 'utf8')
}
if (AS_JSON) {
console.log(JSON.stringify(findings, null, 2))
} else {
const byKind = {}
for (const f of findings) (byKind[f.kind] ||= []).push(f)
for (const [kind, list] of Object.entries(byKind)) {
console.log(`\n【${kind}】${list.length} 处`)
for (const f of list) console.log(` ${f.file}:${f.line} ${f.detail}`)
}
console.log(`\n合计命中:${findings.length}`)
}
process.exit(findings.length ? 1 : 0)
}
+5
View File
@@ -39,6 +39,11 @@ const UI_TESTS_DIR = resolve(root, 'ui-tests')
// 界面显示陈旧/默认值。2026-09-23 实测数据统计页漏斗卡恒为 0 即此因。
// 该缺陷无 TS/lint/构建报错,前 19 个守卫全绿也抓不到,只有真跑起来看渲染
// 结果才暴露 → 必须由源码结构断言接管)。
// 20 → 21:新增 miniapp-capsule-safe-contracts(胶囊按钮安全区几何,防 Tab 页与右上角
// 胶囊按钮重合——这类布局缺陷目视才发现,结构化断言钉住安全区变量)。
// 21 → 22:新增 miniapp-language-contracts(界面语种守卫:复用 scripts/audit-miniapp-language.mjs,
// 断言「全量扫描 0 命中」,兜住英文/非中文简体枚举码 any/accept/reject、never_married、
// bachelor、U51… 直接上屏这类只有真机才暴露的缺陷)。
// 新增/删减守卫时同步修改本默认值。
// 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/**
* 小程序界面语种契约守卫
* ============================
* 复用 scripts/audit-miniapp-language.mjs 的全量扫描(A 文本节点 / B 属性 /
* C picker 选项 / D JS 文案字段 / E 繁体字 / F 窗口标题),断言「0 命中」。
*
* 为什么必须独立成守卫:2026-09 真机事故——后端枚举码(never_married / bachelor /
* any / accept / reject)直接当展示值上屏,静态类型检查 / WXML·WXSS 编译 / 结构
* 守卫全都抓不到,只有人眼在真机才看得见。这是「界面必须全中文简体」唯一的自动化兜底。
*
* 单一事实源:本守卫不重复实现扫描逻辑,而是直接派生 audit 脚本——改扫描规则只改一处。
*/
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const auditScript = resolve(root, 'scripts/audit-miniapp-language.mjs')
const r = spawnSync(process.execPath, [auditScript, '--json'], {
cwd: root,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
})
let findings = []
if (r.status === 3) {
// 审计脚本自身崩溃(异常分支),绝不能当成「通过」默默绿掉
throw new Error(
'语种审计脚本执行失败(exit 3),请先修复 scripts/audit-miniapp-language.mjs:\n' +
(r.stderr || r.stdout || '(无输出)'),
)
}
if (r.stdout && r.stdout.trim()) {
try {
findings = JSON.parse(r.stdout)
} catch {
// 理论上 --json 只输出 JSON;解析失败则交给下面的长度断言兜底
}
}
assert.equal(
findings.length,
0,
`小程序仍存在英文/非中文简体上屏(${findings.length} 处)。示例:\n` +
findings
.slice(0, 8)
.map((f) => ` ${f.file}:${f.line} [${f.kind}] ${f.detail}`)
.join('\n'),
)
console.log(`Miniapp language contracts passed(语种审计 0 命中)`)