chore(release): freeze v0.2.0

版本冻结 v0.2.0,固化 2026-09-24 本轮全部变更。

admin-web:
- 修复旧物善用管理页缺失样式类 .workspace-view / .view-header / .table-wrap,
  此前页面无内边距导致内容被侧边栏遮挡、窄屏表格无横向滚动
- 修复服务统计页缺失样式类 .dashboard-grid / .dashboard-card / .stat-row,
  此前 8 个指标卡退化为裸文本堆叠
- 补齐未定义的 --shadow-card 变量(财务编辑面板阴影此前失效)
- 侧边栏 13 项导航由手写重复改为 NAV_ITEMS 数据驱动,并按业务顺序重排
- AdminIcon 补齐 coins / phone / volunteer / flag / calendar / recycle 语义图标
- 侧边栏行高与间距压缩使 13 项一屏放下,滚动条隐藏
- 小于等于 760px 断点导航由 display:none 改为横向滚动,移动端恢复可切换

api:
- prisma.config.ts 显式加载仓库根 .env,修复 prisma generate / migrate 取不到 DATABASE_URL
- dev / bootstrap:admin / test:integration 增加 tsx --env-file-if-exists=../../.env

miniprogram:
- custom-tab-bar 选中态与申请表单分步流程抽为可测纯函数并补充单测
- 页面完整性门禁脚本增强

工程:
- 版本统一升位 0.1.0 到 0.2.0(根与 4 个 workspace 包)
- .gitignore 增加 .pnpm-store/、.workbuddy/、微信开发者工具私人配置
  与仓库根目录误生成的工程配置,避免本地产物与机器相关信息入库

验证:pnpm lint 通过、pnpm test:unit 19 个用例通过、miniprogram typecheck 通过、
admin-web typecheck 与 build 通过、pnpm install --frozen-lockfile 未改写 lockfile、
无头浏览器端到端登录复验通过、待提交内容密钥扫描无命中。

