fix(miniprogram): restore secondary pages on real devices
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type { ApplicationApplyMode, ServiceCategory, ServiceRecordType } from '@lehuo/contracts';
|
||||
|
||||
/**
|
||||
* 小程序运行时使用的稳定业务字典。
|
||||
* 类型继续由共享 contracts 约束,但真机运行不得依赖未打包的 workspace 模块。
|
||||
*/
|
||||
export const APPLICATION_APPLY_MODES: readonly ApplicationApplyMode[] = [
|
||||
'SELF',
|
||||
'FAMILY',
|
||||
'COMMUNITY',
|
||||
'VOLUNTEER',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
export const APPLICATION_APPLY_MODE_LABELS: Record<ApplicationApplyMode, string> = {
|
||||
SELF: '为自己申请',
|
||||
FAMILY: '帮家人申请',
|
||||
COMMUNITY: '社区或机构代办',
|
||||
VOLUNTEER: '志愿者代办',
|
||||
OTHER: '其他代办',
|
||||
};
|
||||
|
||||
export const SERVICE_CATEGORIES: readonly ServiceCategory[] = [
|
||||
'OLD_AGE',
|
||||
'WOMEN',
|
||||
'CHILD',
|
||||
'DISABILITY',
|
||||
'LIFE_DIFFICULTY',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
export const SERVICE_CATEGORY_LABELS: Record<ServiceCategory, string> = {
|
||||
OLD_AGE: '关爱老人',
|
||||
WOMEN: '困境妇女',
|
||||
CHILD: '儿童关爱',
|
||||
DISABILITY: '残障帮扶',
|
||||
LIFE_DIFFICULTY: '生活困难',
|
||||
OTHER: '其他服务',
|
||||
};
|
||||
|
||||
export const SERVICE_RECORD_TYPES: readonly ServiceRecordType[] = [
|
||||
'HOME_VISIT',
|
||||
'PHONE_CONTACT',
|
||||
'ESCORT',
|
||||
'MATERIAL_SUPPORT',
|
||||
'ACTIVITY',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
export const SERVICE_RECORD_TYPE_LABELS: Record<ServiceRecordType, string> = {
|
||||
HOME_VISIT: '上门探访',
|
||||
PHONE_CONTACT: '电话关怀',
|
||||
ESCORT: '陪同协助',
|
||||
MATERIAL_SUPPORT: '物资支持',
|
||||
ACTIVITY: '公益活动',
|
||||
OTHER: '其他服务',
|
||||
};
|
||||
@@ -4,7 +4,9 @@ import { fetchPublicActivity, registerActivity } from '../../services/participat
|
||||
Page({
|
||||
data: { loading: true, busy: false, message: '', activity: null as ActivityDetail | null },
|
||||
onLoad(options: Record<string, string>) {
|
||||
if (options['publicId']) void this.load(options['publicId']);
|
||||
const publicId = options['publicId'];
|
||||
if (publicId) void this.load(publicId);
|
||||
else this.setData({ loading: false, message: '缺少活动标识,请返回活动列表重新选择。' });
|
||||
},
|
||||
async load(publicId: string) {
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
APPLICATION_APPLY_MODES,
|
||||
SERVICE_CATEGORIES,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
} from '@lehuo/contracts';
|
||||
} from '../../constants/business-options';
|
||||
|
||||
import { MiniProgramApiError } from '../../services/api-client';
|
||||
import {
|
||||
@@ -20,6 +20,15 @@ import { createClientRequestId } from '../../utils/request-id';
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
const LOCAL_SERVICE_CATEGORIES: ServiceCategoryOption[] = [
|
||||
{ code: 'OLD_AGE', label: '关爱老人', description: '生活照料、陪伴关怀与健康支持' },
|
||||
{ code: 'WOMEN', label: '困境妇女', description: '困境支持、权益关爱与资源链接' },
|
||||
{ code: 'CHILD', label: '儿童关爱', description: '成长陪伴、家庭支持与儿童关爱' },
|
||||
{ code: 'DISABILITY', label: '残障帮扶', description: '生活协助、康复支持与无障碍关怀' },
|
||||
{ code: 'LIFE_DIFFICULTY', label: '生活困难', description: '生活支持、资源链接与持续关爱' },
|
||||
{ code: 'OTHER', label: '其他服务', description: '由工作人员进一步了解并协助判断' },
|
||||
];
|
||||
|
||||
function isServiceCategory(value: string): value is ServiceCategory {
|
||||
return (SERVICE_CATEGORIES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -35,7 +44,7 @@ Page({
|
||||
loading: true,
|
||||
submitting: false,
|
||||
errorMessage: '',
|
||||
categories: [] as ServiceCategoryOption[],
|
||||
categories: LOCAL_SERVICE_CATEGORIES,
|
||||
modeOptions: APPLICATION_APPLY_MODES.map((code) => ({
|
||||
code,
|
||||
label: APPLICATION_APPLY_MODE_LABELS[code],
|
||||
@@ -76,7 +85,11 @@ Page({
|
||||
const result = await fetchServiceCategories();
|
||||
this.setData({ categories: result.items, loading: false });
|
||||
} catch (error) {
|
||||
this.setData({ loading: false, errorMessage: this.errorText(error) });
|
||||
this.setData({
|
||||
loading: false,
|
||||
categories: LOCAL_SERVICE_CATEGORIES,
|
||||
errorMessage: `${this.errorText(error)};您仍可先填写申请,提交前请确认服务连接。`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
SERVICE_RECORD_TYPES,
|
||||
SERVICE_RECORD_TYPE_LABELS,
|
||||
type ServiceCaseDetail,
|
||||
} from '@lehuo/contracts';
|
||||
import type { ServiceCaseDetail } from '@lehuo/contracts';
|
||||
|
||||
import { SERVICE_RECORD_TYPES, SERVICE_RECORD_TYPE_LABELS } from '../../constants/business-options';
|
||||
|
||||
import { MiniProgramApiError } from '../../services/api-client';
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@ Page({
|
||||
onLoad(o) {
|
||||
const id = o['publicId'];
|
||||
if (!id) {
|
||||
this.setData({ loading: false, message: '内容参数无效' });
|
||||
this.setData({ loading: false, message: '缺少内容标识,请返回资讯列表重新选择。' });
|
||||
return;
|
||||
}
|
||||
void this.load(id);
|
||||
|
||||
@@ -1 +1 @@
|
||||
<view class="page"><view wx:if="{{loading}}" class="state-card">加载中…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><article wx:else class="detail-card"><text class="detail-title">{{post.title}}</text><text class="detail-summary">{{post.summary}}</text><text class="detail-content" selectable>{{post.content}}</text><text class="detail-date">发布时间:{{post.publishedAt}}</text></article></view>
|
||||
<view class="page"><view wx:if="{{loading}}" class="state-card">加载中…</view><view wx:elif="{{message}}" class="state-card">{{message}}</view><view wx:else class="detail-card"><text class="detail-title">{{post.title}}</text><text class="detail-summary">{{post.summary}}</text><text class="detail-content" selectable>{{post.content}}</text><text class="detail-date">发布时间:{{post.publishedAt}}</text></view></view>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<view wx:if="{{loading}}" class="profile-skeleton"><view class="skeleton-card"></view><view class="skeleton-card"></view></view>
|
||||
<view wx:elif="{{error}}" class="state-card"><text class="state-title">暂时无法打开个人中心</text><text class="state-text">{{error}}</text><button class="secondary-button" bindtap="load">重新加载</button></view>
|
||||
<block wx:else>
|
||||
<view class="profile-hero"><view class="avatar">乐</view><view><text class="profile-name">{{user.displayName || '乐活用户'}}</text><text class="profile-id">账号已安全登录</text></view><text class="profile-badge">{{isVolunteer ? '认证志愿者' : '公益伙伴'}}</text><view class="profile-trust"><view><strong>隐私保护</strong><text>最少采集</text></view><view><strong>服务可查</strong><text>进度留痕</text></view><view><strong>消息可达</strong><text>及时通知</text></view></view></view>
|
||||
<view class="profile-hero"><view class="avatar">乐</view><view><text class="profile-name">{{user.displayName || '乐活用户'}}</text><text class="profile-id">账号已安全登录</text></view><text class="profile-badge">{{isVolunteer ? '认证志愿者' : '公益伙伴'}}</text><view class="profile-trust"><view><text class="profile-trust__title">隐私保护</text><text>最少采集</text></view><view><text class="profile-trust__title">服务可查</text><text>进度留痕</text></view><view><text class="profile-trust__title">消息可达</text><text>及时通知</text></view></view></view>
|
||||
<view class="summary-card"><button bindtap="applications"><text class="summary-card__icon orange">服</text><text class="summary-card__label">我的申请</text></button><button bindtap="volunteer"><text class="summary-card__icon green">愿</text><text class="summary-card__label">志愿服务</text></button><button bindtap="notifications"><text class="summary-card__icon blue">信</text><text class="summary-card__label">我的消息</text></button></view>
|
||||
<view class="section-heading"><text class="section-title">我的服务</text></view>
|
||||
<view class="menu-card"><button bindtap="applications"><text class="menu-icon orange">申</text><view><text class="menu-title">我的服务申请</text><text class="menu-copy">查看需求受理与服务进度</text></view><text class="menu-arrow">›</text></button><button bindtap="volunteer"><text class="menu-icon green">愿</text><view><text class="menu-title">志愿者中心</text><text class="menu-copy">资料认证、活动报名与服务时长</text></view><text class="menu-arrow">›</text></button><button wx:if="{{canCollaborate}}" bindtap="assignedCases"><text class="menu-icon blue">协</text><view><text class="menu-title">我的协作任务</text><text class="menu-copy">查看被指派的必要服务信息</text></view><text class="menu-arrow">›</text></button></view>
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
.profile-trust view:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.profile-trust strong,
|
||||
.profile-trust__title,
|
||||
.profile-trust text {
|
||||
display: block;
|
||||
}
|
||||
.profile-trust strong {
|
||||
.profile-trust__title {
|
||||
font-size: 25rpx;
|
||||
}
|
||||
.profile-trust text {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { fetchPublicProject } from '../../services/participation';
|
||||
Page({
|
||||
data: { loading: true, message: '', project: null as ProjectDetail | null },
|
||||
onLoad(options: Record<string, string>) {
|
||||
if (options['publicId']) void this.load(options['publicId']);
|
||||
const publicId = options['publicId'];
|
||||
if (publicId) void this.load(publicId);
|
||||
else this.setData({ loading: false, message: '缺少项目标识,请返回项目列表重新选择。' });
|
||||
},
|
||||
async load(publicId: string) {
|
||||
try {
|
||||
|
||||
@@ -1 +1 @@
|
||||
<view class="page project-page"><block wx:if="{{loading}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block><view wx:elif="{{message}}" class="state-card"><text class="state-title">项目暂时无法查看</text><text class="state-text">{{message}}</text></view><block wx:elif="{{project}}"><image class="project-visual" src="/assets/images/brand-elder-care.jpg" mode="aspectFill"/><view class="project-title-card"><view class="project-title-row"><text class="project-title">{{project.name}}</text><text class="status-pill">{{project.statusLabel}}</text></view><text class="project-summary">{{project.summary}}</text><view class="project-facts"><view><text>服务对象</text><strong>{{project.targetPeople || '以项目说明为准'}}</strong></view><view><text>活动数量</text><strong>{{project.activityCount}}</strong></view><view><text>服务工单</text><strong>{{project.serviceCaseCount}}</strong></view></view><view class="project-period"><text>项目周期</text><text>{{project.startDate}} 至 {{project.endDate}}</text></view></view><view class="detail-card"><text class="detail-section-title">项目介绍</text><text class="detail-copy" selectable>{{project.content}}</text></view><view class="detail-card"><text class="detail-section-title">参与说明</text><text class="detail-copy">查看项目关联活动,选择适合的时间报名参与;提交后可在志愿者中心查询审核与签到状态。</text></view><button class="primary-button sticky-action" bindtap="activities">查看项目活动</button></block></view>
|
||||
<view class="page project-page"><block wx:if="{{loading}}"><view class="skeleton-card"></view><view class="skeleton-card"></view></block><view wx:elif="{{message}}" class="state-card"><text class="state-title">项目暂时无法查看</text><text class="state-text">{{message}}</text></view><block wx:elif="{{project}}"><image class="project-visual" src="/assets/images/brand-elder-care.jpg" mode="aspectFill"/><view class="project-title-card"><view class="project-title-row"><text class="project-title">{{project.name}}</text><text class="status-pill">{{project.statusLabel}}</text></view><text class="project-summary">{{project.summary}}</text><view class="project-facts"><view><text>服务对象</text><text class="project-fact-value">{{project.targetPeople === null ? '以项目说明为准' : project.targetPeople}}</text></view><view><text>活动数量</text><text class="project-fact-value">{{project.activityCount}}</text></view><view><text>服务工单</text><text class="project-fact-value">{{project.serviceCaseCount}}</text></view></view><view class="project-period"><text>项目周期</text><text>{{project.startDate}} 至 {{project.endDate}}</text></view></view><view class="detail-card"><text class="detail-section-title">项目介绍</text><text class="detail-copy" selectable>{{project.content}}</text></view><view class="detail-card"><text class="detail-section-title">参与说明</text><text class="detail-copy">查看项目关联活动,选择适合的时间报名参与;提交后可在志愿者中心查询审核与签到状态。</text></view><button class="primary-button sticky-action" bindtap="activities">查看项目活动</button></block></view>
|
||||
|
||||
@@ -111,14 +111,14 @@
|
||||
border-right: 0;
|
||||
}
|
||||
.project-facts text,
|
||||
.project-facts strong {
|
||||
.project-fact-value {
|
||||
display: block;
|
||||
}
|
||||
.project-facts text {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.project-facts strong {
|
||||
.project-fact-value {
|
||||
margin-top: 6rpx;
|
||||
color: var(--color-primary);
|
||||
font-size: 28rpx;
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ApiErrorResponse, ApiSuccessResponse } from '@lehuo/contracts';
|
||||
|
||||
import { readSession, removeSession } from './session';
|
||||
|
||||
const API_BASE_URL = getApp<IAppOption>().globalData.apiBaseUrl;
|
||||
function resolveApiBaseUrl(): string {
|
||||
const stored = String(wx.getStorageSync<string>('lehuo_api_base_url') || '').trim();
|
||||
return stored || getApp<IAppOption>().globalData.apiBaseUrl.trim();
|
||||
}
|
||||
|
||||
type ApiRequestOptions = Omit<
|
||||
WechatMiniprogram.RequestOption,
|
||||
@@ -20,7 +23,8 @@ export class MiniProgramApiError extends Error {
|
||||
}
|
||||
|
||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
if (!API_BASE_URL) {
|
||||
const apiBaseUrl = resolveApiBaseUrl();
|
||||
if (!apiBaseUrl) {
|
||||
return Promise.reject(
|
||||
new MiniProgramApiError(
|
||||
'小程序尚未配置服务地址,请联系工作人员',
|
||||
@@ -33,7 +37,7 @@ export function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Pr
|
||||
const session = readSession();
|
||||
wx.request<ApiSuccessResponse<T> | ApiErrorResponse>({
|
||||
...options,
|
||||
url: `${API_BASE_URL}${path}`,
|
||||
url: `${apiBaseUrl}${path}`,
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
...(session ? { authorization: `Bearer ${session.accessToken}` } : {}),
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
"@lehuo/contracts": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"build": "pnpm check:pages && pnpm check:runtime-imports && tsc --noEmit",
|
||||
"check:pages": "node scripts/check-pages.mjs",
|
||||
"check:runtime-imports": "node scripts/check-runtime-imports.mjs",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "pnpm check:pages && pnpm check:runtime-imports && tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/wechat-miniprogram": "^3.4.8",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, '..');
|
||||
const miniRoot = join(projectRoot, 'miniprogram');
|
||||
const app = JSON.parse(readFileSync(join(miniRoot, 'app.json'), 'utf8'));
|
||||
const allowedTags = new Set([
|
||||
'block',
|
||||
'button',
|
||||
'checkbox',
|
||||
'checkbox-group',
|
||||
'image',
|
||||
'input',
|
||||
'label',
|
||||
'picker',
|
||||
'switch',
|
||||
'text',
|
||||
'textarea',
|
||||
'view',
|
||||
]);
|
||||
const failures = [];
|
||||
|
||||
for (const page of app.pages) {
|
||||
const base = join(miniRoot, page);
|
||||
for (const extension of ['.ts', '.json', '.wxml', '.wxss']) {
|
||||
if (!existsSync(`${base}${extension}`)) failures.push(`${page}${extension} 缺失`);
|
||||
}
|
||||
const wxml = readFileSync(`${base}.wxml`, 'utf8');
|
||||
if (!wxml.trim()) failures.push(`${page}.wxml 为空`);
|
||||
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]}>`);
|
||||
}
|
||||
const source = readFileSync(`${base}.ts`, 'utf8');
|
||||
for (const match of source.matchAll(
|
||||
/wx\.(?:navigateTo|redirectTo)\(\{\s*url:\s*[`'"]([^`'"]+)/g,
|
||||
)) {
|
||||
const target = match[1].split('?')[0].replace(/^\//, '');
|
||||
if (target.includes('${')) continue;
|
||||
if (!app.pages.includes(target)) failures.push(`${page}.ts 路由未注册:${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(failures.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`小程序页面完整性检查通过:${app.pages.length} 个页面`);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const root = new URL('../miniprogram/', import.meta.url);
|
||||
const sourceRoot = decodeURIComponent(root.pathname).replace(/^\/(?:([A-Za-z]:))/, '$1');
|
||||
const failures = [];
|
||||
|
||||
function walk(directory) {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const path = join(directory, name);
|
||||
if (statSync(path).isDirectory()) walk(path);
|
||||
else if (path.endsWith('.ts') && !path.endsWith('.spec.ts')) inspect(path);
|
||||
}
|
||||
}
|
||||
|
||||
function inspect(path) {
|
||||
const source = readFileSync(path, 'utf8');
|
||||
const imports = source.matchAll(
|
||||
/import\s+(?!type\b)[\s\S]*?from\s+['"](@lehuo\/contracts)['"];?/g,
|
||||
);
|
||||
for (const match of imports) failures.push(relative(sourceRoot, path));
|
||||
}
|
||||
|
||||
walk(sourceRoot);
|
||||
if (failures.length) {
|
||||
console.error(`小程序存在未打包的运行时 workspace 依赖:\n${[...new Set(failures)].join('\n')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('小程序运行时依赖检查通过');
|
||||
@@ -6,6 +6,20 @@
|
||||
|
||||
2026-08-13
|
||||
|
||||
任务:微信真机二级页面白屏修复。
|
||||
|
||||
根因:服务申请和协作任务详情/服务记录页面在运行时从 workspace 包 `@lehuo/contracts` 导入业务常量。现有小程序工程没有生成 `miniprogram_npm`,常规 TypeScript 检查会擦除类型导入并通过,但真机无法解析运行时 workspace 模块,导致页面脚本在 `Page()` 注册前终止并显示空白。另发现内容详情使用非小程序原生 `article` 标签、项目详情使用 `strong` 标签,以及详情页缺少路由参数时可能持续处于加载状态。
|
||||
|
||||
完成:将申请方式、服务分类和服务记录类型的真机运行时字典迁入小程序本地模块,继续用共享契约类型做编译约束;API 地址改为按请求动态读取,并安全兼容空存储值;服务申请在服务分类接口暂不可用时展示本地批准分类与明确连接提示,不再出现无内容页面;项目、活动和内容详情补充缺少参数的恢复状态;替换不受支持的 WXML 标签。新增页面完整性门禁和运行时 workspace 导入门禁,校验 18 个页面四件套、静态路由注册、WXML 标签和真机运行依赖,纳入小程序 typecheck/build。
|
||||
|
||||
范围:未新增模拟业务数据、未绕过认证/权限/审计;本地服务分类仅用于表单可见与选择,最终提交仍调用真实 API。服务对象档案和服务记录继续只对获得 `assigned_cases:read` 权限且被指派的人员展示最小必要数据。
|
||||
|
||||
验证:格式检查、Lint、全仓类型、27 个单元测试、34 个真实 MySQL 集成测试和全仓生产构建通过;新增页面完整性门禁通过 18 个页面,运行时 workspace 导入门禁通过。8 个 migration 在隔离 `lehuo_test` 库无待执行项。仍需用户在微信开发者工具执行“清缓存并重新编译”,再进行真机二级页面复验。
|
||||
|
||||
---
|
||||
|
||||
2026-08-13
|
||||
|
||||
任务:参考图小程序端与管理端视觉全量还原(延期能力仅预留)。
|
||||
|
||||
完成:建立 primitive→semantic→component 三层设计令牌;按参考图重建小程序品牌首页、八宫格服务入口、公告、项目图文卡、正式底部导航、图文服务申请、项目详情事实区和绿色个人中心;保留既有真实申请、项目、活动、志愿报名、协作任务、消息和内容业务流程。管理端新增深色图标侧栏、工作概览、真实 KPI、月度申请/完成趋势、服务类型分布、快捷入口和工单横向状态步骤条,并沿用既有脱敏表格、筛选、详情、权限和审计逻辑。新增四张虚构公益品牌场景素材并压缩用于本地包,不作为真实成果证据。
|
||||
|
||||
@@ -9,6 +9,7 @@ export default tseslint.config(
|
||||
'**/node_modules/**',
|
||||
'**/miniprogram_npm/**',
|
||||
'**/generated/prisma/**',
|
||||
'apps/miniprogram/scripts/*.mjs',
|
||||
'coverage/**',
|
||||
'eslint.config.mjs',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user