说明:本轮定位并排除了一起 Prisma 生成目录残缺事故——API 开发进程与并发的
prisma generate 争抢同一输出目录,Windows 文件锁导致只生成 34/56 个模型文件,
使 TS 全量类型解析失败并产生数千条 lint 报错;停止竞争进程后干净重新生成即恢复。
该生成目录已被 .gitignore 忽略,不进入版本库。
This commit is contained in:
2026-09-24 19:50:07 +08:00
parent 7aae913c9e
commit 7e71455c6b
22 changed files with 437 additions and 149 deletions
@@ -1,3 +1,5 @@
import { selectedTabIndex } from './tab-state';
Component({
data: {
selected: 0,
@@ -10,16 +12,27 @@ Component({
},
lifetimes: {
attached() {
const route = `/${getCurrentPages().at(-1)?.route ?? 'pages/index/index'}`;
const selected = this.data.tabs.findIndex((item) => item.pagePath === route);
if (selected >= 0) this.setData({ selected });
this.syncSelected();
},
},
pageLifetimes: {
show() {
this.syncSelected();
},
},
methods: {
syncSelected() {
const route = getCurrentPages().at(-1)?.route ?? 'pages/index/index';
const selected = selectedTabIndex(route, this.data.tabs);
if (selected !== null && selected !== this.data.selected) this.setData({ selected });
},
switchTab(event: WechatMiniprogram.BaseEvent) {
const index = Number(event.currentTarget.dataset['index']);
const tab = this.data.tabs[index];
if (tab) void wx.switchTab({ url: tab.pagePath });
if (tab) {
this.setData({ selected: index });
void wx.switchTab({ url: tab.pagePath });
}
},
},
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { selectedTabIndex } from './tab-state';
const tabs = [
{ pagePath: '/pages/index/index' },
{ pagePath: '/pages/service-hub/service-hub' },
{ pagePath: '/pages/public-welfare/public-welfare' },
{ pagePath: '/pages/profile/profile' },
];
describe('selectedTabIndex', () => {
it('matches the current page route after a tab page becomes visible', () => {
expect(selectedTabIndex('pages/profile/profile', tabs)).toBe(3);
expect(selectedTabIndex('/pages/service-hub/service-hub', tabs)).toBe(1);
});
it('does not select a tab for a non-tab route', () => {
expect(selectedTabIndex('pages/application-form/application-form', tabs)).toBeNull();
});
});
@@ -0,0 +1,9 @@
export interface TabRoute {
pagePath: string;
}
export function selectedTabIndex(route: string, tabs: readonly TabRoute[]): number | null {
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
const index = tabs.findIndex((tab) => tab.pagePath === normalizedRoute);
return index >= 0 ? index : null;
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { APPLICATION_FORM_STEPS, createApplicationFormEntry } from './application-form-flow';
describe('application form flow', () => {
it('starts at the relationship step when a service category is preselected', () => {
expect(createApplicationFormEntry(true)).toEqual({
choosingCategory: false,
step: 1,
totalSteps: 4,
});
});
it('shows an unnumbered category chooser when no category is preselected', () => {
expect(createApplicationFormEntry(false)).toEqual({
choosingCategory: true,
step: 1,
totalSteps: 4,
});
});
it('renumbers the remaining application steps from one to four', () => {
expect(APPLICATION_FORM_STEPS).toEqual(['申请关系', '联系方式', '描述需求', '确认提交']);
});
});
@@ -0,0 +1,9 @@
export const APPLICATION_FORM_STEPS = ['申请关系', '联系方式', '描述需求', '确认提交'] as const;
export function createApplicationFormEntry(hasPreselectedCategory: boolean) {
return {
choosingCategory: !hasPreselectedCategory,
step: 1,
totalSteps: APPLICATION_FORM_STEPS.length,
};
}
@@ -10,6 +10,7 @@ import {
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
} from '../../constants/business-options';
import { createApplicationFormEntry } from './application-form-flow';
import { MiniProgramApiError } from '../../services/api-client';
import {
@@ -18,7 +19,7 @@ import {
} from '../../services/service-applications';
import { createClientRequestId } from '../../utils/request-id';
const TOTAL_STEPS = 5;
const DEFAULT_FORM_ENTRY = createApplicationFormEntry(false);
const CATEGORY_IMAGES: Record<ServiceCategory, string> = {
OLD_AGE: '/assets/images/brand-elder-care.jpg',
@@ -54,8 +55,7 @@ function isApplyMode(value: string): value is ApplicationApplyMode {
Page({
data: {
step: 1,
totalSteps: TOTAL_STEPS,
...DEFAULT_FORM_ENTRY,
loading: true,
submitting: false,
errorMessage: '',
@@ -87,9 +87,11 @@ Page({
}
if (options.category && isServiceCategory(options.category)) {
this.setData({
...createApplicationFormEntry(true),
serviceCategory: options.category,
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[options.category],
});
return;
}
void this.loadCategories();
},
@@ -123,6 +125,8 @@ Page({
this.setData({
serviceCategory: code,
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[code],
choosingCategory: false,
step: 1,
clientRequestId: createClientRequestId(),
errorMessage: '',
});
@@ -176,7 +180,7 @@ Page({
this.setData({ errorMessage: message });
return;
}
const next = Math.min(TOTAL_STEPS, this.data.step + 1);
const next = Math.min(this.data.totalSteps, this.data.step + 1);
this.setData({
step: next,
errorMessage: '',
@@ -234,14 +238,13 @@ Page({
},
validateStep(step: number): string {
if (step === 1 && !this.data.serviceCategory) return '请选择需要的服务类型';
if (step === 2 && !this.data.applyMode) return '请选择您与服务对象的关系';
if (step === 3) {
if (step === 1 && !this.data.applyMode) return '请选择您与服务对象的关系';
if (step === 2) {
if (!this.data.beneficiaryName.trim()) return '请填写服务对象姓名或称呼';
if (!/^1[3-9]\d{9}$/.test(this.data.contactMobile.trim())) return '请填写正确的手机号码';
if (this.data.regionCode.trim().length < 2) return '请填写所在市、区或街道';
}
if (step === 4 && this.data.description.trim().length < 10) {
if (step === 3 && this.data.description.trim().length < 10) {
return '请至少用 10 个字描述目前的困难或希望得到的帮助';
}
return '';
@@ -1,25 +1,25 @@
<view class="page application-page">
<view class="progress-card" aria-label="申请步骤">
<view wx:if="{{!choosingCategory}}" class="progress-card" aria-label="申请步骤">
<view class="progress-meta">
<text>第 {{step}} 步,共 {{totalSteps}} 步</text>
<text>{{step === 1 ? '选择服务' : step === 2 ? '申请关系' : step === 3 ? '联系方式' : step === 4 ? '描述需求' : '确认提交'}}</text>
<text>{{step === 1 ? '申请关系' : step === 2 ? '联系方式' : step === 3 ? '描述需求' : '确认提交'}}</text>
</view>
<view class="progress-track"><view class="progress-value step-{{step}}"></view></view>
</view>
<view wx:if="{{loading}}" class="form-card" aria-live="polite">
<view wx:if="{{choosingCategory && loading}}" class="form-card" aria-live="polite">
<view class="skeleton-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
<text class="state-text">正在读取服务类型…</text>
</view>
<view wx:elif="{{loading === false && categories.length === 0}}" class="state-card">
<view wx:elif="{{choosingCategory && loading === false && categories.length === 0}}" class="state-card">
<text class="state-title">暂时无法读取服务类型</text>
<text class="state-text">{{errorMessage}}</text>
<button class="secondary-button" bindtap="retryLoad">重新加载</button>
</view>
<block wx:else>
<view wx:if="{{step === 1}}" class="form-card">
<view wx:if="{{choosingCategory}}" class="form-card">
<view class="application-banner"><text>请选择需要的服务类型</text><text>我们会认真了解并及时回应</text></view>
<button
wx:for="{{categories}}"
@@ -38,7 +38,7 @@
</button>
</view>
<view wx:elif="{{step === 2}}" class="form-card">
<view wx:elif="{{step === 1}}" class="form-card">
<text class="form-title">帮谁申请?</text>
<text class="form-hint">请选择您与服务对象的关系。</text>
<button
@@ -53,7 +53,7 @@
</button>
</view>
<view wx:elif="{{step === 3}}" class="form-card">
<view wx:elif="{{step === 2}}" class="form-card">
<text class="form-title">留下联系方式</text>
<text class="form-hint">只填写联系服务所必需的信息,不需要精确门牌地址。</text>
<text class="field-label">服务对象姓名或称呼 *</text>
@@ -66,7 +66,7 @@
<input class="field-input" value="{{preferredContactTime}}" maxlength="100" placeholder="例如:工作日上午" bindinput="updatePreferredContactTime" />
</view>
<view wx:elif="{{step === 4}}" class="form-card">
<view wx:elif="{{step === 3}}" class="form-card">
<text class="form-title">简单描述需要的帮助</text>
<text class="form-hint">请勿填写身份证号码、银行卡号或精确门牌地址。</text>
<textarea class="field-textarea" value="{{description}}" maxlength="1000" placeholder="请至少用 10 个字描述目前遇到的困难或希望得到的帮助" bindinput="updateDescription"></textarea>
@@ -102,7 +102,7 @@
</view>
<text wx:if="{{errorMessage}}" class="inline-error" aria-live="assertive">{{errorMessage}}</text>
<view class="form-actions">
<view wx:if="{{!choosingCategory}}" class="form-actions">
<button wx:if="{{step > 1}}" class="secondary-button action-button" disabled="{{submitting}}" bindtap="previousStep">上一步</button>
<button wx:if="{{step < totalSteps}}" class="primary-button action-button" bindtap="nextStep">下一步</button>
<button wx:else class="primary-button action-button" loading="{{submitting}}" disabled="{{submitting}}" bindtap="submitApplication">{{submitting ? '正在提交…' : '提交帮扶需求'}}</button>
@@ -28,18 +28,15 @@
}
.step-1 {
width: 20%;
width: 25%;
}
.step-2 {
width: 40%;
width: 50%;
}
.step-3 {
width: 60%;
width: 75%;
}
.step-4 {
width: 80%;
}
.step-5 {
width: 100%;
}
@@ -4,5 +4,7 @@
<block wx:if="{{state === 'loading'}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block>
<view wx:elif="{{state === 'error'}}" class="state-card"><text class="state-title">任务加载失败</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
<view wx:elif="{{state === 'empty'}}" class="state-card"><text class="state-title">暂无协作任务</text><text class="state-text">当工作人员将服务工单指派给您后,会显示在这里。</text></view>
<button wx:else wx:for="{{items}}" wx:key="publicId" class="case-card" data-id="{{item.publicId}}" bindtap="openCase"><view class="case-top"><text class="case-no">{{item.caseNo}}</text><text class="status-pill">{{item.statusLabel}}</text></view><text class="case-name">{{item.beneficiaryDisplayName}}</text><text class="case-meta">{{item.priorityLabel}}优先级 · 负责人 {{item.ownerDisplayName}}</text><text class="case-link">查看服务任务 ›</text></button>
<block wx:else>
<button wx:for="{{items}}" wx:key="publicId" class="case-card" data-id="{{item.publicId}}" bindtap="openCase"><view class="case-top"><text class="case-no">{{item.caseNo}}</text><text class="status-pill">{{item.statusLabel}}</text></view><text class="case-name">{{item.beneficiaryDisplayName}}</text><text class="case-meta">{{item.priorityLabel}}优先级 · 负责人 {{item.ownerDisplayName}}</text><text class="case-link">查看服务任务 ›</text></button>
</block>
</view>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lehuo/miniprogram",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"dependencies": {
"@lehuo/contracts": "workspace:*"
+27 -3
View File
@@ -1,5 +1,5 @@
{
"appid": "touristappid",
"appid": "wx6cfca7c83002d678",
"compileType": "miniprogram",
"libVersion": "trial",
"miniprogramRoot": "miniprogram/",
@@ -8,6 +8,30 @@
"es6": true,
"minified": true,
"postcss": true,
"useCompilerPlugins": ["typescript"]
}
"useCompilerPlugins": [
"typescript"
],
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"enhance": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}
+8
View File
@@ -32,6 +32,14 @@ for (const page of app.pages) {
for (const match of wxml.matchAll(/<\/?([A-Za-z][A-Za-z0-9-]*)/g)) {
if (!allowedTags.has(match[1])) failures.push(`${page}.wxml 使用不支持标签 <${match[1]}>`);
}
for (const match of wxml.matchAll(/<([A-Za-z][A-Za-z0-9-]*)\b([^>]*)>/g)) {
const attributes = match[2];
if (/\bwx:(?:elif|else)\b/.test(attributes) && /\bwx:for\b/.test(attributes)) {
failures.push(
`${page}.wxml 的 <${match[1]}> 不能同时使用 wx:elif/wx:else 与 wx:for,请用条件 block 包裹循环`,
);
}
}
const source = readFileSync(`${base}.ts`, 'utf8');
for (const match of source.matchAll(
/wx\.(?:navigateTo|redirectTo)\(\{\s*url:\s*[`'"]([^`'"]+)/g,